docs: sort the top-level directories into five sections by what the reader wants
Every docs directory moves under 1-getting-started, 2-handbook, 4-implementation or 5-project; the options reference (section 3) comes from the website and has no directory here. trust-boundary stays whole under the handbook for now. web-ui, swarm and tools split between handbook and implementation, and the command-line references move to 2-handbook/reference. Links, path references in code and nix, and the docs index follow the moves; no page content changes.
This commit is contained in:
parent
53ccbcb4b3
commit
6739bb903d
3 changed files with 736 additions and 701 deletions
586
docs/5-project/process/conventions.md
Normal file
586
docs/5-project/process/conventions.md
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
# Conventions
|
||||
|
||||
Code-style and process expectations across the workspace. Most of these
|
||||
exist because something already went wrong without them.
|
||||
|
||||
## Naming
|
||||
|
||||
- Containers are length-bounded by `nixos-container` (≤ 11 chars).
|
||||
- Sub-agents are `h-<name>` with `<name>` ≤ 9 chars.
|
||||
- One agent is the bootstrap/root container, with a fixed name (`ruth` today).
|
||||
- `MAX_AGENT_NAME` in `hive-c0re/src/lifecycle/mod.rs` enforces the cap.
|
||||
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
|
||||
(8100..8999) for every agent; dashboard
|
||||
`services.hyperhive.deploy.hive-controller.dashboardPort` (default 7000).
|
||||
|
||||
## Hive identity (label + domain + display names)
|
||||
|
||||
Four env vars cover the identity surface, read by
|
||||
`hive-agent/src/identity.rs`:
|
||||
|
||||
- `HIVE_LABEL` — short, hive-local agent label (`iris`,
|
||||
`damocles`). `label()` returns it; falls back to empty string if
|
||||
the env var is missing so downstream callers can decide how to
|
||||
surface "unknown agent" rather than getting a panic from this
|
||||
module.
|
||||
- `HYPERHIVE_HIVE_DOMAIN` — the hive's canonical DNS domain (e.g.
|
||||
`darkest.space`), set by `nix/host-modules/hive-c0re/environment.nix`
|
||||
from `services.hyperhive.domain`. When configured, `qualify(label)`
|
||||
returns `${label}@${domain}` (for example `iris@darkest.space`); when
|
||||
unset (single-hive deployments, dev/test) it degrades to just
|
||||
the short label so existing callers see no change. The
|
||||
qualified form surfaces in the per-agent web UI title, the
|
||||
system-prompt template, and `/api/state.qualified_label`.
|
||||
- `HYPERHIVE_HIVE_NAME` — human display name of this hive
|
||||
(`pr1ma`). Read by `hive_name()`; `None` when unset.
|
||||
- `HYPERHIVE_SWARM_NAME` — human display name of the wider swarm
|
||||
this hive belongs to (`constellat1on`). Read by `swarm_name()`;
|
||||
federated hives at different DNS domains can share a swarm
|
||||
name.
|
||||
|
||||
Inside an agent container, the hive's name and domain don't come from the
|
||||
agent's build, and neither do the hive's other per-host values: the dashboard
|
||||
port (`HIVE_DASHBOARD_PORT`), the pinned claude-code
|
||||
(`HYPERHIVE_CLAUDE_CODE_PATH`), extra OTEL labels
|
||||
(`HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`) and the queue's address
|
||||
(`HIVE_AGENT_NATS_URL`, from `deploy.hive-controller.queue.agentNatsUrl`) and the egress proxy's address and direct hosts (`HIVE_SQUID_PROXY`, `HIVE_EGRESS_NO_PROXY`, see [the egress proxy](../../4-implementation/networking/network.md#egress-proxy-squid)). `hive-priv` writes them to
|
||||
`/etc/hyperhive-hive-facts` before every container start, leaving out any the
|
||||
hive doesn't set; the agent then uses its own default. The agent's environment
|
||||
generator (`nix/agent-modules/hive-facts.nix`) passes all but the two egress facts, which only the proxy fetch reads, to every unit,
|
||||
including `hivectl choom` sessions, and `/etc/set-environment` exports them in login
|
||||
shells that no unit starts (`nixos-container root-login`). None is part of
|
||||
an agent's closure, and an agent started on another hive reports that hive.
|
||||
|
||||
`hive_name` + `swarm_name` are **distinct** from
|
||||
`HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as
|
||||
its leftmost label by convention, but the convention isn't
|
||||
machine-readable, and federated hives at different DNS domains
|
||||
can share a swarm name. Humans want both: the address
|
||||
(`@darkest.space`) AND the prose name (`pr1ma`). Matrix MXIDs
|
||||
use the domain-based convention.
|
||||
|
||||
## Identity = socket
|
||||
|
||||
No auth tokens exist on the per-agent unix sockets. The socket
|
||||
*path* identifies the principal; perms come from "who has the
|
||||
bind-mount." A sub-agent only sees its own `/run/hive/mcp.sock`;
|
||||
hive-c0re owns the host admin socket.
|
||||
|
||||
### Wake injection
|
||||
|
||||
`hive_core_agent_sock::Request::Wake { from, body }` is the
|
||||
wake-event-injection surface on the host-served per-agent socket.
|
||||
Recipient is implicit — the agent the socket belongs to — and `from`
|
||||
is caller-chosen so the wake prompt can label the source verbatim
|
||||
(`"matrix: new message in #general"`, `"forge: PR #42 opened"`, etc.). <!-- lint:allow: example strings, not real tags -->
|
||||
`hive-c0re` handles it; matrix, bash and forge don't call it — their
|
||||
notifications upsert a todo on the harness's in-agent socket
|
||||
(`HIVE_AGENT_SOCKET`), which signals the same turn loop without a
|
||||
hive-c0re round-trip. See
|
||||
[`docs/4-implementation/turn-loop/mcp.md` § Waking the agent from inside the
|
||||
container](../../4-implementation/turn-loop/mcp.md#waking-the-agent-from-inside-the-container).
|
||||
|
||||
Identity = socket means anything that can connect to
|
||||
`/run/hive/mcp.sock` is implicitly trusted to inject wakes. That's
|
||||
fine: the bind-mount only exposes the socket inside the agent's own
|
||||
container, so the trust boundary is the container's process
|
||||
namespace, not the wire surface.
|
||||
|
||||
## Recipient sentinels
|
||||
|
||||
The broker reserves a few recipient names, which have a fixed
|
||||
meaning that ordinary agent labels can never collide with — agent
|
||||
name validation rejects any character outside `[a-z0-9_-]`, so the
|
||||
angle-bracket and asterisk shapes below are structurally safe.
|
||||
|
||||
- `*` — broadcast: deliver to every running agent except the sender
|
||||
(`socket_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
||||
- `operator` — the human at the dashboard. Reserved so no agent can take
|
||||
the name, and refused as a recipient: the broker answers a `send` to it
|
||||
with `OPERATOR_SEND_REFUSAL` (`hive-sh4re/src/manager.rs`). It appears
|
||||
only as the sender of operator-authored messages.
|
||||
|
||||
The broker stores the recipient exactly as the agent passed it.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
JSON line-delimited over unix sockets in both directions (host admin
|
||||
/ manager / agent). SSE streams (`/dashboard/stream` on hive-c0re,
|
||||
`/events/stream` on the per-agent web UIs) are `text/event-stream`;
|
||||
each frame carries a `seq` field for the snapshot-dedupe dance
|
||||
(see `docs/4-implementation/web-ui/shape.md`). Request/response types live in `hive-sh4re`
|
||||
— change them in one place. The dashboard event vocabulary lives
|
||||
in `hive-c0re::dashboard_events::DashboardEvent`.
|
||||
|
||||
### Broker delivery + ack cycle
|
||||
|
||||
`AgentRequest::Recv` is the only path that delivers messages to an
|
||||
agent. Always returns a list (`Messages { messages }`) — empty when
|
||||
nothing's pending, single-pop when `max = None` (default 1, the
|
||||
single-message behaviour), batched up to `max` when caller asks for
|
||||
more (server-side cap is 5; values above clamp silently). The wire
|
||||
request still carries an optional `wait_seconds` (long-poll the first
|
||||
message, once one arrives — or one is already pending — the call
|
||||
drains up to `max` in total): the harness's own turn-driving loop
|
||||
uses it internally (`hive-agent`'s `recv_next`, 180s). The
|
||||
agent-facing MCP `recv` tool doesn't expose this parameter — it
|
||||
always passes `wait_seconds: None`, an immediate peek.
|
||||
|
||||
Per-row bookkeeping inside the broker:
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
- `delivered_at = NOW` set on every popped row.
|
||||
- Each recipient has an in-memory `unacked_ids` list of every row
|
||||
delivered since the last `AckTurn`.
|
||||
- `redelivered = true` on a row if `RequeueInflight` resurfaced it
|
||||
(the harness prepends a "may already be handled" hint when this
|
||||
flag is set so the per-message warning is visible).
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
`AgentRequest::AckTurn` closes out the in-memory list — the harness
|
||||
fires it after `TurnOutcome::Ok`, marking every message popped since
|
||||
the last ack as fully handled. Claude doesn't see this surface; it's
|
||||
strictly a harness↔broker pairing. On `TurnOutcome::Failed` the
|
||||
harness intentionally skips the ack so the unacked rows stay
|
||||
in-flight in the DB and get picked up by the next requeue sweep.
|
||||
|
||||
`AgentRequest::RequeueInflight` is the recovery pair: fired by the
|
||||
harness exactly once at boot, before the serve loop starts. Catches
|
||||
the crashed-mid-turn / OOM-killed / container-restarted cases where
|
||||
a previous harness session popped messages but never drove them to
|
||||
a clean turn-end. Resets `delivered_at` back to NULL on every
|
||||
unacked row (so the next `Recv` pops them again), and remembers
|
||||
each id in a per-recipient in-memory set so the next `Recv` can tag
|
||||
the row with `redelivered: true`. Idempotent + cheap when there's
|
||||
nothing in flight, so the at-boot fire is unconditional.
|
||||
|
||||
`AgentRequest::AckUntil { up_to }` is the agent-facing bulk-triage
|
||||
escape hatch (`mcp__hyperhive__ack_until`). Unlike `AckTurn` **it's**
|
||||
visible to claude: each recv row and wake prompt carries a
|
||||
`[msg #<id>]` marker (the broker row id; transient pings show no
|
||||
marker — their sentinel id 0 has nothing to ack), and
|
||||
`ack_until(up_to: n)` marks every one of the agent's rows with
|
||||
`id <= n` handled in a single UPDATE — pending and delivered alike.
|
||||
This bounds the redelivered-flood cost after a restart: instead of
|
||||
popping dozens of already-handled messages one turn at a time, the
|
||||
agent notes the highest id it has seen and acks up to it.
|
||||
Recipient-scoped (an agent can only ack its own rows); also drains
|
||||
the in-memory `unacked_ids` / `requeued_ids` bookkeeping below the
|
||||
cutoff so a later `AckTurn` doesn't double-update and a stale
|
||||
redelivery tag can't outlive its row. The operator-side sibling is
|
||||
the dashboard's "mark all read" (unbounded, per-agent).
|
||||
|
||||
### Loose-ends wire shape
|
||||
|
||||
`LooseEnd` is the per-row response shape for `GetLooseEnds` — one
|
||||
request shape, no per-socket flavours. Tagged enum so new thread kinds
|
||||
(forge PRs, etc.) can land later without breaking existing handlers.
|
||||
Each row carries enough context that the caller renders
|
||||
it directly as a bulleted list, no follow-up fetch needed.
|
||||
|
||||
`GetLooseEnds` takes no target — it always returns the caller's own
|
||||
rows. `Reminder` rows always carry `owner == self`.
|
||||
|
||||
Per-variant fields:
|
||||
|
||||
- `Reminder { id, owner, message, due_at, age_seconds }` —
|
||||
`due_at` is the absolute time the scheduler is targeting (RFC
|
||||
3339 on the wire, see *Timestamps on the wire* below); clients
|
||||
compute time-until-fire against it.
|
||||
- `PendingMessages { count }` — undelivered inbox messages the
|
||||
agent still owes itself a `recv` for. Informational + not
|
||||
cancellable (drain with `recv`); only emitted when `count > 0`,
|
||||
and surfaced first in the list as the most
|
||||
actionable signal. Counted host-side from the broker
|
||||
(`count_pending`), so it reflects what's genuinely still queued —
|
||||
the wake-message that drove the current turn is already delivered
|
||||
and not counted.
|
||||
- `UnreadMatrix { rooms, summary }` — unread matrix notifications.
|
||||
Informational + not cancellable (clear with `mark_read`). Unlike
|
||||
the others, the in-container harness injects this, not hive-c0re,
|
||||
because the matrix daemon lives inside the agent.
|
||||
|
||||
`age_seconds` saturates at zero on any clock anomaly (back-step,
|
||||
unsynchronised wall clock, etc.) so the bulleted list never
|
||||
shows nonsense ages.
|
||||
|
||||
`cancel_loose_end` is the matching write surface, handled in the
|
||||
harness without a hive-c0re round-trip: `reminder` deletes one of the
|
||||
caller's own pending reminders, `todo` clears one of its todos.
|
||||
|
||||
### Agent metadata
|
||||
|
||||
`AgentRequest::GetAgentMeta { name }` returns identity + status for
|
||||
an agent. Self-introspection when `name = None`; target query when
|
||||
`name = Some`.
|
||||
|
||||
Response is `AgentMeta { name, running, hyperhive_rev,
|
||||
status_text, status_set_at, hive_name, swarm_name, matrix_accounts }`:
|
||||
|
||||
- `hyperhive_rev`: `None` only when the configured flake URL has
|
||||
no canonical path. Otherwise carries the rev the target is
|
||||
currently pinned at.
|
||||
- `running`: whether the target's container is currently up. When
|
||||
`false`, the host clears `status_text` / `status_set_at` —
|
||||
on-disk values from before the stop are stale snapshots, not
|
||||
live status. Defaults to `true` on the
|
||||
wire: the deserializer treats a payload without the field as
|
||||
running.
|
||||
- `status_text` / `status_set_at`: last value written via
|
||||
`SetStatus`, plus its unix timestamp. Both `None` when the
|
||||
target has never set a status, when the agent name is unknown,
|
||||
or when `running = false` (see above).
|
||||
- `hive_name` / `swarm_name`: display names read from
|
||||
`HYPERHIVE_HIVE_NAME` / `HYPERHIVE_SWARM_NAME` env (sourced from
|
||||
`services.hyperhive.deploy.hive-controller.hiveName` / `services.hyperhive.swarm.name`).
|
||||
Both `None` when the options aren't configured.
|
||||
- `matrix_accounts`: one `MatrixIdentity` per configured + live matrix
|
||||
account the agent can act as. Empty for agents with no matrix
|
||||
provisioning.
|
||||
|
||||
### Timestamps on the wire
|
||||
|
||||
Timestamp fields that cross a JSON boundary (dashboard API + SSE,
|
||||
the wire structs in hive-sh4re) serialize as **RFC 3339 UTC strings**
|
||||
(`2026-07-02T18:30:00Z`) via `hive_sh4re::wire_time` — Rust keeps the
|
||||
fields as `i64` unix seconds internally, only the JSON representation
|
||||
changes, and deserialization accepts both the string form and a bare
|
||||
integer (rolling-deploy skew, persisted blobs).
|
||||
**Input-direction** fields agents compute as epoch (`remind`'s
|
||||
`at_unix_timestamp`) stay integers. The
|
||||
`*_unix` field *names* stay for now — renaming is the wire-types
|
||||
refactor's concern. The dashboard frontend parses via
|
||||
`util.js::epochSec` wherever it needs arithmetic and feeds the string
|
||||
straight to `new Date(s)` for display.
|
||||
|
||||
### HTTP error bodies
|
||||
|
||||
Every HTTP API in this repo answers failures with **RFC 9457
|
||||
`application/problem+json`** (`{ type, title, status, detail }`), with the
|
||||
human-readable cause in `detail`. An endpoint returning a bare string
|
||||
or a bespoke error shape is a **bug to file against the daemon that
|
||||
returned it**, not something for the caller to work around.
|
||||
|
||||
Use the `problem_details` crate (`features = ["axum"]`), which the daemons
|
||||
already depend on: type a handler `Result<_, ProblemDetails>` and hand
|
||||
`ProblemDetails::from_status_code(...).with_detail(...)` to `Err`.
|
||||
|
||||
The reason is the consumer, not tidiness. The UIs show errors through one
|
||||
shared component with a copy button, so a caller has to know **which part of
|
||||
the body is the message**. A bare string forces it to treat the whole payload
|
||||
as prose, which is the difference between offering "copy the cause" and
|
||||
dumping a response — and the cause is frequently the entire diagnosis (a
|
||||
JetStream permission refusal, a TLS chain failure) rather than a summary.
|
||||
|
||||
Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a
|
||||
JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
|
||||
|
||||
## Permissions
|
||||
|
||||
One set of named `Permission` values (`hive_types::Permission`)
|
||||
decides what an agent may do: which MCP and built-in tools it gets, and which
|
||||
host access it has.
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `messaging` | `send`, `recv`, `ack_until` |
|
||||
| `meta` | `get_agent_meta` (`set_status` is always-on, see below) |
|
||||
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` |
|
||||
| `execution` | the `bash` MCP server (`mcp__bash__run`/`status`/`kill`), rendered into the agent's MCP config only with it; a subagent gets Claude's own `Bash` instead. See `docs/2-handbook/tools/bash.md`. |
|
||||
| `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` |
|
||||
| `manage_root_agent` | every agent's state (rw) bind-mounted into the holder's container, plus `/meta` (ro) |
|
||||
| `logs` | the log store: `swarm-logs` queries. Checked by `swarm-logs-auth` at the gateway, not by a tool list. Off by default. |
|
||||
| `metrics` | the metrics store: `swarm-metrics` queries. Checked by `swarm-metrics-agent-auth`, the same binary, for the metrics gateway. Off by default. |
|
||||
|
||||
**Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`,
|
||||
`compact`, and `mark_todos_done` to every agent regardless of its
|
||||
permissions. The operator dashboard depends on every agent being able to
|
||||
report its status chip, and the server-side `SetStatus` handler has no
|
||||
permission check (only length validation), so gating it would only desync
|
||||
the `--allowedTools` list from what the host actually accepts. Revoking
|
||||
`meta` therefore drops `get_agent_meta` but never `set_status`.
|
||||
`mark_todos_done` is here because producers push todos to an agent whether
|
||||
or not it holds `inbox`, and an agent without it still needs to clear them.
|
||||
|
||||
**Where they live** — in the swarm, not on the hive: the `permissions` field of
|
||||
the agent's key in the `agent-permissions` KV bucket
|
||||
(`swarm-wire/src/agent_permissions.rs`), which swarm-controller alone
|
||||
writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
|
||||
(`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen
|
||||
(`permissions` in `POST /api/agents`). A missing key or an unset field means
|
||||
the default (`Permission::DEFAULT`): `messaging`, `meta`, `inbox`,
|
||||
`execution`, for every agent whatever its name. `"permissions": []` grants
|
||||
none. swarm-controller logs every refused `PUT` or `DELETE` on that route at `warn` as
|
||||
`agent permissions: request refused`, with the fields `op`, `agent`, `status` and `detail`.
|
||||
|
||||
**`logs` and `metrics`** — off for every agent until granted, and separate
|
||||
because logs are the more sensitive. Grant one with
|
||||
`swarmctl agent permissions set <agent>` (listing it with the others) or the
|
||||
create-agent screen; the setup assistant creates ruth with both. An agent
|
||||
without it gets a 403 from the store's gateway when `swarm-logs` or
|
||||
`swarm-metrics` queries; a bad secret is still a 401.
|
||||
|
||||
An agent decodes its own record, in its harness
|
||||
(`hive-agent/src/swarm_permissions.rs`, through `swarm_queue_client::agent_permissions::read`),
|
||||
and a build that doesn't know a name would drop to no permissions. The controller
|
||||
therefore refuses a change that adds `logs` or `metrics` to an existing
|
||||
agent, with a 409, unless both the agent's hive (`tolerant-permissions` in its
|
||||
status snapshot) and its running harness (the same entry in its turn-state
|
||||
header, `swarm_wire::hive_status`) advertise it. A harness not yet updated, a
|
||||
stopped agent and one that hasn't sent its first header (up to a minute after
|
||||
a restart) all refuse; keeping a grant it already holds needs no report. A
|
||||
create has no harness yet and checks the hive only.
|
||||
|
||||
The controller mirrors the two grants to the store leaf
|
||||
`swarm/agents/<agent>/observability` (`swarm_secret_client::observability`),
|
||||
which the gateways read, and the gateway denies when it can't read that leaf.
|
||||
A change that drops a grant writes the leaf first and the record second, so a
|
||||
failed write changes nothing; a change that adds one writes the record first,
|
||||
so a failed write leaves the gateway denying. Every five minutes the controller
|
||||
also makes each agent's leaf equal its record and logs a warning for each leaf
|
||||
it corrects. A revoked grant stops working within `authVerdictTtl` (60
|
||||
seconds by default) of the agent's last allowed query.
|
||||
|
||||
**Internet flag** — the record also carries `unrestricted_internet`, a
|
||||
boolean that isn't a `Permission`. An absent value means on, so every agent
|
||||
that existed before the field keeps the open access it had. `POST /api/agents`
|
||||
writes `false` for an agent that has no record, unless the request names a
|
||||
value. Over an existing record (a re-create) it keeps the stored list and flag,
|
||||
an absent flag included, and a value in the request wins. A `PUT` that leaves
|
||||
the field out and a `DELETE` (`swarmctl agent permissions clear`) also keep the
|
||||
stored value; a `DELETE` on an agent that has one rewrites the record to just
|
||||
that value. Read it with
|
||||
`swarm_queue_client::agent_permissions::unrestricted`. The hive reads it for
|
||||
the agent's squid policy: [the egress proxy](../../4-implementation/networking/network.md#egress-proxy-squid)
|
||||
lets an unrestricted agent's credential reach any destination, and holds the
|
||||
others to the base list and the allow-list. An agent sends its credential once it has fetched it, from its next start.
|
||||
|
||||
**Log mode** — `internet_log` is a second boolean, meaningful only beside an
|
||||
explicit `unrestricted_internet: false`; read the pair with
|
||||
`swarm_queue_client::agent_permissions::internet_mode`. A `PUT` that sets
|
||||
`unrestricted_internet` without `internet_log` turns it off, the route refuses
|
||||
`internet_log: true` unless the stored or requested flag is `false`, and a
|
||||
`DELETE` keeps it. A hive that doesn't read the field sees `false` and enforces
|
||||
the list. The proxy's handling is under _Log mode_ in [the egress proxy](../../4-implementation/networking/network.md#egress-proxy-squid).
|
||||
|
||||
**How a change applies** —
|
||||
- The agent's harness reads its own key every 10 seconds and builds every
|
||||
turn's `--tools`, `--allowedTools` and MCP config from the last value, so a
|
||||
change reaches the next turn. An ACP agent's permission check reads it on
|
||||
each request.
|
||||
- The subagent daemon reads the key on each `start`/`continue`; a running
|
||||
subagent keeps its tools.
|
||||
- hive-c0re reads each of its agents' keys every 30 seconds
|
||||
(`workers/swarm_permissions.rs`) into
|
||||
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo.
|
||||
nspawn fixes the bind mounts at container start, so when an agent's
|
||||
`manage_root_agent` changes hive-c0re restarts it after its current turn.
|
||||
- Until its first read succeeds, an agent holds no permissions: no
|
||||
`execution` (so no `bash`), only the always-on tools. A subagent it starts
|
||||
holds the same. After a read, an unset key means the default. A failed read
|
||||
keeps the last value read, so an unreachable queue at start keeps the agent
|
||||
closed until a read succeeds.
|
||||
|
||||
Every reader keeps its last good value on a failed read.
|
||||
|
||||
**Moving off the meta repo** — on its first boot with this code a hive copies
|
||||
`meta/capabilities.json` and `meta/tool-groups.json` into the stored value,
|
||||
sends them to swarm-controller once as one list per agent (without
|
||||
`lifecycle` and `forge`, which gave no tools; the controller writes a key
|
||||
only where there is none, and only for agents that hive's wanted state
|
||||
declares), and deletes
|
||||
both files from the meta repo when the controller answers
|
||||
(`workers/permissions_import.rs`). Until then hive-c0re reads no keys. An
|
||||
agent reads a key the controller hasn't yet written as unset, the default,
|
||||
so an agent whose meta grant differs from the default holds the default
|
||||
until the import lands.
|
||||
|
||||
**Not configurable from `agent.nix`** — that file changes through config PRs
|
||||
an agent can open, so letting it declare its own permissions would let an
|
||||
agent grant itself any tool by submitting a config commit, bypassing the
|
||||
operator gate.
|
||||
|
||||
**Adding to the set** — add a variant to `Permission` in
|
||||
`hive-types/src/permission.rs` and to `Permission::ALL`, and enforce it where
|
||||
it applies. A new `#[tool]` fn on `AgentServer` (`hive-agent-mcp/src/mcp/mod.rs`)
|
||||
goes into the matching `Permission::tools()` slice: that's the single source
|
||||
of truth `mcp_config::allowed_mcp_tools` (`hive-agent/src/mcp_config.rs`)
|
||||
reads each turn.
|
||||
|
||||
## Async forms
|
||||
|
||||
Dashboard + per-agent mutating forms carry `data-async`; the shared
|
||||
`bindAsyncForms` `submit` listener (`frontend/packages/shared/src/forms.js`,
|
||||
imported as `@hive/shared/forms.js` and wired up from `tabs.js` on the
|
||||
dashboard and `app.js` on the per-agent UI) intercepts, shows a spinner,
|
||||
POSTs `application/x-www-form-urlencoded` (axum's `Form` extractor
|
||||
rejects multipart), calls `refreshState()` on success. New mutating
|
||||
forms should add `data-async` and optionally `data-confirm` (for a
|
||||
JS-side `confirm()` prompt).
|
||||
|
||||
`refreshState` defers automatically when `document.activeElement`
|
||||
sits inside a managed section so the operator's typing isn't lost;
|
||||
collapsible `<details data-restore-key=…>` survive the re-render
|
||||
via `snapshotOpenDetails` / `restoreOpenDetails`.
|
||||
|
||||
## `rebuild` is the reconcile verb
|
||||
|
||||
`job_queue::templates::rebuild` builds the DAG that reconciles a
|
||||
container to its wanted state: it folds `write_dropins` (the nspawn-conf
|
||||
rewrite — `PRIVATE_NETWORK=1`, `HOST_ADDRESS` = the bridge gateway IP,
|
||||
sets `EXTRA_NSPAWN_FLAGS` — plus the systemd resource-limits drop-in)
|
||||
into the `Swap` node, then runs `nixos-container update` + stop +
|
||||
start across the `StopForUpdate → Swap → RebuildBookkeeping`
|
||||
brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's
|
||||
`agent-configs/<name>` repo and rides along with its pinned commit (see
|
||||
`docs/2-handbook/agent-lifecycle/config-changes.md::Deploy markers per agent`).
|
||||
|
||||
Anything that changes per-container state on the host should be
|
||||
re-applied here so a manual `↻ R3BU1LD` from the dashboard is
|
||||
sufficient to recover.
|
||||
|
||||
## Actions live in one place
|
||||
|
||||
`destroy` (and the lifecycle helper) live in
|
||||
`actions.rs` / `hive-c0re/src/dashboard/`. The admin socket and the dashboard
|
||||
POST handlers both call into them so the two surfaces never drift.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Short, lowercase, no `Co-Authored-By` trailer. Imperative mood, no
|
||||
period. Body explains *why* when the reasoning isn't self-evident;
|
||||
otherwise the subject alone is fine. Wrap at ~72 cols.
|
||||
|
||||
## Commit before test
|
||||
|
||||
Stage and commit when work *looks* ready, then run validation
|
||||
(`cargo check`, `nix flake check`, real deploy). Failures get a
|
||||
follow-up commit rather than an amend. The commit history is the
|
||||
work log; rewriting it loses signal.
|
||||
|
||||
## Building & local checks
|
||||
|
||||
Build through the **flake devshell**, not a bare toolchain — agent
|
||||
containers ship no global rust. `nix develop -c <cmd>` runs one
|
||||
command inside the project-pinned env (cargo/clippy/rustfmt plus the
|
||||
C compiler + `libsqlite3`/`ring` link deps); outside it a bare
|
||||
`cargo build` fails with `failed to find tool "cc"` / `cannot find
|
||||
-lsqlite3`. One command per invocation — agents run each task as a
|
||||
fresh non-interactive process, so there's no persistent shell to
|
||||
reuse.
|
||||
|
||||
```sh
|
||||
nix develop -c cargo clippy --all-targets -- -D warnings
|
||||
nix develop -c cargo test
|
||||
nix fmt # treefmt — authoritative, NOT bare cargo fmt
|
||||
```
|
||||
|
||||
`nix fmt` (treefmt) is the formatter CI gates on; bare `cargo fmt`
|
||||
misses the non-rust files treefmt also covers, so always run `nix
|
||||
fmt` before pushing.
|
||||
|
||||
**Clippy discipline — never add `#[allow(clippy::…)]`.** All lints are
|
||||
CI-fatal at `-D warnings` (pedantic included); every warning that fires
|
||||
must be *fixed*, not silenced. Common patterns:
|
||||
|
||||
- `too_many_lines` — extract a helper function or a sub-struct
|
||||
(the `TurnAccum` extraction in `stats.rs` is a worked example).
|
||||
- `doc_markdown` (brand name without backticks in a doc comment) — add
|
||||
backticks: `` `DOMPurify` `` instead of `DOMPurify`.
|
||||
- `must_use` / `unused_results` — actually handle or explicitly discard
|
||||
the return value (`let _ = …` is fine when intentional).
|
||||
|
||||
If a lint seems wrong for a specific call site, file an issue and ask
|
||||
mara — don't add `#[allow]` speculatively. The gate is intentional.
|
||||
|
||||
**The devshell checks aren't the full `nix flake check`.** Clippy /
|
||||
fmt / `cargo test` cover most gates, but `nix flake check` runs extra
|
||||
check derivations they don't:
|
||||
|
||||
- **`hivectl-docs`** regenerates `docs/2-handbook/reference/hivectl-cli.md` from
|
||||
hivectl's clap tree and **fails if the committed copy is stale**.
|
||||
**After any change to a hivectl verb or flag, regenerate it**:
|
||||
```sh
|
||||
nix develop -c cargo run --bin hivectl -- markdown-docs > docs/2-handbook/reference/hivectl-cli.md
|
||||
```
|
||||
clippy / fmt / `cargo test` all pass *without* this — only the
|
||||
flake check catches the drift, and `ci-log` often can't show you
|
||||
why (it 500s on a fast failure), so you're left guessing "builder
|
||||
flake" when it's a stale doc.
|
||||
- **`swarmctl-docs`** is the same check for `swarmctl` /
|
||||
`docs/2-handbook/reference/swarmctl-cli.md`:
|
||||
```sh
|
||||
nix develop -c cargo run --bin swarmctl -- markdown-docs > docs/2-handbook/reference/swarmctl-cli.md
|
||||
```
|
||||
- there's also a flake `cargo-test` check and NixOS module
|
||||
evaluation in the set.
|
||||
|
||||
When local clippy/fmt/test pass but CI's `nix flake check` fails,
|
||||
**don't assume a transient builder problem** — reproduce the real
|
||||
gate locally: `nix flake check` (shares the build farm, use
|
||||
sparingly) or build just the suspect check, for example `nix build
|
||||
.#checks.x86_64-linux.hivectl-docs`.
|
||||
|
||||
## Testing seams and test doubles
|
||||
|
||||
- **A new test seam is a trait, used generically** (`impl Seam` or a type
|
||||
parameter). No `dyn Fn` closure bags: a trait names each question the code
|
||||
asks and gives the test one place to answer it.
|
||||
- **A double that only answers and records uses `mockall`** (a
|
||||
dev-dependency): put `#[cfg_attr(test, mockall::automock)]` on the trait,
|
||||
set canned replies with `returning`, and assert calls with `times` and
|
||||
`withf`. The attribute keeps `mockall` out of release builds. Examples:
|
||||
`Probes` in `hive-priv/src/storage.rs`, `Publisher` and `Tokens` in
|
||||
`swarm-builder/src/main.rs`.
|
||||
- **`automock` can't mock a trait with a supertrait** such as `Clone`: a mock
|
||||
doesn't implement it. Declare the mock with `mockall::mock!` as a plain
|
||||
struct and implement the trait for `Arc<Mock…>`, as `swarm-builder` does.
|
||||
Better, leave the supertrait off the trait and bound the callers.
|
||||
- **Stateful fakes stay hand-written**: in-memory stores whose reads see earlier
|
||||
writes. Real-process stubs such as `ScriptSpawn` also stay, since they run a
|
||||
real `sh` on purpose.
|
||||
- **Nothing migrates in one sweep.** Move an existing expectation-style fake
|
||||
to `mockall` when you touch its file for another reason.
|
||||
|
||||
## Best-effort oneshot services
|
||||
|
||||
The harness ships a family of one-shot systemd services that
|
||||
configure agent-side surfaces from values delivered to the agent at
|
||||
provisioning time:
|
||||
|
||||
- `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the
|
||||
agent's Forgejo profile, so the icon shows up on commits / PRs /
|
||||
issue comments.
|
||||
|
||||
(The matrix profile avatar is **not** a oneshot — `hive-matrix-daemon`
|
||||
sets it over its live authenticated Client; see
|
||||
`docs/2-handbook/agent-lifecycle/persistence.md::matrix avatar`.)
|
||||
|
||||
Shape contract — every one of these:
|
||||
|
||||
1. **Always `exit 0`**, even on internal failure. A non-zero exit
|
||||
would mark the unit `failed`, which in turn aborts
|
||||
`nixos-container update` and blocks rebuilds. The agent's
|
||||
capability surface isn't allowed to gate the container
|
||||
build.
|
||||
2. **No `set -e`** in the script body. Subshell failures must not
|
||||
propagate. Use `... || true` on every external call that can
|
||||
fail (forge unreachable, missing icon, parse error, etc.)
|
||||
3. **Skip silently when prerequisites are missing**: no token
|
||||
file, no icon, no reachable upstream → `echo` a short skip
|
||||
line + `exit 0`. The next boot tries again.
|
||||
4. **Wired to `multi-user.target`** so they run on every boot
|
||||
(lets a rotated token / new icon take effect without
|
||||
`systemctl restart` gymnastics).
|
||||
5. **Re-runnable**: a second invocation produces the same final
|
||||
state (idempotent uploads, idempotent config rewrites). Used
|
||||
by the `.path` watchers that re-fire on token appearance (see
|
||||
`docs/2-handbook/agent-lifecycle/persistence.md::Matrix per-agent daemon`).
|
||||
|
||||
The service stays root-owned so the bootstrap ordering doesn't need a
|
||||
user-existence check before each fire.
|
||||
|
||||
This pattern keeps the rebuild path resilient: any failure inside
|
||||
these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The
|
||||
operator notices through `journalctl -u <unit>` rather than a
|
||||
broken switch-to-configuration.
|
||||
552
docs/5-project/process/gotchas.md
Normal file
552
docs/5-project/process/gotchas.md
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
# Gotchas
|
||||
|
||||
NixOS + nspawn quirks and hard-won lessons. If something
|
||||
here looks unmotivated in the code, there's probably a story underneath.
|
||||
Grouped by area — jump to the section that matches what you're
|
||||
touching.
|
||||
|
||||
## NixOS / nspawn containers
|
||||
|
||||
### `nixos-container` doesn't expose `--bind` on the CLI
|
||||
|
||||
The CLI doesn't accept `--bind`. Path is via `EXTRA_NSPAWN_FLAGS` in
|
||||
`/etc/nixos-containers/<NAME>.conf` — the start script
|
||||
(`/nix/store/.../container_-start`) expands it unquoted into the
|
||||
`systemd-nspawn` invocation. `lifecycle::host_config::set_nspawn_flags()`
|
||||
rewrites this line.
|
||||
|
||||
### `nixos-container` ignores `/run/systemd/nspawn/*.nspawn` overrides
|
||||
|
||||
`nixos-container`'s start script builds the nspawn command line
|
||||
directly. Dropping a `.nspawn` file under `/run/systemd/nspawn/`
|
||||
looks like a natural extension point and does nothing. Use
|
||||
`EXTRA_NSPAWN_FLAGS` (above).
|
||||
|
||||
### `boot.isNspawnContainer = true`
|
||||
|
||||
Not `boot.isContainer = true`. Renamed in nixos-25.11+.
|
||||
|
||||
### `nixos-container create` autoassigns `HOST_ADDRESS` / `LOCAL_ADDRESS`
|
||||
|
||||
…in the `.conf`. The start script's `if HOST_ADDRESS set →
|
||||
--network-veth` branch then forces a private netns — silently fatal
|
||||
for the web UIs (the bind is invisible from the host). Every agent
|
||||
container runs isolated: `hive-priv`'s `write_nspawn_flags` writes
|
||||
`PRIVATE_NETWORK=1` plus a veth pair onto the host bridge, `HOST_ADDRESS`
|
||||
set to the bridge gateway IP (so `nixos-container`'s in-container init
|
||||
installs a default route before the DHCP lease arrives), rest left for
|
||||
DHCP.
|
||||
|
||||
### systemd service PATH ≠ host PATH
|
||||
|
||||
The hive-c0re service sets `path = [ pkgs.git "/run/current-system/sw" ]`.
|
||||
In-container harness services do the same so anything an agent adds
|
||||
to its own `agent.nix` (`environment.systemPackages`) is visible to
|
||||
the `mcp__bash__run` MCP tool (and any other in-container process) without
|
||||
editing the service definition.
|
||||
`environment.HYPERHIVE_GIT` bakes git's absolute path in (read by
|
||||
`lifecycle::git_command()`) for the host.
|
||||
|
||||
### `systemd.services.*.path` appends `/bin` to every entry
|
||||
|
||||
NixOS's `systemd.services.<unit>.path` list feeds every entry through
|
||||
`lib.makeBinPath`, which **appends `/bin` unconditionally**. That's
|
||||
the right thing for Nix packages (their `outPath` is the store root,
|
||||
not the `bin/` subdir), but it bites when you pass a string that
|
||||
already ends with `/bin`:
|
||||
|
||||
```nix
|
||||
# ❌ /run/wrappers/bin → /run/wrappers/bin/bin (does not exist)
|
||||
path = [ "/run/wrappers/bin" "/run/current-system/sw" ];
|
||||
|
||||
# ✅ /run/wrappers → /run/wrappers/bin (the real wrappers dir)
|
||||
path = [ "/run/wrappers" "/run/current-system/sw" ];
|
||||
```
|
||||
|
||||
The bug is silent: `nix eval` succeeds, the unit starts, but PATH
|
||||
contains a non-existent directory. The first symptom is typically
|
||||
`sudo: must be owned by uid 0 and have the setuid bit set` because
|
||||
the setuid sudo wrapper lives at `/run/wrappers/bin/sudo` and
|
||||
the path entry resolves to `/run/wrappers/bin/bin` instead.
|
||||
|
||||
### `RuntimeDirectoryPreserve = "yes"`
|
||||
|
||||
…keeps `/run/hyperhive/` (and the per-agent sub-dirs) across
|
||||
hive-c0re restarts. Without it, every restart wipes bind sources and
|
||||
existing containers fail to start.
|
||||
|
||||
### `DynamicUser` + `RuntimeDirectoryPreserve` hides the socket from nginx
|
||||
|
||||
With both set, systemd creates the runtime directory under `/run/private/`
|
||||
(`0700 root:root`) and symlinks `/run/<name>` to it (`systemd.exec(5)`,
|
||||
`RuntimeDirectoryPreserve=`). No unprivileged user can traverse that, so
|
||||
nginx's `connect()` to a socket in it fails with `EACCES` whatever the
|
||||
directory and socket modes are. A unit serving a socket to another user
|
||||
runs as a static user instead (`swarm-controller`, `swarm-logs-auth`,
|
||||
`swarm-metrics-auth`, `swarm-metrics-agent-auth`).
|
||||
`module-eval-core-toggle` fails on any `unix:` upstream of nginx owned by
|
||||
such a unit.
|
||||
|
||||
### `RestrictAddressFamilies` fails as "Address family not supported by protocol"
|
||||
|
||||
A unit whose `RestrictAddressFamilies` omits a family gets `EAFNOSUPPORT`
|
||||
(errno 97) back from `socket()`. Clients surface that as _"tcp open error:
|
||||
Address family not supported by protocol"_ — the message names the
|
||||
**protocol** and never the **sandbox**, so it reads like a dead network, a
|
||||
missing route, or an IPv6 problem.
|
||||
|
||||
⇒ On that error, read the unit before you touch the network.
|
||||
|
||||
Two things to get right when a daemon needs outbound TCP:
|
||||
|
||||
- list `AF_INET` **and** `AF_INET6` — omitting one leaves a client that
|
||||
works until DNS hands back the other family;
|
||||
- list `AF_NETLINK` too. glibc's `getaddrinfo` opens a netlink socket to
|
||||
enumerate local addresses before it returns any, so name resolution
|
||||
fails without it even when the unit allows `AF_INET`.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
**The directive is a claim about what the program does, and nothing
|
||||
re-checks it when the program changes.** A unit that only served a unix
|
||||
socket when it was written is correct at `[ "AF_UNIX" ]` and silently wrong
|
||||
the day someone adds an HTTP client. Check the unit in the same commit as
|
||||
the client — and when narrowing it, prefer a test that derives the required
|
||||
families from the code (which fails on the _next_ client too) over one that
|
||||
asserts today's list.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
### `register_agent` is idempotent
|
||||
|
||||
Drops any prior socket task before rebinding. Required so a
|
||||
hive-c0re restart followed by `rebuild alice` recreates the agent's
|
||||
socket without needing a clean reinstall.
|
||||
|
||||
## Claude Code packaging & credentials
|
||||
|
||||
### `claude-code` is unfree
|
||||
|
||||
`claude-code` comes from the flake's main `nixpkgs` (nixos-26.05).
|
||||
It's unfree, so the agent modules set `config.allowUnfreePredicate`
|
||||
at the container level to allowlist `claude-code` specifically —
|
||||
scoped, only this one package, because each per-agent
|
||||
`nixosConfiguration` evaluates its own nixpkgs instance and the
|
||||
operator's host-level `allowUnfree` does **not** propagate in.
|
||||
Operators don't need to set anything on their side. Only `runtime = "claude"`
|
||||
agents get `claude` and the allowance; an ACP agent whose `acp.command`
|
||||
needs `claude` adds `pkgs.claude-code` and
|
||||
`nixpkgs.config.allowUnfreePackages = [ "claude-code" ]` itself.
|
||||
|
||||
That same isolation is why an agent can't pick a claude out of a
|
||||
_different_ nixpkgs by itself: a container only ever sees the one
|
||||
nixpkgs the meta flake injects, so an `agent.nix` naming the host's
|
||||
`nixpkgs-unstable` has nothing to name. A release channel can trail
|
||||
unstable by weeks on this package, which is what
|
||||
`services.hyperhive.deploy.hive-controller.claudeCodePackage` is for — set it host-side
|
||||
and every agent runs that build.
|
||||
|
||||
What crosses is the **store path**, not the derivation. Containers
|
||||
share the host's `/nix/store`, so the binary is already reachable
|
||||
inside them with its whole closure. `hive-priv` writes the path into each
|
||||
container's `/etc/hyperhive-hive-facts` at start, and the agent's `claude`
|
||||
wrapper on PATH runs its `bin/claude`. Two things rule out the natural-seeming alternatives: a
|
||||
`path:/nix/store/<pkg>` flake input is re-copied into the store as a
|
||||
reference-less `-source` (so the runtime closure never arrives), and
|
||||
`lib.types.package` fed a bare path runs `builtins.storePath`, which
|
||||
pure evaluation rejects. `services.hyperhive.agent.docs.source` gets away with being
|
||||
an input only because a docs tree has no runtime dependencies.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
The `storePath` pitfall is worth spelling out, because it's not confined
|
||||
to options the operator writes: **any** option of type `package` fed a
|
||||
store-path _string_ coerces through `lib.toDerivation`, i.e.
|
||||
`builtins.storePath`. `environment.systemPackages` and
|
||||
`systemd.services.<name>.path` both do it (the latter takes plain
|
||||
strings like `/run/wrappers` happily, but Nix treats anything under
|
||||
`builtins.storeDir` as a package). Wrap a path handed to the
|
||||
container as text in a real derivation — a symlink farm built from
|
||||
the interpolated string — before it can go anywhere a package is
|
||||
expected.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
The catch is that a path handed to the container at start is text, not a
|
||||
reference — the container's closure doesn't keep the binary alive.
|
||||
The **host** does: it interpolates the package into
|
||||
`/etc/hyperhive/serve.json`, so it lands in the host's system closure
|
||||
and is gc-rooted by the running generation. `builtins.toJSON` preserves
|
||||
string context, which is the load-bearing detail; discard the context
|
||||
anywhere on that path and `nix-collect-garbage` will eventually take
|
||||
the hive's `claude` out from under it. The price of the root is that
|
||||
`nix-collect-garbage` can't reclaim an old `claude-code` until every
|
||||
agent has restarted past it and the old generations are gone.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
### Claude credentials are per-agent
|
||||
|
||||
`/var/lib/hyperhive/agents/<name>/claude/` bind-mounts to
|
||||
`/home/<name>/.claude` (RW). Sharing one dir across agents **isn't** viable —
|
||||
OAuth refresh tokens rotate, so any sibling refresh invalidates all
|
||||
the others. Login flow runs from the per-agent web UI; creds persist
|
||||
across `destroy`/recreate (`--purge` wipes them).
|
||||
|
||||
### Persistent notes dir per agent
|
||||
|
||||
`/var/lib/hyperhive/agents/<name>/state/` bind-mounts to
|
||||
`/agents/<name>/state` (RW; uniform for all agents).
|
||||
The harness exposes the same path
|
||||
via `$HYPERHIVE_STATE_DIR`. System prompts tell agents to keep
|
||||
durable knowledge here (`notes.md`, anything else) — the harness's own
|
||||
internal files (`hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`,
|
||||
`hyperhive-model`) live in the separate `harness` dir instead, so they
|
||||
don't clutter what claude sees as "my notes dir" (see
|
||||
[`docs/2-handbook/agent-lifecycle/persistence.md`](../../2-handbook/agent-lifecycle/persistence.md)). Survives `destroy`/recreate
|
||||
alongside the claude dir.
|
||||
|
||||
### Persistent work dir per agent
|
||||
|
||||
`/var/lib/hyperhive/agents/<name>/work/` bind-mounts to
|
||||
`/agents/<name>/work` (RW, owned by the agent user). It's the place for
|
||||
git worktrees and build dirs: a claude started under `state/` walks up
|
||||
into `state/AGENTS.md` and takes on the agent's identity, and one
|
||||
started under `work/` doesn't. The container rootfs is ephemeral
|
||||
(nspawn `--ephemeral`), so `/home/<name>` is empty after every restart:
|
||||
anything the agent keeps goes in `work/` or `state/`. `work/` survives
|
||||
`destroy`/recreate and goes on `--purge`. The per-agent disk metric counts it; the
|
||||
dashboard doesn't serve it.
|
||||
|
||||
## Networking & ports
|
||||
|
||||
### Web UI ports collide on hash
|
||||
|
||||
Sub-agent web UI ports are deterministic FNV-1a of the agent name
|
||||
modulo 900 (range 8100..8999). With ~30 agents the birthday-paradox
|
||||
collision rate gets meaningful; at 2–3 agents you can still get
|
||||
unlucky. Operator resolves a collision by renaming the offending
|
||||
agent (different hash → different port) and rebuilding. No state
|
||||
file, no probing, no port-allocation drift — the value is
|
||||
reproducible from just the name. Every agent hashes into
|
||||
8100..8999 via the same FNV-1a; dashboard
|
||||
at `services.hyperhive.deploy.hive-controller.dashboardPort` (default 7000).
|
||||
|
||||
### Restart races on TCP bind
|
||||
|
||||
Both the dashboard and per-agent web UI use `tokio::net::TcpSocket`
|
||||
with `SO_REUSEADDR` plus a retry-on-`AddrInUse` loop (12 tries,
|
||||
exponential backoff capped at 2s, ~22s total). REUSEADDR handles
|
||||
the `TIME_WAIT` case from a clean previous exit; retry covers the
|
||||
genuine "previous process is still alive during a systemd restart
|
||||
overlap" case. REUSEADDR does **not** allow two simultaneous
|
||||
`LISTEN` sockets on the same port (that would be `SO_REUSEPORT`,
|
||||
not used here) — it preserves exclusivity.
|
||||
|
||||
## Gateway / SPA serving
|
||||
|
||||
### SPA fallback: use `Accept` header map, not `try_files ... /index.html`
|
||||
|
||||
The naive nginx pattern for an SPA (`try_files $uri $uri/
|
||||
/index.html`) silently swallows asset 404s — a missing JS file
|
||||
returns `index.html` with a 200, so the JS runtime never loads and the
|
||||
page renders blank with no visible error. Extension allowlists (tried
|
||||
as an alternative) have the same maintenance problem: any new file
|
||||
extension the SPA ships breaks silently.
|
||||
|
||||
The pattern that works (`nix/host-modules/hive-matrix.nix`, serving
|
||||
fluffychat at the matrix gateway vhost's root) keys the fallback on the
|
||||
HTTP `Accept` header:
|
||||
|
||||
```nginx
|
||||
# Outside the server block (appendHttpConfig):
|
||||
map $http_accept $matrix_spa_target {
|
||||
default "/__matrix_spa_no_html_fallback";
|
||||
"~*text/html" "/index.html";
|
||||
}
|
||||
|
||||
# Inside the location:
|
||||
try_files $uri $uri/ $matrix_spa_target =404;
|
||||
```
|
||||
|
||||
Top-frame navigations always send `Accept: text/html,...` (chrome /
|
||||
firefox / safari are consistent). Asset fetches (`image/*`,
|
||||
`application/javascript`, `*/*`) don't carry `text/html`, so they
|
||||
fall through to the trailing `=404`. No extension list to maintain;
|
||||
no named-location indirection needed.
|
||||
|
||||
## Build & dev workflow
|
||||
|
||||
### Nix store `cp -r` preserves read-only bits
|
||||
|
||||
Copying a nix store path with `cp -r src/. $out/` inside a
|
||||
`pkgs.runCommand` derivation preserves the read-only permissions of
|
||||
store files. Any subsequent write into the copied tree (adding new
|
||||
files in subdirectories) fails with `EPERM`. Fix: pass
|
||||
`--no-preserve=mode,ownership` so the output tree is writable.
|
||||
|
||||
### `nix build flake#name` doesn't walk into `nixosConfigurations`
|
||||
|
||||
`nix build` resolves the fragment (`#name`) against the flake's
|
||||
**top-level output attrs** — not against `nixosConfigurations`
|
||||
specifically. `nixos-container` and `nixos-rebuild` use their own
|
||||
internal convention that routes an agent name to
|
||||
`nixosConfigurations.<name>.config.system.build.toplevel`, but
|
||||
`nix build` has no such convention.
|
||||
|
||||
```
|
||||
# ❌ silently builds the wrong thing (or errors if attr doesn't exist)
|
||||
nix build "git+<forge>/internal/meta.git?rev=<rev>#argus.config.system.build.toplevel"
|
||||
|
||||
# ✅ explicit path nix build actually resolves
|
||||
nix build "git+<forge>/internal/meta.git?rev=<rev>#nixosConfigurations.argus.config.system.build.toplevel"
|
||||
```
|
||||
|
||||
### Containerized nix-daemon needs `sandbox-fallback = true`
|
||||
|
||||
nixos-containers' start script bind-mounts the host's nix-daemon socket
|
||||
directory into every container (hyperhive adds no bind for it; an agent
|
||||
with `services.hyperhive.agent.nixBuilds = "none"` gets it shadowed, see
|
||||
`docs/2-handbook/trust-boundary/security.md`). nspawn
|
||||
containers don't get user-namespaces by default, so `nix build`
|
||||
invocations _inside_ the container can't set up the build sandbox
|
||||
and fail outright if the host daemon's
|
||||
`nix.settings.sandbox-fallback` is `false` (nixpkgs default).
|
||||
`nix/agent-modules/default.nix` does `lib.mkForce true` so builds
|
||||
fall back to unsandboxed local builds rather than failing. Security
|
||||
implications: `docs/2-handbook/trust-boundary/security.md`.
|
||||
|
||||
### Linking workspace binaries locally needs `nix develop`
|
||||
|
||||
The Rust workspace links `libsqlite3-sys` (rusqlite) against the
|
||||
system `libsqlite3`. Agent containers carry no system libsqlite3 on
|
||||
the linker path, so a plain `cargo build` of any binary dies with
|
||||
`cannot find -lsqlite3` (deps and `ring` compile fine — only the
|
||||
final link fails). `cargo check` / `cargo clippy` still work in the
|
||||
ambient shell since they never link.
|
||||
|
||||
Build + run binaries through the dev shell, which carries `sqlite`
|
||||
on `NIX_LDFLAGS`:
|
||||
|
||||
```bash
|
||||
nix develop -c cargo build -p hive-c0re --bin hivectl
|
||||
nix develop -c cargo run -p hive-c0re --bin hivectl -- <args>
|
||||
```
|
||||
|
||||
This is also how you regenerate committed generated docs locally —
|
||||
for example `docs/2-handbook/reference/hivectl-cli.md` via the `hivectl markdown-docs`
|
||||
subcommand (its `hivectl-docs` flake check otherwise only fails in
|
||||
CI on drift).
|
||||
|
||||
### Split asset derivations away from the rust workspace
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
`nix/packages/assets.nix` builds the branding SVG/PNG family + claude
|
||||
system-prompt template + claude-settings JSON as its own derivation,
|
||||
separate from the hive-ag3nt / hive-c0re crates. Reason: when the
|
||||
rust build's `src` was the whole repo tree, any tweak to
|
||||
`branding/agent-configs.svg` or `hive-agent/prompts/system.md`
|
||||
invalidated the cargo cache and forced a full rebuild. crane (and
|
||||
naersk before it) couldn't see "these inputs are unused by rust" on
|
||||
its own — the split breaks the coupling at the derivation boundary.
|
||||
`rsvg-convert` renders the agent-configs PNG from the SVG at
|
||||
build time; librsvg dependency lives here, not in the rust
|
||||
derivation's `nativeBuildInputs`.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
### `nix fmt` fails in a git worktree with "object not found"
|
||||
|
||||
`nix fmt` (and any `nix` command that fetches a `git+file://` flake
|
||||
URL) uses libgit2 internally to compute `revCount` — the number of
|
||||
commits reachable from HEAD. This walk fails with:
|
||||
|
||||
```
|
||||
error: getting Git object '<hash>': object not found (libgit2 error code = 9)
|
||||
```
|
||||
|
||||
when a commit that was reachable at some earlier evaluation is now gone
|
||||
(GC'd, rebased away, or pruned). The failure is persistent: clearing
|
||||
`~/.cache/nix/{eval-cache-v6,gitv3,fetcher-cache-v4.sqlite}` doesn't
|
||||
help because the missing object is a structural gap in the git object
|
||||
graph itself, not in nix's caches.
|
||||
|
||||
**Workaround: use a plain clone, not a git worktree.**
|
||||
|
||||
```bash
|
||||
git clone http://<forge>/hyperhive/hyperhive.git ~/hh-work
|
||||
cd ~/hh-work && nix fmt
|
||||
```
|
||||
|
||||
The root cause is specific to worktrees: a worktree shares the object
|
||||
store with its parent repo. If the parent repo's history was rewritten
|
||||
(rebase, force-push, `git gc --prune`) while the worktree remained at a
|
||||
branch tip that references the pruned commits via its reflog or
|
||||
history, libgit2's rev-walk encounters the gap. A plain clone has its
|
||||
own self-consistent object store and is immune to the issue.
|
||||
|
||||
## Tooling
|
||||
|
||||
### `hive-forge`: prefer over raw curl pipelines
|
||||
|
||||
Full CLI reference: [`docs/2-handbook/tools/forge.md`](../../2-handbook/tools/forge.md).
|
||||
Never use raw `curl` for forge access.
|
||||
|
||||
## GUI (weston/VNC)
|
||||
|
||||
### Weston VNC compositor (per-agent `services.hyperhive.agent.gui.enable`)
|
||||
|
||||
`nix/agent-modules/weston-vnc.nix` adds an optional Weston Wayland
|
||||
compositor with the `vnc-backend` plugin, surfaced as
|
||||
`services.hyperhive.agent.gui.enable = true` per-agent. The harness's
|
||||
`/screen/ws` WebSocket relay (`docs/2-handbook/web-ui/agent.md::Per-agent endpoints`)
|
||||
connects to the compositor at `127.0.0.1:<vnc_port>`.
|
||||
|
||||
- **Port allocation**: a **fixed** port (`services.hyperhive.agent.gui.vncPort`,
|
||||
default 5900). No per-agent hashing: network isolation is
|
||||
unconditional (each agent has its own netns — see
|
||||
`docs/4-implementation/networking/network.md#container-isolation`), so the VNC port is
|
||||
container-local and can't collide across agents. The harness learns
|
||||
the port from the `HIVE_GUI_VNC_PORT` env var (set on the harness
|
||||
service when `gui.enable`) — no marker file, no runtime hash. (Unlike
|
||||
the agent **web-UI** port, which is still an FNV-1a hash because those
|
||||
listen on the shared host stack — see `Web UI ports collide on hash`.)
|
||||
- **Non-root, shared user session**: weston runs as the agent's own
|
||||
user (`services.hyperhive.agent.user.name`, the same user hive-ag3nt runs as), not
|
||||
root, so the GUI and the agent share one session. The runtime dir is a
|
||||
fixed `/run/gui` (systemd `RuntimeDirectory=gui`, `0700`,
|
||||
`RuntimeDirectoryPreserve=yes` so it survives weston restarts for the
|
||||
wayland client sharing the `/run/gui/wayland-0` socket). Wayland
|
||||
clients in the agent's config (for example bitburner electron) must run as the
|
||||
same user with `XDG_RUNTIME_DIR=/run/gui`.
|
||||
- **One shared D-Bus session bus (`gui-dbus.service`)**: a single
|
||||
persistent `dbus-daemon --session` bound at `/run/gui/bus`, run as the
|
||||
agent user, ordered `before weston.service` (it shares the same
|
||||
`RuntimeDirectory=gui`, creating the dir first). Chromium/electron via
|
||||
ozone refuse to map an `xdg_toplevel` without a reachable session bus
|
||||
("Failed to connect to the bus" → binds `xdg_wm_base` then destroys it
|
||||
= invisible window even though CDP works). The fix is **not** to wrap
|
||||
each client in its own `dbus-run-session` (a private throwaway bus per
|
||||
process — that's a _separate_ session, defeating the one-session
|
||||
model); it's this one shared bus; `systemd.globalEnvironment` exports
|
||||
its address as `DBUS_SESSION_BUS_ADDRESS=unix:path=/run/gui/bus` so
|
||||
weston, the harness and every GUI client inherit it.
|
||||
- **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
|
||||
launched with `--socket=wayland-0` so the socket path is
|
||||
deterministic. `nix/agent-modules/weston-vnc.nix` exports `WAYLAND_DISPLAY=wayland-0`
|
||||
and `XDG_RUNTIME_DIR=/run/gui` as global system environment
|
||||
variables (gated on `services.hyperhive.agent.gui.enable`) so every systemd service
|
||||
in the container inherits them. Without this, services starting
|
||||
Wayland clients couldn't find the compositor — libwayland falls
|
||||
back to a headless display or errors out, the app "works" on a
|
||||
second invisible display, and the VNC session shows a blank weston
|
||||
desktop (known double-screen issue).
|
||||
- **VNC bind address**: Weston's `vnc-backend` has no CLI
|
||||
bind-address flag (unlike the `rdp-backend`'s `--address`), so the
|
||||
listener binds `0.0.0.0`. The harness relay only connects via
|
||||
`127.0.0.1`; the host firewall blocks the per-agent VNC port range
|
||||
from external access. A future weston.ini `[vnc] address=` would
|
||||
restrict the bind directly, once upstream supports it.
|
||||
- **PAM service name**: literal `weston-remote-access` — that's the
|
||||
string libweston passes to `pam_start()` in `libweston/auth.c`.
|
||||
Using `weston` falls back to the system default PAM stack and
|
||||
rejects auth. `weston-vnc.nix` configures the service to
|
||||
`pam_permit.so` for all three module types (auth / account / session) so the
|
||||
browser's empty Apple-DH credentials (type 30) always pass —
|
||||
neatvnc ≥ 0.9 calls the PAM auth callback regardless of
|
||||
`weston.ini` `auth-method=none`, so the permit fallback is what
|
||||
actually lets the empty-cred client through.
|
||||
- **`Type = "simple"` (not `notify`)**: `switch-to-configuration`
|
||||
must never block on weston signalling readiness. A misconfigured
|
||||
weston degrades to a `Restart=on-failure` loop visible in
|
||||
`journalctl`, it doesn't abort the `nixos-container update`.
|
||||
Same reasoning as the best-effort oneshots in `docs/5-project/process/conventions.md`.
|
||||
- **`[core] idle-time=0`**: disables weston's 300-second idle
|
||||
timeout. Without it the VNC desktop fades to black and
|
||||
desktop-shell shows its unlock screen — useless for an
|
||||
agent desktop viewed over `/screen`. `idle-time=0` updates the
|
||||
idle timer with a 0ms delay, which
|
||||
`wl_event_source_timer_update` treats as "disarm," so the
|
||||
compositor never goes idle and never locks.
|
||||
|
||||
## Nix docs pipeline
|
||||
|
||||
### Nix options reference (`nix/docs/default.nix`)
|
||||
|
||||
`pkgs.nixosOptionsDoc` over two evaluated module trees:
|
||||
`hostEval` (a stub NixOS system loading the `nix/host-modules/` aggregator with every
|
||||
hyperhive subsystem `mkForce false` so heavy build inputs stay out of
|
||||
the eval) and `agentEval` (evaluates `agent.nix` fresh for the
|
||||
per-agent options tree).
|
||||
|
||||
Five output trees consumed by `flake.nix`, all **markdown**:
|
||||
|
||||
- `docs-host` — operator-facing host module options
|
||||
(`services.hyperhive.*` minus the `swarm`/`deploy` subtrees below)
|
||||
- `docs-swarm` — swarm-wide facts, identical on every host in the swarm
|
||||
(`services.hyperhive.swarm.*`)
|
||||
- `docs-deploy` — this host's own deployment decisions, necessarily
|
||||
different per host (`services.hyperhive.deploy.*` — added by
|
||||
`nix/host-modules/deploy.nix`, split out of `swarm.*` for exactly this
|
||||
reason: see that file's own comment)
|
||||
- `docs-agent` — per-agent harness options (`hyperhive.*`
|
||||
declared in `nix/agent-modules/`)
|
||||
- `docs` — bundle of `index.md` + `host.md` + `swarm.md` +
|
||||
`deploy.md` + `agent.md`
|
||||
|
||||
Pipeline:
|
||||
|
||||
- CommonMark from `nixosOptionsDoc.optionsCommonMark` is the only
|
||||
output — the source of truth, emitted as `.md`.
|
||||
- **The website repo renders HTML + CSS downstream**
|
||||
(its own `options.nix`), consuming this bundle's `host.md` /
|
||||
`agent.md`, rendering them with `cmark-gfm`, and sharing one
|
||||
stylesheet (`docs.css`) across `/options/` and the prose `/docs/`
|
||||
tree. Keeping rendering in the website means the theme has a single
|
||||
home, sharing colours across both trees.
|
||||
- `transformOptions` strips the nix-store prefix from option
|
||||
declaration paths and rewrites them as forge URLs, so the
|
||||
rendered docs link back to the source.
|
||||
|
||||
Host options live entirely under `services.hyperhive.*`. `pickSubtrees`
|
||||
takes `["services" "hyperhive"]` as its root, so the options tree picks
|
||||
up everything under that root — picking against
|
||||
stray roots produces an empty tree and renders the host page as
|
||||
template chrome with no `<h2>` headers. `docs-host` then drops the
|
||||
`swarm`/`deploy` subtrees from that picked tree (`removeAttrs`, not a
|
||||
second `pickSubtrees` root — the one exclusion this file needs doesn't
|
||||
earn a general-purpose helper) since `docs-swarm`/`docs-deploy` each
|
||||
pick their own root instead.
|
||||
|
||||
#### Docs drv stability: `nixSrc`
|
||||
|
||||
Naively, the docs evaluation depends on `self` (the flake's store path),
|
||||
so every commit — even Rust-only or frontend-only changes — produces new
|
||||
docs drv hashes. The remote builder must rebuild docs from scratch for
|
||||
every PR branch, and if its store is full the build fails with a cached
|
||||
failure that blocks CI for the whole branch.
|
||||
|
||||
The fix (`nix/docs/default.nix`):
|
||||
|
||||
1. **`nixSrc`** — `builtins.path` on the `nix/` directory, wrapped in
|
||||
`builtins.unsafeDiscardStringContext` to strip `self`'s store-path
|
||||
context. The resulting store path is content-addressed from the nix/
|
||||
file contents only. Docs drvs only change when a `.nix` file changes.
|
||||
|
||||
2. The package options the modules consume (`services.hyperhive.agent.packages.*`,
|
||||
`services.hyperhive.deploy.hive-controller.*`) carry no in-module defaults and every
|
||||
default that references them has a `defaultText`, so the doc walk
|
||||
never forces a package — no stubs needed, and the Rust/frontend
|
||||
build closure stays out of the eval.
|
||||
|
||||
3. Nix evaluates both `hostEval` and `agentEval` from `nixSrc` paths
|
||||
(not `self`), so the docs drv dependency chain ends at `nixSrc`.
|
||||
|
||||
Why `builtins.unsafeDiscardStringContext`? The path string
|
||||
`toString self + "/nix"` carries `self`'s string context, which would
|
||||
make `builtins.path` include `self` as a build dependency even after
|
||||
content-addressing the directory. Discarding the context makes the
|
||||
resulting `nixSrc` truly independent of `self`'s store path.
|
||||
70
docs/5-project/process/pr-review-gate.md
Normal file
70
docs/5-project/process/pr-review-gate.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# The PR review gate
|
||||
|
||||
What a review verdict means, why a reviewer shouldn't wait on CI to
|
||||
submit one, and what "armed to automerge" actually signals about the
|
||||
human review that already happened.
|
||||
|
||||
## The gate has (up to) three parts, and they're per-repo settings
|
||||
|
||||
Each repo's branch-protection settings configure whether a PR can
|
||||
merge, and what counts toward "can" — not a fact true of every hive or
|
||||
every repo. The pieces a repo _can_ require:
|
||||
|
||||
- **CI is green** — the repo's required status checks pass on the
|
||||
PR's current head commit, if the repo requires any.
|
||||
- **Requested reviews are `APPROVED`** — and whether a review is
|
||||
invalidated by a later commit ("stale") is itself a setting
|
||||
(Forgejo's "dismiss stale approvals" branch-protection option), not
|
||||
universal behavior.
|
||||
- **Someone with write access has armed the PR to merge** — a manual
|
||||
merge once the required conditions hold, or Forgejo's automerge
|
||||
(merges automatically the moment the other required conditions are
|
||||
met).
|
||||
|
||||
Where a repo requires these, they're independent of each other. A
|
||||
reviewer only ever owns the review-approval piece — CI resolves (or
|
||||
doesn't) on its own regardless of what a review says, and merge-arming
|
||||
is someone else's call.
|
||||
|
||||
## Reviewers: submit the verdict, don't gate it on CI
|
||||
|
||||
Submit `hive-forge pr reviews <pr> --approve` or `--request-changes`
|
||||
as soon as you've finished checking the diff — don't hold it back
|
||||
waiting for CI to go green first. Mention CI's current state in the
|
||||
review body if it's relevant (for example "approving; `nix flake check` is
|
||||
still running"), but don't gate the formal verdict on it: CI isn't a
|
||||
signal a reviewer waits on, it's a separate condition that resolves
|
||||
independently.
|
||||
|
||||
## What arming automerge actually means
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
Automerge isn't "no human ever looked at this." Whoever arms it has
|
||||
already judged the PR sound at a coarse level — the signal it sends is
|
||||
roughly _"apart from maybe minor tweaks a reviewer can still catch,
|
||||
I think this is fine."_ That's the human-in-the-loop step, and it
|
||||
already happened. No large changes are expected to surface after
|
||||
that point — a reviewer's job past that point is to flag it if one
|
||||
does, not to assume none ever will.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
The practical consequence for a reviewer: on a repo where someone with
|
||||
write access may already have armed automerge before your review
|
||||
lands, a plain `APPROVED` can
|
||||
be the last step before the merge actually happens, with no further
|
||||
review pass after yours. That's a reason to actually finish checking
|
||||
before approving — not a reason to hesitate over every small thing.
|
||||
Genuine, substantive doubt (a claim you haven't verified, a real
|
||||
correctness question) is worth a `request-changes` or a clarifying
|
||||
comment before approving; a stray style nit isn't the same category.
|
||||
|
||||
## Re-review after a repo requires it
|
||||
|
||||
Where a repo dismisses stale approvals on a new commit, a review you
|
||||
already gave stops counting the moment a follow-up commit lands — even
|
||||
one whose message reads as trivial ("just a wording fix," "just
|
||||
trimming comments"). Re-diff and re-verify before submitting a fresh
|
||||
verdict; don't take a small-sounding commit message as an accurate
|
||||
description of the diff.
|
||||
Loading…
Reference in a new issue