Until its first successful read of its key, the harness read its permissions as the default set, `execution` included. So did the subagent daemon. An agent the operator had narrowed ran wider than granted after every restart while the queue was unreachable, and in the gap before the first 10 s poll. During the meta-repo import, every non-ruth key is still unset, and the agent read that as the default too, for as long as the import stayed pending. mara picked option (a) on #5293: "a, removal of the old path as a follow up in 1w". hive-c0re now writes each agent's entry from swarm-agent-permissions.json into the agent's runtime dir as swarm-permissions.json (/run/hive in the container), together with whether the import is still pending. It writes the file at every start (write_dropins) and after every 30 s pass of the permissions worker. The harness and the subagent daemon hold that stored list until their own first read succeeds. While the import is pending, an unset key keeps the stored list rather than reverting it to the default. A queue that is down never blocks a start: the agent runs on the stored grant. The decision lives in swarm_queue_client::agent_permissions::HiveCopy, shared by both readers. Part of #5170.
508 lines
25 KiB
Markdown
508 lines
25 KiB
Markdown
# 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.c0re.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. `hive-priv` writes them to `/etc/hyperhive-hive-facts` before
|
|
every container start. The agent's environment generator
|
|
(`nix/agent-modules/hive-facts.nix`) passes them to every unit, including
|
|
`hivectl choom` sessions, and `/etc/set-environment` exports them in login
|
|
shells that no unit starts (`nixos-container root-login`). Neither 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/turn-loop/mcp.md` § Waking the agent from inside the
|
|
container](../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 special
|
|
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/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, on both the agent and manager sockets alike (the manager, `ruth`,
|
|
is a normal agent here, just deployed automatically with different
|
|
default permissions). `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.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_sh4re::permissions::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/tools/bash.md`. |
|
|
| `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` |
|
|
| `manage_root_agent` | every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus `/applied` and `/meta` (ro) |
|
|
|
|
**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-queue-client/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_for`): `messaging`, `meta`, `inbox`,
|
|
`execution`, plus `manage_root_agent` for `ruth`. `"permissions": []` grants
|
|
none.
|
|
|
|
**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.
|
|
- hive-c0re copies each agent's entry from that file into the agent's
|
|
runtime dir (`/run/hive/swarm-permissions.json` in the container) at every
|
|
start and after every 30-second pass, with whether the import below is
|
|
still pending. The harness and the subagent daemon hold that copy until
|
|
their own first read succeeds, so an agent whose queue is down starts on
|
|
its last stored grant, not the default.
|
|
|
|
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, and an
|
|
agent that reads its own key unset keeps the grant in its copy.
|
|
|
|
**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-sh4re/src/permissions.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
|
|
proposed/applied repos and rides along on every fetch (see
|
|
`docs/agent-lifecycle/config-changes.md::Two repos 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/tools/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/tools/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/tools/swarmctl-cli.md`:
|
|
```sh
|
|
nix develop -c cargo run --bin swarmctl -- markdown-docs > docs/tools/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`.
|
|
|
|
## 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/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/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.
|