refactor(#2464): rename hive-ag3nt crate to hive-agent, collapse lib into main
This commit is contained in:
parent
7b54e7aa50
commit
3f1643c594
57 changed files with 101 additions and 130 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1460,7 +1460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hive-ag3nt"
|
name = "hive-agent"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
"hive-ag3nt",
|
"hive-agent",
|
||||||
"hive-agent-mcp",
|
"hive-agent-mcp",
|
||||||
"hive-agent-wake",
|
"hive-agent-wake",
|
||||||
"hive-bash-mcp",
|
"hive-bash-mcp",
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
//! Shared in-container harness code for the sibling `hive-agent` /
|
|
||||||
//! `hive-agent-mcp` / `hive-agent-wake` binaries, which serve every
|
|
||||||
//! agent role (the manager is just an agent role, not a separate set
|
|
||||||
//! of binaries).
|
|
||||||
|
|
||||||
pub mod client;
|
|
||||||
pub mod events;
|
|
||||||
pub mod forge_notify;
|
|
||||||
pub mod harness_state;
|
|
||||||
pub mod identity;
|
|
||||||
pub mod login;
|
|
||||||
pub mod login_session;
|
|
||||||
pub mod mcp_config;
|
|
||||||
pub mod paths;
|
|
||||||
pub mod plugins;
|
|
||||||
pub mod prompt;
|
|
||||||
pub mod serve_common;
|
|
||||||
pub mod stats;
|
|
||||||
pub mod turn;
|
|
||||||
pub mod turn_stats;
|
|
||||||
pub mod vacuum;
|
|
||||||
pub mod web_ui;
|
|
||||||
|
|
||||||
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
|
|
||||||
pub const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
|
|
||||||
|
|
||||||
/// Default web UI port — used when `HIVE_PORT` env is unset.
|
|
||||||
pub const DEFAULT_WEB_PORT: u16 = 8042;
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//!
|
//!
|
||||||
//! Standalone bin crate: the MCP surface (`mcp/`) plus its small support
|
//! Standalone bin crate: the MCP surface (`mcp/`) plus its small support
|
||||||
//! modules (socket client, send allow-list, loose-end scanner, path
|
//! modules (socket client, send allow-list, loose-end scanner, path
|
||||||
//! resolver) live here rather than in the `hive-ag3nt` harness lib, so the
|
//! resolver) live here rather than in the `hive-agent` harness lib, so the
|
||||||
//! server binary doesn't link the whole turn loop.
|
//! server binary doesn't link the whole turn loop.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
//!
|
//!
|
||||||
//! Standalone bin crate: it dials the per-agent MCP socket directly and
|
//! Standalone bin crate: it dials the per-agent MCP socket directly and
|
||||||
//! carries its own copy of the retrying request client (below), so it
|
//! carries its own copy of the retrying request client (below), so it
|
||||||
//! does not link the whole `hive-ag3nt` harness lib — a helper author
|
//! does not link the whole `hive-agent` harness lib — a helper author
|
||||||
//! wiring up an `extraMcpServers` binary only needs this one small crate.
|
//! wiring up an `extraMcpServers` binary only needs this one small crate.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -66,7 +66,7 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Self-contained retrying unix-socket JSON request client. A trimmed
|
/// Self-contained retrying unix-socket JSON request client. A trimmed
|
||||||
/// copy of `hive_ag3nt::client` (no `request_retried` variant — the wake
|
/// copy of `hive_agent::client` (no `request_retried` variant — the wake
|
||||||
/// CLI never needs the retry-count) so this bin does not link the harness
|
/// CLI never needs the retry-count) so this bin does not link the harness
|
||||||
/// lib. Keeping the retry matters: the socket can be briefly absent while
|
/// lib. Keeping the retry matters: the socket can be briefly absent while
|
||||||
/// hive-c0re restarts under an operator redeploy, and a helper firing a
|
/// hive-c0re restarts under an operator redeploy, and a helper firing a
|
||||||
|
|
|
||||||
41
hive-agent/Cargo.toml
Normal file
41
hive-agent/Cargo.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
[package]
|
||||||
|
name = "hive-agent"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
hyper.workspace = true
|
||||||
|
hyper-util.workspace = true
|
||||||
|
http-body-util.workspace = true
|
||||||
|
forgejo-api.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
futures-util = "0.3"
|
||||||
|
clap.workspace = true
|
||||||
|
hive-claude.workspace = true
|
||||||
|
hive-sh4re.workspace = true
|
||||||
|
rmcp.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
|
schemars.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-stream.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
# Single harness serve-loop binary: `hive-agent` (from `src/main.rs`).
|
||||||
|
# The sibling MCP server + external wake CLI are their own bin crates
|
||||||
|
# now (`hive-agent-mcp`, `hive-agent-wake`). Privilege boundary is
|
||||||
|
# enforced server-side at the socket (tool groups / manager surface).
|
||||||
|
# See `docs/turn-loop.md::Harness binary shape`.
|
||||||
8
hive-agent/prompts/claude-settings.json
Normal file
8
hive-agent/prompts/claude-settings.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"autoCompactEnabled": false,
|
||||||
|
"autoMemoryEnabled": false,
|
||||||
|
"disableWorkflows": true,
|
||||||
|
"permissions": {
|
||||||
|
"deny": ["Task", "TodoWrite"]
|
||||||
|
}
|
||||||
|
}
|
||||||
68
hive-agent/prompts/system.md
Normal file
68
hive-agent/prompts/system.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine.
|
||||||
|
|
||||||
|
Tools (hyperhive surface):
|
||||||
|
|
||||||
|
- `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. `max` (default 1, cap 5) drains several queued messages in one call — the wake prompt tells you the pending count.
|
||||||
|
- `mcp__hyperhive__ack_until(up_to)` — bulk-mark inbox messages handled: every message with broker id `<= up_to` (ids show as `[msg #<id>]` in wake prompts and recv output) is acked in one call, pending and delivered alike. Use it to clear a backlog you've already triaged — e.g. a redelivered flood after a container restart — instead of draining it one recv at a time: note the highest `[msg #N]` you've seen, then `ack_until(up_to: N)`. Acked messages never redeliver; messages newer than `up_to` stay queued. Only affects your own inbox.
|
||||||
|
- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Use `to: "<parent>"` to address your structural parent without hardcoding their name — hive-c0re rewrites it at delivery time per `topology.json`, falling back to `operator` if you're a root agent. Use `to: "<children>"` to fan-out to every direct child of yours per `topology.json` (no-op for leaf agents). Both sentinels let the operator reparent at runtime with zero change on your side. Optional `in_reply_to: <message-id>` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through a peer agent or contact the operator directly.
|
||||||
|
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time.
|
||||||
|
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot.
|
||||||
|
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the operator calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target.
|
||||||
|
- `mcp__hyperhive__get_loose_ends(agent?)` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args to list your own threads — cheap server-side sweep useful at turn start. Pass `agent: "<name>"` to inspect a peer agent's threads. Direct child agents are always accessible. For non-children, the `query_agent_state` capability is required — without it the request is rejected with an error.
|
||||||
|
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks), `"reminder"` (hard-deleted before it fires), or `"approval"` (withdraws a pending approval you submitted that got superseded — root agent only; the server rejects this kind for all other callers). `id` from the matching `get_loose_ends` row or the original submission reply.
|
||||||
|
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your _own_ inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached.
|
||||||
|
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Single line, ≤200 chars — the dashboard renders this as a short chip, so longer multi-line text is rejected. Pass an empty string to clear. Persists across harness restarts.
|
||||||
|
- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Also returns `running: bool` (whether the container is up — when `false`, `status_text`/`status_set_at` are stale pre-stop values) and the hive + swarm display names (`hive_name`, `swarm_name`) when the operator has configured `services.hyperhive.{hiveName, swarmName}`; both lines omitted when unset. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale.
|
||||||
|
- `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args.
|
||||||
|
- `mcp__hyperhive__restart(name)` — _(requires `lifecycle` tool group)_ restart a direct child sub-agent (stop + start). The server enforces topology: the call is rejected unless `name` is a direct child of yours per `topology.json`. No approval required.
|
||||||
|
- `mcp__hyperhive__kill(name)` — _(requires `lifecycle` tool group)_ stop a direct child sub-agent (graceful). Direct children only — server enforces topology. State dir kept; recreating reuses prior config + credentials. No approval required.
|
||||||
|
- `mcp__hyperhive__start(name)` — _(requires `lifecycle` tool group)_ start a stopped direct child sub-agent. Direct children only — server enforces topology. No approval required.
|
||||||
|
- `mcp__hyperhive__update(name)` — _(requires `lifecycle` tool group)_ rebuild a direct child sub-agent: re-applies the current hyperhive flake + agent.nix and restarts it. Direct children only — server enforces topology. No approval required. Idempotent.
|
||||||
|
- `mcp__hyperhive__list_containers()` — _(requires `lifecycle` tool group)_ list all containers that are topological descendants of this agent (children + their subtrees). Returns each name with running/stopped status, ordered parents-first. Useful before kill/update/restart to check what's under you.
|
||||||
|
- `mcp__hyperhive__request_init_config(name, description?)` — _(requires `approvals` tool group)_ initialise a brand-new direct child agent's proposed config repo. Queues an `InitConfig` approval; on approval hive-c0re seeds `/agents/<name>/config/agent.nix`. `name` must be a direct child in the topology tree — server enforces. Fails if the config repo already exists (use `request_apply_commit` instead).
|
||||||
|
- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — _(requires `approvals` tool group)_ submit a config commit for a direct child agent, queued for operator approval. `agent` must be a direct child in the topology tree — server enforces. `commit_ref` must be a 7-40 char hex sha (not a branch/tag name). On approval hive-c0re rebuilds the container with the pinned commit.
|
||||||
|
- `mcp__hyperhive__request_update_meta_inputs(inputs?, description?)` — _(requires `approvals` tool group)_ queue an approval for the operator to run `nix flake update [inputs...]` on the meta flake. Pass specific input names (e.g. `["bitburner-agent"]`) or omit / pass `[]` for all inputs. Returns immediately; lock update runs on operator approval. Does NOT trigger rebuilds — call `update(name)` on affected agents after approval resolves.
|
||||||
|
- `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — _(requires `scheduling` tool group)_ queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Catch-up clamp: long downtime fires ONCE per recurring row on resume.
|
||||||
|
- `mcp__hyperhive__cancel_schedule(id, targets?)` — _(requires `scheduling` tool group)_ cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients. Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree.
|
||||||
|
- `mcp__hyperhive__fire_schedule_now(id)` — _(requires `scheduling` tool group)_ fire a scheduled prompt out of band immediately. Recurring schedules keep their cadence intact; one-shot schedules are consumed. Same authorization as `cancel_schedule`.
|
||||||
|
- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — _(requires `scheduling` tool group)_ partial-update a schedule's mutable fields. Pass only the fields you want to change. `targets_add` / `targets_remove` mutate the recipient list in the same transaction. Refuses cancelled rows. Same authorization as `cancel_schedule`.
|
||||||
|
- `mcp__hyperhive__list_schedules()` — _(requires `scheduling` tool group)_ snapshot every schedule in the queue. Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`.
|
||||||
|
- `mcp__hyperhive__get_logs(agent, lines?)` — _(requires `diagnostics` tool group)_ fetch recent journal lines for a sub-agent container. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500).
|
||||||
|
- `mcp__hyperhive__get_host_journal(unit?, container?, lines?, priority?, grep?, since?, until?)` — _(requires `read_host_journal` capability)_ fetch recent lines from the host journal. All filters optional — omit to get the last N host journal lines. `unit`: filter to a systemd unit (e.g. `hive-c0re.service`). `container`: nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. Agent containers use the `h-` prefix (e.g. `h-iris`); infrastructure containers use their full name (e.g. `hive-ci`, `hive-forge`, `hive-matrix`, `hive-gateway`). `lines`: how many lines (default 30, max 100). `priority`: minimum syslog level enum. `grep`: regex matched against log message fields (journalctl --grep). `since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). `until`: show entries on or older than this.
|
||||||
|
|
||||||
|
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — ask a peer agent with the `approvals` tool group, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
|
||||||
|
|
||||||
|
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
|
||||||
|
|
||||||
|
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`, `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/<agent>/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 greet freshly-spawned agents, retry failed rebuilds, pick up answers to questions you asked.
|
||||||
|
|
||||||
|
Durable knowledge:
|
||||||
|
|
||||||
|
- write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets.
|
||||||
|
|
||||||
|
Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts.
|
||||||
|
|
||||||
|
**Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate.
|
||||||
|
|
||||||
|
**Hive knowledge**: `/knowledge` is a read-only bind-mount (inside your container, at the literal path `/knowledge`) of the `internal/knowledge` repo on the forge — hive-wide reference docs shared across all agents: conventions, runbooks, shared notes. **Start at `/knowledge/README.md`**, its table of contents, then read whatever's relevant for context. The README also covers how to contribute a doc.
|
||||||
|
|
||||||
|
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000/<org>/<repo>.git` (credentials are pre-configured).
|
||||||
|
|
||||||
|
The `hive-forge` CLI is the supported interface to the Forgejo — issues, PRs, comments, labels, reviews, CI status, attachments, triage (`lint`). **Discover the verb list and each verb's full signature with `hive-forge --help` and `hive-forge <verb> --help`** rather than memorising them. Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. A few conventions `--help` won't surface: **never `curl` the forge** — the CLI handles auth and is the only supported path; to check a PR's CI + mergeability use `hive-forge pr-status --pr <n>` (or `--sha <commit>` for a CI-only fast path; exit code is a merge-readiness verdict), not curl. `--body-file -` reads the body from stdin, so a HEREDOC works for multi-line comments/issues: `hive-forge comment <num> --body-file - <<EOF ... EOF`. `hive-forge pr-create --title "..." --head <branch> [--push]` opens a PR and prints its URL; `--push` `git push`es the head branch first (default remote `forge`). Forge notifications are delivered via the internal message daemon (sender `forge`), not polling. A `forge` notification stays unread **on the forge** until you actually read its thread — viewing the referenced issue/PR with `hive-forge comments <n>` or `view <n>` marks that notification read (it's the forge's own read-state, not a local mirror). So when a `forge` message points you at a thread, read the thread to clear the notification instead of letting the same one linger and re-surface. (`hive-forge comment` does the opposite — it *refuses* to post to a thread with unread activity until you've read it, so read first, then comment.)
|
||||||
|
|
||||||
|
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — coordinate through shared space or a common parent.
|
||||||
|
|
||||||
|
When your inbox has a message, handle it and stop. Don't narrate intent — act.
|
||||||
|
|
||||||
|
**Turns are your checkpoint.** The harness runs one claude turn per inbox message; when you stop, it acknowledges that message and your `--continue` session is saved to disk. Ending the turn is how you commit progress — both the session and the inbox acknowledgement. If the container restarts _while a turn is still running_, the message that drove it was never acknowledged, so it gets redelivered on the next boot, prefixed `[redelivered after harness restart — may already be handled]`. A long single turn that does step after step widens the window where a restart loses work and forces that redelivery, so prefer short turns: do a unit of work, write anything durable under `/agents/{label}/state/`, and end.
|
||||||
|
|
||||||
|
**To keep working without waiting for a new message, call `request_next_turn()`** before you stop. The harness immediately starts a fresh turn with `from: "self"`, `body: "continue"` — the supported way to run multi-step work (long builds, sequential edits) as a series of checkpointed turns rather than one monolithic turn. Don't busy-wait inside a turn for a condition to resolve: end the turn and let the next wake drive the continuation — a `remind` you scheduled, an external event, a backgrounded bash task's completion, or `request_next_turn()`. (A long-poll `recv(wait_seconds: …)` blocks _within_ the current turn — it parks for new inbox messages but does not end the turn or checkpoint, so it isn't a substitute for ending the turn.)
|
||||||
167
hive-agent/src/client.rs
Normal file
167
hive-agent/src/client.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
/// Backoff schedule between attempts. Five entries → up to 5 retries on
|
||||||
|
/// top of the initial attempt; total wall-clock cap = 2+4+8+16+30 = 60s.
|
||||||
|
/// Sized to ride out a hive-c0re restart (systemd usually has the unix
|
||||||
|
/// socket back inside ~5s) without the agent-side claude session having
|
||||||
|
/// to handle the transient itself — burning tokens on a tool-error retry
|
||||||
|
/// loop is more expensive than 60s of in-harness sleep.
|
||||||
|
const RETRY_BACKOFFS_MS: &[u64] = &[2_000, 4_000, 8_000, 16_000, 30_000];
|
||||||
|
|
||||||
|
/// Transparent retry wrapper around [`request_retried`] that throws away
|
||||||
|
/// the retry count. Use this from non-tool callers (the harness serve
|
||||||
|
/// loop, web UI, CLI subcommands) where we just want the socket-restart
|
||||||
|
/// resilience without surfacing the bookkeeping.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the socket is unreachable after all retries, or if
|
||||||
|
/// serialization / deserialization of the request or response fails.
|
||||||
|
pub async fn request<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp>
|
||||||
|
where
|
||||||
|
Req: Serialize + ?Sized,
|
||||||
|
Resp: DeserializeOwned,
|
||||||
|
{
|
||||||
|
request_retried(socket, req).await.map(|(resp, _)| resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same wire shape as [`request`], but reports how many retries it took
|
||||||
|
/// past the initial attempt (0 = succeeded first try). MCP tool handlers
|
||||||
|
/// use this so they can append a one-line hint to the tool result when
|
||||||
|
/// retries happened — that way claude knows the prior socket flake
|
||||||
|
/// wasn't a content error and shouldn't trigger an LLM-level retry of
|
||||||
|
/// its own.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if all retries are exhausted, or on a fatal protocol
|
||||||
|
/// error (serialization / deserialization failure).
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `RETRY_BACKOFFS_MS.len()` does not fit in a `u32`, which
|
||||||
|
/// cannot happen with the current compile-time constant.
|
||||||
|
pub async fn request_retried<Req, Resp>(socket: &Path, req: &Req) -> Result<(Resp, u32)>
|
||||||
|
where
|
||||||
|
Req: Serialize + ?Sized,
|
||||||
|
Resp: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
let max_retries = u32::try_from(RETRY_BACKOFFS_MS.len()).unwrap();
|
||||||
|
for attempt in 0..=max_retries {
|
||||||
|
match try_once::<Req, Resp>(socket, req).await {
|
||||||
|
Ok(resp) => return Ok((resp, attempt)),
|
||||||
|
Err(RequestError::Fatal(e)) => return Err(e),
|
||||||
|
Err(RequestError::Transient(e)) => {
|
||||||
|
if attempt < max_retries {
|
||||||
|
let sleep_ms = RETRY_BACKOFFS_MS[attempt as usize];
|
||||||
|
tracing::warn!(
|
||||||
|
attempt = attempt + 1,
|
||||||
|
sleep_ms,
|
||||||
|
error = %e,
|
||||||
|
"hive socket attempt failed; retrying"
|
||||||
|
);
|
||||||
|
last_err = Some(e);
|
||||||
|
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
|
||||||
|
} else {
|
||||||
|
last_err = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reaching here means the final attempt returned `Transient`, which always
|
||||||
|
// sets `last_err` — so this is infallible.
|
||||||
|
Err(last_err.expect("a transient failure on the final attempt set last_err"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transient = connect / IO error worth a retry (server restart, broken
|
||||||
|
/// pipe). Fatal = serialization / deserialization / protocol error
|
||||||
|
/// where retrying would just repeat the same failure.
|
||||||
|
enum RequestError {
|
||||||
|
Transient(anyhow::Error),
|
||||||
|
Fatal(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_once<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp, RequestError>
|
||||||
|
where
|
||||||
|
Req: Serialize + ?Sized,
|
||||||
|
Resp: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let stream = match UnixStream::connect(socket).await {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(e) => {
|
||||||
|
// A refused or missing socket usually means hive-c0re is
|
||||||
|
// mid-restart (operator redeploy / rebuild) — the socket is
|
||||||
|
// recreated on its boot and `request_retried` rides it out. When
|
||||||
|
// the error *does* surface (retries exhausted, or a non-retried
|
||||||
|
// caller) add that context so claude reads it as a likely
|
||||||
|
// transient rather than a hard failure worth escalating.
|
||||||
|
let restarting = matches!(
|
||||||
|
e.kind(),
|
||||||
|
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||||
|
);
|
||||||
|
let mut err = anyhow::Error::new(e).context(format!("connect to {}", socket.display()));
|
||||||
|
if restarting {
|
||||||
|
err = err.context(
|
||||||
|
"hive-c0re may be restarting (e.g. an operator redeploy); \
|
||||||
|
the harness already retried ~60s before surfacing this",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(RequestError::Transient(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (read, mut write) = stream.into_split();
|
||||||
|
|
||||||
|
let mut payload = serde_json::to_string(req).map_err(|e| RequestError::Fatal(e.into()))?;
|
||||||
|
payload.push('\n');
|
||||||
|
write
|
||||||
|
.write_all(payload.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||||
|
write
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||||
|
|
||||||
|
let mut reader = BufReader::new(read);
|
||||||
|
let mut line = String::new();
|
||||||
|
let read_bytes = reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||||
|
if read_bytes == 0 || line.is_empty() {
|
||||||
|
return Err(RequestError::Transient(anyhow!(
|
||||||
|
"server closed connection without responding"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
serde_json::from_str(line.trim()).map_err(|e| RequestError::Fatal(e.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{RequestError, try_once};
|
||||||
|
|
||||||
|
/// A connect to a non-existent socket path (ENOENT → `NotFound`) is
|
||||||
|
/// classified transient AND annotated with the "hive-c0re is restarting"
|
||||||
|
/// hint, so a surfaced tool error reads as the expected transient.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_socket_connect_is_transient_with_restart_hint() {
|
||||||
|
let bogus = std::path::Path::new("/nonexistent/hive/mcp.sock");
|
||||||
|
match try_once::<(), serde_json::Value>(bogus, &()).await {
|
||||||
|
Err(RequestError::Transient(e)) => {
|
||||||
|
let msg = format!("{e:#}");
|
||||||
|
assert!(msg.contains("restarting"), "missing restart hint: {msg}");
|
||||||
|
assert!(msg.contains("connect to"), "missing connect context: {msg}");
|
||||||
|
}
|
||||||
|
Err(RequestError::Fatal(e)) => panic!("expected transient, got fatal: {e:#}"),
|
||||||
|
Ok(_) => panic!("expected connect failure to a non-existent socket"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
872
hive-agent/src/events.rs
Normal file
872
hive-agent/src/events.rs
Normal file
|
|
@ -0,0 +1,872 @@
|
||||||
|
//! Live event stream for the per-agent web UI. The harness emits one
|
||||||
|
//! `LiveEvent` per interesting thing that happens during a turn — wake-up
|
||||||
|
//! (the popped inbox message), every line claude prints on stdout
|
||||||
|
//! (parsed from `--output-format stream-json`), and the turn-end summary.
|
||||||
|
//! The web UI subscribes via SSE and renders rows live.
|
||||||
|
//!
|
||||||
|
//! Channel type is `tokio::sync::broadcast`. New subscribers see only
|
||||||
|
//! future events; the dashboard JS deals with the cold-start case by
|
||||||
|
//! showing "connecting…" until the first event arrives.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use hive_claude::TokenUsage;
|
||||||
|
use hive_sh4re::wire_time::now_unix;
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
use crate::harness_state::{
|
||||||
|
DEFAULT_EFFORT, DEFAULT_MODEL, configured_effort, configured_model, context_window_tokens,
|
||||||
|
load_effort, load_model, persist_effort, persist_model, read_harness_state,
|
||||||
|
write_harness_state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
/// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in
|
||||||
|
/// sqlite. Older rows are vacuumed on a periodic sweep.
|
||||||
|
pub const HISTORY_CAPACITY: usize = 2000;
|
||||||
|
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
|
||||||
|
/// for dev / tests; otherwise derived from the agent's harness dir.
|
||||||
|
fn events_db_path() -> PathBuf {
|
||||||
|
std::env::var_os("HYPERHIVE_EVENTS_DB").map_or_else(
|
||||||
|
|| crate::paths::harness_dir().join("hyperhive-events.sqlite"),
|
||||||
|
PathBuf::from,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Envelope carried over the broadcast channel: the `LiveEvent` itself
|
||||||
|
/// plus a monotonic per-process seq stamped by `Bus::emit`. SSE consumers
|
||||||
|
/// serialize this directly (seq becomes a sibling of the `kind` tag);
|
||||||
|
/// clients use seq to dedupe their buffered live traffic against the
|
||||||
|
/// snapshot/history responses (drop anything with `seq <= snapshot.seq`).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BusEvent {
|
||||||
|
pub seq: u64,
|
||||||
|
/// Unix seconds at emit time. Serialized as a sibling of the `kind`
|
||||||
|
/// tag so the agent terminal can render turn start/end times (and
|
||||||
|
/// turn duration) on the live stream; history rows carry the same
|
||||||
|
/// `ts` field sourced from the persisted `events.ts` column, so the
|
||||||
|
/// renderer reads `ts` identically for live + scrollback.
|
||||||
|
pub ts: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub event: LiveEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A persisted event paired with its stored unix-seconds timestamp.
|
||||||
|
/// Serializes with `ts` as a sibling of the `kind` tag — same wire shape
|
||||||
|
/// as a live [`BusEvent`] minus `seq` — so the agent terminal reads `ts`
|
||||||
|
/// identically whether an event arrives live or is replayed from history.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct StoredEvent {
|
||||||
|
pub ts: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub event: LiveEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum LiveEvent {
|
||||||
|
/// Harness popped a wake-up message and is about to invoke claude.
|
||||||
|
/// `unread` is the count of *other* messages still in the inbox at
|
||||||
|
/// that moment — surfaced as a badge in the live panel header.
|
||||||
|
TurnStart {
|
||||||
|
from: String,
|
||||||
|
body: String,
|
||||||
|
unread: u64,
|
||||||
|
},
|
||||||
|
/// One line of claude's `--output-format stream-json` stdout, parsed as
|
||||||
|
/// a generic JSON value (so we don't have to track every claude-code
|
||||||
|
/// event variant). The frontend pretty-prints by `type` field.
|
||||||
|
Stream(serde_json::Value),
|
||||||
|
/// Free-form note from the harness (e.g. "claude exited 0",
|
||||||
|
/// "stream-json parse error: ..."). Useful when stream-json itself
|
||||||
|
/// fails so the UI doesn't just go silent.
|
||||||
|
///
|
||||||
|
/// Must be a struct variant (not `Note(String)`): internally-tagged
|
||||||
|
/// enums can't flatten a tag onto a primitive newtype, and serde
|
||||||
|
/// fails serialization at runtime — silently, because the SSE
|
||||||
|
/// handler's `filter_map(... .ok()? ...)` swallows the error. From
|
||||||
|
/// 2025-08 through 2026-05 every `Note` emission was a no-op + the
|
||||||
|
/// sqlite history persisted them as the literal string `"null"`.
|
||||||
|
/// The web UI's `note` renderer already reads `ev.text`, so the
|
||||||
|
/// wire shape matches without a JS change.
|
||||||
|
Note { text: String },
|
||||||
|
/// Turn finished. `ok=false` means claude exited non-zero or the
|
||||||
|
/// harness hit a transport error.
|
||||||
|
TurnEnd { ok: bool, note: Option<String> },
|
||||||
|
/// Harness reachability flipped: `"online"` /
|
||||||
|
/// `"needs_login_idle"` / `"needs_login_in_progress"`. The web UI
|
||||||
|
/// drives the alive badge from this so the operator sees a login
|
||||||
|
/// land (or get revoked) without polling. Session detail
|
||||||
|
/// (`url`/`output`/`finished`) is still served by `/api/state`
|
||||||
|
/// during the short-lived in-progress window — the client
|
||||||
|
/// re-fetches only while that flow is active.
|
||||||
|
StatusChanged { status: String },
|
||||||
|
/// `/api/model` switched the active claude model. The web UI
|
||||||
|
/// updates the chip + the per-turn stats sink will key off this
|
||||||
|
/// to mark the boundary in its log.
|
||||||
|
ModelChanged { model: String },
|
||||||
|
/// `/api/effort` switched the active claude effort level. Applies on
|
||||||
|
/// the next session start; the web UI updates the picker chip to
|
||||||
|
/// reflect it.
|
||||||
|
EffortChanged { effort: String },
|
||||||
|
/// Token usage for the turn just ended. Carries two snapshots:
|
||||||
|
/// - `ctx` is the LAST inference's usage block (the actual context
|
||||||
|
/// window in use right now — what the operator needs to decide
|
||||||
|
/// whether to compact / reset).
|
||||||
|
/// - `cost` is the cumulative usage across every inference in the
|
||||||
|
/// turn (sum of per-call billed tokens — the cost signal). For
|
||||||
|
/// tool-heavy turns the cumulative blows past the model's window
|
||||||
|
/// because each tool call's prompt is rebilled.
|
||||||
|
TokenUsageChanged { ctx: TokenUsage, cost: TokenUsage },
|
||||||
|
/// Harness's `TurnState` transitioned (idle / thinking /
|
||||||
|
/// compacting). `since_unix` matches `Bus::state_snapshot().1`
|
||||||
|
/// so the client's elapsed-time ticker keeps progressing across
|
||||||
|
/// SSE reconnects without drift.
|
||||||
|
TurnStateChanged { state: TurnState, since_unix: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sqlite-backed event log. Wraps a `Connection` behind a `Mutex` so the
|
||||||
|
/// `Bus` (which clones cheaply) shares one writer.
|
||||||
|
struct EventStore {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventStore {
|
||||||
|
fn open(path: &Path) -> rusqlite::Result<Self> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.execute_batch(SCHEMA)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append(&self, event: &LiveEvent) -> rusqlite::Result<()> {
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let kind = match event {
|
||||||
|
LiveEvent::TurnStart { .. } => "turn_start",
|
||||||
|
LiveEvent::Stream(_) => "stream",
|
||||||
|
LiveEvent::Note { .. } => "note",
|
||||||
|
LiveEvent::TurnEnd { .. } => "turn_end",
|
||||||
|
LiveEvent::StatusChanged { .. } => "status_changed",
|
||||||
|
LiveEvent::ModelChanged { .. } => "model_changed",
|
||||||
|
LiveEvent::EffortChanged { .. } => "effort_changed",
|
||||||
|
LiveEvent::TokenUsageChanged { .. } => "token_usage_changed",
|
||||||
|
LiveEvent::TurnStateChanged { .. } => "turn_state_changed",
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_string(event).unwrap_or_else(|_| "null".into());
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO events (ts, kind, payload_json) VALUES (?1, ?2, ?3)",
|
||||||
|
params![ts, kind, payload],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch up to `limit` events with id < `before_id` (or the most recent
|
||||||
|
/// `limit` events when `before_id` is `None`). Returns
|
||||||
|
/// `(events_oldest_first, min_row_id, has_more)`.
|
||||||
|
fn page(
|
||||||
|
&self,
|
||||||
|
before_id: Option<i64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> rusqlite::Result<(Vec<StoredEvent>, Option<i64>, bool)> {
|
||||||
|
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
// Fetch one extra row so we can tell whether more exist.
|
||||||
|
let fetch = limit_i.saturating_add(1);
|
||||||
|
// `ts` is the persisted emit-time unix-seconds stamp; carried out
|
||||||
|
// alongside each event so history replay shows the same turn
|
||||||
|
// start/end times the live stream did.
|
||||||
|
let rows: Vec<(i64, StoredEvent)> = if let Some(bid) = before_id {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, ts, payload_json FROM events
|
||||||
|
WHERE id < ?1
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![bid, fetch], |row| {
|
||||||
|
let id: i64 = row.get(0)?;
|
||||||
|
let ts: i64 = row.get(1)?;
|
||||||
|
let s: String = row.get(2)?;
|
||||||
|
Ok(serde_json::from_str::<LiveEvent>(&s)
|
||||||
|
.ok()
|
||||||
|
.map(|event| (id, StoredEvent { ts, event })))
|
||||||
|
})?
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, ts, payload_json FROM events
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![fetch], |row| {
|
||||||
|
let id: i64 = row.get(0)?;
|
||||||
|
let ts: i64 = row.get(1)?;
|
||||||
|
let s: String = row.get(2)?;
|
||||||
|
Ok(serde_json::from_str::<LiveEvent>(&s)
|
||||||
|
.ok()
|
||||||
|
.map(|event| (id, StoredEvent { ts, event })))
|
||||||
|
})?
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let has_more = rows.len() > limit;
|
||||||
|
let mut rows: Vec<(i64, StoredEvent)> = rows.into_iter().take(limit).collect();
|
||||||
|
rows.reverse(); // oldest first
|
||||||
|
let min_id = rows.first().map(|(id, _)| *id);
|
||||||
|
let events = rows.into_iter().map(|(_, e)| e).collect();
|
||||||
|
Ok((events, min_id, has_more))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authoritative turn-loop state. The harness owns it; the web UI
|
||||||
|
/// reads via `/api/state` and renders. Lives alongside the bus
|
||||||
|
/// because everyone who has a `Bus` already has the right handle to
|
||||||
|
/// poke the state on transitions.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TurnState {
|
||||||
|
/// Inbox is empty / waiting on `Recv`.
|
||||||
|
Idle,
|
||||||
|
/// `claude --print` is running for a turn.
|
||||||
|
Thinking,
|
||||||
|
/// Operator-triggered `/compact` is running on the persistent
|
||||||
|
/// session.
|
||||||
|
Compacting,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Bus {
|
||||||
|
tx: Arc<broadcast::Sender<BusEvent>>,
|
||||||
|
/// Monotonic per-process counter stamped onto every `BusEvent`.
|
||||||
|
/// Persisted nowhere — a harness restart resets seq to 0; clients
|
||||||
|
/// always treat reconnect as "fresh state, fresh stream of seqs."
|
||||||
|
/// Historical events served from sqlite carry no seq (they predate
|
||||||
|
/// the live channel the seq is meant to dedupe against).
|
||||||
|
event_seq: Arc<AtomicU64>,
|
||||||
|
/// Persistent event log. `None` only if opening the sqlite db failed
|
||||||
|
/// at construction — we keep going so the harness doesn't die on a
|
||||||
|
/// missing state dir mount in dev / test scenarios.
|
||||||
|
store: Option<Arc<EventStore>>,
|
||||||
|
/// Current turn-loop state + since-when (unix seconds).
|
||||||
|
state: Arc<Mutex<(TurnState, i64)>>,
|
||||||
|
/// Model name passed to `claude --model`. Default `haiku`; the
|
||||||
|
/// operator can override at runtime via `POST /api/model`.
|
||||||
|
model: Arc<Mutex<String>>,
|
||||||
|
effort: Arc<Mutex<String>>,
|
||||||
|
/// Last-inference token usage from the most recent turn's final
|
||||||
|
/// `assistant` event. Represents the actual context window size at
|
||||||
|
/// turn-end — the number the operator watches to decide whether to
|
||||||
|
/// compact. `None` until the first turn completes.
|
||||||
|
last_ctx_usage: Arc<Mutex<Option<TokenUsage>>>,
|
||||||
|
/// Cumulative token usage from the most recent turn's `result`
|
||||||
|
/// event (sum across every inference in the turn). This is the cost
|
||||||
|
/// signal — tool-heavy turns rebill the cached prompt per call and
|
||||||
|
/// blow past the model window. `None` until the first turn completes.
|
||||||
|
last_cost_usage: Arc<Mutex<Option<TokenUsage>>>,
|
||||||
|
/// True while the harness is parked after a rate-limit response.
|
||||||
|
/// Set by `emit_status("rate_limited")`, cleared by
|
||||||
|
/// `emit_status("online")`. Also mirrored to a sentinel file at
|
||||||
|
/// `{state_dir}/hyperhive-rate-limited` so the host-side
|
||||||
|
/// `container_view` can surface the status on the dashboard without
|
||||||
|
/// a live socket call.
|
||||||
|
rate_limited: Arc<AtomicBool>,
|
||||||
|
/// One-shot: operator asked to reset the claude session (`POST
|
||||||
|
/// /api/new-session`). Consumed at the *next* turn boundary by
|
||||||
|
/// `turn::drive_turn`, which archives the current session so the turn
|
||||||
|
/// starts fresh. Deferred (not applied in the handler) so the archive
|
||||||
|
/// never races a claude process mid-write — one claude per container,
|
||||||
|
/// serialized by the serve loop, so the turn boundary is the only point
|
||||||
|
/// where no session file is open.
|
||||||
|
session_reset_pending: Arc<AtomicBool>,
|
||||||
|
/// One-shot: run `/compact` after the next turn ends. Consumed at the end
|
||||||
|
/// of the current/next turn by `turn::drive_turn`. Deferring to the turn
|
||||||
|
/// boundary keeps compaction from racing a live claude process mid-turn.
|
||||||
|
compact_pending: Arc<AtomicBool>,
|
||||||
|
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
|
||||||
|
/// bin loop after minting a session row on a fresh start; stamped onto
|
||||||
|
/// every `turn_stats` row until the next fresh session. `None` before
|
||||||
|
/// the first fresh turn or when the stats db is unavailable.
|
||||||
|
session_id: Arc<Mutex<Option<i64>>>,
|
||||||
|
/// One-shot: `run_claude` flips this true when it creates a fresh claude
|
||||||
|
/// session (`--name <title>` on a title miss). The bin loop takes-and-
|
||||||
|
/// clears it after the turn to decide whether to mint a new `sessions`
|
||||||
|
/// row. Session identity itself is handled entirely in `turn.rs` via the
|
||||||
|
/// constant title + archive — this flag is purely a stats signal.
|
||||||
|
fresh_session: Arc<AtomicBool>,
|
||||||
|
/// Per-turn tool-call counter. Reset by the bin loop between
|
||||||
|
/// turns via `take_tool_calls`. Populated by `observe_stream` as
|
||||||
|
/// the stdout pump parses each stream-json line. Powers the
|
||||||
|
/// `tool_call_count` + `tool_call_breakdown_json` columns on the
|
||||||
|
/// per-turn stats sink.
|
||||||
|
tool_calls: Arc<Mutex<std::collections::HashMap<String, u64>>>,
|
||||||
|
/// Unix timestamp of the most recent completed turn (set by
|
||||||
|
/// `record_turn_usage`). Used by the auto-reset heuristic in
|
||||||
|
/// `turn.rs` to compute how long the session has been idle and
|
||||||
|
/// whether the prompt cache has gone cold. `0` = no turn yet.
|
||||||
|
last_turn_ended_unix: Arc<AtomicI64>,
|
||||||
|
/// Per-inference context-window size as reported by the Anthropic API
|
||||||
|
/// in the stream-json `result` event (`modelUsage.*.contextWindow`).
|
||||||
|
/// Set by the stdout pump on every completed turn. Takes precedence
|
||||||
|
/// over the Nix-configured `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars
|
||||||
|
/// for compaction watermark calculations — it reflects the actual
|
||||||
|
/// limit the model enforces, which may differ from what the operator
|
||||||
|
/// configured (e.g. 200 k active window on a 1 M cache-enabled model).
|
||||||
|
api_context_window: Arc<Mutex<Option<u64>>>,
|
||||||
|
/// Resolved model id from the most recent turn's `assistant` events
|
||||||
|
/// (the API-echoed `message.model`, e.g. `claude-opus-4-8`), as
|
||||||
|
/// opposed to the requested `--model` name which may be a short
|
||||||
|
/// alias. Set once per turn by the stdout pump at result-time;
|
||||||
|
/// `None` until the first assistant event of a turn is seen. The
|
||||||
|
/// per-turn stats sink prefers this over the requested name so the
|
||||||
|
/// model-mix + cost rollup reflect the concrete version that ran.
|
||||||
|
last_resolved_model: Arc<Mutex<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bus {
|
||||||
|
/// Open the events db (path from `events_db_path()`). On failure, fall back
|
||||||
|
/// to a no-store bus — the harness still works, just without persistent history.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let path = events_db_path();
|
||||||
|
let store = match EventStore::open(&path) {
|
||||||
|
Ok(s) => Some(Arc::new(s)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, path = %path.display(), "events db open failed; running without history");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||||
|
// Priority: HIVE_DEFAULT_MODEL (from hyperhive.model in agent.nix) >
|
||||||
|
// persisted runtime override > compiled-in DEFAULT_MODEL.
|
||||||
|
// The nix config always wins on rebuild; the persisted file is kept
|
||||||
|
// for within-session tracking only (see persist_model / set_model).
|
||||||
|
let initial_model = configured_model().map_or_else(
|
||||||
|
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||||
|
str::to_owned,
|
||||||
|
);
|
||||||
|
// Effort precedence is the inverse of model: a persisted operator
|
||||||
|
// pick wins over the nix `HIVE_DEFAULT_EFFORT` baseline so the
|
||||||
|
// runtime choice survives harness restart (it only resets on
|
||||||
|
// `--purge`). nix is the default for a never-touched agent.
|
||||||
|
let initial_effort = load_effort()
|
||||||
|
.or_else(|| configured_effort().map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| DEFAULT_EFFORT.to_owned());
|
||||||
|
// Restore rate_limited (and needs_login) from the consolidated
|
||||||
|
// harness state file so the dashboard shows the correct status
|
||||||
|
// on cold load if the harness crashed while parked.
|
||||||
|
let (was_rate_limited, was_needs_login, _) = read_harness_state();
|
||||||
|
// Write the resolved active model to hyperhive-harness.json on
|
||||||
|
// startup so hive-c0re can surface the model badge without reading
|
||||||
|
// hyperhive-model directly. Written once here; updated on every
|
||||||
|
// set_model() call (runtime override) and emit_status() call.
|
||||||
|
write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model));
|
||||||
|
Self {
|
||||||
|
tx: Arc::new(tx),
|
||||||
|
event_seq: Arc::new(AtomicU64::new(0)),
|
||||||
|
store,
|
||||||
|
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
|
||||||
|
model: Arc::new(Mutex::new(initial_model)),
|
||||||
|
effort: Arc::new(Mutex::new(initial_effort)),
|
||||||
|
last_ctx_usage: Arc::new(Mutex::new(None)),
|
||||||
|
last_cost_usage: Arc::new(Mutex::new(None)),
|
||||||
|
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
||||||
|
session_reset_pending: Arc::new(AtomicBool::new(false)),
|
||||||
|
compact_pending: Arc::new(AtomicBool::new(false)),
|
||||||
|
session_id: Arc::new(Mutex::new(None)),
|
||||||
|
fresh_session: Arc::new(AtomicBool::new(false)),
|
||||||
|
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||||
|
last_turn_ended_unix: Arc::new(AtomicI64::new(0)),
|
||||||
|
api_context_window: Arc::new(Mutex::new(None)),
|
||||||
|
last_resolved_model: Arc::new(Mutex::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current high-water seq. Snapshot endpoints read this before
|
||||||
|
/// gathering state so the resulting (snapshot.seq, snapshot) pair
|
||||||
|
/// satisfies: any live event with seq > snapshot.seq is post-snapshot
|
||||||
|
/// (not yet reflected). Clients dedupe buffered SSE traffic against
|
||||||
|
/// this value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn current_seq(&self) -> u64 {
|
||||||
|
self.event_seq.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_seq(&self) -> u64 {
|
||||||
|
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a session reset (operator `POST /api/new-session`). Deferred:
|
||||||
|
/// the flag is consumed at the next turn boundary by `drive_turn`, which
|
||||||
|
/// archives the current session so no claude process is mid-write when the
|
||||||
|
/// file is renamed. Idempotent — two clicks before the next turn still
|
||||||
|
/// archive once.
|
||||||
|
pub fn request_session_reset(&self) {
|
||||||
|
self.session_reset_pending.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take + clear the session-reset one-shot. Returns true iff `drive_turn`
|
||||||
|
/// should archive the current session before this turn.
|
||||||
|
#[must_use]
|
||||||
|
pub fn take_session_reset(&self) -> bool {
|
||||||
|
self.session_reset_pending.swap(false, Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a compaction after the next turn ends (deferred to the turn
|
||||||
|
/// boundary). Idempotent.
|
||||||
|
pub fn request_compact(&self) {
|
||||||
|
self.compact_pending.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take + clear the compact one-shot. Returns true iff `drive_turn` should
|
||||||
|
/// compact at the end of this turn.
|
||||||
|
#[must_use]
|
||||||
|
pub fn take_compact(&self) -> bool {
|
||||||
|
self.compact_pending.swap(false, Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark that the current turn started a fresh claude session.
|
||||||
|
/// `run_claude` calls this when it creates a new titled session.
|
||||||
|
pub fn mark_fresh_session(&self) {
|
||||||
|
self.fresh_session.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take + clear the fresh-session one-shot. The bin loop calls this
|
||||||
|
/// after the turn to decide whether to mint a new `sessions` row.
|
||||||
|
#[must_use]
|
||||||
|
pub fn take_fresh_session(&self) -> bool {
|
||||||
|
self.fresh_session.swap(false, Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently-active session id (FK to `sessions.id`), or `None`.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn current_session_id(&self) -> Option<i64> {
|
||||||
|
*self.session_id.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the active session id after minting a `sessions` row on a
|
||||||
|
/// fresh start.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn set_session_id(&self, id: Option<i64>) {
|
||||||
|
*self.session_id.lock().unwrap() = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently-selected claude model name. Read on every turn so a
|
||||||
|
/// `/model <name>` flip takes effect on the next turn.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn model(&self) -> String {
|
||||||
|
self.model.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch the model for future turns. The current turn (if any)
|
||||||
|
/// keeps the model it was already running. Persisted to the agent's
|
||||||
|
/// state dir (`hyperhive-model`) so the override survives harness
|
||||||
|
/// restart and container rebuild (gone on `--purge`, matching
|
||||||
|
/// every other piece of agent state).
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn set_model(&self, name: impl Into<String>) {
|
||||||
|
let value: String = name.into();
|
||||||
|
self.model.lock().unwrap().clone_from(&value);
|
||||||
|
if let Err(e) = persist_model(&value) {
|
||||||
|
tracing::warn!(error = ?e, "model: persist failed");
|
||||||
|
}
|
||||||
|
// Mirror the resolved model into hyperhive-harness.json so
|
||||||
|
// hive-c0re can surface it on the dashboard badge without reading
|
||||||
|
// the hyperhive-model override file directly.
|
||||||
|
let (rate_limited, needs_login, _) = read_harness_state();
|
||||||
|
write_harness_state(rate_limited, needs_login, Some(&value));
|
||||||
|
self.emit(LiveEvent::ModelChanged { model: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently-selected claude effort level. Read at session start to
|
||||||
|
/// build the `--effort` arg.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effort(&self) -> String {
|
||||||
|
self.effort.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch the effort level for future sessions. Applies on the next
|
||||||
|
/// claude launch (no mid-session swap). Persisted to the agent's
|
||||||
|
/// state dir (`hyperhive-effort`) so the override survives harness
|
||||||
|
/// restart and container rebuild (gone on `--purge`). Callers must
|
||||||
|
/// pre-validate with [`crate::harness_state::is_valid_effort`].
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn set_effort(&self, level: impl Into<String>) {
|
||||||
|
let value: String = level.into();
|
||||||
|
self.effort.lock().unwrap().clone_from(&value);
|
||||||
|
if let Err(e) = persist_effort(&value) {
|
||||||
|
tracing::warn!(error = ?e, "effort: persist failed");
|
||||||
|
}
|
||||||
|
self.emit(LiveEvent::EffortChanged { effort: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed `last_ctx_usage` + `last_cost_usage` at startup without
|
||||||
|
/// emitting a SSE event. Used by the bin entrypoints to backfill
|
||||||
|
/// from the most recent `turn_stats` row so the per-agent web UI's
|
||||||
|
/// ctx + cost badges paint real numbers on cold load.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if an internal lock is poisoned.
|
||||||
|
pub fn seed_usage(&self, ctx: Option<TokenUsage>, cost: Option<TokenUsage>) {
|
||||||
|
if ctx.is_some() {
|
||||||
|
*self.last_ctx_usage.lock().unwrap() = ctx;
|
||||||
|
}
|
||||||
|
if cost.is_some() {
|
||||||
|
*self.last_cost_usage.lock().unwrap() = cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the just-ended turn's usage. `ctx` is the last inference's
|
||||||
|
/// usage (current context size); `cost` is the cumulative across
|
||||||
|
/// every inference in the turn (cost signal). One SSE event fires
|
||||||
|
/// per turn carrying both.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if an internal lock is poisoned.
|
||||||
|
pub fn record_turn_usage(&self, ctx: TokenUsage, cost: TokenUsage) {
|
||||||
|
*self.last_ctx_usage.lock().unwrap() = Some(ctx);
|
||||||
|
*self.last_cost_usage.lock().unwrap() = Some(cost);
|
||||||
|
self.last_turn_ended_unix
|
||||||
|
.store(now_unix(), Ordering::Relaxed);
|
||||||
|
self.emit(LiveEvent::TokenUsageChanged { ctx, cost });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix timestamp of the most recent completed turn (`record_turn_usage`
|
||||||
|
/// call), or `0` if no turn has finished yet.
|
||||||
|
#[must_use]
|
||||||
|
pub fn last_turn_ended_unix(&self) -> i64 {
|
||||||
|
self.last_turn_ended_unix.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the resolved model id observed for the just-ended turn
|
||||||
|
/// (from `assistant` events' `message.model`). `None` clears it so a
|
||||||
|
/// degenerate turn that produced no assistant event doesn't inherit a
|
||||||
|
/// stale id — the stats sink then falls back to the requested name.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn set_resolved_model(&self, model: Option<String>) {
|
||||||
|
*self.last_resolved_model.lock().unwrap() = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved model id from the most recent turn, if an `assistant`
|
||||||
|
/// event reported one. The per-turn stats sink prefers this over the
|
||||||
|
/// requested `--model` name.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn last_resolved_model(&self) -> Option<String> {
|
||||||
|
self.last_resolved_model.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the API-reported context-window size from the stream-json
|
||||||
|
/// `result` event's `modelUsage.*.contextWindow` field. Called by the
|
||||||
|
/// stdout pump once per completed turn. `0` is ignored (sentinel for
|
||||||
|
/// "not reported").
|
||||||
|
pub fn set_api_context_window(&self, window: u64) {
|
||||||
|
if window > 0 {
|
||||||
|
*self.api_context_window.lock().unwrap() = Some(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the API-reported per-inference context-window size, if the
|
||||||
|
/// harness has seen at least one completed turn for this session.
|
||||||
|
/// `None` until the first result event is processed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn api_context_window(&self) -> Option<u64> {
|
||||||
|
*self.api_context_window.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective context window for `model`: the API-reported window if a
|
||||||
|
/// turn has completed ([`api_context_window`]), else the per-model default
|
||||||
|
/// ([`context_window_tokens`]). Single accessor so the state + dashboard
|
||||||
|
/// endpoints agree by construction.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_context_window(&self, model: &str) -> u64 {
|
||||||
|
self.api_context_window()
|
||||||
|
.unwrap_or_else(|| context_window_tokens(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a stream-json value for `tool_use` blocks and bump the
|
||||||
|
/// per-turn counter for each one we find. Called by the stdout
|
||||||
|
/// pump on every parsed line. Cheap when the line isn't an
|
||||||
|
/// assistant message — the field-check short-circuits.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn observe_stream(&self, v: &serde_json::Value) {
|
||||||
|
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(content) = v
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut counts = self.tool_calls.lock().unwrap();
|
||||||
|
for block in content {
|
||||||
|
if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = block
|
||||||
|
.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("<unnamed>")
|
||||||
|
.to_owned();
|
||||||
|
*counts.entry(name).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot + clear the per-turn tool-call counter. The harness
|
||||||
|
/// calls this between turns to fold the breakdown into a
|
||||||
|
/// `turn_stats` row, then start the next turn with an empty map.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn take_tool_calls(&self) -> std::collections::HashMap<String, u64> {
|
||||||
|
std::mem::take(&mut *self.tool_calls.lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last context-size snapshot (last inference of the most recent
|
||||||
|
/// turn), or `None` if no turn has completed yet.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn last_ctx_usage(&self) -> Option<TokenUsage> {
|
||||||
|
*self.last_ctx_usage.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last cumulative cost snapshot (sum across the most recent turn's
|
||||||
|
/// inferences), or `None` if no turn has completed yet.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn last_cost_usage(&self) -> Option<TokenUsage> {
|
||||||
|
*self.last_cost_usage.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the harness's authoritative turn-loop state. Records
|
||||||
|
/// the transition time so `state_snapshot` can return a since-age.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn set_state(&self, next: TurnState) {
|
||||||
|
let since;
|
||||||
|
{
|
||||||
|
let mut guard = self.state.lock().unwrap();
|
||||||
|
if guard.0 == next {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*guard = (next, now_unix());
|
||||||
|
since = guard.1;
|
||||||
|
}
|
||||||
|
self.emit(LiveEvent::TurnStateChanged {
|
||||||
|
state: next,
|
||||||
|
since_unix: since,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a status flip (online / `needs_login_*` / `rate_limited`).
|
||||||
|
/// Called by the bin entry points + `turn::wait_for_login` + the
|
||||||
|
/// `post_login_*` handlers — every site that mutates the
|
||||||
|
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
||||||
|
/// drops its periodic /api/state poll while a turn loop is running.
|
||||||
|
///
|
||||||
|
/// `hyperhive-harness.json` persists across harness restarts so the
|
||||||
|
/// host-side dashboard can render the status without a live socket call:
|
||||||
|
/// - `"rate_limited"` sets `rate_limited: true` in the JSON.
|
||||||
|
/// - `"needs_login_idle"` sets `needs_login: true` in the JSON.
|
||||||
|
/// - `"online"` clears both fields — the agent is healthy again.
|
||||||
|
/// - Other statuses clear `rate_limited` only; `needs_login` is sticky
|
||||||
|
/// until `"online"` (re-auth completed successfully).
|
||||||
|
///
|
||||||
|
/// Writes are atomic (`.tmp` + `rename`) so hive-c0re never reads a
|
||||||
|
/// partial file during its ~10s sweep.
|
||||||
|
pub fn emit_status(&self, status: impl Into<String>) {
|
||||||
|
let status = status.into();
|
||||||
|
let new_rate_limited = status == "rate_limited";
|
||||||
|
if new_rate_limited {
|
||||||
|
self.rate_limited.store(true, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
self.rate_limited.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
// Read the current persisted needs_login so we don't flip it on
|
||||||
|
// statuses that shouldn't touch it (e.g. `needs_login_in_progress`
|
||||||
|
// is a transient mid-flow status; only `needs_login_idle` and
|
||||||
|
// `online` should change the persistent flag).
|
||||||
|
//
|
||||||
|
// Known non-atomicity: this is a read-modify-write. Two concurrent
|
||||||
|
// `emit_status` calls could clobber each other's `needs_login`
|
||||||
|
// change if they raced between the read and the write. In practice
|
||||||
|
// this is safe: the turn loop is sequential and the login flow
|
||||||
|
// (`needs_login_idle` / `online`) only fires outside of active
|
||||||
|
// turns, so the two callers never overlap. Documented rather than
|
||||||
|
// locked because adding a Mutex here would be overkill for the
|
||||||
|
// actual call pattern.
|
||||||
|
let (_, current_needs_login, _) = read_harness_state();
|
||||||
|
let new_needs_login = if status == "needs_login_idle" {
|
||||||
|
true
|
||||||
|
} else if status == "online" {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
current_needs_login
|
||||||
|
};
|
||||||
|
let current_model = self.model.lock().unwrap().clone();
|
||||||
|
write_harness_state(new_rate_limited, new_needs_login, Some(¤t_model));
|
||||||
|
self.emit(LiveEvent::StatusChanged { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true while the harness is parked after a rate-limit response.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_rate_limited(&self) -> bool {
|
||||||
|
self.rate_limited.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state + since-when (unix seconds). Snapshot copy, no lock held.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state_snapshot(&self) -> (TurnState, i64) {
|
||||||
|
*self.state.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit(&self, event: LiveEvent) {
|
||||||
|
if let Some(store) = &self.store
|
||||||
|
&& let Err(e) = store.append(&event)
|
||||||
|
{
|
||||||
|
tracing::warn!(error = ?e, "events: append failed");
|
||||||
|
}
|
||||||
|
let envelope = BusEvent {
|
||||||
|
seq: self.next_seq(),
|
||||||
|
ts: now_unix(),
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
|
||||||
|
let _ = self.tx.send(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
|
||||||
|
self.tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated history: up to `limit` events before `before_id`
|
||||||
|
/// (or the most recent `limit` when `before_id` is `None`).
|
||||||
|
/// Returns `(events_oldest_first, min_row_id, has_more)`.
|
||||||
|
/// `min_row_id` is the cursor for the next page; pass it as
|
||||||
|
/// `before_id` on the next call.
|
||||||
|
#[must_use]
|
||||||
|
pub fn history_page(
|
||||||
|
&self,
|
||||||
|
before_id: Option<i64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> (Vec<StoredEvent>, Option<i64>, bool) {
|
||||||
|
let Some(store) = &self.store else {
|
||||||
|
return (Vec::new(), None, false);
|
||||||
|
};
|
||||||
|
store.page(before_id, limit).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Bus {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{BusEvent, LiveEvent, StoredEvent};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_event_serializes_ts_beside_kind() {
|
||||||
|
// History-row wire shape: `ts` is a flattened sibling of `kind`,
|
||||||
|
// which is what the agent terminal reads to time turn boundaries.
|
||||||
|
let v = serde_json::to_value(StoredEvent {
|
||||||
|
ts: 1_700_000_000,
|
||||||
|
event: LiveEvent::Note { text: "hi".into() },
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v["ts"], 1_700_000_000_i64);
|
||||||
|
assert_eq!(v["kind"], "note");
|
||||||
|
assert_eq!(v["text"], "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bus_event_serializes_ts_and_seq_beside_kind() {
|
||||||
|
// Live SSE frame: same `ts` sibling as history (plus `seq`), so the
|
||||||
|
// renderer is path-agnostic between live + scrollback.
|
||||||
|
let v = serde_json::to_value(BusEvent {
|
||||||
|
seq: 7,
|
||||||
|
ts: 1_700_000_000,
|
||||||
|
event: LiveEvent::Note { text: "yo".into() },
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v["seq"], 7);
|
||||||
|
assert_eq!(v["ts"], 1_700_000_000_i64);
|
||||||
|
assert_eq!(v["kind"], "note");
|
||||||
|
}
|
||||||
|
}
|
||||||
1312
hive-agent/src/forge_notify.rs
Normal file
1312
hive-agent/src/forge_notify.rs
Normal file
File diff suppressed because it is too large
Load diff
332
hive-agent/src/harness_state.rs
Normal file
332
hive-agent/src/harness_state.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
//! File-backed harness state, split out of [`crate::events`] (which owns
|
||||||
|
//! the live event bus + sqlite store). This module holds the runtime
|
||||||
|
//! model/effort selection, the consolidated `hyperhive-harness.json`
|
||||||
|
//! state file, and the `forge_notify` delivery-dedupe cursor — the
|
||||||
|
//! persisted knobs the harness reads/writes across turns, none of which
|
||||||
|
//! are about the live event stream.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
|
||||||
|
/// for dev / tests; otherwise derived from the agent's harness dir.
|
||||||
|
fn model_file_path() -> PathBuf {
|
||||||
|
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|
||||||
|
|| crate::paths::harness_dir().join("hyperhive-model"),
|
||||||
|
PathBuf::from,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_model() -> Option<String> {
|
||||||
|
let s = std::fs::read_to_string(model_file_path()).ok()?;
|
||||||
|
let name = s.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn persist_model(name: &str) -> std::io::Result<()> {
|
||||||
|
let path = model_file_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
std::fs::write(path, format!("{name}\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`,
|
||||||
|
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise
|
||||||
|
/// derived from the agent's harness dir.
|
||||||
|
fn effort_file_path() -> PathBuf {
|
||||||
|
std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else(
|
||||||
|
|| crate::paths::harness_dir().join("hyperhive-effort"),
|
||||||
|
PathBuf::from,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_effort() -> Option<String> {
|
||||||
|
let s = std::fs::read_to_string(effort_file_path()).ok()?;
|
||||||
|
let level = s.trim();
|
||||||
|
if level.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(level.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
|
||||||
|
let path = effort_file_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
std::fs::write(path, format!("{level}\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Consolidated harness state file
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// `hyperhive-harness.json` replaces the two legacy boolean sentinel files
|
||||||
|
// (`hyperhive-rate-limited`, `hyperhive-needs-login`) that grew organically
|
||||||
|
// and had no shared schema. A single JSON file is self-documenting, atomic
|
||||||
|
// to write, and cheaper for hive-c0re to read on each sweep (one fopen vs
|
||||||
|
// two stat calls). See `docs/persistence.md::Harness state files`.
|
||||||
|
//
|
||||||
|
// Legacy sentinel files written by older harness builds are still honoured
|
||||||
|
// by `read_harness_state` so in-place upgrades don't lose state (the new
|
||||||
|
// harness re-normalises on first write). Old files are not deleted — they
|
||||||
|
// expire naturally when the state dir is purged. `hive-c0re::container_view`
|
||||||
|
// also checks the legacy paths as a fallback during the transition window.
|
||||||
|
|
||||||
|
const HARNESS_JSON: &str = "hyperhive-harness.json";
|
||||||
|
|
||||||
|
fn harness_json_path() -> PathBuf {
|
||||||
|
crate::paths::state_dir().join(HARNESS_JSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialises the read-modify-write of `hyperhive-harness.json`. Two
|
||||||
|
// harness tasks touch it in the same process — the turn loop (rate-limit
|
||||||
|
// / needs-login / active-model) and the forge_notify poller (the
|
||||||
|
// delivery-dedupe cursor) — writing disjoint fields, so each writer must
|
||||||
|
// preserve the other's. The lock closes the lost-update window between a
|
||||||
|
// writer's read and its rename.
|
||||||
|
static HARNESS_JSON_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
/// Read the consolidated state file as a JSON object, or an empty object
|
||||||
|
/// when it is missing / unparseable / not an object.
|
||||||
|
fn read_harness_json() -> serde_json::Value {
|
||||||
|
std::fs::read_to_string(harness_json_path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||||
|
.filter(serde_json::Value::is_object)
|
||||||
|
.unwrap_or_else(|| serde_json::json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically overwrite the state file (`.tmp` + rename) so hive-c0re
|
||||||
|
/// never reads a partial file.
|
||||||
|
fn write_harness_json(v: &serde_json::Value) {
|
||||||
|
let path = harness_json_path();
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
if std::fs::write(&tmp, v.to_string()).is_ok() {
|
||||||
|
let _ = std::fs::rename(&tmp, &path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_harness_state() -> (bool, bool, Option<String>) {
|
||||||
|
// Try the new consolidated file first.
|
||||||
|
if let Ok(raw) = std::fs::read_to_string(harness_json_path())
|
||||||
|
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
|
||||||
|
{
|
||||||
|
let rate_limited = v
|
||||||
|
.get("rate_limited")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let needs_login = v
|
||||||
|
.get("needs_login")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let active_model = v
|
||||||
|
.get("active_model")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned);
|
||||||
|
return (rate_limited, needs_login, active_model);
|
||||||
|
}
|
||||||
|
// Fall back to legacy sentinel files written by older harness builds.
|
||||||
|
let state_dir = crate::paths::state_dir();
|
||||||
|
let rate_limited = state_dir.join("hyperhive-rate-limited").exists();
|
||||||
|
let needs_login = state_dir.join("hyperhive-needs-login").exists();
|
||||||
|
(rate_limited, needs_login, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the turn-loop's harness state fields via a read-modify-write so
|
||||||
|
/// any other writer's fields (e.g. `forge_notify`'s `forge_cursor`) survive.
|
||||||
|
/// Pass `active_model: Some(s)` to update the resolved model (surfaced in
|
||||||
|
/// the dashboard badge); `None` leaves the stored value untouched.
|
||||||
|
pub(crate) fn write_harness_state(
|
||||||
|
rate_limited: bool,
|
||||||
|
needs_login: bool,
|
||||||
|
active_model: Option<&str>,
|
||||||
|
) {
|
||||||
|
let _guard = HARNESS_JSON_LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let mut v = read_harness_json();
|
||||||
|
v["rate_limited"] = rate_limited.into();
|
||||||
|
v["needs_login"] = needs_login.into();
|
||||||
|
if let Some(model) = active_model {
|
||||||
|
v["active_model"] = model.into();
|
||||||
|
}
|
||||||
|
write_harness_json(&v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `forge_notify` delivery-dedupe cursor (notification thread id
|
||||||
|
/// -> last-delivered `updated_at`) out of a harness-state JSON value.
|
||||||
|
/// Empty when the field is absent (first boot) or malformed.
|
||||||
|
fn forge_cursor_from_json(v: &serde_json::Value) -> std::collections::HashMap<u64, String> {
|
||||||
|
v.get("forge_cursor")
|
||||||
|
.cloned()
|
||||||
|
.and_then(|c| serde_json::from_value(c).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the `forge_notify` delivery-dedupe cursor from the consolidated
|
||||||
|
/// state file so a container rebuild/restart doesn't re-deliver the whole
|
||||||
|
/// currently-unread backlog.
|
||||||
|
pub fn read_forge_cursor() -> std::collections::HashMap<u64, String> {
|
||||||
|
forge_cursor_from_json(&read_harness_json())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the `forge_notify` delivery-dedupe cursor into the consolidated
|
||||||
|
/// state file, read-modify-write under the shared lock so the turn-loop's
|
||||||
|
/// own fields survive. Best-effort: a serialize failure is a no-op.
|
||||||
|
pub fn write_forge_cursor<S: std::hash::BuildHasher>(
|
||||||
|
cursor: &std::collections::HashMap<u64, String, S>,
|
||||||
|
) {
|
||||||
|
let Ok(value) = serde_json::to_value(cursor) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _guard = HARNESS_JSON_LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let mut v = read_harness_json();
|
||||||
|
v["forge_cursor"] = value;
|
||||||
|
write_harness_json(&v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
|
||||||
|
/// persisted runtime override is present.
|
||||||
|
pub const DEFAULT_MODEL: &str = "haiku";
|
||||||
|
|
||||||
|
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
|
||||||
|
/// `hyperhive.model` in `agent.nix`), or `None` if the env var is absent /
|
||||||
|
/// empty. When `Some`, this takes precedence over any persisted runtime
|
||||||
|
/// override so that nix config changes always take effect on rebuild.
|
||||||
|
#[must_use]
|
||||||
|
pub fn configured_model() -> Option<&'static str> {
|
||||||
|
// Leak once at startup — acceptable for a single config value.
|
||||||
|
std::env::var("HIVE_DEFAULT_MODEL")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|s| &*Box::leak(s.into_boxed_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compiled-in fallback effort level — matches the `effortLevel` baked
|
||||||
|
/// into `prompts/claude-settings.json`.
|
||||||
|
pub const DEFAULT_EFFORT: &str = "medium";
|
||||||
|
|
||||||
|
/// Valid claude `--effort` levels, ascending. The operator picker is
|
||||||
|
/// constrained to these; [`is_valid_effort`] guards the persist path.
|
||||||
|
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
|
/// True iff `level` is one of [`EFFORT_LEVELS`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_valid_effort(level: &str) -> bool {
|
||||||
|
EFFORT_LEVELS.contains(&level)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
|
||||||
|
/// `hyperhive.effortLevel` in `agent.nix`), or `None` if absent / empty.
|
||||||
|
/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted
|
||||||
|
/// runtime override takes precedence over this baseline (see `Bus::new`):
|
||||||
|
/// the operator's effort pick sticks across harness restart, with the nix
|
||||||
|
/// value only the default when no override was ever set.
|
||||||
|
#[must_use]
|
||||||
|
pub fn configured_effort() -> Option<&'static str> {
|
||||||
|
std::env::var("HIVE_DEFAULT_EFFORT")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|s| &*Box::leak(s.into_boxed_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context-window size in tokens for a given model name.
|
||||||
|
///
|
||||||
|
/// Canonical per-model sizes are declared in the harness nix modules as
|
||||||
|
/// `hyperhive.contextWindowTokens` and injected as
|
||||||
|
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars — so this function
|
||||||
|
/// normally just reads them. The Rust code carries no model knowledge;
|
||||||
|
/// updating model families only requires a Nix change.
|
||||||
|
///
|
||||||
|
/// Resolution order (first match wins):
|
||||||
|
/// 1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` — key (lowercased) is a
|
||||||
|
/// substring of the active model name. Populated by the Nix default
|
||||||
|
/// map for all known families; add/override in `agent.nix`.
|
||||||
|
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model).
|
||||||
|
/// 3. Hard fallback: `200_000` (conservative; only hit outside NixOS).
|
||||||
|
#[must_use]
|
||||||
|
pub fn context_window_tokens(model: &str) -> u64 {
|
||||||
|
let m = model.to_ascii_lowercase();
|
||||||
|
// Per-model env vars set by `hyperhive.contextWindowTokens` in Nix.
|
||||||
|
for (key, val) in std::env::vars() {
|
||||||
|
if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_")
|
||||||
|
&& !suffix.is_empty()
|
||||||
|
&& m.contains(&suffix.to_ascii_lowercase())
|
||||||
|
&& let Ok(v) = val.trim().parse::<u64>()
|
||||||
|
&& v > 0
|
||||||
|
{
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Global override (single value, any model).
|
||||||
|
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
|
||||||
|
&& let Ok(v) = s.trim().parse::<u64>()
|
||||||
|
&& v > 0
|
||||||
|
{
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
// Hard fallback for dev/test outside NixOS where env vars aren't set.
|
||||||
|
200_000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{DEFAULT_EFFORT, EFFORT_LEVELS, forge_cursor_from_json, is_valid_effort};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forge_cursor_absent_field_is_empty() {
|
||||||
|
// First boot / a state file that only carries the turn-loop fields:
|
||||||
|
// no cursor yet, so we re-deliver the currently-unread set once.
|
||||||
|
assert!(forge_cursor_from_json(&json!({ "rate_limited": false })).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forge_cursor_malformed_field_is_empty() {
|
||||||
|
// A wrong-typed / corrupt cursor degrades to empty rather than
|
||||||
|
// aborting the poller.
|
||||||
|
assert!(forge_cursor_from_json(&json!({ "forge_cursor": "nonsense" })).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forge_cursor_roundtrips_u64_keys() {
|
||||||
|
// serde_json stringifies integer map keys; confirm the u64 thread
|
||||||
|
// ids the cursor is keyed on survive the JSON round-trip.
|
||||||
|
let v = json!({
|
||||||
|
"forge_cursor": { "42": "2026-06-22T16:00:00Z", "99": "2026-06-22T17:30:00Z" }
|
||||||
|
});
|
||||||
|
let cursor = forge_cursor_from_json(&v);
|
||||||
|
assert_eq!(cursor.len(), 2);
|
||||||
|
assert_eq!(cursor.get(&42), Some(&"2026-06-22T16:00:00Z".to_owned()));
|
||||||
|
assert_eq!(cursor.get(&99), Some(&"2026-06-22T17:30:00Z".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effort_validation_accepts_only_known_levels() {
|
||||||
|
for level in EFFORT_LEVELS {
|
||||||
|
assert!(is_valid_effort(level), "{level} should be valid");
|
||||||
|
}
|
||||||
|
// Out-of-set, empty, and wrong-case inputs are rejected so a bad
|
||||||
|
// picker POST never reaches `claude --effort`.
|
||||||
|
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] {
|
||||||
|
assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compiled_default_effort_is_selectable() {
|
||||||
|
// The fallback must itself be a valid level, and match the value
|
||||||
|
// baked into prompts/claude-settings.json.
|
||||||
|
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
|
||||||
|
assert_eq!(DEFAULT_EFFORT, "medium");
|
||||||
|
}
|
||||||
|
}
|
||||||
255
hive-agent/src/identity.rs
Normal file
255
hive-agent/src/identity.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
//! Agent identity helpers — short label + hive-qualified long name +
|
||||||
|
//! human display names for the hive and swarm. Full env var surface +
|
||||||
|
//! domain-vs-name distinction documented in
|
||||||
|
//! `docs/conventions.md::Hive identity (label + domain + display names)`.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
/// Short, hive-local agent label. Read from `HIVE_LABEL`; falls back to an
|
||||||
|
/// empty string when the env var is missing, so callers downstream can decide
|
||||||
|
/// how to surface "unknown agent" rather than getting a panic from this
|
||||||
|
/// module.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label() -> String {
|
||||||
|
env::var("HIVE_LABEL").unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `env::var(key)` reduced to `Some(value)` only when the var is set and
|
||||||
|
/// non-empty — the shared shape of the hive/swarm display-name lookups below.
|
||||||
|
fn non_empty_env(key: &str) -> Option<String> {
|
||||||
|
env::var(key).ok().filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hive's canonical DNS domain when set, otherwise None. Single-hive
|
||||||
|
/// deployments where `HYPERHIVE_HIVE_DOMAIN` is unset return None — callers
|
||||||
|
/// then degrade gracefully to the short label.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hive_domain() -> Option<String> {
|
||||||
|
non_empty_env("HYPERHIVE_HIVE_DOMAIN")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human display name of this hive (e.g. `pr1ma`). Distinct from
|
||||||
|
/// [`hive_domain`] — the domain is the machine-readable DNS address;
|
||||||
|
/// this is the prose label humans use in conversation. Returns None
|
||||||
|
/// when the host-side `services.hyperhive.hiveName` option is unset,
|
||||||
|
/// in which case callers fall back to the domain or the short label
|
||||||
|
/// at their discretion.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hive_name() -> Option<String> {
|
||||||
|
non_empty_env("HYPERHIVE_HIVE_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human display name of the wider swarm this hive belongs to (e.g.
|
||||||
|
/// `constellat1on`). Federated hives at different DNS domains can
|
||||||
|
/// share a swarm name. Returns None when the host-side
|
||||||
|
/// `services.hyperhive.swarmName` option is unset.
|
||||||
|
#[must_use]
|
||||||
|
pub fn swarm_name() -> Option<String> {
|
||||||
|
non_empty_env("HYPERHIVE_SWARM_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hive-qualified agent identity. When the hive domain is configured, returns
|
||||||
|
/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just
|
||||||
|
/// the short label so callers can render a single string regardless of
|
||||||
|
/// deployment shape. Callers that want to know whether the result is
|
||||||
|
/// qualified should check [`hive_domain`] directly.
|
||||||
|
#[must_use]
|
||||||
|
pub fn qualified_label() -> String {
|
||||||
|
qualify(&label())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply hive qualification to an arbitrary agent label (e.g. a peer name
|
||||||
|
/// from the broker). Mirrors [`qualified_label`] but lets a caller qualify
|
||||||
|
/// names it didn't read from the env. Returns `${label}@${domain}` when the
|
||||||
|
/// hive domain is set, else just `label`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn qualify(label: &str) -> String {
|
||||||
|
match hive_domain() {
|
||||||
|
Some(domain) if !label.is_empty() => format!("{label}@{domain}"),
|
||||||
|
_ => label.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// cargo's test runner parallelises by default, so a `with_env`
|
||||||
|
/// helper that mutates process-wide env vars races between tests in
|
||||||
|
/// this module. Serialise on a module-scope mutex so each `with_env`
|
||||||
|
/// call holds the lock for its set / run / restore window. Cheap
|
||||||
|
/// (each test body is microseconds) and avoids pulling in
|
||||||
|
/// `serial_test` for just one module.
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Helper: run `f` with a clean env, restoring previous values on exit.
|
||||||
|
/// Acquires `ENV_LOCK` first so concurrent tests don't race the env-var
|
||||||
|
/// state. If a previous test panicked while holding the lock the
|
||||||
|
/// mutex would be poisoned — we use `lock().unwrap_or_else(|e| e.into_inner())`
|
||||||
|
/// to recover so a single test failure doesn't cascade through the
|
||||||
|
/// whole module.
|
||||||
|
fn with_env<F: FnOnce()>(label: Option<&str>, domain: Option<&str>, f: F) {
|
||||||
|
with_full_env(label, domain, None, None, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extended form of [`with_env`] covering the display-name env vars
|
||||||
|
/// (hive name + swarm name) alongside label + domain. Same SAFETY
|
||||||
|
/// contract — serialised on `ENV_LOCK`, restore in scope.
|
||||||
|
fn with_full_env<F: FnOnce()>(
|
||||||
|
label: Option<&str>,
|
||||||
|
domain: Option<&str>,
|
||||||
|
hive_name: Option<&str>,
|
||||||
|
swarm_name: Option<&str>,
|
||||||
|
f: F,
|
||||||
|
) {
|
||||||
|
let _guard = ENV_LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let prev_label = env::var("HIVE_LABEL").ok();
|
||||||
|
let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok();
|
||||||
|
let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok();
|
||||||
|
let prev_swarm_name = env::var("HYPERHIVE_SWARM_NAME").ok();
|
||||||
|
// SAFETY: serialised by ENV_LOCK above; restore in the same scope.
|
||||||
|
unsafe {
|
||||||
|
match label {
|
||||||
|
Some(v) => env::set_var("HIVE_LABEL", v),
|
||||||
|
None => env::remove_var("HIVE_LABEL"),
|
||||||
|
}
|
||||||
|
match domain {
|
||||||
|
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
|
||||||
|
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
|
||||||
|
}
|
||||||
|
match hive_name {
|
||||||
|
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
|
||||||
|
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
|
||||||
|
}
|
||||||
|
match swarm_name {
|
||||||
|
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
|
||||||
|
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f();
|
||||||
|
unsafe {
|
||||||
|
match prev_label {
|
||||||
|
Some(v) => env::set_var("HIVE_LABEL", v),
|
||||||
|
None => env::remove_var("HIVE_LABEL"),
|
||||||
|
}
|
||||||
|
match prev_domain {
|
||||||
|
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
|
||||||
|
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
|
||||||
|
}
|
||||||
|
match prev_hive_name {
|
||||||
|
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
|
||||||
|
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
|
||||||
|
}
|
||||||
|
match prev_swarm_name {
|
||||||
|
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
|
||||||
|
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualified_label_with_domain_set() {
|
||||||
|
with_env(Some("iris"), Some("darkest.space"), || {
|
||||||
|
assert_eq!(qualified_label(), "iris@darkest.space");
|
||||||
|
assert_eq!(hive_domain().as_deref(), Some("darkest.space"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualified_label_falls_back_to_short_when_domain_unset() {
|
||||||
|
with_env(Some("iris"), None, || {
|
||||||
|
assert_eq!(qualified_label(), "iris");
|
||||||
|
assert!(hive_domain().is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualified_label_falls_back_to_short_when_domain_empty() {
|
||||||
|
// Empty string is treated the same as unset — a misconfigured
|
||||||
|
// module shouldn't surface `iris@` (no domain) to the operator.
|
||||||
|
with_env(Some("iris"), Some(""), || {
|
||||||
|
assert_eq!(qualified_label(), "iris");
|
||||||
|
assert!(hive_domain().is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualify_takes_arbitrary_label() {
|
||||||
|
with_env(Some("iris"), Some("darkest.space"), || {
|
||||||
|
// Local label gets local hive applied; useful for rendering
|
||||||
|
// a peer's name when the caller knows it's hive-local.
|
||||||
|
assert_eq!(qualify("damocles"), "damocles@darkest.space");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualify_empty_label_stays_empty() {
|
||||||
|
with_env(Some("iris"), Some("darkest.space"), || {
|
||||||
|
assert_eq!(qualify(""), "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn label_returns_empty_when_unset() {
|
||||||
|
with_env(None, None, || {
|
||||||
|
assert_eq!(label(), "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hive_name_returns_some_when_env_set() {
|
||||||
|
with_full_env(Some("iris"), None, Some("pr1ma"), None, || {
|
||||||
|
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hive_name_returns_none_when_env_unset_or_empty() {
|
||||||
|
with_full_env(Some("iris"), None, None, None, || {
|
||||||
|
assert!(hive_name().is_none());
|
||||||
|
});
|
||||||
|
with_full_env(Some("iris"), None, Some(""), None, || {
|
||||||
|
assert!(hive_name().is_none(), "empty string treated as unset");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swarm_name_returns_some_when_env_set() {
|
||||||
|
with_full_env(Some("iris"), None, None, Some("constellat1on"), || {
|
||||||
|
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swarm_name_returns_none_when_env_unset_or_empty() {
|
||||||
|
with_full_env(Some("iris"), None, None, None, || {
|
||||||
|
assert!(swarm_name().is_none());
|
||||||
|
});
|
||||||
|
with_full_env(Some("iris"), None, None, Some(""), || {
|
||||||
|
assert!(swarm_name().is_none(), "empty string treated as unset");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_accessors_independent_from_domain() {
|
||||||
|
// hive_name + swarm_name surface without HYPERHIVE_HIVE_DOMAIN
|
||||||
|
// being set — the names are display labels, not derived from
|
||||||
|
// the DNS domain.
|
||||||
|
with_full_env(
|
||||||
|
Some("iris"),
|
||||||
|
None,
|
||||||
|
Some("pr1ma"),
|
||||||
|
Some("constellat1on"),
|
||||||
|
|| {
|
||||||
|
assert!(hive_domain().is_none());
|
||||||
|
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
||||||
|
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
||||||
|
// qualified_label still degrades to short label without domain.
|
||||||
|
assert_eq!(qualified_label(), "iris");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
348
hive-agent/src/login.rs
Normal file
348
hive-agent/src/login.rs
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is
|
||||||
|
//! provided by hive-c0re and persists across container destroy/recreate so
|
||||||
|
//! OAuth tokens survive.
|
||||||
|
//!
|
||||||
|
//! "Has session" means the dir contains at least one of the credential files
|
||||||
|
//! in [`CRED_FILE_NAMES`] — the same set `/logout` (`web_ui::auth`) deletes to
|
||||||
|
//! force re-login. Keying both off one constant keeps boot detection and
|
||||||
|
//! logout in agreement: logout deliberately preserves session-history files,
|
||||||
|
//! so a "contains any regular file" check would wrongly report `Online` after
|
||||||
|
//! a logout + container recreate and burn a turn 401-ing before it reroutes.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::events::Bus;
|
||||||
|
|
||||||
|
/// Returns the Claude credentials directory for this agent. Delegates
|
||||||
|
/// to `paths::claude_dir`, which reads `$HOME/.claude`. The service
|
||||||
|
/// runs as a non-root unix user named after the agent so `$HOME`
|
||||||
|
/// resolves to `/home/<agent>` and the OAuth dir lives at
|
||||||
|
/// `/home/<agent>/.claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_dir() -> PathBuf {
|
||||||
|
crate::paths::claude_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The credential files that constitute a logged-in claude session inside
|
||||||
|
/// [`default_dir`]. A session exists iff at least one is present; a login
|
||||||
|
/// "refresh" is a change to one of them. `/logout` (`web_ui::auth`) deletes
|
||||||
|
/// exactly these to force re-login while preserving session-history files —
|
||||||
|
/// so boot detection ([`has_session`]) and logout agree by construction.
|
||||||
|
/// Rationale + the previous wholesale-wipe shape we replaced live in
|
||||||
|
/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../docs/web-ui/agent.md)
|
||||||
|
/// (the `/api/logout` bullet).
|
||||||
|
///
|
||||||
|
/// `.credentials.json` is the actual OAuth session; `mcp-needs-auth-cache.json`
|
||||||
|
/// is claude-code's MCP-auth cache and a weaker signal. Both are kept in the
|
||||||
|
/// set only because `/logout` deletes both, so the "either present ⇒ logged
|
||||||
|
/// in" check can never disagree with a logout. (If a future edit ever removes
|
||||||
|
/// `.credentials.json` without the cache — a state `/logout` doesn't produce —
|
||||||
|
/// keying purely on `.credentials.json` would be the stronger boot signal.)
|
||||||
|
pub const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"];
|
||||||
|
|
||||||
|
/// Is `entry` a regular file whose name is one of [`CRED_FILE_NAMES`]?
|
||||||
|
fn is_cred_file(entry: &std::fs::DirEntry) -> bool {
|
||||||
|
entry.file_type().is_ok_and(|t| t.is_file())
|
||||||
|
&& entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|n| CRED_FILE_NAMES.contains(&n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if `dir` exists and holds at least one credential file
|
||||||
|
/// (see [`CRED_FILE_NAMES`]). Used at startup to decide whether to enter the
|
||||||
|
/// turn loop (logged in) or stay in the partial-run "needs login" state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_session(dir: &Path) -> bool {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
entries.flatten().any(|e| is_cred_file(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of [`clear_session`]: which credential files were removed and any
|
||||||
|
/// non-fatal per-file errors (e.g. permission denied). A file that was already
|
||||||
|
/// absent is not reported — deletion is idempotent.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ClearedSession {
|
||||||
|
pub wiped: Vec<&'static str>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the credential files (see [`CRED_FILE_NAMES`]) from `dir`, forcing a
|
||||||
|
/// re-login on the next turn, while preserving the session-history files
|
||||||
|
/// alongside them so `claude --continue` keeps working after a fresh login.
|
||||||
|
/// Idempotent: an already-absent file is skipped, not reported. This is the
|
||||||
|
/// write-side counterpart to [`has_session`]; `/logout` (`web_ui::auth`) drives
|
||||||
|
/// it.
|
||||||
|
pub async fn clear_session(dir: &Path) -> ClearedSession {
|
||||||
|
let mut cleared = ClearedSession::default();
|
||||||
|
for name in CRED_FILE_NAMES {
|
||||||
|
match tokio::fs::remove_file(dir.join(name)).await {
|
||||||
|
Ok(()) => cleared.wiped.push(name),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => cleared.warnings.push(format!("{name}: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login state the harness reports to its web UI.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LoginState {
|
||||||
|
/// `~/.claude/` has credentials; turn loop is running.
|
||||||
|
Online,
|
||||||
|
/// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT
|
||||||
|
/// running. Operator needs to complete login from the web UI.
|
||||||
|
NeedsLogin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginState {
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_dir(dir: &Path) -> Self {
|
||||||
|
if has_session(dir) {
|
||||||
|
Self::Online
|
||||||
|
} else {
|
||||||
|
Self::NeedsLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until the bound `~/.claude/` dir contains a session that
|
||||||
|
/// post-dates this call, polling on a `poll_ms` interval (min 2s).
|
||||||
|
/// Flips `state` to `Online` when login lands; caller resumes its
|
||||||
|
/// serve loop. Snapshots the dir at entry and only resumes when the
|
||||||
|
/// snapshot advances (mtime OR file-count change), avoiding the
|
||||||
|
/// infinite-401 loop a bare-existence check would produce when stale
|
||||||
|
/// credentials are already on disk. Mtime-snapshot resumption rationale
|
||||||
|
/// and `DirSnapshot` two-axis design: see
|
||||||
|
/// [`docs/turn-loop.md::The loop`](../../docs/turn-loop.md).
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal login-state lock is poisoned.
|
||||||
|
pub async fn wait_for_login(
|
||||||
|
claude_dir: &Path,
|
||||||
|
state: Arc<Mutex<LoginState>>,
|
||||||
|
bus: &Bus,
|
||||||
|
poll_ms: u64,
|
||||||
|
) {
|
||||||
|
tracing::warn!(
|
||||||
|
claude_dir = %claude_dir.display(),
|
||||||
|
"no claude session — staying in partial-run mode (web UI only)"
|
||||||
|
);
|
||||||
|
// Announce `needs_login_idle` to the bus so the sentinel file
|
||||||
|
// (`{state_dir}/hyperhive-needs-login`) gets written on every entry
|
||||||
|
// path — cold-boot, 401-mid-turn, and `/api/logout`. The host's
|
||||||
|
// `auth_failed_sentinel` reads that file to surface `needs_login`
|
||||||
|
// on the dashboard. Idempotent — `emit_status` is a `write` on a
|
||||||
|
// small empty file, so re-entering this function after a transient
|
||||||
|
// operator action is a no-op for the on-disk state.
|
||||||
|
bus.emit_status("needs_login_idle");
|
||||||
|
let snapshot = snapshot_dir(claude_dir);
|
||||||
|
let probe = Duration::from_millis(poll_ms.max(2000));
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(probe).await;
|
||||||
|
if session_refreshed(snapshot, snapshot_dir(claude_dir)) {
|
||||||
|
tracing::info!("claude session refreshed — entering turn loop");
|
||||||
|
*state.lock().unwrap() = LoginState::Online;
|
||||||
|
bus.emit_status("online");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the credential files (see [`CRED_FILE_NAMES`]) in the dir at a
|
||||||
|
/// point in time: how many are present + newest `mtime` across them. The two
|
||||||
|
/// axes are both load-bearing for `wait_for_login`'s refresh check
|
||||||
|
/// (`session_refreshed`): mtime catches the common case (re-login overwrites
|
||||||
|
/// an existing credentials file in-place), `file_count` catches the
|
||||||
|
/// pathological case where `meta.modified()` errors on every file (exotic fs,
|
||||||
|
/// NFS quirks) so the mtime axis stays `None` forever but a new credential
|
||||||
|
/// file still triggers a resume. Defaults to `{0, None}` on `read_dir` failure
|
||||||
|
/// (missing or unreadable dir) — `wait_for_login` then resumes when a
|
||||||
|
/// credential file first appears.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
struct DirSnapshot {
|
||||||
|
file_count: usize,
|
||||||
|
newest_mtime: Option<std::time::SystemTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_dir(dir: &Path) -> DirSnapshot {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return DirSnapshot::default();
|
||||||
|
};
|
||||||
|
let mut snap = DirSnapshot::default();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if !is_cred_file(&entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
snap.file_count += 1;
|
||||||
|
let Ok(meta) = entry.metadata() else { continue };
|
||||||
|
let Ok(mtime) = meta.modified() else { continue };
|
||||||
|
if snap.newest_mtime.is_none_or(|cur| mtime > cur) {
|
||||||
|
snap.newest_mtime = Some(mtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Has the credentials dir been written since `prev`? Used as the
|
||||||
|
/// exit condition for `wait_for_login`:
|
||||||
|
///
|
||||||
|
/// - `file_count` changed → something was added or removed, treat as
|
||||||
|
/// refresh (covers the "all files have unreadable mtime" edge case).
|
||||||
|
/// - `newest_mtime` advanced → existing file was rewritten in place
|
||||||
|
/// (the common claude re-login path).
|
||||||
|
/// - prev had no mtime (empty or all-unreadable) and now has one →
|
||||||
|
/// first useful signal we've seen, treat as refresh.
|
||||||
|
fn session_refreshed(prev: DirSnapshot, now: DirSnapshot) -> bool {
|
||||||
|
if now.file_count != prev.file_count {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match (prev.newest_mtime, now.newest_mtime) {
|
||||||
|
(None, Some(_)) => true,
|
||||||
|
(Some(p), Some(n)) => n > p,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use super::{DirSnapshot, has_session, session_refreshed, snapshot_dir};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_session_only_counts_credential_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// Session-history files (what `/logout` preserves) must NOT read as a
|
||||||
|
// logged-in session — this is the logout+recreate 401 bug.
|
||||||
|
fs::write(dir.path().join("history.jsonl"), b"{}").unwrap();
|
||||||
|
fs::write(dir.path().join("some-project-uuid.json"), b"{}").unwrap();
|
||||||
|
assert!(!has_session(dir.path()));
|
||||||
|
// A real credential file does.
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||||
|
assert!(has_session(dir.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_dir_ignores_non_credential_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("history.jsonl"), b"{}").unwrap();
|
||||||
|
let snap = snapshot_dir(dir.path());
|
||||||
|
assert_eq!(
|
||||||
|
snap.file_count, 0,
|
||||||
|
"history files must not count as session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_dir_empty_dir_is_default() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let snap = snapshot_dir(dir.path());
|
||||||
|
assert_eq!(snap.file_count, 0);
|
||||||
|
assert!(snap.newest_mtime.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_dir_missing_dir_is_default() {
|
||||||
|
// Defensive: a nonexistent dir must NOT panic. Bind mounts that
|
||||||
|
// disappear mid-poll (host purge during operator intervention)
|
||||||
|
// would otherwise crash the harness.
|
||||||
|
let missing = tempfile::tempdir()
|
||||||
|
.unwrap()
|
||||||
|
.path()
|
||||||
|
.join("never-created-subdir");
|
||||||
|
let snap = snapshot_dir(&missing);
|
||||||
|
assert_eq!(snap, DirSnapshot::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_dir_picks_latest_mtime_and_counts_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||||
|
// Sleep so the second file's mtime is strictly greater than
|
||||||
|
// the first on filesystems with low timestamp resolution.
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
let newer_path = dir.path().join("mcp-needs-auth-cache.json");
|
||||||
|
fs::write(&newer_path, b"{}").unwrap();
|
||||||
|
let snap = snapshot_dir(dir.path());
|
||||||
|
assert_eq!(snap.file_count, 2);
|
||||||
|
let newer_meta = fs::metadata(&newer_path).unwrap().modified().unwrap();
|
||||||
|
assert_eq!(snap.newest_mtime, Some(newer_meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_refreshed_first_login_flips_on_cred_file() {
|
||||||
|
// Empty-dir snapshot → a credential file appearing means a fresh
|
||||||
|
// login landed. First-time login semantics.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let snapshot = snapshot_dir(dir.path());
|
||||||
|
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||||
|
assert!(session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_refreshed_stale_creds_dont_flip_immediately() {
|
||||||
|
// Stale credentials.json already exists at entry; wait_for_login
|
||||||
|
// must NOT immediately return — it would loop straight into
|
||||||
|
// another 401-failing turn.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||||
|
let snapshot = snapshot_dir(dir.path());
|
||||||
|
assert_eq!(snapshot.file_count, 1);
|
||||||
|
// No change to the file → loop must NOT exit.
|
||||||
|
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_refreshed_after_creds_rewrite_flips() {
|
||||||
|
// After the stale-creds snapshot, the operator's `/login/code`
|
||||||
|
// flow lands a refreshed credentials file — its mtime bumps
|
||||||
|
// strictly past the snapshot and wait_for_login resumes.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||||
|
let snapshot = snapshot_dir(dir.path());
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{\"v\":2}").unwrap();
|
||||||
|
assert!(session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_refreshed_snapshot_with_future_mtime_doesnt_flip() {
|
||||||
|
// Defensive: a snapshot set to a future timestamp (e.g. clock
|
||||||
|
// skew between snapshot and probe) must keep waiting until a
|
||||||
|
// file's mtime actually exceeds it, not return on first poll.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||||
|
let snapshot = DirSnapshot {
|
||||||
|
file_count: 1,
|
||||||
|
newest_mtime: Some(SystemTime::now() + Duration::from_hours(1)),
|
||||||
|
};
|
||||||
|
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_refreshed_count_change_flips_when_mtime_unreadable() {
|
||||||
|
// Defensive: if all files have unreadable `meta.modified()`
|
||||||
|
// (exotic fs / NFS), newest_mtime stays `None` forever — but
|
||||||
|
// file_count axis still catches new files appearing. Simulated
|
||||||
|
// here by forging a snapshot with file_count=1 + no mtime, then
|
||||||
|
// writing a second file.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||||
|
let forged = DirSnapshot {
|
||||||
|
file_count: 1,
|
||||||
|
newest_mtime: None,
|
||||||
|
};
|
||||||
|
fs::write(dir.path().join("mcp-needs-auth-cache.json"), b"{}").unwrap();
|
||||||
|
// Real snapshot has file_count=2, so refresh fires even
|
||||||
|
// though the mtime axis would be inconclusive.
|
||||||
|
assert!(session_refreshed(forged, snapshot_dir(dir.path())));
|
||||||
|
}
|
||||||
|
}
|
||||||
304
hive-agent/src/login_session.rs
Normal file
304
hive-agent/src/login_session.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
//! `claude auth login` driver. Spawns the login command under plain stdio pipes,
|
||||||
|
//! accumulates stdout+stderr in a shared buffer (so the web UI can show
|
||||||
|
//! whatever URL/prompt claude emits), and writes paste-back codes from the
|
||||||
|
//! UI into the child's stdin.
|
||||||
|
//!
|
||||||
|
//! No PTY — we're betting `claude` produces a parseable URL on stdout and
|
||||||
|
//! accepts a code on stdin even when not on a terminal. If it refuses or
|
||||||
|
//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md
|
||||||
|
//! Phase 8).
|
||||||
|
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::{Child, ChildStdin, Command};
|
||||||
|
|
||||||
|
const DEFAULT_CMD: &str = "claude";
|
||||||
|
const DEFAULT_ARGS: &[&str] = &["auth", "login"];
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
/// Concatenated stdout+stderr as it streams from the child.
|
||||||
|
output: String,
|
||||||
|
/// First URL-looking substring we saw in the output. Surface this on the
|
||||||
|
/// web UI as the link the operator should open.
|
||||||
|
url: Option<String>,
|
||||||
|
/// Set when the child has exited. The web UI uses this to know whether
|
||||||
|
/// the operator can still paste a code.
|
||||||
|
finished: bool,
|
||||||
|
/// Exit status note (e.g. "exited with code 0", "killed by signal 15"),
|
||||||
|
/// shown next to a "finished" badge once the child returns.
|
||||||
|
exit_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running `claude auth login` subprocess.
|
||||||
|
pub struct LoginSession {
|
||||||
|
child: Mutex<Child>,
|
||||||
|
/// Tokio mutex because we hold the guard across the `write_all().await`
|
||||||
|
/// in `submit_code`. The other locks are blocking-only and stay on
|
||||||
|
/// `std::sync::Mutex`.
|
||||||
|
stdin: tokio::sync::Mutex<Option<ChildStdin>>,
|
||||||
|
state: Arc<Mutex<State>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginSession {
|
||||||
|
/// Spawn the login command. The exact binary/args are configurable via
|
||||||
|
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
|
||||||
|
/// default we run `claude auth login`. Failing to spawn returns an error
|
||||||
|
/// before any state is registered.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if spawning the login command fails, or if the child's
|
||||||
|
/// stdio handles cannot be acquired.
|
||||||
|
pub fn start() -> Result<Self> {
|
||||||
|
let (cmd, args) = resolve_command();
|
||||||
|
tracing::info!(%cmd, ?args, "spawning login session");
|
||||||
|
|
||||||
|
let mut child = Command::new(&cmd)
|
||||||
|
.args(&args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
// `claude` reads $HOME/.claude for the credentials dir. The
|
||||||
|
// harness service env sets HOME to /home/<agent> and the
|
||||||
|
// bind-mount lands the OAuth dir at the same path, so the
|
||||||
|
// child inherits the right HOME without any further wiring
|
||||||
|
// here.
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawn `{cmd}`"))?;
|
||||||
|
|
||||||
|
let stdin = child.stdin.take().context("child stdin")?;
|
||||||
|
let stdout = child.stdout.take().context("child stdout")?;
|
||||||
|
let stderr = child.stderr.take().context("child stderr")?;
|
||||||
|
|
||||||
|
let state = Arc::new(Mutex::new(State::default()));
|
||||||
|
tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout"));
|
||||||
|
tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr"));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
child: Mutex::new(child),
|
||||||
|
stdin: tokio::sync::Mutex::new(Some(stdin)),
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `code` (plus a newline) to the child's stdin. Returns an error
|
||||||
|
/// if the stdin has already been closed (e.g. after the child exited or
|
||||||
|
/// after a prior submission consumed it).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the login stdin is already closed, or if writing
|
||||||
|
/// to or flushing the stdin pipe fails.
|
||||||
|
pub async fn submit_code(&self, code: &str) -> Result<()> {
|
||||||
|
let mut guard = self.stdin.lock().await;
|
||||||
|
let stdin = guard.as_mut().context("login stdin already closed")?;
|
||||||
|
let line = format!("{}\n", code.trim());
|
||||||
|
stdin
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.await
|
||||||
|
.context("write code to claude stdin")?;
|
||||||
|
stdin.flush().await.context("flush claude stdin")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close stdin so claude sees EOF (useful if it's waiting for more input
|
||||||
|
/// after the code submit).
|
||||||
|
pub async fn close_stdin(&self) {
|
||||||
|
let _ = self.stdin.lock().await.take();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn output(&self) -> String {
|
||||||
|
self.state.lock().unwrap().output.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn url(&self) -> Option<String> {
|
||||||
|
self.state.lock().unwrap().url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn finished(&self) -> bool {
|
||||||
|
self.state.lock().unwrap().finished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
#[must_use]
|
||||||
|
pub fn exit_note(&self) -> Option<String> {
|
||||||
|
self.state.lock().unwrap().exit_note.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort: poll the child once and update `finished`/`exit_note`.
|
||||||
|
/// Called by the web UI on each render so the state stays fresh without
|
||||||
|
/// running a dedicated reaper task.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if an internal lock is poisoned.
|
||||||
|
pub fn poll(&self) {
|
||||||
|
let mut child = self.child.lock().unwrap();
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
let mut s = self.state.lock().unwrap();
|
||||||
|
s.finished = true;
|
||||||
|
s.exit_note = Some(format!("{status}"));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
let mut s = self.state.lock().unwrap();
|
||||||
|
s.finished = true;
|
||||||
|
s.exit_note = Some(format!("try_wait error: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill the child if it's still running. Idempotent.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn kill(&self) {
|
||||||
|
if let Err(e) = self.child.lock().unwrap().start_kill() {
|
||||||
|
tracing::warn!(error = ?e, "kill login child");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_command() -> (String, Vec<String>) {
|
||||||
|
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
|
||||||
|
// Whitespace-only split — no quote handling. Fine for "claude auth login"
|
||||||
|
// style overrides; if we need anything with embedded spaces we'll
|
||||||
|
// switch to shell-words.
|
||||||
|
let mut parts = raw.split_whitespace().map(str::to_owned);
|
||||||
|
if let Some(cmd) = parts.next() {
|
||||||
|
return (cmd, parts.collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
DEFAULT_CMD.into(),
|
||||||
|
DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump<R: tokio::io::AsyncRead + Unpin>(
|
||||||
|
mut reader: BufReader<R>,
|
||||||
|
state: Arc<Mutex<State>>,
|
||||||
|
tag: &'static str,
|
||||||
|
) {
|
||||||
|
let mut buf = String::new();
|
||||||
|
loop {
|
||||||
|
buf.clear();
|
||||||
|
// read_line breaks on \n; for claude's TUI output that flushes by
|
||||||
|
// line this is fine. If it ever blasts a single un-newlined blob,
|
||||||
|
// we'll miss it until EOF (acceptable for the URL surface — claude
|
||||||
|
// prints the URL on its own line).
|
||||||
|
match reader.read_line(&mut buf).await {
|
||||||
|
Ok(0) => {
|
||||||
|
state.lock().unwrap().finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
if s.url.is_none()
|
||||||
|
&& let Some(url) = extract_url(&buf)
|
||||||
|
{
|
||||||
|
tracing::info!(%url, %tag, "login URL detected");
|
||||||
|
s.url = Some(url);
|
||||||
|
}
|
||||||
|
s.output.push_str(&buf);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %tag, "login pump read error");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.finished = true;
|
||||||
|
s.exit_note = Some(format!("pump {tag} error: {e}"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the first `https://…` substring on the line, terminating at any
|
||||||
|
/// ASCII whitespace. Good enough for capturing claude's OAuth link without a
|
||||||
|
/// regex dependency.
|
||||||
|
fn extract_url(line: &str) -> Option<String> {
|
||||||
|
let start = line.find("https://")?;
|
||||||
|
let tail = &line[start..];
|
||||||
|
let end = tail
|
||||||
|
.find(|c: char| c.is_ascii_whitespace())
|
||||||
|
.unwrap_or(tail.len());
|
||||||
|
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
|
||||||
|
if url.len() > "https://".len() {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper used by the web UI to gate "is there a session running right now"
|
||||||
|
/// without holding both this module's mutex and the `AppState`'s at once.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal lock is poisoned.
|
||||||
|
pub fn drop_if_finished(slot: &Mutex<Option<Arc<LoginSession>>>) {
|
||||||
|
let mut guard = slot.lock().unwrap();
|
||||||
|
if let Some(s) = guard.as_ref() {
|
||||||
|
s.poll();
|
||||||
|
if s.finished() {
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LoginSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// kill_on_drop on the Command also ensures the child dies, but we
|
||||||
|
// belt-and-brace it in case the runtime detaches.
|
||||||
|
let _ = self.child.lock().unwrap().start_kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::extract_url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_first_https() {
|
||||||
|
let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n";
|
||||||
|
assert_eq!(
|
||||||
|
extract_url(line).as_deref(),
|
||||||
|
Some("https://claude.ai/oauth/abc?xyz=1"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_punctuation_stripped() {
|
||||||
|
let line = "Open https://example.com/abc).\n";
|
||||||
|
assert_eq!(
|
||||||
|
extract_url(line).as_deref(),
|
||||||
|
Some("https://example.com/abc"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_url() {
|
||||||
|
assert_eq!(extract_url("nothing here\n"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
705
hive-agent/src/main.rs
Normal file
705
hive-agent/src/main.rs
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
//! Harness serve-loop binary. Long-polls the broker inbox and drives one
|
||||||
|
//! claude turn per message. There is one role: agent. The `Surface`
|
||||||
|
//! trait + `AgentSurface` zero-sized type tag keeps the turn loop
|
||||||
|
//! generic and testable. Siblings: `hive-agent-mcp` (the MCP server this
|
||||||
|
//! loop points claude at) and `hive-agent-wake` (external wake CLI).
|
||||||
|
//! Architecture lives in
|
||||||
|
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
|
||||||
|
//!
|
||||||
|
//! Single bin crate: the module tree below (formerly this crate's `lib.rs`,
|
||||||
|
//! before lib + bin were collapsed into one) plus the serve loop.
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod events;
|
||||||
|
mod forge_notify;
|
||||||
|
mod harness_state;
|
||||||
|
mod identity;
|
||||||
|
mod login;
|
||||||
|
mod login_session;
|
||||||
|
mod mcp_config;
|
||||||
|
mod paths;
|
||||||
|
mod plugins;
|
||||||
|
mod prompt;
|
||||||
|
mod serve_common;
|
||||||
|
mod stats;
|
||||||
|
mod turn;
|
||||||
|
mod turn_stats;
|
||||||
|
mod vacuum;
|
||||||
|
mod web_ui;
|
||||||
|
|
||||||
|
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
|
||||||
|
const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
|
||||||
|
|
||||||
|
/// Default web UI port — used when `HIVE_PORT` env is unset.
|
||||||
|
const DEFAULT_WEB_PORT: u16 = 8042;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::events::{Bus, LiveEvent, TurnState};
|
||||||
|
use crate::login::LoginState;
|
||||||
|
use crate::turn_stats::TurnStats;
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "hive-agent", about = "hyperhive harness serve loop")]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||||
|
#[arg(long, default_value = DEFAULT_SOCKET)]
|
||||||
|
socket: PathBuf,
|
||||||
|
|
||||||
|
/// Inbox poll interval in milliseconds.
|
||||||
|
#[arg(long, default_value_t = 1000)]
|
||||||
|
poll_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
serve_main::<AgentSurface>(&cli.socket, cli.poll_ms).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- shared turn helpers ----------
|
||||||
|
|
||||||
|
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
|
||||||
|
/// log. Both agents and the manager receive `QuestionAnswered`,
|
||||||
|
/// `ContainerCrash`, reparent notifications, and friends; the parse
|
||||||
|
/// and 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;
|
||||||
|
}
|
||||||
|
let parsed = serde_json::from_str::<HelperEvent>(body).ok();
|
||||||
|
if let Some(event) = parsed {
|
||||||
|
tracing::info!(?event, "helper event");
|
||||||
|
} else {
|
||||||
|
tracing::info!(%from, %body, "system message");
|
||||||
|
}
|
||||||
|
bus.emit(LiveEvent::Note {
|
||||||
|
text: format!("[system] {body}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body string for the turn-failure notification we route to
|
||||||
|
/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified
|
||||||
|
/// identity so the receiver sees `agent@hive` rather than relying on
|
||||||
|
/// the caller threading a `label` through every turn-handling layer.
|
||||||
|
/// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a
|
||||||
|
/// misconfigured harness still produces a parseable line.
|
||||||
|
fn format_turn_failure(err: &anyhow::Error) -> String {
|
||||||
|
let who = crate::identity::qualified_label();
|
||||||
|
let who = if who.is_empty() {
|
||||||
|
"<unknown>".to_owned()
|
||||||
|
} else {
|
||||||
|
who
|
||||||
|
};
|
||||||
|
format!("[system] `{who}` claude turn failed:\n{err:#}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for the `hyperhive-continue` sentinel under the state dir
|
||||||
|
/// (dropped by the `request_next_turn` MCP tool). Returns true and
|
||||||
|
/// consumes the file when present; false otherwise. Caller fires
|
||||||
|
/// the role-specific `Wake` request — the sentinel itself is wire-
|
||||||
|
/// agnostic so this helper lives outside both surfaces.
|
||||||
|
fn consume_continue_sentinel() -> bool {
|
||||||
|
let sentinel = crate::paths::state_dir().join("hyperhive-continue");
|
||||||
|
if !sentinel.exists() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::remove_file(&sentinel) {
|
||||||
|
tracing::warn!(error = %e, "consume_continue_sentinel: remove sentinel failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a finished turn tells the serve loop to do next. Replaces the
|
||||||
|
/// bare `auth_failed` bool so the loop can also act on a pending
|
||||||
|
/// `request_next_turn` without round-tripping a synthetic message
|
||||||
|
/// through the broker.
|
||||||
|
struct TurnControl {
|
||||||
|
/// The turn ended in `AuthFailed` — caller parks on login.
|
||||||
|
auth_failed: bool,
|
||||||
|
/// `request_next_turn` was called during the turn (the
|
||||||
|
/// `hyperhive-continue` sentinel was dropped + consumed).
|
||||||
|
continue_requested: bool,
|
||||||
|
/// Inbox unread count observed right after the turn. Used to
|
||||||
|
/// decide whether a self-continue is actually needed.
|
||||||
|
pending: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether the serve loop should drive a self-continue turn
|
||||||
|
/// in-process. A continue is only "needed" when nothing else will
|
||||||
|
/// wake the agent: if real messages are already pending they drive
|
||||||
|
/// the next turn(s) and the continue is dropped (matches the
|
||||||
|
/// `request_next_turn` contract — "no effect if a new inbox message
|
||||||
|
/// arrives before this turn ends"). Auth-failed parks the loop on
|
||||||
|
/// login, so it suppresses the continue too.
|
||||||
|
fn should_self_continue(ctrl: &TurnControl) -> bool {
|
||||||
|
ctrl.continue_requested && !ctrl.auth_failed && ctrl.pending == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthesize the `from: "self"` / `body: "continue"` message that a
|
||||||
|
/// `request_next_turn` self-continue drives. Built in-process rather
|
||||||
|
/// than fetched from the broker — it never touches the send/recv
|
||||||
|
/// path, so it doesn't persist to sqlite or pollute the inbox.
|
||||||
|
/// `id = 0` is a non-broker sentinel: the synthetic message
|
||||||
|
/// has no DB row, and `AckTurn` keys off the recipient's in-flight
|
||||||
|
/// list (which is empty here) rather than this id.
|
||||||
|
fn synthetic_continue() -> hive_sh4re::DeliveredMessage {
|
||||||
|
hive_sh4re::DeliveredMessage {
|
||||||
|
from: "self".into(),
|
||||||
|
body: "continue".into(),
|
||||||
|
id: 0,
|
||||||
|
redelivered: false,
|
||||||
|
in_reply_to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthetic message that drives the single stop-checkpoint turn when c0re
|
||||||
|
/// signals a graceful stop. The agent gets one final turn to flush durable
|
||||||
|
/// `/state` before the container is stopped; new inbound is already fenced.
|
||||||
|
fn graceful_stop_message() -> hive_sh4re::DeliveredMessage {
|
||||||
|
hive_sh4re::DeliveredMessage {
|
||||||
|
from: "graceful-stop".into(),
|
||||||
|
body: "You are being gracefully stopped — the container will shut down after this turn, \
|
||||||
|
and new inbound messages are already fenced. This is your one checkpoint turn: \
|
||||||
|
flush anything worth keeping into your durable /state files now — update your \
|
||||||
|
notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \
|
||||||
|
file paths, and whatever you'd need to resume cleanly later with only a summary \
|
||||||
|
of this conversation to go on. Do not start new work or reply to anyone; just \
|
||||||
|
write your notes and end your turn. The session may be compacted after this turn \
|
||||||
|
so a later cold start resumes cheaply."
|
||||||
|
.into(),
|
||||||
|
id: 0,
|
||||||
|
redelivered: false,
|
||||||
|
in_reply_to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- surface trait ----------
|
||||||
|
|
||||||
|
/// What a `Recv` long-poll returned. Decoupled from the per-role
|
||||||
|
/// Response enum so `serve_loop` can pattern-match without seeing
|
||||||
|
/// either `AgentResponse` or `ManagerResponse` directly.
|
||||||
|
enum RecvOutcome {
|
||||||
|
/// Long-poll returned at least one message; first one is detached.
|
||||||
|
Message(hive_sh4re::DeliveredMessage),
|
||||||
|
/// Long-poll timed out cleanly (empty `Messages` response). Caller
|
||||||
|
/// sleeps then retries.
|
||||||
|
Empty,
|
||||||
|
/// Wire returned an error / unexpected variant. Caller logs +
|
||||||
|
/// retries; the surface impl is responsible for tracing the
|
||||||
|
/// detail before returning this.
|
||||||
|
TransportError,
|
||||||
|
/// c0re signalled a graceful stop for this agent. The serve loop runs
|
||||||
|
/// one stop-checkpoint turn (flush durable `/state`), reports
|
||||||
|
/// `GracefulStopComplete`, and exits so the container can be stopped.
|
||||||
|
GracefulStop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire surface abstraction. `AgentSurface` is the only impl — the trait
|
||||||
|
/// exists to keep the turn loop generic and testable. Every function that
|
||||||
|
/// talks to the broker goes through this so there are zero hard-coded
|
||||||
|
/// `AgentRequest` / `AgentResponse` references in the turn loop itself.
|
||||||
|
trait Surface {
|
||||||
|
/// Ack the in-flight turn. Logs warnings on transport/broker
|
||||||
|
/// errors but never propagates — turn loop continues either way.
|
||||||
|
fn ack_turn(socket: &Path) -> impl Future<Output = ()>;
|
||||||
|
|
||||||
|
/// Requeue any messages that were "in-flight" (delivered but not
|
||||||
|
/// ack'd) — fires on harness boot to recover from a crash mid-turn.
|
||||||
|
fn requeue_inflight(socket: &Path) -> impl Future<Output = ()>;
|
||||||
|
|
||||||
|
/// Current inbox unread count via `Status`. Returns 0 on any
|
||||||
|
/// transport/wire error so the caller falls through cleanly.
|
||||||
|
fn inbox_unread(socket: &Path) -> impl Future<Output = u64>;
|
||||||
|
|
||||||
|
/// `(open_threads, open_reminders)` for the post-turn stats row.
|
||||||
|
/// Either field is `None` when the underlying request errors.
|
||||||
|
fn post_turn_counts(socket: &Path) -> impl Future<Output = (Option<u64>, Option<u64>)>;
|
||||||
|
|
||||||
|
/// Tell c0re the graceful-stop checkpoint is done and the harness is
|
||||||
|
/// exiting its serve loop (fire-and-forget; logs on error). Lets the
|
||||||
|
/// `GracefulStop` orchestration stop the container without waiting out
|
||||||
|
/// its timeout fallback.
|
||||||
|
fn graceful_stop_complete(socket: &Path) -> impl Future<Output = ()>;
|
||||||
|
|
||||||
|
/// Send a message addressed to `<parent>` (broker resolves the
|
||||||
|
/// sentinel via `topology::parent_of` at delivery time; root
|
||||||
|
/// agents/manager fall through to operator).
|
||||||
|
fn send_to_parent(socket: &Path, body: String) -> impl Future<Output = ()>;
|
||||||
|
|
||||||
|
/// Long-poll the broker for the next message. Wraps the
|
||||||
|
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
||||||
|
/// generic `serve_loop` doesn't need the per-role Response enum
|
||||||
|
/// at all.
|
||||||
|
fn recv_next(socket: &Path) -> impl Future<Output = RecvOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AgentSurface ----------
|
||||||
|
|
||||||
|
/// Zero-sized type tag for the agent wire surface.
|
||||||
|
/// Talks `AgentRequest` / `AgentResponse`.
|
||||||
|
struct AgentSurface;
|
||||||
|
|
||||||
|
/// Issue an `Ok`-expecting fire-and-forget broker request, logging any
|
||||||
|
/// rejection / unexpected response / transport error under `label`. Shared by
|
||||||
|
/// the `Surface` methods that don't need the reply (`ack_turn`,
|
||||||
|
/// `requeue_inflight`, `graceful_stop_complete`).
|
||||||
|
async fn fire_and_forget(socket: &Path, req: AgentRequest, label: &str) {
|
||||||
|
match client::request::<_, AgentResponse>(socket, &req).await {
|
||||||
|
Ok(AgentResponse::Ok) => {}
|
||||||
|
Ok(AgentResponse::Err { message }) => {
|
||||||
|
tracing::warn!(%message, "{label} rejected by broker");
|
||||||
|
}
|
||||||
|
Ok(other) => tracing::warn!(?other, "{label} unexpected response"),
|
||||||
|
Err(e) => tracing::warn!(error = ?e, "{label} transport error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Surface for AgentSurface {
|
||||||
|
async fn ack_turn(socket: &Path) {
|
||||||
|
fire_and_forget(socket, AgentRequest::AckTurn, "ack_turn").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn requeue_inflight(socket: &Path) {
|
||||||
|
fire_and_forget(socket, AgentRequest::RequeueInflight, "requeue_inflight").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn graceful_stop_complete(socket: &Path) {
|
||||||
|
fire_and_forget(
|
||||||
|
socket,
|
||||||
|
AgentRequest::GracefulStopComplete,
|
||||||
|
"graceful_stop_complete",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inbox_unread(socket: &Path) -> u64 {
|
||||||
|
match client::request::<_, AgentResponse>(socket, &AgentRequest::Status).await {
|
||||||
|
Ok(AgentResponse::Status { unread }) => unread,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
|
||||||
|
let threads = match client::request::<_, AgentResponse>(
|
||||||
|
socket,
|
||||||
|
&AgentRequest::GetLooseEnds { agent: None },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let reminders = match client::request::<_, AgentResponse>(
|
||||||
|
socket,
|
||||||
|
&AgentRequest::CountPendingReminders { agent: None },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(AgentResponse::PendingRemindersCount { count }) => Some(count),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
(threads, reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_to_parent(socket: &Path, body: String) {
|
||||||
|
let res = client::request::<_, AgentResponse>(
|
||||||
|
socket,
|
||||||
|
&AgentRequest::Send {
|
||||||
|
to: hive_sh4re::PARENT_RECIPIENT.into(),
|
||||||
|
body,
|
||||||
|
in_reply_to: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::warn!(error = ?e, "failed to notify parent of turn failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_next(socket: &Path) -> RecvOutcome {
|
||||||
|
let recv: Result<AgentResponse> = client::request(
|
||||||
|
socket,
|
||||||
|
&AgentRequest::Recv {
|
||||||
|
wait_seconds: Some(180),
|
||||||
|
max: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match recv {
|
||||||
|
Ok(AgentResponse::Messages { messages, .. }) if !messages.is_empty() => {
|
||||||
|
let first = messages.into_iter().next().expect("checked non-empty");
|
||||||
|
RecvOutcome::Message(first)
|
||||||
|
}
|
||||||
|
Ok(AgentResponse::Messages { .. }) => RecvOutcome::Empty,
|
||||||
|
Ok(AgentResponse::GracefulStop) => RecvOutcome::GracefulStop,
|
||||||
|
Ok(AgentResponse::Err { message }) => {
|
||||||
|
tracing::warn!(%message, "recv error");
|
||||||
|
RecvOutcome::TransportError
|
||||||
|
}
|
||||||
|
Ok(other) => {
|
||||||
|
tracing::warn!(?other, "recv produced unexpected response kind");
|
||||||
|
RecvOutcome::TransportError
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "recv failed; retrying");
|
||||||
|
RecvOutcome::TransportError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- generic turn loop ----------
|
||||||
|
|
||||||
|
/// 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`). 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()
|
||||||
|
.and_then(|s| s.parse::<u16>().ok())
|
||||||
|
.unwrap_or(DEFAULT_WEB_PORT);
|
||||||
|
// `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. 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);
|
||||||
|
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
|
||||||
|
let login_state = Arc::new(Mutex::new(initial));
|
||||||
|
let bus = Bus::new();
|
||||||
|
let stats = TurnStats::open_default();
|
||||||
|
if let Some(s) = &stats {
|
||||||
|
let (ctx, cost) = s.last_usage();
|
||||||
|
if ctx.is_some() || cost.is_some() {
|
||||||
|
bus.seed_usage(ctx, cost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let files = turn::TurnFiles::prepare(socket, &label).await?;
|
||||||
|
// Plugin install failures come back as a Vec<String> — route each
|
||||||
|
// through `<parent>` via the `send_to_parent` failure-notify path.
|
||||||
|
// The broker resolves `<parent>` per `topology::parent_of`;
|
||||||
|
// root agents fall through to operator.
|
||||||
|
for failure in plugins::install_configured().await {
|
||||||
|
S::send_to_parent(socket, failure).await;
|
||||||
|
}
|
||||||
|
tokio::spawn(crate::forge_notify::run(socket.to_path_buf()));
|
||||||
|
// Agent-side cleanup of this agent's own harness artifacts (completed
|
||||||
|
// bash-task files + verbose event rows). Runs here, not host-side in
|
||||||
|
// hive-c0re, because the files are agent-owned — see `vacuum` module docs.
|
||||||
|
tokio::spawn(crate::vacuum::run());
|
||||||
|
// Log web_ui::serve's error instead of dropping it. A bare
|
||||||
|
// `tokio::spawn(web_ui::serve(...))` discards the JoinHandle, so
|
||||||
|
// any Err (e.g. EACCES from `bind_unix` when HIVE_WEB_SOCKET points
|
||||||
|
// at a dir the agent user can't write) vanishes — leaving an
|
||||||
|
// operator with no log line and no socket, debuggable only by
|
||||||
|
// staring at lifecycle.rs.
|
||||||
|
let web_ui_args = (
|
||||||
|
label.clone(),
|
||||||
|
port,
|
||||||
|
login_state.clone(),
|
||||||
|
bus.clone(),
|
||||||
|
socket.to_path_buf(),
|
||||||
|
);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (label, port, login_state, bus, socket) = web_ui_args;
|
||||||
|
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket).await {
|
||||||
|
tracing::error!(error = %e, "web_ui::serve exited with error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if matches!(initial, LoginState::NeedsLogin) {
|
||||||
|
login::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 — `online` status writes the sentinel
|
||||||
|
// cleanup in `Bus::emit_status`.
|
||||||
|
bus.emit_status("online");
|
||||||
|
}
|
||||||
|
serve_loop::<S>(
|
||||||
|
socket,
|
||||||
|
Duration::from_millis(poll_ms),
|
||||||
|
login_state,
|
||||||
|
claude_dir,
|
||||||
|
bus,
|
||||||
|
stats,
|
||||||
|
&files,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The long-running message loop. Long-polls the broker via
|
||||||
|
/// `S::recv_next`, drives a turn per message, parks on auth-failed,
|
||||||
|
/// otherwise retries.
|
||||||
|
#[allow(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "the harness's long-lived deps threaded into one serve loop, \
|
||||||
|
wired once from main; bundling into a struct would just move the \
|
||||||
|
same fields one level out (cf. Coordinator::open)"
|
||||||
|
)]
|
||||||
|
async fn serve_loop<S: Surface>(
|
||||||
|
socket: &Path,
|
||||||
|
interval: Duration,
|
||||||
|
login_state: Arc<Mutex<LoginState>>,
|
||||||
|
claude_dir: std::path::PathBuf,
|
||||||
|
bus: Bus,
|
||||||
|
stats: Option<TurnStats>,
|
||||||
|
files: &turn::TurnFiles,
|
||||||
|
) -> Result<()> {
|
||||||
|
tracing::info!(socket = %socket.display(), "harness serve");
|
||||||
|
S::requeue_inflight(socket).await;
|
||||||
|
// The durable claude session, built once and reused for every turn +
|
||||||
|
// idle compaction below (it's effectively stateless).
|
||||||
|
let session = turn::make_session(&bus);
|
||||||
|
// Set when a turn calls `request_next_turn` and no real work is
|
||||||
|
// pending — the next iteration drives this synthetic message
|
||||||
|
// in-process instead of long-polling the broker. Never
|
||||||
|
// persisted: it lives entirely in this loop's stack.
|
||||||
|
let mut self_continue: Option<hive_sh4re::DeliveredMessage> = None;
|
||||||
|
loop {
|
||||||
|
let next = match self_continue.take() {
|
||||||
|
Some(msg) => msg,
|
||||||
|
None => match S::recv_next(socket).await {
|
||||||
|
RecvOutcome::Message(first) => first,
|
||||||
|
RecvOutcome::Empty => {
|
||||||
|
// Idle: no message this poll. Service a queued operator
|
||||||
|
// `/compact` here so it runs even when no turn is driving
|
||||||
|
// (the in-flight case is handled at the end of drive_turn).
|
||||||
|
let compacted = turn::run_pending_compact(files, &bus, &session).await;
|
||||||
|
if !compacted {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
RecvOutcome::TransportError => {
|
||||||
|
// `recv_next` already logged the detail; just retry.
|
||||||
|
// No backoff: the long-poll wait is itself the throttle.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
RecvOutcome::GracefulStop => {
|
||||||
|
// c0re fenced our inbox and wants a clean stop. Run one
|
||||||
|
// checkpoint turn so the agent flushes durable /state,
|
||||||
|
// report completion, then exit the loop → the harness
|
||||||
|
// process ends and the container can be stopped.
|
||||||
|
tracing::info!(
|
||||||
|
"graceful stop signalled — running stop-checkpoint turn, then exiting"
|
||||||
|
);
|
||||||
|
let _ = handle_turn::<S>(
|
||||||
|
socket,
|
||||||
|
&bus,
|
||||||
|
stats.as_ref(),
|
||||||
|
files,
|
||||||
|
&session,
|
||||||
|
graceful_stop_message(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
S::graceful_stop_complete(socket).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let ctrl = handle_turn::<S>(socket, &bus, stats.as_ref(), files, &session, next).await;
|
||||||
|
if ctrl.auth_failed {
|
||||||
|
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||||
|
login::wait_for_login(
|
||||||
|
&claude_dir,
|
||||||
|
login_state.clone(),
|
||||||
|
&bus,
|
||||||
|
u64::try_from(interval.as_millis()).unwrap_or(2000),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else if should_self_continue(&ctrl) {
|
||||||
|
tracing::info!("request_next_turn: driving self-continue turn in-process");
|
||||||
|
self_continue = Some(synthetic_continue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive a single turn: emit boot-of-turn events, run claude, ack on
|
||||||
|
/// success / requeue on rate-limit-or-401 / notify parent on failure,
|
||||||
|
/// record stats, then pick up the `request_next_turn` sentinel if it's
|
||||||
|
/// been dropped during the turn. Returns a `TurnControl` carrying the
|
||||||
|
/// auth-failed flag, whether a self-continue was requested, and the
|
||||||
|
/// post-turn inbox count — the serve loop decides what to do next.
|
||||||
|
async fn handle_turn<S: Surface>(
|
||||||
|
socket: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
stats: Option<&TurnStats>,
|
||||||
|
files: &turn::TurnFiles,
|
||||||
|
session: &turn::AgentSession,
|
||||||
|
first: hive_sh4re::DeliveredMessage,
|
||||||
|
) -> TurnControl {
|
||||||
|
let from = first.from;
|
||||||
|
let body = first.body;
|
||||||
|
let redelivered = first.redelivered;
|
||||||
|
let msg_id = first.id;
|
||||||
|
log_system_event(bus, &from, &body);
|
||||||
|
tracing::info!(%from, %body, %redelivered, "inbox");
|
||||||
|
let unread = S::inbox_unread(socket).await;
|
||||||
|
bus.emit(LiveEvent::TurnStart {
|
||||||
|
from: from.clone(),
|
||||||
|
body: body.clone(),
|
||||||
|
unread,
|
||||||
|
});
|
||||||
|
bus.set_state(TurnState::Thinking);
|
||||||
|
let started_at = serve_common::now_unix();
|
||||||
|
let started_instant = std::time::Instant::now();
|
||||||
|
let model_at_start = bus.model();
|
||||||
|
let prompt = serve_common::format_wake_prompt(msg_id, &from, &body, unread, redelivered);
|
||||||
|
let outcome = turn::drive_turn(&prompt, files, bus, session).await;
|
||||||
|
turn::emit_turn_end(bus, &outcome);
|
||||||
|
bus.set_state(TurnState::Idle);
|
||||||
|
if outcome.is_ok() {
|
||||||
|
S::ack_turn(socket).await;
|
||||||
|
}
|
||||||
|
if matches!(outcome, Err(turn::TurnError::RateLimited)) {
|
||||||
|
let secs = turn::rate_limit_sleep_secs();
|
||||||
|
bus.emit_status("rate_limited");
|
||||||
|
bus.emit(LiveEvent::Note {
|
||||||
|
text: format!("API rate-limited — sleeping {secs}s before retry"),
|
||||||
|
});
|
||||||
|
tracing::warn!(sleep_secs = secs, "rate-limited; parking");
|
||||||
|
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||||
|
S::requeue_inflight(socket).await;
|
||||||
|
bus.emit_status("online");
|
||||||
|
}
|
||||||
|
if matches!(outcome, Err(turn::TurnError::ApiStall)) {
|
||||||
|
// Idle watchdog killed claude on a suspected API stall. Park briefly to
|
||||||
|
// let the API recover, then requeue — same shape as the rate-limit path.
|
||||||
|
let secs = turn::stall_sleep_secs();
|
||||||
|
bus.emit_status("api_stall");
|
||||||
|
bus.emit(LiveEvent::Note {
|
||||||
|
text: format!(
|
||||||
|
"API stall timeout — sleeping {secs}s before retry \
|
||||||
|
(tune HIVE_STALL_SLEEP_SECS; disable the watchdog with HIVE_TURN_IDLE_SECS=0)"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
tracing::warn!(sleep_secs = secs, "API stall; parking before retry");
|
||||||
|
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||||
|
S::requeue_inflight(socket).await;
|
||||||
|
bus.emit_status("online");
|
||||||
|
}
|
||||||
|
if matches!(outcome, Err(turn::TurnError::AuthFailed)) {
|
||||||
|
bus.emit_status("needs_login_idle");
|
||||||
|
bus.emit(LiveEvent::Note {
|
||||||
|
text: "API 401 — waiting for re-login via web UI".into(),
|
||||||
|
});
|
||||||
|
tracing::warn!("auth-failed; parking until re-login");
|
||||||
|
S::requeue_inflight(socket).await;
|
||||||
|
}
|
||||||
|
if matches!(outcome, Err(turn::TurnError::PromptTooLong)) {
|
||||||
|
// `drive_turn` already archived the session; requeue the message so it
|
||||||
|
// redelivers into the fresh session (which fits — the wake prompt is
|
||||||
|
// tiny, the overflow was the now-cleared context). No status park: the
|
||||||
|
// agent is healthy, it just needs one more delivery.
|
||||||
|
tracing::warn!("prompt-too-long; session archived, requeueing message for a fresh turn");
|
||||||
|
S::requeue_inflight(socket).await;
|
||||||
|
}
|
||||||
|
if matches!(outcome, Err(turn::TurnError::SessionNotFound)) {
|
||||||
|
// "Shouldn't happen": resume missed and the lib's create self-heal
|
||||||
|
// didn't resolve it. Requeue rather than ack-and-drop so the wake
|
||||||
|
// message isn't silently lost; the next turn creates the session fresh.
|
||||||
|
tracing::warn!("session-not-found; requeueing message for a fresh turn");
|
||||||
|
S::requeue_inflight(socket).await;
|
||||||
|
}
|
||||||
|
if let Err(turn::TurnError::Failed(e)) = &outcome {
|
||||||
|
S::send_to_parent(socket, format_turn_failure(e)).await;
|
||||||
|
}
|
||||||
|
if let Some(stats) = stats {
|
||||||
|
// Fresh session this turn → mint a `sessions` row and set its id on
|
||||||
|
// the bus so this turn (and subsequent ones until the next fresh
|
||||||
|
// start) stamp `turn_stats.session_id`. Takes the one-shot flag
|
||||||
|
// `run_claude` set when it suppressed `--continue`.
|
||||||
|
if bus.take_fresh_session() {
|
||||||
|
let sid = stats.start_session(started_at, &model_at_start);
|
||||||
|
bus.set_session_id(sid);
|
||||||
|
}
|
||||||
|
let ended_at = serve_common::now_unix();
|
||||||
|
let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
|
||||||
|
let (open_threads, open_reminders) = S::post_turn_counts(socket).await;
|
||||||
|
let row = serve_common::build_row(serve_common::TurnRowArgs {
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
duration_ms,
|
||||||
|
model: model_at_start,
|
||||||
|
wake_from: from.clone(),
|
||||||
|
outcome: &outcome,
|
||||||
|
bus,
|
||||||
|
open_threads_count: open_threads,
|
||||||
|
open_reminders_count: open_reminders,
|
||||||
|
});
|
||||||
|
stats.record(&row);
|
||||||
|
}
|
||||||
|
let pending = S::inbox_unread(socket).await;
|
||||||
|
if pending > 0 {
|
||||||
|
tracing::info!(%pending, "pending messages after turn; fetching next");
|
||||||
|
}
|
||||||
|
TurnControl {
|
||||||
|
auth_failed: matches!(outcome, Err(turn::TurnError::AuthFailed)),
|
||||||
|
continue_requested: consume_continue_sentinel(),
|
||||||
|
pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod continue_tests {
|
||||||
|
use super::{TurnControl, should_self_continue, synthetic_continue};
|
||||||
|
|
||||||
|
fn ctrl(auth_failed: bool, continue_requested: bool, pending: u64) -> TurnControl {
|
||||||
|
TurnControl {
|
||||||
|
auth_failed,
|
||||||
|
continue_requested,
|
||||||
|
pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_continue_when_requested_and_inbox_empty() {
|
||||||
|
assert!(should_self_continue(&ctrl(false, true, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_self_continue_when_not_requested() {
|
||||||
|
assert!(!should_self_continue(&ctrl(false, false, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_self_continue_when_real_messages_pending() {
|
||||||
|
// A real message will drive the next turn via recv — the
|
||||||
|
// continue is superseded, not needed (request_next_turn contract).
|
||||||
|
assert!(!should_self_continue(&ctrl(false, true, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_self_continue_when_auth_failed() {
|
||||||
|
// Auth-failed parks the loop on login; a queued continue must
|
||||||
|
// not jump the gate.
|
||||||
|
assert!(!should_self_continue(&ctrl(true, true, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_continue_shape() {
|
||||||
|
let m = synthetic_continue();
|
||||||
|
assert_eq!(m.from, "self");
|
||||||
|
assert_eq!(m.body, "continue");
|
||||||
|
assert_eq!(m.id, 0);
|
||||||
|
assert!(!m.redelivered);
|
||||||
|
assert!(m.in_reply_to.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
389
hive-agent/src/mcp_config.rs
Normal file
389
hive-agent/src/mcp_config.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
//! Claude launch-config layer: resolves the agent's tool-group / capability
|
||||||
|
//! set into the `--allowedTools` / `--tools` argument strings and renders the
|
||||||
|
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
|
||||||
|
//! any `hyperhive.extraMcpServers`). Pure config-string generation consumed by
|
||||||
|
//! [`crate::turn`] when it builds the claude command. It never touches the
|
||||||
|
//! running MCP server (a separate binary) — the `send` allow-list check that
|
||||||
|
//! server enforces lives alongside it in the `hive-agent-mcp` crate.
|
||||||
|
|
||||||
|
/// Name of the hyperhive MCP server inside claude's view. Claude prefixes
|
||||||
|
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
|
||||||
|
pub const SERVER_NAME: &str = "hyperhive";
|
||||||
|
|
||||||
|
/// Default loopback port the built-in hyperhive MCP surface is served on
|
||||||
|
/// (streamable HTTP, via the persistent `hive-mcp-http` daemon). Overridable
|
||||||
|
/// via `hyperhive.mcp.httpPort`; **must match that option's default** in
|
||||||
|
/// `nix/templates/harness/`. Safe as a single fixed value across all
|
||||||
|
/// agents because each container runs in its own private network namespace,
|
||||||
|
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
|
||||||
|
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
|
||||||
|
|
||||||
|
/// Built-in claude tools always present in every session. Anything not
|
||||||
|
/// in this list (or added by `extra_builtin_tools`) literally doesn't
|
||||||
|
/// exist in the session. Web egress (`WebFetch`/`WebSearch`) are
|
||||||
|
/// tool-group-gated (`web_tools`) — off by default. Nested agents
|
||||||
|
/// (`Task`) are intentionally omitted. `Bash` is disallowed — shell
|
||||||
|
/// execution goes through `mcp__bash__run` (background tasks
|
||||||
|
/// with structured output via `hive-bash-mcp`) instead of a raw interactive shell. `TodoWrite`
|
||||||
|
/// is omitted because the todo list lives in claude's in-process session
|
||||||
|
/// state and silently evaporates on /compact or session reset — agents
|
||||||
|
/// should plan in /state notes instead.
|
||||||
|
pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Write"];
|
||||||
|
|
||||||
|
/// Env var written by the meta renderer with a comma-separated list of
|
||||||
|
/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`).
|
||||||
|
/// When present, the harness expands the groups into per-tool allow entries
|
||||||
|
/// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`.
|
||||||
|
const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
|
||||||
|
|
||||||
|
/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the
|
||||||
|
/// operator grants capabilities to this agent. Comma-separated
|
||||||
|
/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities.
|
||||||
|
const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES";
|
||||||
|
|
||||||
|
/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are
|
||||||
|
/// unlocked by the agent's current capability set. These are added to the
|
||||||
|
/// `--allowedTools` list so claude can call them without prompting, and
|
||||||
|
/// hive-c0re performs a second server-side capability check before executing.
|
||||||
|
fn allowed_capability_tools() -> Vec<String> {
|
||||||
|
let raw = match std::env::var(CAPABILITIES_ENV) {
|
||||||
|
Ok(v) if !v.trim().is_empty() => v,
|
||||||
|
_ => return vec![],
|
||||||
|
};
|
||||||
|
let mut tools = Vec::new();
|
||||||
|
for token in raw.split(',') {
|
||||||
|
let t = token.trim().to_ascii_lowercase();
|
||||||
|
match t.as_str() {
|
||||||
|
"read_host_journal" => tools.push("get_host_journal".to_owned()),
|
||||||
|
// infra_admin lets an agent restart hive infrastructure
|
||||||
|
// containers (hive-ci / hive-gateway / hive-forge) through the
|
||||||
|
// existing `restart` tool. Unlock it here so agents that hold
|
||||||
|
// the capability without the full `lifecycle` group can still
|
||||||
|
// call it; c0re re-checks the capability server-side and only
|
||||||
|
// honours infra-container names via this path.
|
||||||
|
"infra_admin" => tools.push("restart".to_owned()),
|
||||||
|
// manage_root_agent / query_agent_state don't expose new MCP
|
||||||
|
// tools: manage_root_agent gates existing lifecycle tools via
|
||||||
|
// topology enforcement; query_agent_state unlocks the `agent`
|
||||||
|
// field in get_loose_ends / count_pending_reminders /
|
||||||
|
// reminder_rollup (c0re enforces the cap server-side).
|
||||||
|
"manage_root_agent" | "query_agent_state" => {}
|
||||||
|
unknown => {
|
||||||
|
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tools
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the active tool groups for a harness session.
|
||||||
|
///
|
||||||
|
/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated
|
||||||
|
/// token is matched (case-insensitive) against the `ToolGroup` serde names
|
||||||
|
/// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`,
|
||||||
|
/// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped.
|
||||||
|
/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty.
|
||||||
|
fn effective_tool_groups() -> Vec<hive_sh4re::ToolGroup> {
|
||||||
|
let raw = match std::env::var(TOOL_GROUPS_ENV) {
|
||||||
|
Ok(v) if !v.trim().is_empty() => v,
|
||||||
|
_ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(),
|
||||||
|
};
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
for token in raw.split(',') {
|
||||||
|
let t = token.trim().to_ascii_lowercase();
|
||||||
|
// Parse via serde_json (the canonical deserialization path).
|
||||||
|
if let Ok(g) =
|
||||||
|
serde_json::from_value::<hive_sh4re::ToolGroup>(serde_json::Value::String(t.clone()))
|
||||||
|
{
|
||||||
|
groups.push(g);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if groups.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
|
||||||
|
falling back to AGENT_DEFAULT"
|
||||||
|
);
|
||||||
|
return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec();
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool group an extra (out-of-process) MCP server is gated behind, if any.
|
||||||
|
///
|
||||||
|
/// Most `hyperhive.extraMcpServers` entries are ungated — available whenever
|
||||||
|
/// the operator declares them. The `bash` server is the exception: raw shell
|
||||||
|
/// execution is a privilege, so it is only exposed when the agent holds the
|
||||||
|
/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at
|
||||||
|
/// dispatch) and the capability tools (re-checked server-side by hive-c0re),
|
||||||
|
/// an out-of-process server has **no** later enforcement point — once it is
|
||||||
|
/// in the claude MCP config the agent can call it. So this gate, applied at
|
||||||
|
/// config-render time, is the security boundary for those servers.
|
||||||
|
fn extra_server_required_group(server: &str) -> Option<hive_sh4re::ToolGroup> {
|
||||||
|
match server {
|
||||||
|
"bash" => Some(hive_sh4re::ToolGroup::Execution),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an extra MCP server should be exposed to claude given the active
|
||||||
|
/// tool `groups`. A gated server (see [`extra_server_required_group`]) is
|
||||||
|
/// suppressed when the agent lacks its required group.
|
||||||
|
fn extra_server_enabled(server: &str, groups: &[hive_sh4re::ToolGroup]) -> bool {
|
||||||
|
extra_server_required_group(server).is_none_or(|required| groups.contains(&required))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod extra_server_gate_tests {
|
||||||
|
use super::{extra_server_enabled, extra_server_required_group};
|
||||||
|
use hive_sh4re::ToolGroup;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bash_is_gated_behind_execution() {
|
||||||
|
assert_eq!(
|
||||||
|
extra_server_required_group("bash"),
|
||||||
|
Some(ToolGroup::Execution)
|
||||||
|
);
|
||||||
|
// Suppressed without Execution, even if other groups are present.
|
||||||
|
assert!(!extra_server_enabled(
|
||||||
|
"bash",
|
||||||
|
&[ToolGroup::Messaging, ToolGroup::Inbox]
|
||||||
|
));
|
||||||
|
// Available once Execution is granted.
|
||||||
|
assert!(extra_server_enabled("bash", &[ToolGroup::Execution]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_servers_are_ungated() {
|
||||||
|
assert_eq!(extra_server_required_group("matrix"), None);
|
||||||
|
assert_eq!(extra_server_required_group("scraper"), None);
|
||||||
|
// An ungated server is available regardless of (even empty) groups.
|
||||||
|
assert!(extra_server_enabled("matrix", &[]));
|
||||||
|
assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP tools claude is allowed to call without prompting, derived from
|
||||||
|
/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl
|
||||||
|
/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re
|
||||||
|
/// (single source of truth). See `docs/conventions.md::Tool groups`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> {
|
||||||
|
// Collect all tool names, deduplicating while preserving order.
|
||||||
|
// Always-on tools (e.g. `set_status`) come first so they're present
|
||||||
|
// regardless of which groups the agent is granted — a misconfigured
|
||||||
|
// agent still has to be able to report its dashboard status.
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut out: Vec<String> = hive_sh4re::ToolGroup::ALWAYS_ON_TOOLS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.chain(groups.iter().flat_map(|g| g.tools().iter().copied()))
|
||||||
|
.filter(|t| seen.insert(*t))
|
||||||
|
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
|
||||||
|
.collect();
|
||||||
|
// Extra MCP servers declared via `hyperhive.extraMcpServers` in
|
||||||
|
// the agent's NixOS config. Each entry maps its `allowedTools`
|
||||||
|
// pattern list to `mcp__<server>__<pattern>` so claude can call
|
||||||
|
// them without per-tool operator approval. `["*"]` (the default)
|
||||||
|
// expands to `mcp__<server>__*` — every tool from that server.
|
||||||
|
for (server, spec) in load_extra_mcp() {
|
||||||
|
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for pat in spec.allowed_tools {
|
||||||
|
out.push(format!("mcp__{server}__{pat}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers
|
||||||
|
/// both the built-ins and the MCP surface.
|
||||||
|
#[must_use]
|
||||||
|
pub fn allowed_tools_arg() -> String {
|
||||||
|
let groups = effective_tool_groups();
|
||||||
|
// Base built-ins always present.
|
||||||
|
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS
|
||||||
|
.iter()
|
||||||
|
.map(|s| (*s).to_owned())
|
||||||
|
.collect();
|
||||||
|
// Extra built-ins gated by tool groups (e.g. WebFetch/WebSearch via web_tools).
|
||||||
|
for group in &groups {
|
||||||
|
for tool in group.builtin_tools() {
|
||||||
|
if !all.iter().any(|t| t == *tool) {
|
||||||
|
all.push((*tool).to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.extend(allowed_mcp_tools(&groups));
|
||||||
|
// Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES
|
||||||
|
// includes the corresponding capability. hive-c0re performs a second
|
||||||
|
// server-side check, so this is a usability gate (no annoying prompts),
|
||||||
|
// not the security boundary.
|
||||||
|
for tool in allowed_capability_tools() {
|
||||||
|
all.push(format!("mcp__{SERVER_NAME}__{tool}"));
|
||||||
|
}
|
||||||
|
all.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-in tools list for `--tools` (which built-ins exist in this
|
||||||
|
/// session). Base set plus any group-gated built-ins (e.g.
|
||||||
|
/// `WebFetch`/`WebSearch` when the `web_tools` group is active).
|
||||||
|
#[must_use]
|
||||||
|
pub fn builtin_tools_arg() -> String {
|
||||||
|
let groups = effective_tool_groups();
|
||||||
|
let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec();
|
||||||
|
for group in &groups {
|
||||||
|
for t in group.builtin_tools() {
|
||||||
|
if !tools.contains(t) {
|
||||||
|
tools.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tools.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the NixOS module writes the per-agent extra-MCP spec (see
|
||||||
|
/// `nix/templates/harness/`). Each entry becomes an additional
|
||||||
|
/// `mcpServers.<key>` block in the rendered claude config + a
|
||||||
|
/// `mcp__<key>__<tool>` pattern in `--allowedTools`.
|
||||||
|
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct ExtraMcpServer {
|
||||||
|
command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
env: std::collections::BTreeMap<String, String>,
|
||||||
|
#[serde(default = "default_allowed_tools")]
|
||||||
|
#[serde(rename = "allowedTools")]
|
||||||
|
allowed_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_allowed_tools() -> Vec<String> {
|
||||||
|
vec!["*".to_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read + parse the extra-MCP spec. Returns an empty map when
|
||||||
|
/// the file is missing or unparsable (the agent has none configured,
|
||||||
|
/// or the file is malformed — both cases degrade to "no extra servers").
|
||||||
|
fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
|
||||||
|
let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else {
|
||||||
|
return std::collections::BTreeMap::new();
|
||||||
|
};
|
||||||
|
serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
path = EXTRA_MCP_PATH,
|
||||||
|
error = ?e,
|
||||||
|
"extra-mcp spec parse failed; ignoring",
|
||||||
|
);
|
||||||
|
std::collections::BTreeMap::new()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
|
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
|
||||||
|
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
|
||||||
|
/// is no per-turn stdio child for it. Merges in any extra MCP servers
|
||||||
|
/// declared via `hyperhive.extraMcpServers` (those stay stdio bridges).
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_claude_config() -> String {
|
||||||
|
let mut servers = serde_json::Map::new();
|
||||||
|
// The built-in hyperhive surface is served exclusively over streamable
|
||||||
|
// HTTP by the persistent `hive-mcp-http` daemon (loopback, inside the
|
||||||
|
// agent's private network namespace). Point claude at the stable URL
|
||||||
|
// rather than respawning a fresh stdio child each turn: the URL survives
|
||||||
|
// the per-turn claude re-spawn, so there is no per-turn re-registration
|
||||||
|
// race for the hyperhive surface. Extra servers (matrix/bash) stay stdio
|
||||||
|
// bridges. The port comes from `HYPERHIVE_MCP_HTTP_PORT` (always set by
|
||||||
|
// the harness); `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix
|
||||||
|
// default.
|
||||||
|
let port = std::env::var("HYPERHIVE_MCP_HTTP_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.trim().parse::<u16>().ok())
|
||||||
|
.unwrap_or(DEFAULT_MCP_HTTP_PORT);
|
||||||
|
let hyperhive_entry = serde_json::json!({
|
||||||
|
"type": "http",
|
||||||
|
"url": format!("http://127.0.0.1:{port}/mcp"),
|
||||||
|
});
|
||||||
|
servers.insert(SERVER_NAME.to_owned(), hyperhive_entry);
|
||||||
|
// Auto-inject HYPERHIVE_STATE_DIR so extra MCP servers can resolve the
|
||||||
|
// agent's durable state dir without the agent author hard-coding it.
|
||||||
|
// User-supplied env takes precedence — we only fill in the missing key.
|
||||||
|
let state_dir = crate::paths::state_dir();
|
||||||
|
// Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`).
|
||||||
|
// This is the security boundary for them: an out-of-process server the
|
||||||
|
// agent isn't entitled to must not even appear in the MCP config, or the
|
||||||
|
// agent could call it directly (there is no later enforcement point).
|
||||||
|
let groups = effective_tool_groups();
|
||||||
|
for (name, mut spec) in load_extra_mcp() {
|
||||||
|
if name == SERVER_NAME {
|
||||||
|
tracing::warn!(
|
||||||
|
"extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !extra_server_enabled(&name, &groups) {
|
||||||
|
tracing::info!(
|
||||||
|
server = %name,
|
||||||
|
"extra MCP server suppressed: agent lacks the required tool group"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
spec.env
|
||||||
|
.entry("HYPERHIVE_STATE_DIR".to_owned())
|
||||||
|
.or_insert_with(|| state_dir.display().to_string());
|
||||||
|
servers.insert(
|
||||||
|
name,
|
||||||
|
serde_json::json!({
|
||||||
|
"command": spec.command,
|
||||||
|
"args": spec.args,
|
||||||
|
"env": spec.env,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let config = serde_json::json!({ "mcpServers": servers });
|
||||||
|
serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{SERVER_NAME, allowed_mcp_tools};
|
||||||
|
use hive_sh4re::ToolGroup;
|
||||||
|
|
||||||
|
fn qualified(tool: &str) -> String {
|
||||||
|
format!("mcp__{SERVER_NAME}__{tool}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_status_present_with_no_groups() {
|
||||||
|
// An agent with zero tool groups (or any group set that omits
|
||||||
|
// `meta`) must still be able to report its dashboard status.
|
||||||
|
let tools = allowed_mcp_tools(&[]);
|
||||||
|
assert!(
|
||||||
|
tools.contains(&qualified("set_status")),
|
||||||
|
"set_status missing from empty-group allow-list: {tools:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_status_present_without_meta_group() {
|
||||||
|
let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::Inbox]);
|
||||||
|
assert!(tools.contains(&qualified("set_status")));
|
||||||
|
// get_agent_meta stays gated behind `meta` — only set_status is always-on.
|
||||||
|
assert!(!tools.contains(&qualified("get_agent_meta")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_duplicate_set_status_when_meta_granted() {
|
||||||
|
let tools = allowed_mcp_tools(&[ToolGroup::Meta]);
|
||||||
|
let count = tools
|
||||||
|
.iter()
|
||||||
|
.filter(|t| **t == qualified("set_status"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(count, 1, "set_status duplicated: {tools:?}");
|
||||||
|
assert!(tools.contains(&qualified("get_agent_meta")));
|
||||||
|
}
|
||||||
|
}
|
||||||
76
hive-agent/src/paths.rs
Normal file
76
hive-agent/src/paths.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
//! Per-agent path resolution for state, harness, and credential directories.
|
||||||
|
//!
|
||||||
|
//! All agents (including the manager `root`) use `/agents/{label}/state`
|
||||||
|
//! for agent-owned durable notes, and `/agents/{label}/harness` for
|
||||||
|
//! harness-internal files (`hyperhive-events.sqlite`, `hyperhive-model`, etc.)
|
||||||
|
//! that should not clutter what claude sees as "my notes dir".
|
||||||
|
//! Claude credentials live at `$HOME/.claude` (resolves to
|
||||||
|
//! `/home/<agent-name>/.claude` because the harness service runs as a
|
||||||
|
//! non-root unix user matching the agent label — see
|
||||||
|
//! `docs/persistence.md::First-boot agent-user migration`).
|
||||||
|
//!
|
||||||
|
//! All three paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
|
||||||
|
//! `HYPERHIVE_HARNESS_DIR`, `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Durable state directory for the current agent. Reads `HYPERHIVE_STATE_DIR`
|
||||||
|
/// first (always set by the meta flake to `/agents/{label}/state`); falls back
|
||||||
|
/// to the same pattern derived from `HIVE_LABEL` for dev/test environments
|
||||||
|
/// where the env var may not be set.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state_dir() -> PathBuf {
|
||||||
|
if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
|
||||||
|
PathBuf::from(format!("/agents/{label}/state"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Harness-internal state directory. Holds files the harness owns
|
||||||
|
/// (`hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`,
|
||||||
|
/// `hyperhive-model`) so they do not appear inside the agent-visible
|
||||||
|
/// `/agents/{label}/state` tree. Delegates to the shared canonical
|
||||||
|
/// resolver in `hive_sh4re::paths` so the harness + every out-of-process
|
||||||
|
/// MCP daemon resolve this identically (reads `HYPERHIVE_HARNESS_DIR`,
|
||||||
|
/// then a `harness/` sibling of `HYPERHIVE_STATE_DIR`, then
|
||||||
|
/// `/agents/{HIVE_LABEL}/harness`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn harness_dir() -> PathBuf {
|
||||||
|
hive_sh4re::paths::harness_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-turn config dir for the regenerated claude-{mcp-config,settings,
|
||||||
|
/// system-prompt} files the harness drops before each turn. Set by
|
||||||
|
/// systemd via `RuntimeDirectory = "hive-config"`: a per-service runtime
|
||||||
|
/// dir owned by the agent unix user, auto-cleared on stop. Kept separate
|
||||||
|
/// from `/run/hive` (the host-owned mcp.sock bind) so the harness owns
|
||||||
|
/// its own write surface and we don't have to chown a bind-mounted dir.
|
||||||
|
/// Overridable via `HYPERHIVE_CONFIG_DIR` for dev / test scenarios.
|
||||||
|
#[must_use]
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
if let Some(p) = std::env::var_os("HYPERHIVE_CONFIG_DIR") {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
PathBuf::from("/run/hive-config")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude credentials directory for the current agent. `$HOME/.claude`
|
||||||
|
/// matches what the `claude` CLI reads at runtime — the harness sees
|
||||||
|
/// the same `$HOME` set by the per-service systemd `environment`
|
||||||
|
/// declaration (`/home/<agent>`). Falls back to `/root/.claude` for
|
||||||
|
/// dev / test environments where `HOME` isn't set so the previous
|
||||||
|
/// root-by-default shape keeps working without env wiring.
|
||||||
|
/// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios.
|
||||||
|
#[must_use]
|
||||||
|
pub fn claude_dir() -> PathBuf {
|
||||||
|
if let Some(p) = std::env::var_os("HYPERHIVE_CLAUDE_DIR") {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
let mut path = PathBuf::from(home);
|
||||||
|
path.push(".claude");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
PathBuf::from("/root/.claude")
|
||||||
|
}
|
||||||
156
hive-agent/src/plugins.rs
Normal file
156
hive-agent/src/plugins.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
//! Boot-time `claude plugin install` driver. Reads the list declared
|
||||||
|
//! via the `hyperhive.claudePlugins` NixOS option (rendered to
|
||||||
|
//! `/etc/hyperhive/claude-plugins.json` by the harness module) and
|
||||||
|
//! shells out `claude plugin install <spec>` for each entry. Runs once
|
||||||
|
//! per harness boot before the turn loop; `claude plugin install`
|
||||||
|
//! is expected to be idempotent so reinstalling on each container
|
||||||
|
//! recreate is fine. Failures log a warning but do not abort boot —
|
||||||
|
//! we'd rather start without a plugin than refuse to serve.
|
||||||
|
//!
|
||||||
|
//! Before installing, all configured marketplaces are updated so that
|
||||||
|
//! plugin specs resolve against current index data. Marketplace update
|
||||||
|
//! failures are non-fatal — stale index is better than no install attempt.
|
||||||
|
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
||||||
|
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
||||||
|
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
|
||||||
|
|
||||||
|
/// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
|
||||||
|
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of
|
||||||
|
/// an existing marketplace is treated as success (claude prints an
|
||||||
|
/// "already exists" message and exits non-zero on some versions).
|
||||||
|
/// Required before any `<plugin>@<marketplace>` install can resolve.
|
||||||
|
async fn add_marketplaces() {
|
||||||
|
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let sources: Vec<String> = match serde_json::from_str(&raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for source in sources {
|
||||||
|
match Command::new("claude")
|
||||||
|
.args(["plugin", "marketplace", "add", &source])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
tracing::info!(source = %source, "claude plugin marketplace add ok");
|
||||||
|
}
|
||||||
|
Ok(out) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
if stderr.contains("already") {
|
||||||
|
tracing::debug!(source = %source, "marketplace already added");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
source = %source,
|
||||||
|
status = ?out.status,
|
||||||
|
stderr = %stderr,
|
||||||
|
"claude plugin marketplace add failed (non-fatal)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the `hyperhive.claudePluginsAutoUpdate` flag written by the NixOS
|
||||||
|
/// module. Defaults to `false` when the file is absent or unparseable.
|
||||||
|
async fn auto_update_enabled() -> bool {
|
||||||
|
match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await {
|
||||||
|
Ok(s) => serde_json::from_str::<bool>(s.trim()).unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update all configured plugin marketplaces. Non-fatal — logs a warning
|
||||||
|
/// on failure but does not abort the install sequence.
|
||||||
|
async fn update_marketplaces() {
|
||||||
|
match Command::new("claude")
|
||||||
|
.args(["plugin", "marketplace", "update"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
tracing::info!("claude plugin marketplace update ok");
|
||||||
|
}
|
||||||
|
Ok(out) => {
|
||||||
|
tracing::warn!(
|
||||||
|
status = ?out.status,
|
||||||
|
stderr = %String::from_utf8_lossy(&out.stderr),
|
||||||
|
"claude plugin marketplace update failed (non-fatal)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
||||||
|
/// Returns a list of human-readable failure messages so the caller can
|
||||||
|
/// route them through their own per-role surface (turn-failure-style
|
||||||
|
/// notification, see `Surface::send_to_parent`). Wire-agnostic: the
|
||||||
|
/// caller picks the recipient via the same `<parent>` sentinel that
|
||||||
|
/// failure-notify uses everywhere else.
|
||||||
|
pub async fn install_configured() -> Vec<String> {
|
||||||
|
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if specs.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
add_marketplaces().await;
|
||||||
|
if auto_update_enabled().await {
|
||||||
|
update_marketplaces().await;
|
||||||
|
} else {
|
||||||
|
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
||||||
|
}
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for spec in specs {
|
||||||
|
match Command::new("claude")
|
||||||
|
.args(["plugin", "install", &spec])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
tracing::info!(spec = %spec, "claude plugin install ok");
|
||||||
|
}
|
||||||
|
Ok(out) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||||
|
tracing::warn!(
|
||||||
|
spec = %spec,
|
||||||
|
status = ?out.status,
|
||||||
|
stderr = %stderr,
|
||||||
|
"claude plugin install failed",
|
||||||
|
);
|
||||||
|
failures.push(format!(
|
||||||
|
"claude plugin install failed for `{spec}`:\n{}",
|
||||||
|
stderr.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
||||||
|
failures.push(format!(
|
||||||
|
"claude plugin install spawn failed for `{spec}`: {e}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failures
|
||||||
|
}
|
||||||
384
hive-agent/src/prompt.rs
Normal file
384
hive-agent/src/prompt.rs
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
//! System-prompt renderer. Single `prompts/system.md` with
|
||||||
|
//! HTML-comment markers gating role-specific blocks; this module
|
||||||
|
//! assembles the final prompt (always "agent" role — there is only one
|
||||||
|
//! role). Marker grammar + placeholder substitution rules in
|
||||||
|
//! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// Assemble the system prompt for a given label + pronouns + optional hive /
|
||||||
|
/// swarm display names. Pure function — no I/O. Splits out from
|
||||||
|
/// [`write_system_prompt`] so the marker logic + substitution is unit-testable
|
||||||
|
/// in isolation. The caller supplies the template body so tests can pass an
|
||||||
|
/// inline fixture and production reads it once at harness startup via
|
||||||
|
/// [`hive_sh4re::assets::prompt_template`]
|
||||||
|
/// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders +
|
||||||
|
/// marker grammar documented in
|
||||||
|
/// `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn render(
|
||||||
|
template: &str,
|
||||||
|
label: &str,
|
||||||
|
operator_pronouns: &str,
|
||||||
|
hive_name: Option<&str>,
|
||||||
|
swarm_name: Option<&str>,
|
||||||
|
docs_dir: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let body = filter_role_blocks(template, "agent");
|
||||||
|
let qualified = crate::identity::qualify(label);
|
||||||
|
let hive_identity = hive_name
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.map_or(String::new(), |n| format!(" on hive `{n}`"));
|
||||||
|
let swarm_identity = swarm_name
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.map_or(String::new(), |n| format!(" in swarm `{n}`"));
|
||||||
|
let rendered = body
|
||||||
|
.replace("{label}", label)
|
||||||
|
.replace("{qualified_label}", &qualified)
|
||||||
|
.replace("{operator_pronouns}", operator_pronouns)
|
||||||
|
.replace("{hive_identity}", &hive_identity)
|
||||||
|
.replace("{swarm_identity}", &swarm_identity);
|
||||||
|
// When the reference docs are mounted in-container (`hyperhive.docs.enable`
|
||||||
|
// wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer
|
||||||
|
// sentence so the agent knows they exist. Additive: it doesn't replace the
|
||||||
|
// agent's own memory/project instructions. Absent env → no change.
|
||||||
|
match docs_dir.filter(|d| !d.is_empty()) {
|
||||||
|
Some(dir) => format!(
|
||||||
|
"{rendered}\n\nThe hyperhive reference docs (the repo `docs/` tree \
|
||||||
|
describing this live system) are mounted read-only at `{dir}` — read \
|
||||||
|
them (start at the index, then the topic file for the area you're \
|
||||||
|
touching) rather than guessing.\n"
|
||||||
|
),
|
||||||
|
None => rendered,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk `template` line-by-line, stripping `<!-- role:X -->` /
|
||||||
|
/// `<!-- /role:X -->` marker lines and suppressing the lines inside a block
|
||||||
|
/// unless `X == target`. A close marker ends the current block. Production
|
||||||
|
/// `system.md` carries no markers today (single agent role — see the module
|
||||||
|
/// doc), so this is effectively a passthrough; the marker grammar stays wired
|
||||||
|
/// for a future manager / multi-role prompt.
|
||||||
|
fn filter_role_blocks(template: &str, target: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(template.len());
|
||||||
|
// None = outside any block; Some(role) = inside role-tagged block.
|
||||||
|
let mut active_role: Option<&str> = None;
|
||||||
|
for line in template.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(role) = parse_open_marker(trimmed) {
|
||||||
|
active_role = Some(role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if parse_close_marker(trimmed).is_some() {
|
||||||
|
active_role = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if active_role.is_none_or(|role| role == target) {
|
||||||
|
out.push_str(line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<!-- role:agent -->` → `Some("agent")`. Anything else returns
|
||||||
|
/// None. Whitespace inside the marker is tolerated so a future
|
||||||
|
/// author's `<!--role:foo-->` (no spaces) still parses; the dashboard
|
||||||
|
/// markdown renderer is equally lenient. Close tags (`/role:...`)
|
||||||
|
/// can't accidentally match — the `strip_prefix("role:")` rejects
|
||||||
|
/// the leading slash before we'd ever see it.
|
||||||
|
fn parse_open_marker(line: &str) -> Option<&str> {
|
||||||
|
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||||
|
let role = inside.strip_prefix("role:")?.trim();
|
||||||
|
Some(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<!-- /role:agent -->` → `Some("agent")`. Mirror of
|
||||||
|
/// [`parse_open_marker`] for the closing tag.
|
||||||
|
fn parse_close_marker(line: &str) -> Option<&str> {
|
||||||
|
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||||
|
inside.strip_prefix("/role:").map(str::trim)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the assembled prompt to a stable path next to the harness
|
||||||
|
/// socket and return the path. The Rust harness passes this path to
|
||||||
|
/// `claude --system-prompt-file` so the per-turn prompts only carry
|
||||||
|
/// the role + tools instructions in the system slot; per-turn prompts
|
||||||
|
/// become much smaller (just the wake-message body).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the system prompt file cannot be written.
|
||||||
|
pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf> {
|
||||||
|
let parent = crate::paths::config_dir();
|
||||||
|
tokio::fs::create_dir_all(&parent).await.ok();
|
||||||
|
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
|
||||||
|
let template_path = hive_sh4re::assets::prompt_template();
|
||||||
|
let template = tokio::fs::read_to_string(&template_path)
|
||||||
|
.await
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"read claude system prompt template from {}",
|
||||||
|
template_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Surface hive + swarm display names in the prompt opener when
|
||||||
|
// configured. Both `None` falls back to the non-identity wording
|
||||||
|
// verbatim (single-hive deployments see no diff).
|
||||||
|
let hive_name = crate::identity::hive_name();
|
||||||
|
let swarm_name = crate::identity::swarm_name();
|
||||||
|
// `hyperhive.docs.enable` sets HIVE_DOCS_DIR (and the harness passes it to
|
||||||
|
// claude via `--add-dir`); when present, render() appends a pointer line.
|
||||||
|
let docs_dir = std::env::var("HIVE_DOCS_DIR")
|
||||||
|
.ok()
|
||||||
|
.filter(|d| !d.is_empty());
|
||||||
|
let body = render(
|
||||||
|
&template,
|
||||||
|
label,
|
||||||
|
&pronouns,
|
||||||
|
hive_name.as_deref(),
|
||||||
|
swarm_name.as_deref(),
|
||||||
|
docs_dir.as_deref(),
|
||||||
|
);
|
||||||
|
let path = parent.join("claude-system-prompt.md");
|
||||||
|
tokio::fs::write(&path, body).await?;
|
||||||
|
tracing::info!(path = %path.display(), "wrote claude system prompt");
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
// The production template lives at `$HIVE_ASSETS_DIR/prompts/system.md`
|
||||||
|
// and is loaded at runtime. The unit tests below want to assert
|
||||||
|
// against the actual production wording (so the renderer + tool
|
||||||
|
// surface stay honest), so they resolve the same path at test
|
||||||
|
// runtime via two fallbacks:
|
||||||
|
// 1. `$HIVE_ASSETS_DIR/prompts/system.md` — the runtime contract
|
||||||
|
// production uses. The flake's `checks.cargo-test` derivation
|
||||||
|
// sets this to the `hyperhive-assets` output so `cargo test`
|
||||||
|
// inside the nix sandbox finds the file without needing
|
||||||
|
// `prompts/` in the cargo source tree. `packages.default`
|
||||||
|
// explicitly does NOT carry the assets dep, so a prompt edit
|
||||||
|
// doesn't bust the binary derivation — only this test check.
|
||||||
|
// 2. `env!("CARGO_MANIFEST_DIR")/prompts/system.md` — for plain
|
||||||
|
// `cargo test --workspace` from a checked-out repo where the
|
||||||
|
// env var isn't set; `env!` is a compile-time string lookup,
|
||||||
|
// no file open at compile, so this still doesn't pull
|
||||||
|
// `prompts/` into the build hash.
|
||||||
|
// The combined effect is that the flake's `cleanSrc` no longer
|
||||||
|
// unions `./hive-agent/prompts` — tweaks to system.md don't bust
|
||||||
|
// the cargo cache anymore.
|
||||||
|
static PRODUCTION_TEMPLATE: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
let path = match std::env::var("HIVE_ASSETS_DIR") {
|
||||||
|
Ok(v) if !v.is_empty() => format!("{v}/prompts/system.md"),
|
||||||
|
_ => concat!(env!("CARGO_MANIFEST_DIR"), "/prompts/system.md").to_owned(),
|
||||||
|
};
|
||||||
|
std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}"))
|
||||||
|
});
|
||||||
|
|
||||||
|
const SAMPLE: &str = "\
|
||||||
|
shared opener
|
||||||
|
<!-- role:agent -->
|
||||||
|
agent-only line
|
||||||
|
<!-- /role:agent -->
|
||||||
|
<!-- role:manager -->
|
||||||
|
manager-only line
|
||||||
|
<!-- /role:manager -->
|
||||||
|
shared closer
|
||||||
|
";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_keeps_shared_and_target_role() {
|
||||||
|
let agent = filter_role_blocks(SAMPLE, "agent");
|
||||||
|
assert!(agent.contains("shared opener"));
|
||||||
|
assert!(agent.contains("agent-only line"));
|
||||||
|
assert!(!agent.contains("manager-only line"));
|
||||||
|
assert!(agent.contains("shared closer"));
|
||||||
|
// Marker lines themselves are stripped — no `<!--` left behind.
|
||||||
|
assert!(!agent.contains("<!--"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_for_manager_picks_manager_block() {
|
||||||
|
let manager = filter_role_blocks(SAMPLE, "manager");
|
||||||
|
assert!(manager.contains("shared opener"));
|
||||||
|
assert!(!manager.contains("agent-only line"));
|
||||||
|
assert!(manager.contains("manager-only line"));
|
||||||
|
assert!(manager.contains("shared closer"));
|
||||||
|
assert!(!manager.contains("<!--"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_open_marker_handles_whitespace_variants() {
|
||||||
|
assert_eq!(parse_open_marker("<!-- role:agent -->"), Some("agent"));
|
||||||
|
assert_eq!(parse_open_marker("<!--role:agent-->"), Some("agent"));
|
||||||
|
assert_eq!(parse_open_marker("<!-- role:manager -->"), Some("manager"));
|
||||||
|
// Close tags must NOT match open-tag parser.
|
||||||
|
assert_eq!(parse_open_marker("<!-- /role:agent -->"), None);
|
||||||
|
// Non-markers pass through (return None).
|
||||||
|
assert_eq!(parse_open_marker("just text"), None);
|
||||||
|
assert_eq!(parse_open_marker("<!-- not a role -->"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_close_marker_handles_whitespace_variants() {
|
||||||
|
assert_eq!(parse_close_marker("<!-- /role:agent -->"), Some("agent"));
|
||||||
|
assert_eq!(parse_close_marker("<!--/role:manager-->"), Some("manager"));
|
||||||
|
// Open tags must NOT match close-tag parser.
|
||||||
|
assert_eq!(parse_close_marker("<!-- role:agent -->"), None);
|
||||||
|
assert_eq!(parse_close_marker("just text"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_substitutes_label_and_pronouns() {
|
||||||
|
// Real template's first agent line — keeps the renderer
|
||||||
|
// honest about the {label} / {operator_pronouns} pair the
|
||||||
|
// harness already relied on.
|
||||||
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "they/them", None, None, None);
|
||||||
|
assert!(rendered.contains("hyperhive agent `alice`"));
|
||||||
|
assert!(rendered.contains("**they/them** pronouns"));
|
||||||
|
assert!(!rendered.contains("{label}"));
|
||||||
|
assert!(!rendered.contains("{operator_pronouns}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_no_role_markers_in_output() {
|
||||||
|
// No raw role markers should survive into the rendered prompt.
|
||||||
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
|
||||||
|
assert!(!rendered.contains("<!-- role:"));
|
||||||
|
assert!(!rendered.contains("<!-- /role:"));
|
||||||
|
// Shared tools appear.
|
||||||
|
assert!(rendered.contains("mcp__hyperhive__recv"));
|
||||||
|
assert!(rendered.contains("mcp__hyperhive__ask"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_uses_agent_opener() {
|
||||||
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
|
||||||
|
assert!(rendered.starts_with("You are hyperhive agent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline fixture for the {hive_identity} / {swarm_identity}
|
||||||
|
// placeholders. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
|
||||||
|
// against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake
|
||||||
|
// builds at derivation time — a fresh placeholder added on the
|
||||||
|
// source side isn't in the shipped asset until the flake rebuilds,
|
||||||
|
// so PRODUCTION_TEMPLATE can't be the fixture here. The string
|
||||||
|
// below carries just enough of the opener shape to exercise the
|
||||||
|
// substitution logic; nothing here depends on the production
|
||||||
|
// template's flavor markers.
|
||||||
|
const IDENTITY_FIXTURE: &str = "\
|
||||||
|
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. Pronouns: **{operator_pronouns}**.
|
||||||
|
";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_substitutes_hive_identity_when_set() {
|
||||||
|
let rendered = render(
|
||||||
|
IDENTITY_FIXTURE,
|
||||||
|
"alice",
|
||||||
|
"she/her",
|
||||||
|
Some("pr1ma"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
|
||||||
|
// swarm clause stays absent when only hive is set.
|
||||||
|
assert!(!rendered.contains("in swarm"));
|
||||||
|
// No raw placeholder leaks.
|
||||||
|
assert!(!rendered.contains("{hive_identity}"));
|
||||||
|
assert!(!rendered.contains("{swarm_identity}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_substitutes_swarm_identity_when_set() {
|
||||||
|
let rendered = render(
|
||||||
|
IDENTITY_FIXTURE,
|
||||||
|
"ruth",
|
||||||
|
"she/her",
|
||||||
|
None,
|
||||||
|
Some("constellat1on"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(rendered.contains("in swarm `constellat1on`"));
|
||||||
|
assert!(!rendered.contains("on hive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_substitutes_both_when_both_set() {
|
||||||
|
let rendered = render(
|
||||||
|
IDENTITY_FIXTURE,
|
||||||
|
"iris",
|
||||||
|
"she/her",
|
||||||
|
Some("pr1ma"),
|
||||||
|
Some("constellat1on"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
// Order: hive then swarm, both inline before "in a multi-agent
|
||||||
|
// system" — keeps the opener grammar intact.
|
||||||
|
assert!(rendered.contains("on hive `pr1ma` in swarm `constellat1on`"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_omits_identity_when_unset() {
|
||||||
|
// None / None must round-trip the non-identity opener verbatim
|
||||||
|
// — single-hive deployments see zero diff.
|
||||||
|
let rendered = render(IDENTITY_FIXTURE, "alice", "she/her", None, None, None);
|
||||||
|
assert!(!rendered.contains("on hive"));
|
||||||
|
assert!(!rendered.contains("in swarm"));
|
||||||
|
assert!(!rendered.contains("{hive_identity}"));
|
||||||
|
assert!(!rendered.contains("{swarm_identity}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_treats_empty_identity_as_none() {
|
||||||
|
// Defensive: an env var set to empty string round-trips
|
||||||
|
// through `identity::hive_name()` as None (the accessor
|
||||||
|
// filters empty), but `render` should still no-op on a
|
||||||
|
// direct `Some("")` from a test fixture or a future caller.
|
||||||
|
let rendered = render(
|
||||||
|
IDENTITY_FIXTURE,
|
||||||
|
"alice",
|
||||||
|
"she/her",
|
||||||
|
Some(""),
|
||||||
|
Some(""),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(!rendered.contains("on hive"));
|
||||||
|
assert!(!rendered.contains("in swarm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_appends_docs_pointer_when_docs_dir_set() {
|
||||||
|
let rendered = render(
|
||||||
|
&PRODUCTION_TEMPLATE,
|
||||||
|
"alice",
|
||||||
|
"she/her",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("/run/hive-docs"),
|
||||||
|
);
|
||||||
|
// Key on a phrase unique to the pointer sentence — "reference docs"
|
||||||
|
// alone also appears in the /knowledge blurb of the base template.
|
||||||
|
assert!(
|
||||||
|
rendered.contains("mounted read-only at `/run/hive-docs`"),
|
||||||
|
"expected docs pointer sentence with the dir:\n{rendered}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
|
||||||
|
for docs in [None, Some("")] {
|
||||||
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, docs);
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("mounted read-only at"),
|
||||||
|
"docs pointer must not appear for {docs:?}:\n{rendered}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue