208 lines
9.4 KiB
Markdown
208 lines
9.4 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.
|
|
- The manager is `hm1nd` (no `h-` prefix, fixed name).
|
|
- `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap.
|
|
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
|
|
(8100..8999) for every agent including the manager; dashboard
|
|
`cfg.dashboardPort` (default 7000).
|
|
|
|
## Identity = socket
|
|
|
|
There are no auth tokens 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`; the
|
|
manager has access to its privileged socket; hive-c0re owns the host
|
|
admin socket.
|
|
|
|
### Wake injection
|
|
|
|
`AgentRequest::Wake { from, body }` (and the manager-flavour mirror)
|
|
is the wake-event-injection surface. 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.). Typical caller: an
|
|
in-container background task (the matrix daemon, a scraper, the
|
|
forge-notify webhook subscriber) that needs to signal "external work
|
|
has arrived" without going through the broker as a peer agent.
|
|
|
|
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
|
|
|
|
A few recipient names are reserved by the broker and 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
|
|
(`agent_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
|
- `operator` — the human at the dashboard. Messages accumulate in the
|
|
inbox view; no agent ever `recv`'s them.
|
|
- `<parent>` — the sender's parent per `topology.json`. Rewritten at
|
|
send time by `topology::resolve_recipient`: looks up
|
|
`parent_of(sender)` and falls back to `operator` when the sender is
|
|
a root agent (or absent from topology entirely). Lets agents address
|
|
their parent without learning the label, so runtime reparenting
|
|
propagates with zero agent-side restart.
|
|
|
|
When the resolver rewrites `<parent>`, the broker stores the
|
|
*resolved* label as the message's recipient — the dashboard and
|
|
recv side both see the real route. The sentinel is purely a
|
|
send-time addressing convenience.
|
|
|
|
## 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.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 32; values above clamp silently).
|
|
`wait_seconds` long-polls for the first message; once one arrives —
|
|
or one is already pending — the call drains up to `max` in total
|
|
before returning, so a single `Recv` call coalesces a burst.
|
|
|
|
Per-row bookkeeping inside the broker:
|
|
|
|
- `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).
|
|
|
|
`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.
|
|
|
|
### Loose-ends wire shape
|
|
|
|
`LooseEnd` is the per-row response shape for `GetLooseEnds` (both
|
|
the agent-flavour and manager-flavour requests). Tagged enum so
|
|
new thread kinds (forge PRs, long-running approvals from a
|
|
privileged bot, 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.
|
|
|
|
Per-flavour scoping is uniform across the three variants:
|
|
|
|
- **agent-flavour** `GetLooseEnds` only surfaces rows the calling
|
|
agent has standing in. `Approval` rows only appear when the
|
|
calling agent is the manager (sub-agents don't submit
|
|
approvals). `Question` rows surface where the agent is `asker`
|
|
OR `target` (the routing semantics from the Ask/Answer
|
|
subsection above). `Reminder` rows are scoped to `owner ==
|
|
self`.
|
|
- **manager-flavour** `GetLooseEnds` lists every pending row in
|
|
the swarm — full audit view.
|
|
|
|
Per-variant fields:
|
|
|
|
- `Approval { id, agent, commit_ref, description?, age_seconds }`
|
|
— `agent` is the affected agent (target of the spawn / config
|
|
commit), not the asker. `description` is the manager's free-text
|
|
blurb shown on the dashboard card. `commit_ref` is the
|
|
kind-specific payload (see `docs/approvals.md::Approval kinds
|
|
(wire shapes)`).
|
|
- `Question { id, asker, target?, question, age_seconds }` —
|
|
`target = None` = operator-routed (dashboard); `Some(agent)` =
|
|
peer-to-peer thread.
|
|
- `Reminder { id, owner, message, due_at, age_seconds }` —
|
|
`due_at` is the absolute unix timestamp the scheduler is
|
|
targeting; clients compute time-until-fire as `due_at - now`.
|
|
|
|
`age_seconds` saturates at zero on any clock anomaly (back-step,
|
|
unsynchronised wall clock, etc.) so the bulleted list never
|
|
shows nonsense ages.
|
|
|
|
`CancelLooseEnd { kind, id }` is the matching write surface. The
|
|
`kind` enum (`Question` / `Reminder` / `Approval`) selects which
|
|
underlying store the dispatcher reaches into. `Question` and
|
|
`Reminder` cancel from either surface subject to ownership
|
|
checks (asker for the question, scheduler for the reminder).
|
|
`Approval` is manager-only — sub-agents don't submit approvals
|
|
so they have nothing of their own to withdraw; their wire
|
|
surface returns a clear error if they try. Cancelling an approval
|
|
transitions the row to `ApprovalStatus::Cancelled` and fires
|
|
`ApprovalResolved { status: "cancelled" }` so the dashboard pulls
|
|
the card out of the pending pane.
|
|
|
|
## Async forms
|
|
|
|
Dashboard + per-agent mutating forms carry `data-async`; a delegated
|
|
`submit` listener in `assets/tabs.js` 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) or `data-prompt="…"` (for a
|
|
`window.prompt()` whose answer goes into a hidden input named by
|
|
`data-prompt-field`, default `note`).
|
|
|
|
`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
|
|
|
|
`lifecycle::rebuild` idempotently rewrites
|
|
`/etc/nixos-containers/<C>.conf` (`PRIVATE_NETWORK=0`, clears
|
|
`HOST_ADDRESS` / `LOCAL_ADDRESS`, sets `EXTRA_NSPAWN_FLAGS`),
|
|
regenerates `applied/<name>/flake.nix`, writes the systemd limits
|
|
drop-in, then `nixos-container update` + stop + start.
|
|
|
|
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 are factored
|
|
|
|
`approve` / `deny` / `destroy` (and the lifecycle helper) live in
|
|
`actions.rs` / `dashboard.rs`. 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* if non-obvious; 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.
|