diff --git a/docs/approvals.md b/docs/approvals.md index ccb8458f..bdf8a325 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -132,8 +132,8 @@ without it has nothing of its own to withdraw. `InitConfig` approvals seed a brand-new agent's proposed config repo. On approve, hive-c0re seeds it with a default `agent.nix` template and -pushes a todo (`push_todo_submitter`) into the submitting agent's -in-container store. The submitting agent reviews, edits, and +sends `HelperEvent::ConfigReady { agent }` to the submitting agent's +inbox via `notify_submitter`. The submitting agent reviews, edits, and commits the template; the operator then **spawns** the agent (the `Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the container from that config. From then on, config changes go through a @@ -626,6 +626,12 @@ root agent. Variants (`hive_sh4re::HelperEvent`): - `ApprovalResolved { id, agent, commit_ref, status, note }` — fired by `actions::approve` + `actions::deny` whenever an approval transitions to its terminal state. +- `Rebuilt { agent, ok, note }` — `auto_update::rebuild_agent` + (covers startup scan + manual `/rebuild` from dashboard) + + the `MergeConfigPr` deploy. +- `Killed { agent }` — admin `HostRequest::Kill` + dashboard + `/kill` + the `Kill` MCP tool. +- `Destroyed { agent }` — `actions::destroy`. - `ContainerCrash { agent, note }` — `crash_watch`: a previously- running container went away with no operator-initiated transient state (Stopping / Restarting / Destroying / Rebuilding) AND no @@ -634,6 +640,16 @@ root agent. Variants (`hive_sh4re::HelperEvent`): lifecycle op finishes between two crash-watch polls and the container shows briefly as "stopped without transient" before the next start). The root agent can `start` it again or escalate. +- `NeedsLogin { agent }` — sub-agent has no claude session yet. + The root agent can't act directly (interactive OAuth); typically flags + the operator. +- `LoggedIn { agent }` — sub-agent just completed login. The root agent + often greets the agent on this event. +- `ConfigReady { agent }` — a new agent's proposed config repo was + just seeded (post-`InitConfig` approval). The root agent can now + edit `/agents//config/agent.nix`, commit the changes, and + hand off to the operator to **spawn** the agent (the `Spawn` + approval creates the container from that config). - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is stale. The root agent calls `update(name)` to rebuild — idempotent, no approval required. @@ -646,33 +662,20 @@ root agent. Variants (`hive_sh4re::HelperEvent`): The recipient responds via `Answer { id, answer }` and the asker sees the matching `QuestionAnswered`. -The rest of the original lifecycle notices — `Rebuilt`, `Killed`, -`Destroyed`, `NeedsLogin`, `LoggedIn`, `ConfigReady` — were pure "FYI, -check when convenient" events with no reason to drive an immediate -turn, so they've been migrated off `HelperEvent` onto -`push_todo`/`push_todo_submitter` (see above): `agent_todo_socket` -push instead of a broker message, `subsystem = "core"`, -`key = ":"` for dedup, one free-text `summary` in place -of the old structured fields (`rebuilt_todo_summary` renders -`Rebuilt`'s former `ok`/`note`/`sha`/`tag` into that string). - -Optional `sha` field on `ApprovalResolved` carries the canonical -hive-c0re-vouched commit sha. Optional `tag` carries the deploy -bookkeeping tag — `deployed/` on a successful build or -`failed/` on a failed one, planted by the `MergeConfigPr` deploy. -Both fields are `Option`: `None` on the paths that don't deploy a new -commit (spawn / init_config / meta-update / deny, and +Optional `sha` field on `ApprovalResolved` and `Rebuilt` carries the +canonical hive-c0re-vouched commit sha. Optional `tag` on the same two +carries the deploy bookkeeping tag — `deployed/` on a successful +build or `failed/` on a failed one, planted by the `MergeConfigPr` +deploy. Both fields are `Option`: `None` on the paths that don't deploy +a new commit (spawn / init_config / meta-update / deny, and `auto_update::rebuild_agent` reapplying the existing main, or the dashboard `↻ R3BU1LD` button when the lock didn't move). When set, `git show ` against `/agents//applied.git` inside the bootstrap container yields the exact tree that was referenced. -To add a new lifecycle notice: if it needs to drive an immediate turn -(a live conversation like `Ask`/`Answer`, or something genuinely -urgent), add a `HelperEvent` variant + call sites + update -`prompts/system.md`'s message-event list. If it's "FYI, check when -convenient," call `push_todo`/`push_todo_submitter` directly instead — -no new wire type needed. +To add a new event: new `HelperEvent` variant + call sites + update +`prompts/system.md` (`` block, the lifecycle- +event list) so the root agent knows the new shape. ## Auto-update on startup diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 44e114dd..e545ec0b 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -15,13 +15,14 @@ Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agen Approval boundary: lifecycle ops on _existing_ direct children (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent (two-step: `request_init_config` + `request_apply_commit`) and _changing_ any agent's config (`request_apply_commit`) both go through the approval queue (requires `approvals` tool group). The operator only signs off on changes; you run the day-to-day. -Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes: +Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `config_ready`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes: +- `config_ready` — the proposed config repo for a new agent was just seeded (post-`InitConfig` approval). Review and edit `/agents//config/agent.nix`, commit your changes, then call `request_apply_commit` with the commit sha. +- `needs_login` — agent has no claude session yet. Flag the operator if it's been long. +- `logged_in` — agent just completed login; first useful turn is imminent. - `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval. - `container_crash` — restart with `start(name)`. If it crashes again, ask the operator. -- otherwise pick up answers to questions you asked. - -Lifecycle notices that don't need an immediate turn — a new agent spawned, its config repo seeded, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them. +- otherwise greet freshly-spawned agents, retry failed rebuilds, pick up answers to questions you asked. Durable knowledge: diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 602b3990..c647d42d 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -739,18 +739,12 @@ async fn finish_approval( match approval.kind { ApprovalKind::InitConfig => { if ok { - coord - .push_todo_submitter( - approval.id, - "core", - Some(format!("config_ready:{}", approval.agent)), - format!( - "agent '{}' config repo ready — edit + apply", - approval.agent - ), - None, - ) - .await; + coord.notify_submitter( + approval.id, + &HelperEvent::ConfigReady { + agent: approval.agent.to_string(), + }, + ); } } ApprovalKind::Spawn => { @@ -777,22 +771,16 @@ async fn finish_approval( // lifecycle event. (It is never a first spawn — the agent already // exists — so it never needs the Spawned arm above.) ApprovalKind::MergeConfigPr => { - let summary = crate::coordinator::rebuilt_todo_summary( - approval.agent.as_str(), - ok, - note.as_deref(), - approval.fetched_sha.as_deref(), - terminal_tag.as_deref(), + coord.notify_submitter( + approval.id, + &HelperEvent::Rebuilt { + agent: approval.agent.to_string(), + ok, + note, + sha: approval.fetched_sha.clone(), + tag: terminal_tag, + }, ); - coord - .push_todo_submitter( - approval.id, - "core", - Some(format!("rebuilt:{}", approval.agent)), - summary, - None, - ) - .await; } // UpdateMetaInputs / SchedulePrompt: ApprovalResolved already // carries the result. No separate lifecycle event needed. @@ -963,15 +951,9 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul tracing::warn!(%name, error = ?e, "agent_power: remove on destroy failed"); } drop(guard); - coord - .push_todo( - hive_sh4re::MANAGER_AGENT, - "core", - Some(format!("destroyed:{name}")), - format!("agent '{name}' destroyed"), - None, - ) - .await; + coord.notify_manager(&HelperEvent::Destroyed { + agent: name.to_owned(), + }); // Container row disappeared — rescan so the dashboard fires // `ContainerRemoved` for the gone row, then emit the // tombstones snapshot (gained one on destroy, lost one on diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 80686842..67446c2a 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -1613,82 +1613,3 @@ impl Coordinator { .collect() } } - -/// `push_todo`/`push_todo_submitter` summary text for a rebuild outcome — -/// shared by the two `Rebuilt` call sites (`actions::finish_approval`'s -/// `MergeConfigPr` arm, `job_queue::exec::run_emit_rebuilt`) so the wording -/// stays identical regardless of which path fired. Pure + independently -/// testable, unlike the old `HelperEvent::Rebuilt`'s separate `sha`/`tag` -/// fields — those become part of the summary text itself now, since a todo -/// carries one string, not a structured payload. -#[must_use] -pub fn rebuilt_todo_summary( - agent: &str, - ok: bool, - note: Option<&str>, - sha: Option<&str>, - tag: Option<&str>, -) -> String { - use std::fmt::Write as _; - - if !ok { - return format!( - "agent '{agent}' rebuild FAILED: {}", - note.unwrap_or("unknown error") - ); - } - - let mut summary = format!("agent '{agent}' rebuilt"); - if let Some(sha) = sha { - let _ = write!(summary, " @ {sha}"); - } - if let Some(tag) = tag { - let _ = write!(summary, " ({tag})"); - } - summary -} - -#[cfg(test)] -mod rebuilt_todo_summary_tests { - use super::rebuilt_todo_summary; - - #[test] - fn failure_reports_the_note() { - assert_eq!( - rebuilt_todo_summary("iris", false, Some("build failed"), None, None), - "agent 'iris' rebuild FAILED: build failed" - ); - } - - #[test] - fn failure_without_a_note_says_unknown() { - assert_eq!( - rebuilt_todo_summary("iris", false, None, None, None), - "agent 'iris' rebuild FAILED: unknown error" - ); - } - - #[test] - fn success_with_no_sha_or_tag_is_bare() { - assert_eq!( - rebuilt_todo_summary("iris", true, None, None, None), - "agent 'iris' rebuilt" - ); - } - - #[test] - fn success_with_sha_and_tag_carries_both() { - assert_eq!( - rebuilt_todo_summary("iris", true, None, Some("abc123"), Some("deployed/42")), - "agent 'iris' rebuilt @ abc123 (deployed/42)" - ); - } - - #[test] - fn success_with_sha_only() { - assert_eq!( - rebuilt_todo_summary("iris", true, None, Some("abc123"), None), - "agent 'iris' rebuilt @ abc123" - ); - } -} diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 5208a43f..d1449bdd 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -109,7 +109,7 @@ pub(super) async fn run_node( outcome, } => run_resolve_approval(coord, coord.job_queue.dag_of(id), *approval_id, *outcome).await, NodeKind::EmitRebuilt { ok, .. } => { - run_emit_rebuilt(coord, agent, coord.job_queue.dag_of(id), *ok).await; + run_emit_rebuilt(coord, agent, coord.job_queue.dag_of(id), *ok); Ok(()) } NodeKind::SetWanted { up, .. } => run_set_wanted(coord, agent, *up), @@ -144,23 +144,19 @@ async fn run_resolve_approval( Ok(()) } -/// Emit this agent's rebuild-complete todo. `ok` is not computed — it is which +/// Emit this agent's `Rebuilt` manager event. `ok` is not computed — it is which /// of the tail pair the graph let run. The failure note comes from the DAG's /// first failing node, since the branch knows *that* it failed but not *why*. -async fn run_emit_rebuilt(coord: &Arc, agent: &str, dag_id: Option, ok: bool) { - let note = (!ok) - .then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag))) - .flatten(); - let summary = crate::coordinator::rebuilt_todo_summary(agent, ok, note.as_deref(), None, None); - coord - .push_todo( - hive_sh4re::MANAGER_AGENT, - "core", - Some(format!("rebuilt:{agent}")), - summary, - None, - ) - .await; +fn run_emit_rebuilt(coord: &Arc, agent: &str, dag_id: Option, ok: bool) { + coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt { + agent: agent.to_owned(), + ok, + note: (!ok) + .then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag))) + .flatten(), + sha: None, + tag: None, + }); } /// Write the agent's durable power intent — the DAG-node form of the old @@ -429,15 +425,9 @@ async fn run_stop(coord: &Arc, name: &str) -> Result<()> { // own kind now. crate::lifecycle::kill(name).await?; coord.unregister_agent(name); - coord - .push_todo( - hive_sh4re::MANAGER_AGENT, - "core", - Some(format!("killed:{name}")), - format!("agent '{name}' killed"), - None, - ) - .await; + coord.notify_manager(&hive_sh4re::HelperEvent::Killed { + agent: name.to_owned(), + }); coord.rescan_containers_and_emit().await; Ok(()) } diff --git a/hive-c0re/src/socket_server/lifecycle_handlers.rs b/hive-c0re/src/socket_server/lifecycle_handlers.rs index 0b7449ad..92a9312f 100644 --- a/hive-c0re/src/socket_server/lifecycle_handlers.rs +++ b/hive-c0re/src/socket_server/lifecycle_handlers.rs @@ -129,15 +129,9 @@ pub(super) async fn handle_kill(coord: &Arc, agent: &str, name: &st .await; match result { Ok(()) => { - coord - .push_todo( - hive_sh4re::MANAGER_AGENT, - "core", - Some(format!("killed:{name}")), - format!("agent '{name}' killed"), - None, - ) - .await; + coord.notify_manager(&hive_sh4re::HelperEvent::Killed { + agent: name.to_owned(), + }); Response::Ok } Err(e) => Response::Err { diff --git a/hive-c0re/src/workers/crash_watch.rs b/hive-c0re/src/workers/crash_watch.rs index df41815f..73999f1c 100644 --- a/hive-c0re/src/workers/crash_watch.rs +++ b/hive-c0re/src/workers/crash_watch.rs @@ -56,8 +56,7 @@ pub fn spawn(coord: Arc) { ¤t_logged_in, &sub_agents, &prev_sub_agents, - ) - .await; + ); } // Periodic container rescan — catches state flips that // happen outside our mutation surface (operator runs @@ -123,7 +122,7 @@ fn is_deliberate_stop(active: Option, recently_cleared: Option) -> b active.unwrap_or(false) || recently_cleared.unwrap_or(false) } -async fn emit_login_transitions( +fn emit_login_transitions( coord: &Coordinator, prev: &HashSet, current: &HashSet, @@ -132,15 +131,9 @@ async fn emit_login_transitions( ) { for agent in current.difference(prev) { tracing::info!(%agent, "agent logged in"); - coord - .push_todo( - hive_sh4re::MANAGER_AGENT, - "core", - Some(format!("logged_in:{agent}")), - format!("agent '{agent}' logged in"), - None, - ) - .await; + coord.notify_manager(&hive_sh4re::HelperEvent::LoggedIn { + agent: agent.clone(), + }); } // Detect transitions into "needs login": an agent that was previously // logged-in goes unsigned (credentials deleted), OR a brand-new agent @@ -163,15 +156,9 @@ async fn emit_login_transitions( .collect(); for agent in current_needs.difference(&prev_needs) { tracing::info!(%agent, "agent needs login"); - coord - .push_todo( - hive_sh4re::MANAGER_AGENT, - "core", - Some(format!("needs_login:{agent}")), - format!("agent '{agent}' needs login"), - None, - ) - .await; + coord.notify_manager(&hive_sh4re::HelperEvent::NeedsLogin { + agent: (*agent).to_owned(), + }); } } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 6509778f..8bcc7853 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -507,6 +507,27 @@ pub enum HelperEvent { #[serde(default, skip_serializing_if = "Option::is_none")] tag: Option, }, + /// A container was rebuilt (auto-update or manual). + Rebuilt { + agent: String, + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tag: Option, + }, + /// A new agent's proposed config repo was seeded post-`InitConfig`. + ConfigReady { agent: String }, + /// A sub-agent's container was stopped (systemd unit down; state kept). + Killed { agent: String }, + /// A sub-agent's container was torn down (state dirs preserved by default). + Destroyed { agent: String }, + /// A sub-agent's container has no claude session yet. + NeedsLogin { agent: String }, + /// A sub-agent just completed claude login. + LoggedIn { agent: String }, /// A sub-agent's recorded flake rev is stale relative to hyperhive. NeedsUpdate { agent: String }, /// Container exited without an operator-initiated stop (crash).