hive-ag3nt + docs: extract harness binary shape prose (#716 batch 5)

`bin/hive.rs` carries ~15 attribution cookies (#598, #676, #692,
#693, #778, #788, #682, #688) plus structural prose about the
single-binary architecture (role-from-env), the `Surface` trait
pattern (zero-sized type tags + generic dispatch), boot wiring
(label fallback, plugin install → send_to_parent failure routing),
and turn-outcome branching (ack / requeue / wait_for_login / parent
notify).

Migrated to a new `docs/turn-loop.md::Harness binary shape`
subsection covering:

- Single-binary rationale (server-side privilege boundary on the
  broker socket means no escalation risk in shipping both wire
  surfaces in the same process).
- Three subcommands (`serve` / `mcp` / `wake`).
- Surface trait + zero-sized type tags (AgentSurface,
  ManagerSurface) — picks per-role FLAVOR / FORGE_IS_MANAGER and
  exposes the broker wire ops behind trait methods so the turn
  loop is written once.
- Boot wiring (HIVE_PORT, HIVE_LABEL fallback, plugin failures
  routed via send_to_parent, web_ui + forge_notify spawn).
- Turn outcomes table (Ok/Compacted/RateLimited/AuthFailed/Failed)
  + continue-sentinel pickup.

In-code rustdocs reduced to 1-line summaries + doc pointers;
inline cookies stripped from boot wiring + plugin install +
sentinel cleanup paths.

15 → 0 cookies in bin/hive.rs; cargo check -p hive-ag3nt passes.
This commit is contained in:
iris 2026-05-31 17:15:05 +02:00 committed by mara
commit 94c110fd5f
2 changed files with 114 additions and 53 deletions

View file

@ -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 <name> --body <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::<AgentSurface>` vs
`serve_main::<ManagerSurface>` 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 `<parent>` 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] \`<qualified-label>\` claude turn failed:\n<err>` to `<parent>` 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
```

View file

@ -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::<AgentSurface>` and
//! `serve_main::<ManagerSurface>` 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::<AgentSurface>(&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<Output = Result<()>>;
}
// ---------- 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<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
let port = std::env::var("HIVE_PORT")
.ok()
@ -553,10 +535,8 @@ async fn serve_main<S: Surface>(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<S: Surface>(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<String> and we route each through `<parent>`
// 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<String> and we route each through `<parent>` via the same
// `send_to_parent` failure-notify path the turn loop uses. The
// broker resolves `<parent>` 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<S: Surface>(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::<S>(