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:
parent
2ce8bb5b77
commit
94c110fd5f
2 changed files with 114 additions and 53 deletions
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
Loading…
Reference in a new issue