agents get a kickoff inbox message on start/restart/rebuild
new Coordinator::kick_agent(name, reason) drops a system message into the agent's inbox so the next turn picks it up with a 'you were just (re)started, check /state/ for notes, --continue session is intact' hint. wakes the turn loop without any harness-side handling needed — it's just another inbox message with sender = 'system'. wired from: - dashboard /start /restart /rebuild handlers (via lifecycle_action's on-success tail) - manager mcp_hyperhive_start / restart dashboard: pending approvals + tombstones + questions now refresh on a 5s heartbeat when nothing else is happening. previously refresh only fired on async-form submit or on broker traffic addressed to operator — manager-queued approvals went through neither, so the operator had to reload to see them. 5s is the slow-path; 2s remains for in-flight transients.
This commit is contained in:
parent
8b10731aa4
commit
2413d664a1
4 changed files with 42 additions and 9 deletions
|
|
@ -397,10 +397,14 @@
|
||||||
renderQuestions(s);
|
renderQuestions(s);
|
||||||
renderInbox(s);
|
renderInbox(s);
|
||||||
renderApprovals(s);
|
renderApprovals(s);
|
||||||
// Auto-refresh while a spawn is in flight OR while any container
|
// Auto-refresh: fast (2s) while a spawn or a per-container
|
||||||
// has a pending lifecycle action; otherwise back off.
|
// action is in flight, otherwise heartbeat (5s) so newly-queued
|
||||||
|
// approvals from the manager show up without the operator
|
||||||
|
// having to reload the page. Broker SSE already triggers a
|
||||||
|
// refresh on operator-bound messages; this catches the rest
|
||||||
|
// (approvals, tombstones, questions).
|
||||||
const anyPending = s.containers.some((c) => c.pending);
|
const anyPending = s.containers.some((c) => c.pending);
|
||||||
const next = (s.transients.length || anyPending) ? 2000 : 0;
|
const next = (s.transients.length || anyPending) ? 2000 : 5000;
|
||||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||||
if (next) pollTimer = setTimeout(refreshState, next);
|
if (next) pollTimer = setTimeout(refreshState, next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,28 @@ impl Coordinator {
|
||||||
self.transient.lock().unwrap().clone()
|
self.transient.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a system message into the given agent's inbox. Wakes the
|
||||||
|
/// turn loop with a "you were just (re)started" hint — operator
|
||||||
|
/// caused the transition, agent picks up where it left off
|
||||||
|
/// (notes are in /state/, last turn is in --continue's session).
|
||||||
|
/// Best-effort; broker errors are logged but don't propagate.
|
||||||
|
pub fn kick_agent(&self, name: &str, reason: &str) {
|
||||||
|
let body = format!(
|
||||||
|
"{reason}\n\nYou were just (re)started by the operator. \
|
||||||
|
If you were mid-task, check `/state/` for your notes \
|
||||||
|
and pick up where you left off. claude's `--continue` \
|
||||||
|
session is intact, so prior context is still in your \
|
||||||
|
window."
|
||||||
|
);
|
||||||
|
if let Err(e) = self.broker.send(&hive_sh4re::Message {
|
||||||
|
from: hive_sh4re::SYSTEM_SENDER.to_owned(),
|
||||||
|
to: name.to_owned(),
|
||||||
|
body,
|
||||||
|
}) {
|
||||||
|
tracing::warn!(error = ?e, %name, "kick_agent: broker.send failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Push a `HelperEvent` into the manager's inbox. Encoded as JSON in
|
/// Push a `HelperEvent` into the manager's inbox. Encoded as JSON in
|
||||||
/// `Message::body`; sender = `SYSTEM_SENDER`. The manager harness
|
/// `Message::body`; sender = `SYSTEM_SENDER`. The manager harness
|
||||||
/// recognises the sender and parses the body. Best-effort: a serde or
|
/// recognises the sender and parses the body. Best-effort: a serde or
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,8 @@ async fn build_container_views(
|
||||||
if needs_update {
|
if needs_update {
|
||||||
any_stale = true;
|
any_stale = true;
|
||||||
}
|
}
|
||||||
let needs_login = !is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
|
let needs_login =
|
||||||
|
!is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
|
||||||
let pending = transient_snapshot
|
let pending = transient_snapshot
|
||||||
.get(&logical)
|
.get(&logical)
|
||||||
.map(|st| transient_label(st.kind));
|
.map(|st| transient_label(st.kind));
|
||||||
|
|
@ -497,7 +498,7 @@ async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<St
|
||||||
let rev = current_rev.clone();
|
let rev = current_rev.clone();
|
||||||
async move { crate::auto_update::rebuild_agent(&coord, &n, &rev).await }
|
async move { crate::auto_update::rebuild_agent(&coord, &n, &rev).await }
|
||||||
},
|
},
|
||||||
|_, _| {},
|
|s, n| s.coord.kick_agent(n, "container rebuilt"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -562,7 +563,7 @@ async fn post_restart(State(state): State<AppState>, AxumPath(name): AxumPath<St
|
||||||
crate::coordinator::TransientKind::Restarting,
|
crate::coordinator::TransientKind::Restarting,
|
||||||
"restart",
|
"restart",
|
||||||
|n| async move { lifecycle::restart(&n).await },
|
|n| async move { lifecycle::restart(&n).await },
|
||||||
|_, _| {},
|
|s, n| s.coord.kick_agent(n, "container restarted"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -574,7 +575,7 @@ async fn post_start(State(state): State<AppState>, AxumPath(name): AxumPath<Stri
|
||||||
crate::coordinator::TransientKind::Starting,
|
crate::coordinator::TransientKind::Starting,
|
||||||
"start",
|
"start",
|
||||||
|n| async move { lifecycle::start(&n).await },
|
|n| async move { lifecycle::start(&n).await },
|
||||||
|_, _| {},
|
|s, n| s.coord.kick_agent(n, "container started"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,10 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
match lifecycle::start(name).await {
|
match lifecycle::start(name).await {
|
||||||
Ok(()) => ManagerResponse::Ok,
|
Ok(()) => {
|
||||||
|
coord.kick_agent(name, "container started");
|
||||||
|
ManagerResponse::Ok
|
||||||
|
}
|
||||||
Err(e) => ManagerResponse::Err {
|
Err(e) => ManagerResponse::Err {
|
||||||
message: format!("{e:#}"),
|
message: format!("{e:#}"),
|
||||||
},
|
},
|
||||||
|
|
@ -176,7 +179,10 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
match lifecycle::restart(name).await {
|
match lifecycle::restart(name).await {
|
||||||
Ok(()) => ManagerResponse::Ok,
|
Ok(()) => {
|
||||||
|
coord.kick_agent(name, "container restarted");
|
||||||
|
ManagerResponse::Ok
|
||||||
|
}
|
||||||
Err(e) => ManagerResponse::Err {
|
Err(e) => ManagerResponse::Err {
|
||||||
message: format!("{e:#}"),
|
message: format!("{e:#}"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue