Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-11-06 18:30:12 +01:00 • committed by mara
commit 6739bb903d
3 changed files with 736 additions and 701 deletions

View 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.

View 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.

View 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.