hive-claude 0.1.4 (hive-claude #28) sends the idle watchdog's signal as SIGINT to claude's process group instead of SIGKILL. It then waits for claude to exit, with no SIGKILL fallback. claude stops the commands its Bash tool started when it shuts down on SIGINT, and a SIGKILL left them running. This is the last SIGKILL of a claude process from #4999. hive-agent's `idle_timeout` -> `Error::IdleTimeout` -> `TurnError::ApiStall` path is unchanged. The difference is that the error now arrives only once claude has exited. A claude that ignores the SIGINT keeps the turn open until it is killed by hand or the unit stops. The Cargo.lock change is only hive-claude's version and checksum. A plain `cargo update -p hive-claude --precise 0.1.4` also moved seven crates' Windows-only `windows-sys` edges from 0.61.2 to 0.52.0, unrelated to this bump. The minimal lock passes `--locked`. The turn-loop and web-UI docs, and hive-agent's watchdog docs and stall note, now say SIGINT instead of "killed". That includes the ACP spots that the previous commit's `Drop` change made stale.
13 KiB
Turn loop + MCP
How the harness wakes up, what it asks the agent's runtime to do, and what tools the agent has access to in return.
Runtimes
Every turn runs through hive-runtime, on the runtime
services.hyperhive.agent.runtime selects:
claude(default) — oneclaude --printprocess per turn. → claude-invocation.mdacp— any Agent Client Protocol agent, run as one long-lived child of the harness. →hive-runtime/README.md
Both report the turn in claude's stream-json shape, so the loop below is
the same for either.
The loop
Each agent harness (hive-agent — one serve-loop binary for all
agents) runs:
- Check the pause marker (
<harness>/paused). While it exists the loop does nothing but re-stat it every 5 s — no broker poll, no claude process. Because step 1 is never reached, messages stay queued and unacked, so a resume drains the backlog instead of losing it; reminders and todo wakes buffer in their channels. Set it withhivectl agent <name> pauseor the dashboard toggle; see persistence. - Long-poll
Recvon its socket. The host-side broker (broker.rs::recv_blocking_batch) returns immediately if there's a pending message, otherwise waits up to 30 s for a brokerSentevent for this recipient. - Pop one message. Peek the remaining inbox depth with
Status. - Emit
LiveEvent::TurnStart { from, body, unread }onto the SSE bus. - Hand the wake prompt to the runtime: on
claude, spawnclaude --printand pipe it over stdin; onacp, send it as a prompt on the agent's session. - Stream the turn's
stream-jsonlines into the bus asLiveEvent::Stream(value). Pump stderr asNote. - Wait for the turn to end and classify its outcome from the stream + result — success, compaction, rate-limit, auth-failure, stall, or hard failure. The outcome drives the post-turn action (see Turn outcomes); the session handles compaction internally (see Compaction). This page describes rate-limit and auth-failure detection below.
- Emit
LiveEvent::TurnEnd { ok, note }. Sleeppoll_msto avoid tight loops on transient failures.
Failure detection and login (claude)
- Rate limit — a
429/rate_limitmarker on stderr, or a parsed{"type":"error"}rate-limit event on stdout (conversation-text mentions don't count), sets therate_limitedsentinel, parks forHIVE_RATE_LIMIT_SLEEP_SECS(default 300), then retries. The UI shows a⊘ rate limitedbadge while parked. - Auth failure (401) —
drive_turnretries once (transient token-refresh races clear on retry); a secondAuthFailedwrites{state_dir}/hyperhive-needs-login, requeues the message, and parks inwait_for_login— the same path as a cold boot with no session. The operator re-auths via the per-agent web UI; the queued message then drives the next turn. - Login detection — both boot (
login::has_session, Online vs NeedsLogin) andwait_for_login's resume check key off the credential files inlogin::CRED_FILE_NAMES(the set/logoutdeletes).wait_for_logintakes asince: SystemTimebaseline (the instant of the 401 that parked it, orlogin::NO_PRIOR_FAILUREat cold boot) and resumes only once a credential file's mtime postdates it — so stale credentials already on disk at the 401 don't trigger an instant false-resume, and a login that lands beforewait_for_logineven starts polling still resumes correctly (baselining on a fixed instant rather than an entry-time directory snapshot is what closes that race). Leftover session-history files still don't read as a live session after a logout + container recreate (has_session/is_cred_filescope toCRED_FILE_NAMESeither way).
Harness binary shape
Two sibling binaries over one runtime library, all role-agnostic (there
is one role: agent — the privilege boundary lives server-side at the
broker socket (/run/hive/mcp.sock), which refuses privileged Request
variants regardless of who sends them):
hive-agent— long-running harness loop (the inbox poll + runtime turn + ack/requeue cycle described above).hive-runtime— the library every turn runs through, oneRuntimeimpl per runtime (ClaudeRuntime,AcpRuntime);hive-subagent-mcpdrives its nested sessions through it too.hive-agent-mcp— MCP server for the built-inhyperhivesurface. Run with--http <addr>as a persistent streamable-HTTP daemon (thehive-mcp-httpsystemd unit, onservices.hyperhive.agent.mcp.httpPort, default 8790); claude connects to its URL via--mcp-config. HTTP is the sole transport — no per-turn stdio child (eliminates the re-registration race).
A small Surface trait, with one zero-sized impl, factors
hive-agent's wire types (hive_core_agent_sock::{Request, Response} —
one unified enum shared by the agent and manager sockets) and its turn
loop, so the loop itself has no per-role branches. See
hive-agent/src/main.rs's module
doc for the trait shape.
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/process/conventions.md::Hive identity for the env stack),
opens turn-stats sqlite, prepares the on-boot files (see
claude-invocation),
installs claude plugins, spawns web_ui::serve + vacuum::run,
and either drops into serve_loop directly (Online) or parks on
the login flow first (NeedsLogin). hive-forge-notify polls forge
notifications on its own, not this loop — see
forge.md.
Boot also opens the todos store and the socket in-container producers
dial. Matrix / bash / forge-notify daemons and the in-process
disk_watch todo producer (low state-disk space) are the built-in
producers, but the socket accepts any subsystem marker — a
user-configured MCP server can push its own todos the same way. See
docs/agent-lifecycle/persistence.md for
what each built-in todo producer watches and how the store + get_loose_ends
merge work.
Plugin install failures aren't fatal: each entry comes back as a
human-readable failure string that gets routed via
Surface::send_to_operator to the operator.
Turn outcomes
turn::TurnOutcome (Result<bool, TurnError> — Ok(compacted) on success,
else a TurnError) drives the post-claude branch:
| Outcome | Action |
|---|---|
Ok(_) (false normal / true compacted) |
ack_turn |
Err(PromptTooLong) |
drive_turn archived the session (the lib already compacted + retried and it still overflowed); requeue inflight so the message redelivers into a fresh session that fits — no status park |
Err(RateLimited) |
sleep HIVE_RATE_LIMIT_SLEEP_SECS (default 300), requeue inflight, status back to online |
Err(AuthFailed) |
emit needs_login_idle sentinel, requeue inflight, park in wait_for_login |
Err(SessionNotFound) |
resume + create self-heal both missed ("shouldn't happen"); requeue inflight so the next turn creates fresh — no status park, message not dropped |
Err(ApiStall) |
idle watchdog sent claude SIGINT after HIVE_TURN_IDLE_SECS (default 600) of output silence and claude exited; sleep HIVE_STALL_SLEEP_SECS (default 60), requeue inflight, status back to online |
Err(AgentStall(note)) |
the same, for an ACP agent: after HIVE_TURN_IDLE_SECS with no session/update the harness sent the agent session/cancel, and SIGINT if it ignored that; note says which |
Err(Failed(err)) |
route [system] \` claude turn failed:\ntooperatorviasend_to_operator` |
ApiStall catches an Anthropic API stall — a multi-retry connection storm where
the stream goes silent for minutes. On claude the idle watchdog lives in
hive-claude's driver (Config::idle_timeout, enforced around child.wait()): the timer
resets on every stdout line, so a large but still-streaming turn is never cut —
only complete output silence for the window trips it. The harness sets the
window from HIVE_TURN_IDLE_SECS (0 disables) and maps the driver's
Error::IdleTimeout onto TurnError::ApiStall. The watchdog sends claude's
process group SIGINT, so claude stops the commands it started, and then
waits for claude to exit. Nothing escalates to SIGKILL: a claude that
ignores the SIGINT keeps the turn open until someone kills it or the unit
stops.
hive-runtime applies the same window to an ACP agent's turns. That's
also how a provider HTTP 429 ends an ACP turn when the agent retries it without
reporting it: the turn goes silent until the watchdog stops it.
After the outcome handler, the stats sink records a row. handle_turn
reports the result to serve_loop via TurnControl { auth_failed } —
on auth failure the loop parks in wait_for_login; otherwise it loops
straight back to the idle wait (step 1). No same-turn
self-continue mechanism exists: every multi-step continuation rides an
external wake instead — a new inbox message, a remind, or an
in-container todo wake (bash-task completion, forge notification,
matrix activity). Ending the turn and letting one of
those drive the next one is strictly better than parking in-process:
it checkpoints the session and observes wakes that only reach the
harness between turns.
Harness systemd unit shape
One harness serve binary (hive-agent, with its hive-agent-mcp
sibling), one shared nix/agent-modules/ tree, one service unit
(systemd.services.hive-agent) for all agents. Privilege
differences live server-side in the broker socket (which tool groups
and manager-surface calls each agent receives).
agent.nix and ruth.nix both import the shared nix/agent-modules/.
ruth.nix additionally defaults
services.hyperhive.agent.docs.enable to true, so the root/manager
gets the hyperhive reference docs readable at $HIVE_DOCS_DIR by
default; other agents opt in per agent.nix (see
config.md, "Reference docs").
Environment variables set on the unit
HOME = /home/<userName>— systemd defaultsHOMEto/for services withoutUser=set; with the per-agent user the harness needs the right home so claude finds its bind-mounted~/.claude/session dir.HIVE_STATIC_DIR = <mergedDist>—tower_http::ServeDirroot for the per-agent web UI; merged dist = agent default + everyservices.hyperhive.agent.frontend.extraFilesoverlay.HIVE_ASSETS_DIR = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive"(nix/agent-modules/agent-service.nix:480;packages.assetsresolves to thehyperhive-assetsderivation) — set directly on the unit, not viaenvironment.variables, because the latter only populates/etc/profilewhich systemd services don't inherit.
PATH setup (the wrapper-dir trick)
path = [ "/run/wrappers" "/run/current-system/sw" ];
/run/wrappers (not /run/wrappers/bin) comes first so setuid
wrappers — notably sudo — resolve before bare nix-store binaries; see
docs/process/gotchas.md ("systemd.services.*.path appends
/bin to every entry") for why the trailing /bin matters in
general. It's load-bearing here because the harness runs as the
per-agent user: without the wrapper dir on PATH, sudo resolves to
the non-setuid nix-store binary and every
services.hyperhive.agent.user.passwordlessSudo grant fails with "must be owned by
uid 0 and have the setuid bit set."
serviceConfig highlights
ExecStart = "${config.services.hyperhive.agent.packages.hive-agent}/bin/${binary}"(nix/agent-modules/agent-service.nix:535);packages.hive-agentis wired to the flake'shyperhive.packages.<system>.hive-agentoutput (nix/agent-modules/packages.nix:8-22) — same binary for every agent.RuntimeDirectory = "hive-config"→/run/hive-config/owned byUser=, autocleared on stop. The harness writes regeneratedclaude-{mcp-config,settings,system-prompt}files there (paths::config_dir). Deliberately separate from/run/hive, which the host bind-mounts in root-owned and which holds hive-c0re'smcp.sock.User = Group = userName— drops root inside the container; sudo is the explicit escalation surface (services.hyperhive.agent.user.passwordlessSudo).
Sub-pages
The rest lives alongside this page, in three topic files:
- claude-invocation.md — how the harness
spawns
claude --printeach turn, the two-pronged compaction (reactive + proactive), and the on-boot files it materialises (--mcp-config,--system-prompt-file). - config.md — the optional per-agent knobs the meta flake wires in (reference docs, icon, passwordless sudo, dashboard links, custom static files, connectivity overrides, claude plugins, cargo message filtering).
- mcp.md — the MCP tool surface claude sees: core tools, privileged tool groups, self-wake, authoritative state, the tool envelope, and the built-in tool allowlist.
Per-subsystem impl detail lives in each module's //! doc-comment; these pages
describe present-state behaviour + wiring, not line-level mechanics.