From cd61ea4a37cbddf0bef250644eb868ba48d43dae Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 4 Oct 2026 15:28:58 +0200 Subject: [PATCH] hive-agent: self-announce boot instead of host-side kick_agent --- docs/turn-loop/claude-invocation.md | 18 ++++++++--- hive-agent/src/boot_announce.rs | 47 +++++++++++++++++++++++++++++ hive-agent/src/main.rs | 7 +++++ hive-c0re/src/job_queue/exec.rs | 16 +++++++--- 4 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 hive-agent/src/boot_announce.rs diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index 07deba2b..38d9b4f7 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -222,11 +222,19 @@ to ack), plus an inline `({unread} more pending — drain via …)` hint when `unread > 0`. Claude drives any further `recv`/`send` itself via the embedded MCP server. -Whenever hive-c0re starts / restarts / rebuilds a container, it -also drops a `system` message into the agent's inbox via -`Coordinator::kick_agent` — a one-line "you were just (re)started, -check /state/ for your notes, your session is intact." The -next turn picks it up like any other inbox message. +`hive-agent` self-announces once on every boot: +`boot_announce::announce` pushes a one-line "you were just +(re)started, check /state/ for your notes, your session is intact" +todo the moment the in-agent todo store opens, before the serve loop +starts. This replaced `hive-c0re`'s old `Coordinator::kick_agent` +calls at the tail of the rebuild and start DAG nodes — those fired a +broker message per DAG node, so one rebuild (always a real +`StopForUpdate` → `Swap` → `RebuildBookkeeping` → `Reconcile` → +`Start` cycle) queued two separate restart messages for a single +actual boot. The harness itself is the one side that knows for +certain whether a boot happened, so it dedups for free: exactly one +todo per real process start, surfaced the normal todo-wake way +rather than as an inbox message. ## On-boot files diff --git a/hive-agent/src/boot_announce.rs b/hive-agent/src/boot_announce.rs new file mode 100644 index 00000000..65a8f9ff --- /dev/null +++ b/hive-agent/src/boot_announce.rs @@ -0,0 +1,47 @@ +//! One-shot startup self-announce: pushes a todo on every harness boot so +//! the agent gets a "you were just (re)started" notice without the host +//! having to send one. +//! +//! Replaces `hive-c0re`'s old `Coordinator::kick_agent` calls at the tail +//! of the rebuild and start DAG nodes, which an operator reported as +//! duplicate restart messages: those fired one broker message per DAG +//! node, so a single rebuild — always a real +//! `StopForUpdate` → `Swap` → `RebuildBookkeeping` → `Reconcile` → `Start` +//! cycle — queued *two* separate restart messages for one actual boot, and +//! an agent paused for days could rack up dozens more from repeated +//! host-side sweeps. The harness itself has perfect knowledge of "did a +//! boot actually happen": it only runs this once, at the top of +//! `serve_main`. Self-announcing here dedups for free — one todo per real +//! process start, no host-side bookkeeping needed. +//! +//! No first-spawn-vs-restart distinction: every boot gets the same notice +//! (the old first-spawn message didn't reliably fire anyway, so unifying +//! the two loses nothing). +//! +//! Keyless on purpose: each boot is a genuinely new occurrence, not a +//! reconciled condition like `disk_watch`'s, so there's nothing to dedup +//! *against* within one store — `Todos::upsert`'s keyless path always +//! inserts a fresh row and always reports `changed`, which is exactly +//! "wake me, this happened" with no extra bookkeeping. + +use tokio::sync::Notify; + +use crate::todos::Todos; + +/// Producing subsystem marker for the boot-announce todo row. +const SUBSYSTEM: &str = "harness"; + +/// Push the "you were just (re)started" todo and wake the turn loop. +/// Best-effort: a store error is logged, never fatal to boot. +pub fn announce(label: &str, todos: &Todos, wake: &Notify) { + let summary = format!( + "You were just (re)started. If you were mid-task, check \ + `/agents/{label}/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." + ); + match todos.upsert(SUBSYSTEM, None, &summary, None, false) { + Ok(_) => wake.notify_one(), + Err(e) => tracing::warn!(error = ?e, "boot_announce: todo upsert failed"), + } +} diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index f51552bb..c749f43c 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -9,6 +9,7 @@ //! Single bin crate: the module tree below (formerly this crate's `lib.rs`, //! before lib + bin were collapsed into one) plus the serve loop. +mod boot_announce; mod claude_md_watch; mod claude_usage_watch; mod db_migrate; @@ -613,6 +614,12 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { }; tokio::spawn(reminder_timer::run(reminder_store.clone(), local_msg_tx)); let (todo_wake, todos_store) = spawn_todo_socket(reminder_store.clone(), &bus); + // Self-announce this boot as a todo instead of relying on hive-c0re's + // old `kick_agent` broker messages — see `boot_announce`'s module doc + // for why this is strictly better than a host-side kick. + if let Some(store) = &todos_store { + boot_announce::announce(&label, store, &todo_wake); + } if matches!(initial, LoginState::NeedsLogin) { login::wait_for_login( &claude_dir, diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index c00a4016..9749d7d3 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -513,10 +513,16 @@ async fn run_rebuild_bookkeeping(coord: &Arc, name: &str) -> Result // access all recover without a hive-c0re restart. (No matrix step: the // swarm mints an agent's matrix account, not this hive.) crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await; - // Wake the agent on its next turn so claude sees a "you were rebuilt" - // hint; rescan so dashboards drop the "needs update" chip; lock bump → + // No `kick_agent` here any more — reporting from `hive-agent`'s own + // boot instead avoids the duplicate-restart-message problem this used + // to cause: the container is still stopped at this point (`swap_update` requires it stopped and + // never restarts it — the DAG's tail `Reconcile`/`Start` does), so a + // host-side "you were rebuilt" broker message would just queue + // alongside `run_start`'s own kick for the same boot. `hive-agent` now + // self-announces once on its own startup instead — one message per + // actual boot, not one per DAG node that happens to run during it. + // Rescan so dashboards drop the "needs update" chip; lock bump → // meta-inputs re-render. - coord.kick_agent(name, "container rebuilt"); coord.rescan_containers_and_emit().await; crate::dashboard::emit_meta_inputs_snapshot(coord); Ok(()) @@ -674,7 +680,9 @@ async fn run_start(coord: &Arc, name: &str) -> Result<()> { // (no background poll) — c0re owns the listener lifecycle, so // register here rather than waiting for a sweep. coord.register_agent(name)?; - coord.kick_agent(name, "container started"); + // No `kick_agent` here any more — `hive-agent` pushes its own "you + // were just (re)started" todo on boot now, so this node doesn't need + // to tell it. See the matching comment in `run_rebuild_bookkeeping`. coord.rescan_containers_and_emit().await; Ok(()) }