diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 2c28d7ce..2e4f24cc 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -52,6 +52,86 @@ binary, not two) runs: 7. Emit `LiveEvent::TurnEnd { ok, note }`. Sleep `poll_ms` to avoid tight loops on transient failures. +## Harness binary shape + +One `hive` binary serves both roles. The split into +`hive-ag3nt` + `hive-m1nd` was collapsed because the privilege +boundary lives server-side at the broker socket +(`/run/hive/mcp.sock`): an agent-flavor socket refuses +`ManagerRequest` calls regardless of who sends them, so there's no +escalation risk in shipping the same code to both. `main()` reads +`$HIVE_ROLE` (set by `harness-base.nix` from `hyperhive.role`; +defaults to `"agent"` for standalone `nix run` invocations) and +dispatches. + +Three subcommands: + +- `serve` — long-running harness loop (the inbox poll + + claude-pump + ack/requeue cycle described above). +- `mcp` — stdio MCP server claude spawns via `--mcp-config` per + turn. Same binary, different mode. +- `wake --from --body ` — push a message into our own + inbox so the next turn fires with the given body. Used by + co-process daemons (matrix bridge, scraper, webhook listeners) + to nudge claude on external events. `--body -` reads from stdin. + +### `Surface` trait + zero-sized type tags + +`AgentRequest` / `AgentResponse` and `ManagerRequest` / +`ManagerResponse` are wire-disjoint, but the turn loop itself +(boot → recv → drive → ack/requeue → stats → continue-sentinel) +is identical regardless of role. `bin/hive.rs` factors that +sameness through a `Surface` trait with two zero-sized impls +(`AgentSurface`, `ManagerSurface`) wrapping: + +- Per-role MCP `Flavor` constant (picks which system-prompt block + + tool registration goes into the spawned claude). +- Per-role `forge_notify::run` flag (picks `AgentRequest::Wake` + vs `ManagerRequest::Wake` so the broker socket accepts the + push). +- One async method per wire op: `ack_turn`, `requeue_inflight`, + `inbox_unread`, `post_turn_counts`, `send_to_parent`, + `self_wake`, `recv_next`, `wake_external`. + +`main()`'s dispatch picks `serve_main::` vs +`serve_main::` and the turn logic stays in +lockstep by construction — there's no separate per-role copy of +`serve_loop` / `handle_turn` / `wake`. + +### Boot wiring + +`serve_main` reads `HIVE_PORT` (default `DEFAULT_WEB_PORT`) + +`HIVE_LABEL` (default `"hive"` for standalone runs; the meta +flake sets it unconditionally for any container-deployed agent; +see `docs/conventions.md::Hive identity` for the env stack), +opens turn-stats sqlite, prepares the on-boot files (see below), +installs claude plugins, spawns `forge_notify::run` + `web_ui::serve`, +and either drops into `serve_loop` directly (`Online`) or parks on +the login flow first (`NeedsLogin`). + +Plugin install failures are not fatal: each entry comes back as a +human-readable failure string that gets routed via +`Surface::send_to_parent` to the agent's topology parent (the +broker resolves `` per `topology::parent_of`; root agents +and the manager fall through to operator). + +### Turn outcomes + +`turn::TurnOutcome` drives the post-claude branch: + +| Outcome | Action | +| --- | --- | +| `Ok` / `Compacted` | `ack_turn` | +| `RateLimited` | sleep `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), requeue inflight, status back to `online` | +| `AuthFailed` | emit `needs_login_idle` sentinel, requeue inflight, park in `wait_for_login` | +| `Failed(err)` | route `[system] \`\` claude turn failed:\n` to `` via `send_to_parent` | + +After the outcome handler, the stats sink records a row and the +`hyperhive-continue` sentinel (dropped by the `request_next_turn` +MCP tool) is consumed if present, firing `self_wake` so the next +turn starts with `{ from: "self", body: "continue" }` even if the +inbox is empty. + ## The claude invocation ``` diff --git a/hive-ag3nt/src/bin/hive.rs b/hive-ag3nt/src/bin/hive.rs index 95a60e8c..fb3f0d1c 100644 --- a/hive-ag3nt/src/bin/hive.rs +++ b/hive-ag3nt/src/bin/hive.rs @@ -1,26 +1,11 @@ -//! Unified hyperhive harness binary (#598). +//! Unified hyperhive harness binary. Picks role from `HIVE_ROLE` +//! (`"agent"` | `"manager"`), dispatches one of three subcommands +//! (`serve` / `mcp` / `wake`), and runs the turn loop through a +//! generic `Surface` trait so both wire surfaces stay in lockstep. //! -//! Replaces the pre-#598 `hive-ag3nt` + `hive-m1nd` split. One binary, -//! picks its role at startup from `HIVE_ROLE` (set by post-#676 -//! `harness-base.nix` from `hyperhive.role` — `"agent"` or -//! `"manager"`). Both agent + manager wire surfaces live in the same -//! binary because the privilege boundary is enforced server-side at -//! the socket (`/run/hive/mcp.sock`): an agent socket refuses -//! `ManagerRequest` calls regardless of who sends them, so there's no -//! escalation risk in shipping the same code to both. -//! -//! `HIVE_ROLE` defaults to `"agent"` if unset to keep the standalone -//! `nix run .#hive-ag3nt` shape working without env plumbing. -//! -//! Post-#692 the entire turn loop (`serve_main` / `serve_loop` / -//! `handle_turn` / `wake`) is one generic implementation parameterised -//! by a `Surface` trait. Two zero-sized impls (`AgentSurface` / -//! `ManagerSurface`) wrap the per-role wire enums + boot-time defaults -//! (label fallback, MCP flavor, plugins arg, forge-notify mode); the -//! turn logic itself is written exactly once. Lets `main`'s dispatch -//! pick between `serve_main::` and -//! `serve_main::` and keep both code paths in lockstep -//! by construction. +//! Architecture (single-binary rationale, Surface-trait + zero-sized +//! type tags, boot wiring, turn-outcome branch) lives in +//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md). use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -69,9 +54,8 @@ enum Cmd { /// turn fires with the given body. Intended for extra MCP servers /// / helpers (matrix bridge, scraper, webhook listener, etc.) that /// need to nudge claude on external events. Available on both - /// agent and manager roles — closes #693, mirrors the - /// `AgentRequest::Wake` / `ManagerRequest::Wake` pair already on - /// the wire. + /// agent and manager roles; mirrors the `AgentRequest::Wake` / + /// `ManagerRequest::Wake` pair already on the wire. Wake { #[arg(long)] from: String, @@ -107,10 +91,9 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let role = resolve_role()?; - // Generic dispatch (#692): one `serve_main` / `wake` body, two - // monomorphisations driven by the `Surface` type parameter. The - // wire-type-disjoint enums live behind the trait methods; the - // turn loop itself is identical regardless of role. + // Generic dispatch: one `serve_main` / `wake` body, two + // monomorphisations driven by the `Surface` type parameter. See + // `docs/turn-loop.md::Surface trait + zero-sized type tags`. match (role, cli.cmd) { (Role::Agent, Cmd::Serve { poll_ms }) => { serve_main::(&cli.socket, poll_ms).await @@ -132,10 +115,10 @@ async fn main() -> Result<()> { // ---------- shared turn helpers ---------- /// Surface a `SYSTEM_SENDER` message in the live event bus + tracing -/// log. Was manager-only pre-#692; agents receive `QuestionAnswered`, -/// `ContainerCrash`, reparent notifications, and friends the same way -/// the manager does, so the parse + log path is identical too. Quiet -/// no-op when `from` isn't `SYSTEM_SENDER`. +/// log. Both agents and the manager receive `QuestionAnswered`, +/// `ContainerCrash`, reparent notifications, and friends; the parse +/// + log path is identical. Quiet no-op when `from` isn't +/// `SYSTEM_SENDER`. fn log_system_event(bus: &Bus, from: &str, body: &str) { if from != SYSTEM_SENDER { return; @@ -178,7 +161,7 @@ fn consume_continue_sentinel() -> bool { true } -// ---------- surface trait (#692) ---------- +// ---------- surface trait ---------- /// What a `Recv` long-poll returned. Decoupled from the per-role /// Response enum so `serve_loop` can pattern-match without seeing @@ -208,11 +191,9 @@ trait Surface { /// `is_manager` flag passed to `forge_notify::run`. Picks which /// wire enum (`AgentRequest::Wake` vs `ManagerRequest::Wake`) the /// poller uses to push notifications into the harness inbox — the - /// per-role broker socket rejects the wrong type. Not actually - /// about "mentions only" (the skip-reasons drop-list is a separate - /// nix-side option). Real fix is to lift `Surface` into the lib - /// crate and make `forge_notify::run` generic; deferred to its - /// own issue. + /// per-role broker socket rejects the wrong type. Lifting + /// `Surface` into the lib crate to make `forge_notify::run` + /// generic is deferred to its own issue. const FORGE_IS_MANAGER: bool; /// Ack the in-flight turn. Logs warnings on transport/broker @@ -259,7 +240,7 @@ trait Surface { ) -> impl Future>; } -// ---------- AgentSurface (#692) ---------- +// ---------- AgentSurface ---------- /// Zero-sized type tag for the sub-agent wire surface. /// Talks `AgentRequest` / `AgentResponse`. @@ -397,7 +378,7 @@ impl Surface for AgentSurface { } } -// ---------- ManagerSurface (#692) ---------- +// ---------- ManagerSurface ---------- /// Zero-sized type tag for the manager wire surface. /// Talks `ManagerRequest` / `ManagerResponse`. @@ -540,11 +521,12 @@ impl Surface for ManagerSurface { } } -// ---------- generic turn loop (#692) ---------- +// ---------- generic turn loop ---------- /// Per-role boot — wires up the web UI, login state, stats, plugins, /// forge notifier, and either drops into `serve_loop` directly -/// (`Online`) or parks on the login flow first (`NeedsLogin`). +/// (`Online`) or parks on the login flow first (`NeedsLogin`). See +/// `docs/turn-loop.md::Boot wiring`. async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { let port = std::env::var("HIVE_PORT") .ok() @@ -553,10 +535,8 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { // `HIVE_LABEL` is set unconditionally by the meta-flake envelope // for any container-deployed agent; the `"hive"` fallback here // covers standalone `nix run .#hive` invocations and pre-meta - // dev shells. Previously this was per-role (`"hive-ag3nt"` / - // `"hm1nd"`) — closing #788 collapsed it: there's no semantic - // reason for the fallback to differ when the env var is missing, - // and both branches are unreachable in production. + // dev shells. Role-independent: no semantic reason for the + // fallback to differ when the env var is missing. let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive".into()); let claude_dir = login::default_dir(); let initial = LoginState::from_dir(&claude_dir); @@ -572,11 +552,11 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { } let files = turn::TurnFiles::prepare(socket, &label, S::FLAVOR).await?; let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(())); - // Plugin install runs role-agnostic now (#778 v3, mara): failures - // come back as a Vec and we route each through `` - // via the same `send_to_parent` failure-notify path the turn loop - // uses. Manager failures now reach operator via root → operator - // fallback (improvement on the pre-#778 silent-drop on manager). + // Plugin install runs role-agnostic: failures come back as a + // Vec and we route each through `` via the same + // `send_to_parent` failure-notify path the turn loop uses. The + // broker resolves `` per `topology::parent_of`; root + // agents and the manager fall through to operator. for failure in plugins::install_configured(socket).await { S::send_to_parent(socket, failure).await; } @@ -597,7 +577,8 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await; } else { // Clear any stale `hyperhive-needs-login` sentinel left over - // from a prior boot (closes #682, see #688). + // from a prior boot — `online` status writes the sentinel + // cleanup in `Bus::emit_status`. bus.emit_status("online"); } serve_loop::(