Compare commits

...
Author SHA1 Message Date
müde
fc61cb9310 fmt: clippy doc_markdown backticks 2026-05-15 23:11:10 +02:00
müde
edb0108ae7 docs+prompt: tag-driven flow + /applied RO mount
manager prompt: explain that arbitrary files now travel with
the proposal, document the /applied/<n>/.git RO mount and the
tag scheme (git show applied/deployed/<id> etc.), call out
that applied/main only advances on deployed so a failed build
isn't terminal. approvals.md: drop the old per-agent
applied.git phrasing in favour of the single /applied RO
bind, mention both manager binds together. claude.md
scratchpad flips from in-flight to just-landed.
2026-05-15 23:03:48 +02:00
müde
4a8204f035 lifecycle: bind /applied into manager read-only
set_nspawn_flags now adds --bind-ro=/var/lib/hyperhive/applied
:/applied for the manager container alongside the existing
/agents RW mount. manager can git-fetch deployed/failed/denied
tags out of /applied/<n>/.git to mirror them into its proposed
clones; the read-only bind means git plumbing inside the
container cannot corrupt the authoritative repos. picked up by
the next rebuild of hm1nd (no spawn-time change needed since
set_nspawn_flags runs on every spawn + rebuild).
2026-05-15 23:02:31 +02:00
müde
6cf66e23dc actions: deny plants annotated denied/<id> tag
apply-commit denials now leave a git object behind: tag
denied/<id> annotated with the operator's note (or empty body
if they didn't supply one) at proposal/<id> inside the applied
repo. rejected configs become first-class git history — git
show denied/<id> in the manager's applied.git mount yields the
tree the operator rejected plus the reason. helper event
carries the tag for parity with deployed/failed. spawn denials
fall through unannotated since they have no proposal commit.
deny becomes async (single git plumbing call); dashboard +
admin-socket callers grow .await.
2026-05-15 23:01:22 +02:00
müde
df9da4d6e1 todo: recv default should not sleep, agent opts into wait 2026-05-15 23:00:25 +02:00
müde
315d4289c7 actions: tag-driven approve(ApplyCommit) flow
run_apply_commit walks the approval through the tag state
machine in applied: approved/<id> + building/<id> stamped
before the build, then git read-tree --reset to proposal/<id>
populates the working dir without moving HEAD. on rebuild
success deployed/<id> is planted and refs/heads/main fast-
forwards to the proposal. on failure failed/<id> is annotated
with the build error and the working tree resets back to main
so the agent stays evaluable. helper events Rebuilt +
ApprovalResolved both carry the terminal tag so the manager
can git-show the exact tree (and read the failure note from
an annotated tag) against its read-only applied.git mount.
finish_approval grows a terminal_tag param; spawn path passes
None. lifecycle::apply_commit deleted.
2026-05-15 23:00:01 +02:00
müde
35b0edaf27 manager_server: fetch+tag at request_apply_commit submit
submit_apply_commit (1) queues the approval row, (2) git-fetches
the manager-supplied sha from proposed into applied, pins it as
refs/tags/proposal/<id>, (3) persists the resolved sha on the
row via approvals.set_fetched_sha. from this point on the
proposal is immutable from the manager's perspective: amends
or force-pushes in proposed do not change what hive-c0re will
build. fetch failures mark the row failed and surface the error
to the manager so a phantom pending entry can't linger.
2026-05-15 22:57:43 +02:00
müde
8cb8fcedad lifecycle: setup_applied seeds via fetch + tags deployed/0
new shape: applied is git-init'd at first spawn, fetches
proposed's initial commit into its main, tags deployed/0 there.
the wrapper flake.nix is regenerated on every spawn/rebuild
but no longer tracked — apply churn vanishes, manager-authored
files in the proposal flow now survive untouched. setup_applied
gains an Option<&Path> for proposed (None on rebuild paths
that just refresh the flake). pre-overhaul applied dirs are
detected via the missing deployed/0 tag and bail loudly with
the destroy --purge migration hint. apply_commit is stubbed
with a clear error until the tag-driven approve flow lands.
2026-05-15 22:56:58 +02:00
müde
63ef69674b lifecycle: git helpers for tag-driven applied repo
new plumbing for the upcoming flow: git_fetch_to_tag (pulls a
sha from proposed into applied and pins it as a tag in one
shot), git_rev_parse (normalises shas + reads back tag
targets), git_tag / git_tag_annotated (lightweight vs body-
carrying for failed/denied), git_read_tree_reset (replace
working tree without moving HEAD — lets main stay on last
known-good across an in-flight build), git_update_ref (ff main
on deploy). annotated tag bodies go via stdin to avoid escape
games. all dead-code-allowed; callers land in subsequent
commits.
2026-05-15 22:52:23 +02:00
müde
b32c3d4f98 approvals: persist fetched_sha alongside the queue
new column fetched_sha records the canonical sha hive-c0re
plans to fetch from the proposed repo into applied at submit
time. distinct from commit_ref (manager-supplied, may be
amended out from under the queue). set_fetched_sha is unused
until manager_server wires the fetch step next commit.
2026-05-15 22:49:04 +02:00
müde
871e7bf3fa wire types: add sha + tag to Approval and HelperEvent
approval grows fetched_sha (canonical hive-c0re-vouched sha,
distinct from manager-supplied commit_ref). helperevent
{approvalresolved,spawned,rebuilt} grow optional sha + tag so
the manager can git-show the exact tree it's hearing about
(against the upcoming /agents/<n>/applied.git RO mount) and
know which terminal tag landed. all serde-defaulted; existing
construction sites pass none until the tag-driven flow lands.
2026-05-15 22:47:39 +02:00
müde
497cd15137 docs: tag-driven config-apply plan + migration story
scratchpad in claude.md marks this as in-flight; docs/approvals.md
gets the new tag state machine (proposal/approved/building/deployed/
failed/denied) and the manager applied.git read-only mount. todo
picks up the unprivileged-containers git-identity caveat and a web
ui for config repos as a downstream follow-up.
2026-05-15 22:43:47 +02:00
müde
75e7faff0c docs: full sync ahead of compaction + config-management overhaul
readme: manager mcp surface picks up update; operator-surface
recap mentions /model + last-turn + model chip + the three
collapsibles (inbox / journald / agent.nix).

web-ui.md: details-restore-key story under shape; port-conflict
banner mention on containers; agent.nix viewer alongside journald;
notifications use per-event tags + console.debug log on
block/show; deny endpoint takes note=<reason>; data-prompt /
data-prompt-field generalisation noted.

conventions.md: data-prompt and snapshot/restoreOpenDetails added
to the async-forms section.

persistence.md: operator_questions row picks up deadline_at (ttl)
column with a migration note.

todo.md: new 'Bugs' section captures the manager-question
not-rendering issue with three suspect paths to chase.

claude.md scratchpad rewritten as a clean handoff for the
compaction + the upcoming config-git overhaul. flags the
two-repo (proposed/ + applied/) split as the thing to
reconsider.
2026-05-15 22:12:40 +02:00
müde
6a2ffd521b surface agent-vs-agent port collisions (manager:8000 can't collide)
manager is fixed at 8000, sub-agents are 8100-8999, so collisions
are strictly between two sub-agents hashing to the same value.
the colliding container's harness restart-loops on AddrInUse —
which the user just hit on :8945. previously the only sign was a
buried journalctl warn line.

now surfaced two ways:

- lifecycle::spawn / rebuild preflight: walks the live container
  list, computes each agent's hashed port, refuses with
  'port N already taken by <other> — rename one of them' if any
  running sub-agent shares the new agent's port. so the operator
  sees an actionable error in the dashboard's transient pill /
  approve-result instead of waiting for the harness to die.

- /api/state grows a port_conflicts: [{port, agents: [...]}]
  array; dashboard renders a pulsing red banner above the
  containers list listing each cluster. matches the questions
  panel pulse so it's hard to miss.
2026-05-15 22:08:19 +02:00
18 changed files with 836 additions and 145 deletions

View file

@ -114,17 +114,46 @@ read them à la carte.
In-flight or recent context that hasn't earned a section yet. In-flight or recent context that hasn't earned a section yet.
Prune freely. Prune freely.
- 2026-05-15 ish: tombstones, multi-select ask_operator, broker + - **Just landed:** tag-driven config-apply overhaul. Two-repo
events vacuum, docs split into `docs/`, lifecycle_action helper, split kept (proposed = manager RW, applied = core-only) for
api_state split. safety. New flow: at `request_apply_commit` time hive-c0re
- Then: inline +/- diffs on Write/Edit, operator cancel + ttl on fetches the manager's commit into applied and pins it as
questions, dashboard back-link, per-agent inbox view, bind-retry `proposal/<id>`; the manager-side repo is then irrelevant
+ SO_REUSEADDR, journald viewer, server-side TurnState, for that approval. Approve / deny / build walk through more
recv(wait_seconds) max 180s, runtime /model switch, crash tags (`approved/`, `building/`, `deployed/`, `failed/`,
watcher, model persistence, stopped auto-allowing claude-code `denied/`) on the same commit; `applied/main` only
unfree (operator must opt in), pure-hash agent_web_port (port fast-forwards on `deployed/`. `failed/` and `denied/` are
files reverted), browser notifications, focus-preserving annotated — body is the build error or the operator's deny
refresh. note respectively. Manager has `/applied` bind-mounted RO
- Open threads: telemetry/charts, custom per-agent MCP tools (the (whole tree) so `git fetch /applied/<n>/.git
groundwork for moving bitburner-agent into hyperhive), 'refs/tags/*:refs/tags/applied/*'` mirrors every relevant
two-step spawn, unprivileged containers, Bash allow-list. tag into its proposed clone. `agent.nix` stays the entry
point; the whole tracked tree is now preserved
through apply (arbitrary files supported). The wrapper
`flake.nix` is regenerated by hive-c0re every
spawn/rebuild but never tracked, so the applied log is
exactly the manager's commits in deploy order. Migration:
no in-place — pre-overhaul applied dirs are detected via
the missing `deployed/0` tag and `setup_applied` bails
with `destroy --purge` instructions. See
`docs/approvals.md`.
- **Recent (since last compaction):** inline +/- diffs on
Write/Edit, send full body via collapsed details, operator
cancel + ttl on questions, deny-with-reason, dashboard
back-link + last-turn timing + model chip, per-agent inbox
view, bind-retry + SO_REUSEADDR, journald viewer,
agent.nix viewer, server-side TurnState, recv(wait_seconds)
max 180s, runtime /model switch + persistence to /state,
crash watcher + ContainerCrash / NeedsLogin / LoggedIn /
NeedsUpdate events, manager `update` tool, pure-hash
agent_web_port + collision banner + spawn/rebuild preflight,
browser notifications, focus-preserving refresh, generalised
<details data-restore-key> survival, prompt-on-submit pattern.
- **Open threads:** custom per-agent MCP tools (groundwork for
moving bitburner-agent into hyperhive), two-step spawn,
per-agent send allow-list, telemetry/charts, notes
compaction, unprivileged containers, Bash allow-list,
xterm.js. **Known bug** (in TODO.md): question id=5 was
queued but didn't render — likely a `pending()` row-decode
error swallowed by `unwrap_or_default`; investigate by curl
/api/state | jq '.questions' + browser console.

View file

@ -30,8 +30,8 @@ host (NixOS, runs hive-c0re.service)
├── hm1nd hive-m1nd serve : claude turn loop + ├── hm1nd hive-m1nd serve : claude turn loop +
│ MCP (send / recv / request_spawn / kill / start / │ MCP (send / recv / request_spawn / kill / start /
│ restart / request_apply_commit / ask_operator) │ restart / update / request_apply_commit /
│ + web UI on :8000 ask_operator) + web UI on :8000
└── h-<name> hive-ag3nt serve : claude turn loop + └── h-<name> hive-ag3nt serve : claude turn loop +
MCP (send / recv) + web UI on a hashed :8100-8999 MCP (send / recv) + web UI on a hashed :8100-8999
@ -44,10 +44,13 @@ streams JSON events into the per-agent SSE bus + a sqlite history db →
claude drives any further `recv`/`send` itself via the embedded MCP server. claude drives any further `recv`/`send` itself via the embedded MCP server.
Operator surface per agent: terminal-themed live tail with a textarea Operator surface per agent: terminal-themed live tail with a textarea
prompt; slash commands `/help` `/clear` `/cancel` `/compact`; granular prompt; slash commands `/help` `/clear` `/cancel` `/compact`
state badge (idle / thinking / offline) with age timer; cancel-turn `/model <name>`; granular state badge (idle / thinking /
button while thinking; sticky-bottom auto-scroll with "↓ N new" pill; compacting / offline) with age timer + last-turn duration chip +
event history backfilled on page load. model chip; cancel-turn button while thinking; sticky-bottom
auto-scroll with "↓ N new" pill; event history backfilled on page
load; collapsible inbox + collapsible journald viewer + collapsible
`agent.nix` viewer per agent on the dashboard.
Config changes flow the other way: manager edits `/agents/<name>/config/agent.nix` Config changes flow the other way: manager edits `/agents/<name>/config/agent.nix`
(bind-mounted from the host's proposed repo) → commits → submits the sha as (bind-mounted from the host's proposed repo) → commits → submits the sha as

49
TODO.md
View file

@ -3,6 +3,21 @@
Pick anything from here when relevant. Cross-cutting design notes live in Pick anything from here when relevant. Cross-cutting design notes live in
[CLAUDE.md](CLAUDE.md); high-level project intro in [README.md](README.md). [CLAUDE.md](CLAUDE.md); high-level project intro in [README.md](README.md).
## Turn loop
- **`recv` with no `wait_seconds` should return immediately.**
Today omitting the argument falls through to the 30s
default long-poll (`RECV_LONG_POLL_DEFAULT` in
`hive-c0re/src/agent_server.rs`); a manager that wants a
cheap "anything in the inbox right now?" peek has to
explicitly pass `wait_seconds: 0`. Flip the semantics so
`None` = no sleep, returning `None` (or the empty inbox
shape) right away. The agent opts into the long-poll by
setting a positive value. Update both `AgentRequest::Recv`
and `ManagerRequest::Recv` handlers + the prompt language
in `prompts/{agent,manager}.md`. Tighten the cap (180s)
too — only meaningful when the agent is choosing to wait.
## Permissions / policy ## Permissions / policy
- **Per-agent send allow-list.** Today any agent can `send` to any - **Per-agent send allow-list.** Today any agent can `send` to any
@ -21,7 +36,12 @@ Pick anything from here when relevant. Cross-cutting design notes live in
nixos-container equivalent) so uid 0 inside maps to an unprivileged uid nixos-container equivalent) so uid 0 inside maps to an unprivileged uid
on the host, and a container-root compromise lands the attacker on an on the host, and a container-root compromise lands the attacker on an
ordinary user account, not the host's root. Requires per-agent state ordinary user account, not the host's root. Requires per-agent state
dirs to be chown'd to that uid on the host side. dirs to be chown'd to that uid on the host side. The per-agent git
identity (currently injected via `programs.git.config.user` against
the root user in `setup_applied`'s generated flake) also needs to be
provisioned for whatever non-root user claude runs as, or commits
the manager makes against `/agents/<n>/config` will fall back to a
generic `nixos@…` identity.
- **Bash command allow-list.** Replace the blanket `Bash` allow with a - **Bash command allow-list.** Replace the blanket `Bash` allow with a
pattern allow-list (`Bash(git *)`, `Bash(nix build .*)`, etc.) per pattern allow-list (`Bash(git *)`, `Bash(nix build .*)`, etc.) per
claude-code's `--allowedTools` extended grammar. Likely lives in claude-code's `--allowedTools` extended grammar. Likely lives in
@ -42,8 +62,35 @@ Pick anything from here when relevant. Cross-cutting design notes live in
derived from the same config so the operator stays in control of derived from the same config so the operator stays in control of
what's exposed. what's exposed.
## Bugs
- **Pending question doesn't always appear on the dashboard.**
Repro: manager calls `ask_operator`, tool result is
`question queued (id=N)` (so the row is in sqlite), but the
M1ND H4S QU3STI0NS section keeps showing "no pending
questions". Last seen with id=5. Suspected paths:
- `OperatorQuestions::pending()` returns Err and the
`unwrap_or_default()` in `api_state` hides it. Surface the
error (warn-log) and check.
- serialization: a new field in `OpQuestion` (e.g.
`deadline_at: Option<i64>`) deserializes wrong against an
old row whose columns don't match the new SELECT order →
`row.get(N)?` panics for that row, the whole iterator
errors, `pending()` returns Err. Diagnose by curl
`/api/state | jq '.questions'` and compare with sqlite
counts.
- dashboard JS swallows a render error. Open browser console
and look for exceptions during `renderQuestions`.
## UI / UX ## UI / UX
- **Web UI for config repos.** Browse history, diffs, tags
(proposed + approval/* + applied/*) per agent, all from the
dashboard. Something lighter than a full forge — read-only
log + diff + raw-file view is enough. Pairs naturally with
the upcoming config-repo overhaul (tags become the audit
trail; UI surfaces them).
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by - **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
the harness. Pairs well with the unprivileged-container work — would let the harness. Pairs well with the unprivileged-container work — would let
the operator drop into the container without `nixos-container root-login`. the operator drop into the container without `nixos-container root-login`.

View file

@ -8,15 +8,29 @@ happens after a decision lands.
## End-to-end approval flow ## End-to-end approval flow
1. Manager edits `/agents/<name>/config/agent.nix` (bind-mounted 1. Manager edits files under `/agents/<name>/config/` (any tracked
from the host's per-agent `proposed` repo) and commits. path, but `agent.nix` is the contract entry point) and commits
with its own git identity.
2. Manager submits the commit sha via `request_apply_commit(agent, 2. Manager submits the commit sha via `request_apply_commit(agent,
commit_ref)`. commit_ref)`.
3. Operator sees the diff on the dashboard, clicks ◆ APPR0VE (or 3. **hive-c0re immediately fetches that commit from the proposed
repo into the applied repo and tags it `proposal/<id>`.** The
approval row stores both the manager-supplied sha and the
canonical hive-c0re-vouched sha. From here on the proposed
repo is irrelevant for this approval — the manager can amend,
force-push, or `rm -rf` the proposed repo and the queued
approval still points at an immutable git object inside
applied.
4. Operator sees the diff on the dashboard, clicks ◆ APPR0VE (or
`hive-c0re approve <id>` on the CLI). `hive-c0re approve <id>` on the CLI).
4. hive-c0re reads the file at that sha from `proposed`, applies 5. hive-c0re moves the working tree to `proposal/<id>` and runs
into `applied`, commits there, runs `nixos-container update`. the build under a sequence of tags (see below). On success,
5. `HelperEvent::ApprovalResolved` lands in the manager's inbox. `applied/main` fast-forwards to the proposal commit. On
failure, main stays put and the working tree resets back to
the previous deployed commit.
6. `HelperEvent::ApprovalResolved` (and `Rebuilt` for the
ApplyCommit kind) land in the manager's inbox, carrying both
the canonical sha and the terminal tag.
`Spawn` approvals follow the same shape but skip the commit-diff `Spawn` approvals follow the same shape but skip the commit-diff
step — the operator just sees the name. On approve, hive-c0re step — the operator just sees the name. On approve, hive-c0re
@ -26,27 +40,83 @@ shows a spinner.
## Two repos per agent ## Two repos per agent
``` ```
/var/lib/hyperhive/agents/<name>/config/ proposed /var/lib/hyperhive/agents/<name>/config/ proposed — manager RW
└── agent.nix # the only file the └── <anything> # any files the manager
# manager can change # wants in the commit.
# (initial commit by # agent.nix is the
# hive-c0re on first # convention entry
# spawn, never touched # point; flake.nix is
# again). # generated and not
# tracked here.
/var/lib/hyperhive/applied/<name>/ applied — hive-c0re-only /var/lib/hyperhive/applied/<name>/ applied — core-only
├── flake.nix # auto-generated ├── .git/ # tag-rich history
└── agent.nix # overwritten by approve ├── .gitignore # ignores flake.nix
# from the proposed commit ├── flake.nix # hive-c0re-generated,
│ # untracked, rewritten
│ # on spawn/rebuild only
├── agent.nix # working tree of main
└── <other manager files> # also tracked
``` ```
The container's `--flake` ref is `<applied_dir>#default`. The flake Why two physical repos: the manager's `/agents/<n>/config/` is
extends `hyperhive.nixosConfigurations.{agent-base|manager}` with RW — a buggy or hostile agent can `git clean -fdx` its own
proposed tree. The applied repo is never bind-mounted (except
the read-only `.git` exposure described below) so a destructive
move inside the container cannot reach it.
The container's `--flake` ref is `<applied_dir>#default`. The
generated `flake.nix` extends
`hyperhive.nixosConfigurations.{agent-base|manager}` with
`./agent.nix` plus an inline module setting `./agent.nix` plus an inline module setting
`programs.git.config.user` (committer identity = the agent's name) `programs.git.config.user` (committer identity = the agent's name)
and `systemd.services.<harness>.environment` (`HIVE_PORT`, and `systemd.services.<harness>.environment` (`HIVE_PORT`,
`HIVE_LABEL`, `HIVE_DASHBOARD_PORT`). `HIVE_LABEL`, `HIVE_DASHBOARD_PORT`).
### Tag state machine
Every approval id walks through a fixed set of tags on the
underlying commit inside the applied repo:
| Tag | When | Annotated? |
|---|---|---|
| `proposal/<id>` | request_apply_commit, after fetch | no |
| `approved/<id>` | operator approve | no |
| `building/<id>` | rebuild started | no |
| `deployed/<id>` | rebuild succeeded — `main` ff's here | no |
| `failed/<id>` | rebuild failed | yes (body = error) |
| `denied/<id>` | operator deny | yes (body = operator note) |
`applied/main` is always the latest `deployed/*`. `denied/` and
`failed/` are terminal; the manager submits a new commit + new
approval id to retry. Because tags are first-class git objects,
rejected and failed trees stay browsable forever — `git log
--tags` in the applied repo is the audit trail.
### Manager view of applied
`/applied/` is a **read-only bind-mount** of
`/var/lib/hyperhive/applied/` (the entire tree) inside the
manager container. The manager fetches tags into its proposed
clone with `git fetch /applied/<n>/.git
'refs/tags/*:refs/tags/applied/*'` and `git show` any
deployed / failed / denied tree to see what actually shipped,
what error blocked the last build, or what note the operator
left on a denial. The RO bind means git plumbing inside the
manager cannot corrupt the applied repos — and a single mount
covers every agent (existing + future) without rebuilding the
manager on each spawn.
## Migration from the pre-tag scheme
There is no in-place migration. Each existing agent must be
purged and re-spawned: `hive-c0re destroy --purge <name>` (or
PURG3 on the dashboard), then `request_spawn` and the operator
approves the fresh agent. The new agent starts with `deployed/0`
seeded by hive-c0re; the manager's first config edit becomes
`proposal/1` and walks the tag scheme from there. Pre-overhaul
tombstones lose their config history.
## Manager (`hm1nd`) is hive-c0re-managed ## Manager (`hm1nd`) is hive-c0re-managed
The manager container runs through the **same lifecycle as The manager container runs through the **same lifecycle as
@ -64,9 +134,11 @@ Differences from sub-agents:
(vs `agent-base`). (vs `agent-base`).
- Container name is `hm1nd` (no `h-` prefix). - Container name is `hm1nd` (no `h-` prefix).
- Fixed web UI port (`MANAGER_PORT = 8000`). - Fixed web UI port (`MANAGER_PORT = 8000`).
- `set_nspawn_flags` adds an extra bind: - `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents`
`/var/lib/hyperhive/agents``/agents` (RW), so the manager can `/agents` (RW) so the manager can edit per-agent proposed repos,
edit per-agent proposed repos. and `/var/lib/hyperhive/applied``/applied` (RO) so the manager
can `git fetch` deployed/failed/denied tags from any agent's
authoritative applied repo (see "Manager view of applied" below).
- First-deploy spawn bypasses the approval queue (manager is - First-deploy spawn bypasses the approval queue (manager is
required infrastructure). required infrastructure).
- Per-agent socket lives at `/run/hyperhive/manager/`, owned by - Per-agent socket lives at `/run/hyperhive/manager/`, owned by

View file

@ -34,8 +34,15 @@ Dashboard + per-agent mutating forms carry `data-async`; a delegated
`submit` listener in `assets/app.js` intercepts, shows a spinner, `submit` listener in `assets/app.js` intercepts, shows a spinner,
POSTs `application/x-www-form-urlencoded` (axum's `Form` extractor POSTs `application/x-www-form-urlencoded` (axum's `Form` extractor
rejects multipart), calls `refreshState()` on success. New mutating rejects multipart), calls `refreshState()` on success. New mutating
forms should add `data-async` and optionally `data-confirm` for a forms should add `data-async` and optionally `data-confirm` (for a
JS-side confirmation prompt. 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 ## `rebuild` is the reconcile verb

View file

@ -14,7 +14,8 @@ Three tables, all in one file:
commit_ref / requested_at / status / resolved_at / note`. commit_ref / requested_at / status / resolved_at / note`.
- `operator_questions``ask_operator` queue. - `operator_questions``ask_operator` queue.
`asker / question / options_json / multi / asked_at / `asker / question / options_json / multi / asked_at /
answered_at / answer`. deadline_at (ttl) / answered_at / answer`. Migrated via
`ALTER TABLE ADD COLUMN` against `pragma_table_info`.
Retention: Retention:

View file

@ -30,6 +30,16 @@ and, if so, skips the refresh (defers 2s). The operator never has
the form yanked out from under them mid-type; the update lands as the form yanked out from under them mid-type; the update lands as
soon as they blur. soon as they blur.
**`<details>` open-state preservation:** any collapsible element
tagged with `data-restore-key="<stable-key>"` survives the
refresh. `snapshotOpenDetails()` walks managed sections before
render, `restoreOpenDetails()` re-applies after. Used today for
the journald viewer (`journal:<container>`), the agent-config
viewer (`agent-config:<name>`), and approval diff blocks
(`approval-diff:<id>`). Setting `.open = true` programmatically
also fires the `toggle` event, so any lazy-fetch wired to it
re-runs cleanly on restore.
Both bind their listeners with `SO_REUSEADDR` via Both bind their listeners with `SO_REUSEADDR` via
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries, `tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
exponential backoff capped at 2s) so an nspawn restart that races exponential backoff capped at 2s) so an nspawn restart that races
@ -42,6 +52,11 @@ the previous process's socket release resolves itself.
inline "unsupported / blocked" message when applicable. Sits inline "unsupported / blocked" message when applicable. Sits
under the banner. under the banner.
2. **C0NTAINERS** — live containers with their action surface. 2. **C0NTAINERS** — live containers with their action surface.
Pulsing red banner at the top of this section if any two
sub-agents hash to the same port (`port_conflicts` from
`/api/state`): the operator must rename one of them and
rebuild. `lifecycle::{spawn,rebuild}` also preflight this and
refuse with a clear error message naming the conflicting agent.
3. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size + 3. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
Spawn approval; existing state is reused), `PURG3` (wipes Spawn approval; existing state is reused), `PURG3` (wipes
@ -73,12 +88,16 @@ Two-line layout (`assets/app.js::renderContainers`):
on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running, on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running,
`▶ ST4RT` when stopped. Buttons dim + disable while a transient `▶ ST4RT` when stopped. Buttons dim + disable while a transient
lifecycle action is in flight. lifecycle action is in flight.
- Plus a collapsible `↳ logs · <container>` `<details>` block. - Plus two collapsible `<details>` blocks:
Expanding lazy-fetches journald output via `GET - `↳ logs · <container>` — lazy-fetches journald output via
/api/journal/{name}?unit=...&lines=...` (`journalctl -M `GET /api/journal/{name}?unit=...&lines=...` (`journalctl -M
<container> -b --no-pager --output=short-iso`). A unit dropdown <container> -b --no-pager --output=short-iso`). A unit
switches between the harness service (default) and the full dropdown switches between the harness service (default) and
machine journal; refresh button re-fetches. the full machine journal; refresh button re-fetches.
- `↳ agent.nix · <name>` — lazy-fetches the applied config
file via `GET /api/agent-config/{name}` (read-only mirror of
`/var/lib/hyperhive/applied/<name>/agent.nix`). Mutating
this still requires `request_apply_commit` + approval.
`↻ UPD4TE 4LL` button appears above the containers list when any `↻ UPD4TE 4LL` button appears above the containers list when any
agent is stale. Banner pulses on each broker SSE event agent is stale. Banner pulses on each broker SSE event
@ -94,15 +113,27 @@ Pure frontend (`Notification` API). Three signals trigger them:
First `/api/state` after page load seeds "seen" sets without First `/api/state` after page load seeds "seen" sets without
firing — only items that arrive while the page is open count. firing — only items that arrive while the page is open count.
`tag: "hyperhive"` collapses bursts; click focuses the dashboard Per-event tags (`hyperhive:approval:<id>`, `hyperhive:question:<id>`,
tab. localStorage-backed mute toggle silences without revoking `hyperhive:msg:<at>:<rand>`) so distinct events stack in the OS
the OS permission. Requires a secure context (HTTPS or notification center instead of overwriting each other.
localhost); on other origins the controls hide themselves. `console.debug` logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Click
focuses the dashboard tab. localStorage-backed mute toggle
silences without revoking the OS permission. Requires a secure
context (HTTPS or localhost); on other origins the controls hide
themselves. Browsers typically suppress notifications while the
originating tab is focused — that's a browser-level decision,
not ours.
### Dashboard endpoints ### Dashboard endpoints
- `POST /{approve,deny}/{id}` — approve/deny a pending approval. - `POST /approve/{id}` — approve a pending approval.
- `POST /deny/{id}` (`note=<reason>`, optional) — deny a pending
approval with an optional operator-supplied reason. The reason
travels to the manager as `HelperEvent::ApprovalResolved.note`.
Dashboard prompts via `window.prompt()` on click.
- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. - `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
`destroy` accepts `purge=on` to also wipe state dirs.
- `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs. - `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /answer-question/{id}` — answer a pending operator question. - `POST /answer-question/{id}` — answer a pending operator question.
- `POST /cancel-question/{id}` — cancel a pending question with - `POST /cancel-question/{id}` — cancel a pending question with
@ -111,6 +142,13 @@ localhost); on other origins the controls hide themselves.
- `POST /update-all` — rebuild every stale container. - `POST /update-all` — rebuild every stale container.
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
a managed container. a managed container.
- `GET /api/agent-config/{name}` — read-only view of the applied
`agent.nix`.
Generalised form helpers: `form[data-confirm="…"]` pops
`confirm()` before submit; `form[data-prompt="…"]` pops
`prompt()` and stashes the answer in a hidden input named by
`data-prompt-field` (default `note`).
## Per-agent page ## Per-agent page
@ -134,7 +172,9 @@ Layout, top to bottom:
POSTs `/api/cancel`. POSTs `/api/cancel`.
- Inbox `<details>` block (collapsed): `inbox · N` — last 30 - Inbox `<details>` block (collapsed): `inbox · N` — last 30
messages addressed to this agent, fetched via messages addressed to this agent, fetched via
`AgentRequest::Recent { limit: 30 }`. `AgentRequest::Recent { limit: 30 }`. (Separate from
`AgentRequest::Recv { wait_seconds }` which the harness uses
internally to long-poll the broker.)
- Terminal-wrap: live event tail (sticky-bottom auto-scroll + - Terminal-wrap: live event tail (sticky-bottom auto-scroll +
`↓ N new` pill when not at bottom) followed by an `↓ N new` pill when not at bottom) followed by an
operator-input textarea acting as a prompt. operator-input textarea acting as a prompt.

View file

@ -9,12 +9,21 @@ Tools (hyperhive surface):
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required. - `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required.
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required. - `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
- `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event. - `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event.
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval. - `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/<id>`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build.
- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands. - `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands.
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day. Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day.
Your own editable config lives at `/agents/hm1nd/config/agent.nix`; every sub-agent's lives at `/agents/<name>/config/agent.nix`. Use file/git tools to edit + commit, then `request_apply_commit`. Your own editable config lives at `/agents/hm1nd/config/`; every sub-agent's lives at `/agents/<name>/config/`. `agent.nix` is the entry point but you can commit any extra files (modules, overlays, prompt fragments) and the whole tree gets deployed together. Use file/git tools to edit + commit, then `request_apply_commit`.
To see what hive-c0re actually deployed (or rejected, or failed to build), there's a read-only mirror of every agent's applied repo at `/applied/<name>/.git`. Useful patterns:
- `git -C /agents/<name>/config fetch /applied/<name>/.git 'refs/tags/*:refs/tags/applied/*'` — mirror all tags into your proposed clone.
- `git -C /agents/<name>/config show applied/deployed/<id>` — see the tree that's currently running.
- `git -C /agents/<name>/config show applied/failed/<id>` — annotated tag body is the build error from a rejected rebuild.
- `git -C /agents/<name>/config show applied/denied/<id>` — annotated tag body is the operator's reason for denial.
Tag scheme on every approval id: `proposal → approved → building → deployed | failed`, plus `denied` as a terminal alternative to `approved`. `applied/main` only advances on `deployed/*`, so a failed build does not corrupt the agent — submit a fix as a new commit and a fresh `request_apply_commit`.
Sub-agents are NOT trusted by default. When one asks for a config change (new packages, env vars, etc.), verify the request before staging: Sub-agents are NOT trusted by default. When one asks for a config change (new packages, env vars, etc.), verify the request before staging:

View file

@ -215,6 +215,18 @@
const root = $('containers-section'); const root = $('containers-section');
root.innerHTML = ''; root.innerHTML = '';
// Port-hash collisions: rename one of the listed agents and
// rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged.
if (s.port_conflicts && s.port_conflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = s.port_conflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (s.any_stale) { if (s.any_stale) {
root.append(form( root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', '/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',

View file

@ -193,6 +193,22 @@ a:hover {
/* Notification controls sit between the banner and the /* Notification controls sit between the banner and the
containers section. Hidden by JS when notifications are containers section. Hidden by JS when notifications are
unsupported, denied, or already in the right state. */ unsupported, denied, or already in the right state. */
/* Port-collision banner: appears above the containers list when
two sub-agents hash to the same web UI port. Critical without
resolution, one of the harnesses will restart-loop on
AddrInUse. */
.port-conflict {
background: rgba(243, 139, 168, 0.08);
border: 1px solid var(--red);
color: var(--red);
padding: 0.5em 0.8em;
margin-bottom: 0.6em;
border-radius: 4px;
text-shadow: 0 0 6px rgba(243, 139, 168, 0.4);
animation: questions-pulse 2.4s ease-in-out infinite;
}
.port-conflict strong { color: var(--red); }
.notif-row { .notif-row {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;

View file

@ -41,21 +41,16 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
match approval.kind { match approval.kind {
ApprovalKind::ApplyCommit => { ApprovalKind::ApplyCommit => {
let result = async { let (result, terminal_tag) = run_apply_commit(
lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?; &coord,
lifecycle::rebuild( &approval,
&approval.agent, &agent_dir,
&coord.hyperhive_flake, &applied_dir,
&agent_dir, &claude_dir,
&applied_dir, &notes_dir,
&claude_dir, )
&notes_dir,
coord.dashboard_port,
)
.await
}
.await; .await;
finish_approval(&coord, &approval, result) finish_approval(&coord, &approval, result, terminal_tag)
} }
ApprovalKind::Spawn => { ApprovalKind::Spawn => {
// Run the spawn in the background so the approve POST returns // Run the spawn in the background so the approve POST returns
@ -77,7 +72,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
) )
.await; .await;
coord_bg.clear_transient(&agent_bg); coord_bg.clear_transient(&agent_bg);
if let Err(e) = finish_approval(&coord_bg, &approval_bg, result) { if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) {
tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed");
} }
}); });
@ -90,6 +85,7 @@ fn finish_approval(
coord: &Coordinator, coord: &Coordinator,
approval: &hive_sh4re::Approval, approval: &hive_sh4re::Approval,
result: Result<()>, result: Result<()>,
terminal_tag: Option<String>,
) -> Result<()> { ) -> Result<()> {
let (status, note, ok) = match &result { let (status, note, ok) = match &result {
Ok(()) => (ApprovalStatus::Approved, None, true), Ok(()) => (ApprovalStatus::Approved, None, true),
@ -105,6 +101,8 @@ fn finish_approval(
commit_ref: approval.commit_ref.clone(), commit_ref: approval.commit_ref.clone(),
status, status,
note: note.clone(), note: note.clone(),
sha: approval.fetched_sha.clone(),
tag: terminal_tag.clone(),
}); });
// For spawn/rebuild approvals, also surface the underlying action so // For spawn/rebuild approvals, also surface the underlying action so
// the manager knows whether the container actually came up. The // the manager knows whether the container actually came up. The
@ -116,16 +114,116 @@ fn finish_approval(
agent: approval.agent.clone(), agent: approval.agent.clone(),
ok, ok,
note, note,
sha: approval.fetched_sha.clone(),
}), }),
ApprovalKind::ApplyCommit => coord.notify_manager(&HelperEvent::Rebuilt { ApprovalKind::ApplyCommit => coord.notify_manager(&HelperEvent::Rebuilt {
agent: approval.agent.clone(), agent: approval.agent.clone(),
ok, ok,
note, note,
sha: approval.fetched_sha.clone(),
tag: terminal_tag,
}), }),
} }
result result
} }
/// Tag-driven `ApplyCommit` handler. Walks the approval through the tag
/// state machine documented in `docs/approvals.md`: stamp
/// `approved/<id>` and `building/<id>` first so the audit trail
/// captures intent, then drop the candidate tree into the working dir
/// without moving HEAD, run the rebuild, and either fast-forward
/// `applied/main` to the proposal commit on success
/// (`deployed/<id>`) or annotate `failed/<id>` with the build error
/// and reset the working tree back to the last known-good main. main
/// never advances on a failed build, so a crash-and-recover doesn't
/// leave the agent pointing at a tree it can't evaluate.
async fn run_apply_commit(
coord: &Arc<Coordinator>,
approval: &hive_sh4re::Approval,
agent_dir: &std::path::Path,
applied_dir: &std::path::Path,
claude_dir: &std::path::Path,
notes_dir: &std::path::Path,
) -> (Result<()>, Option<String>) {
let id = approval.id;
let proposal_ref = format!("refs/tags/proposal/{id}");
// Defensive: submit-time should have planted proposal/<id>, but if
// the row was migrated from an older schema or the tag got pruned
// we fail early with a clear note rather than building a stale
// tree.
if let Err(e) = lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
return (
Err(anyhow::anyhow!(
"missing proposal tag {proposal_ref}: {e:#}"
)),
None,
);
}
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("approved/{id}"), &proposal_ref).await
{
return (Err(anyhow::anyhow!("plant approved/{id}: {e:#}")), None);
}
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("building/{id}"), &proposal_ref).await
{
return (Err(anyhow::anyhow!("plant building/{id}: {e:#}")), None);
}
if let Err(e) = lifecycle::git_read_tree_reset(applied_dir, &proposal_ref).await {
return (
Err(anyhow::anyhow!("read-tree to {proposal_ref}: {e:#}")),
None,
);
}
let rebuild_result = lifecycle::rebuild(
&approval.agent,
&coord.hyperhive_flake,
agent_dir,
applied_dir,
claude_dir,
notes_dir,
coord.dashboard_port,
)
.await;
match rebuild_result {
Ok(()) => {
let tag = format!("deployed/{id}");
if let Err(e) = lifecycle::git_tag(applied_dir, &tag, &proposal_ref).await {
tracing::warn!(agent = %approval.agent, %id, error = ?e, "plant deployed tag failed");
}
if let Err(e) =
lifecycle::git_update_ref(applied_dir, "refs/heads/main", &proposal_ref).await
{
// Working tree already matches proposal/<id>, but main
// didn't advance — surface as a build failure so the
// operator notices the desync.
return (
Err(anyhow::anyhow!("ff main to {proposal_ref}: {e:#}")),
Some(tag),
);
}
(Ok(()), Some(tag))
}
Err(e) => {
let tag = format!("failed/{id}");
let body = format!("{e:#}");
if let Err(te) =
lifecycle::git_tag_annotated(applied_dir, &tag, &proposal_ref, &body).await
{
tracing::warn!(agent = %approval.agent, %id, error = ?te, "annotate failed tag failed");
}
// Roll working tree back to last known-good main so the
// on-disk state matches what nixos-container last
// successfully built. main hasn't moved, so this is just
// a content reset.
if let Err(re) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await {
tracing::warn!(agent = %approval.agent, %id, error = ?re, "rollback read-tree failed");
}
(Err(e), Some(tag))
}
}
}
/// Tear down a sub-agent container. By default this is non-destructive to /// Tear down a sub-agent container. By default this is non-destructive to
/// persistent state: the proposed/applied config repos and the Claude /// persistent state: the proposed/applied config repos and the Claude
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are /// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
@ -178,17 +276,50 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()>
Ok(()) Ok(())
} }
pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
let approval = coord.approvals.get(id)?; let approval = coord.approvals.get(id)?;
coord.approvals.mark_denied(id, note)?; coord.approvals.mark_denied(id, note)?;
tracing::info!(%id, note, "approval denied"); tracing::info!(%id, note, "approval denied");
let mut tag = None;
if let Some(a) = approval { if let Some(a) = approval {
let sha = a.fetched_sha.clone();
// ApplyCommit approvals leave a `denied/<id>` tag on the
// proposal commit so rejected configs are first-class git
// objects — `git show denied/<id>` in the manager's applied
// mount yields both the tree the operator rejected and (in
// the annotated body) the reason. Spawn approvals have no
// commit to tag, so they fall through unannotated.
if matches!(a.kind, ApprovalKind::ApplyCommit) {
let applied_dir = Coordinator::agent_applied_dir(&a.agent);
let proposal_ref = format!("refs/tags/proposal/{id}");
if lifecycle::git_rev_parse(&applied_dir, &proposal_ref)
.await
.is_ok()
{
let tag_name = format!("denied/{id}");
let body = note.unwrap_or("").to_owned();
if let Err(e) = lifecycle::git_tag_annotated(
&applied_dir,
&tag_name,
&proposal_ref,
&body,
)
.await
{
tracing::warn!(%id, error = ?e, "plant denied tag failed");
} else {
tag = Some(tag_name);
}
}
}
coord.notify_manager(&HelperEvent::ApprovalResolved { coord.notify_manager(&HelperEvent::ApprovalResolved {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent,
commit_ref: a.commit_ref, commit_ref: a.commit_ref,
status: ApprovalStatus::Denied, status: ApprovalStatus::Denied,
note: note.map(String::from), note: note.map(String::from),
sha,
tag,
}); });
} }
Ok(()) Ok(())

View file

@ -41,6 +41,21 @@ fn ensure_kind_column(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// Same shape as `ensure_kind_column` but for `fetched_sha` — the
/// canonical sha hive-c0re vouched for at `request_apply_commit` time.
/// Distinct from `commit_ref` (manager-supplied, may not even resolve
/// in proposed by the time we approve).
fn ensure_fetched_sha_column(conn: &Connection) -> Result<()> {
let has: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'fetched_sha'")?
.exists([])?;
if !has {
conn.execute_batch("ALTER TABLE approvals ADD COLUMN fetched_sha TEXT;")
.context("add approvals.fetched_sha column")?;
}
Ok(())
}
pub struct Approvals { pub struct Approvals {
conn: Mutex<Connection>, conn: Mutex<Connection>,
} }
@ -56,6 +71,7 @@ impl Approvals {
conn.execute_batch(SCHEMA) conn.execute_batch(SCHEMA)
.context("apply approvals schema")?; .context("apply approvals schema")?;
ensure_kind_column(&conn).context("migrate approvals.kind")?; ensure_kind_column(&conn).context("migrate approvals.kind")?;
ensure_fetched_sha_column(&conn).context("migrate approvals.fetched_sha")?;
Ok(Self { Ok(Self {
conn: Mutex::new(conn), conn: Mutex::new(conn),
}) })
@ -75,10 +91,22 @@ impl Approvals {
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
/// Record the canonical sha hive-c0re fetched from the proposed repo
/// into applied at submission time. Idempotent on identical values.
#[allow(dead_code)] // wired up by manager_server in the next commit
pub fn set_fetched_sha(&self, id: i64, sha: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE approvals SET fetched_sha = ?1 WHERE id = ?2",
params![sha, id],
)?;
Ok(())
}
pub fn pending(&self) -> Result<Vec<Approval>> { pub fn pending(&self) -> Result<Vec<Approval>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha
FROM approvals FROM approvals
WHERE status = 'pending' WHERE status = 'pending'
ORDER BY id ASC", ORDER BY id ASC",
@ -91,7 +119,7 @@ impl Approvals {
pub fn get(&self, id: i64) -> Result<Option<Approval>> { pub fn get(&self, id: i64) -> Result<Option<Approval>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.query_row( conn.query_row(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha
FROM approvals WHERE id = ?1", FROM approvals WHERE id = ?1",
params![id], params![id],
row_to_approval, row_to_approval,
@ -104,9 +132,10 @@ impl Approvals {
/// approval so the caller can run the action and pass the agent name. /// approval so the caller can run the action and pass the agent name.
pub fn mark_approved(&self, id: i64) -> Result<Approval> { pub fn mark_approved(&self, id: i64) -> Result<Approval> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let current: Option<(String, String, String, i64, String)> = conn let current: Option<(String, String, String, i64, String, Option<String>)> = conn
.query_row( .query_row(
"SELECT agent, kind, commit_ref, requested_at, status FROM approvals WHERE id = ?1", "SELECT agent, kind, commit_ref, requested_at, status, fetched_sha
FROM approvals WHERE id = ?1",
params![id], params![id],
|row| { |row| {
Ok(( Ok((
@ -115,11 +144,12 @@ impl Approvals {
row.get(2)?, row.get(2)?,
row.get(3)?, row.get(3)?,
row.get(4)?, row.get(4)?,
row.get(5)?,
)) ))
}, },
) )
.optional()?; .optional()?;
let Some((agent, kind, commit_ref, requested_at, status)) = current else { let Some((agent, kind, commit_ref, requested_at, status, fetched_sha)) = current else {
bail!("approval {id} not found"); bail!("approval {id} not found");
}; };
if status != "pending" { if status != "pending" {
@ -139,6 +169,7 @@ impl Approvals {
status: ApprovalStatus::Approved, status: ApprovalStatus::Approved,
resolved_at: Some(resolved_at), resolved_at: Some(resolved_at),
note: None, note: None,
fetched_sha,
}) })
} }
@ -178,7 +209,7 @@ impl Approvals {
} }
fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> { fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note. // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha.
let kind: String = row.get(2)?; let kind: String = row.get(2)?;
let kind = match kind.as_str() { let kind = match kind.as_str() {
"apply_commit" => ApprovalKind::ApplyCommit, "apply_commit" => ApprovalKind::ApplyCommit,
@ -214,6 +245,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
status, status,
resolved_at: row.get(6)?, resolved_at: row.get(6)?,
note: row.get(7)?, note: row.get(7)?,
fetched_sha: row.get(8)?,
}) })
} }

View file

@ -78,6 +78,8 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
agent: name.to_owned(), agent: name.to_owned(),
ok: true, ok: true,
note: None, note: None,
sha: None,
tag: None,
}); });
} }
Err(e) => { Err(e) => {
@ -85,6 +87,8 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
agent: name.to_owned(), agent: name.to_owned(),
ok: false, ok: false,
note: Some(format!("{e:#}")), note: Some(format!("{e:#}")),
sha: None,
tag: None,
}); });
} }
} }

View file

@ -146,6 +146,17 @@ struct StateSnapshot {
/// survive after a destroy-without-purge. The operator can re-spawn /// survive after a destroy-without-purge. The operator can re-spawn
/// with the same name to resume, or PURG3 to wipe them. /// with the same name to resume, or PURG3 to wipe them.
tombstones: Vec<TombstoneView>, tombstones: Vec<TombstoneView>,
/// Sub-agents whose FNV-1a hashed web UI port collides with at
/// least one other agent. Operator resolves by renaming. The
/// dashboard renders a banner at the top listing each cluster.
port_conflicts: Vec<PortConflict>,
}
#[derive(Serialize)]
struct PortConflict {
port: u16,
/// All agent names sharing this port (sorted, ≥2 entries).
agents: Vec<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -216,6 +227,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
let transients = build_transient_views(&raw_containers, &transient_snapshot); let transients = build_transient_views(&raw_containers, &transient_snapshot);
let approvals = build_approval_views(pending_approvals).await; let approvals = build_approval_views(pending_approvals).await;
let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot); let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot);
let port_conflicts = build_port_conflicts(&containers);
let operator_inbox = state let operator_inbox = state
.coord .coord
@ -234,9 +246,31 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
operator_inbox, operator_inbox,
questions, questions,
tombstones, tombstones,
port_conflicts,
}) })
} }
/// Group live containers by their assigned web UI port; clusters with
/// more than one member are port-hash collisions the operator needs
/// to resolve by renaming. Manager (fixed at 8000) and sub-agents
/// (8100..8999) can't collide with each other — collisions are
/// strictly between sub-agents.
fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
let mut by_port: std::collections::BTreeMap<u16, Vec<String>> =
std::collections::BTreeMap::new();
for c in containers {
by_port.entry(c.port).or_default().push(c.name.clone());
}
by_port
.into_iter()
.filter(|(_, agents)| agents.len() > 1)
.map(|(port, mut agents)| {
agents.sort();
PortConflict { port, agents }
})
.collect()
}
/// Build `ContainerView`s for every live nixos-container. Returns the /// Build `ContainerView`s for every live nixos-container. Returns the
/// list and whether any container is stale (drives the "↻ UPD4TE 4LL" /// list and whether any container is stale (drives the "↻ UPD4TE 4LL"
/// banner). /// banner).
@ -426,7 +460,7 @@ async fn post_deny(
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
match actions::deny(&state.coord, id, note) { match actions::deny(&state.coord, id, note).await {
Ok(()) => Redirect::to("/").into_response(), Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("deny {id} failed: {e:#}")), Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
} }

View file

@ -107,6 +107,32 @@ fn validate(name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// First name (≠ `self_name`) currently running whose hashed port
/// matches this agent's. The harness inside the colliding container
/// would otherwise loop on `AddrInUse` forever; we surface the
/// conflict here so spawn / rebuild fails loudly with an actionable
/// message instead.
async fn port_collision(self_name: &str) -> Option<String> {
let port = agent_web_port(self_name);
let raw = list().await.unwrap_or_default();
for c in raw {
let other = if c == MANAGER_NAME {
MANAGER_NAME.to_owned()
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
n.to_owned()
} else {
continue;
};
if other == self_name {
continue;
}
if agent_web_port(&other) == port && is_running(&other).await {
return Some(other);
}
}
None
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn spawn( pub async fn spawn(
name: &str, name: &str,
@ -119,8 +145,21 @@ pub async fn spawn(
dashboard_port: u16, dashboard_port: u16,
) -> Result<()> { ) -> Result<()> {
validate(name)?; validate(name)?;
if let Some(other) = port_collision(name).await {
bail!(
"port {} is already taken by '{other}' — rename one of them and retry",
agent_web_port(name)
);
}
setup_proposed(proposed_dir, name).await?; setup_proposed(proposed_dir, name).await?;
setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?; setup_applied(
applied_dir,
Some(proposed_dir),
name,
hyperhive_flake,
dashboard_port,
)
.await?;
ensure_claude_dir(claude_dir)?; ensure_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?; ensure_state_dir(notes_dir)?;
let container = container_name(name); let container = container_name(name);
@ -192,7 +231,13 @@ pub async fn rebuild(
dashboard_port: u16, dashboard_port: u16,
) -> Result<()> { ) -> Result<()> {
validate(name)?; validate(name)?;
setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?; if let Some(other) = port_collision(name).await {
bail!(
"port {} is already taken by '{other}' — rename one of them and retry",
agent_web_port(name)
);
}
setup_applied(applied_dir, None, name, hyperhive_flake, dashboard_port).await?;
ensure_claude_dir(claude_dir)?; ensure_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?; ensure_state_dir(notes_dir)?;
let container = container_name(name); let container = container_name(name);
@ -248,11 +293,22 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call /// Set up the applied repo. Two responsibilities:
/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix` /// - First-spawn only: init the repo, pull proposed's initial commit
/// only on first call. `apply_commit` overwrites `agent.nix` later. /// in via `git fetch`, tag it `deployed/0`. This is the *only* time
/// hive-c0re reads from `proposed` for an agent — subsequent
/// proposals are fetched at `request_apply_commit` time and tagged
/// `proposal/<id>` (see `actions::approve` for the tag state
/// machine).
/// - Every call: regenerate the untracked `flake.nix` so flake-url /
/// dashboard-port changes pick up on rebuild without churning the
/// git log.
///
/// `proposed_dir` is `None` on rebuild paths that just want the flake
/// refreshed.
pub async fn setup_applied( pub async fn setup_applied(
applied_dir: &Path, applied_dir: &Path,
proposed_dir: Option<&Path>,
name: &str, name: &str,
hyperhive_flake: &str, hyperhive_flake: &str,
dashboard_port: u16, dashboard_port: u16,
@ -260,6 +316,42 @@ pub async fn setup_applied(
std::fs::create_dir_all(applied_dir) std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?; .with_context(|| format!("create {}", applied_dir.display()))?;
// 1. First-spawn git init from proposed (or pre-overhaul detection).
if !applied_dir.join(".git").exists() {
let Some(proposed) = proposed_dir else {
bail!(
"applied repo at {} is missing its .git directory; \
cannot rebuild without a proposed source to seed from. \
destroy --purge and re-spawn this agent.",
applied_dir.display()
);
};
git(applied_dir, &["init", "--initial-branch=main"]).await?;
let proposed_str = proposed.display().to_string();
git(
applied_dir,
&["fetch", "--no-tags", &proposed_str, "main:refs/heads/main"],
)
.await?;
git_read_tree_reset(applied_dir, "refs/heads/main").await?;
git_tag(applied_dir, "deployed/0", "refs/heads/main").await?;
} else if git_rev_parse(applied_dir, "refs/tags/deployed/0")
.await
.is_err()
{
// Pre-overhaul applied repo — agent.nix is tracked directly,
// commits authored by hive-c0re, no deployed/* tag scheme.
// No in-place migration; fail loudly so the operator purges.
bail!(
"applied repo at {} predates the tag-driven config flow. \
Run `hive-c0re destroy --purge {name}` and re-spawn.",
applied_dir.display()
);
}
// 2. (Re)write the untracked wrapper flake. Tracked files in the
// working tree (agent.nix and anything the manager committed) are
// untouched.
let port = agent_web_port(name); let port = agent_web_port(name);
let base = flake_base(name); let base = flake_base(name);
let service = if is_manager(name) { let service = if is_manager(name) {
@ -301,48 +393,6 @@ pub async fn setup_applied(
); );
std::fs::write(applied_dir.join("flake.nix"), flake_body) std::fs::write(applied_dir.join("flake.nix"), flake_body)
.with_context(|| format!("write {}/flake.nix", applied_dir.display()))?; .with_context(|| format!("write {}/flake.nix", applied_dir.display()))?;
let agent_path = applied_dir.join("agent.nix");
if !agent_path.exists() {
std::fs::write(&agent_path, initial_agent_nix(name))
.with_context(|| format!("write {}", agent_path.display()))?;
}
if !applied_dir.join(".git").exists() {
git(applied_dir, &["init", "--initial-branch=main"]).await?;
}
git(applied_dir, &["add", "-A"]).await?;
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
if !clean {
git_commit(applied_dir, "hive-c0re sync").await?;
}
Ok(())
}
/// Apply a manager-proposed commit: read `agent.nix` at `commit_ref` from the
/// proposed repo, write it into the applied repo, commit. Hive-c0re alone
/// advances `applied`'s `main`; the manager only sees `proposed/`.
pub async fn apply_commit(applied_dir: &Path, proposed_dir: &Path, commit_ref: &str) -> Result<()> {
let out = git_command()
.current_dir(proposed_dir)
.args(["show", &format!("{commit_ref}:agent.nix")])
.output()
.await
.with_context(|| format!("git show in {}", proposed_dir.display()))?;
if !out.status.success() {
bail!(
"agent.nix at commit {commit_ref} not found in {}: {}",
proposed_dir.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
std::fs::write(applied_dir.join("agent.nix"), &out.stdout)
.with_context(|| format!("write {}/agent.nix", applied_dir.display()))?;
git(applied_dir, &["add", "agent.nix"]).await?;
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
if !clean {
git_commit(applied_dir, &format!("apply {commit_ref}")).await?;
}
Ok(()) Ok(())
} }
@ -419,15 +469,97 @@ async fn git(dir: &Path, args: &[&str]) -> Result<()> {
Ok(()) Ok(())
} }
/// Returns true if the command exits 0. /// Fetch `sha` from the `src` git repo into `dst` and pin it as
async fn git_status(dir: &Path, args: &[&str]) -> Result<bool> { /// `refs/tags/<tag>`. Used at `request_apply_commit` time so hive-c0re
let st = git_command() /// captures an immutable handle on the manager's commit; subsequent
/// amendments / force-pushes in `src` no longer affect what gets
/// built. Returns the resolved sha (which equals `sha` on success
/// but normalised — short shas get expanded).
#[allow(dead_code)] // wired up by manager_server in a later commit
pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result<String> {
let src_str = src.display().to_string();
let refspec = format!("{sha}:refs/tags/{tag}");
git(dst, &["fetch", "--no-tags", &src_str, &refspec]).await?;
git_rev_parse(dst, &format!("refs/tags/{tag}")).await
}
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
#[allow(dead_code)]
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
let out = git_command()
.current_dir(dir) .current_dir(dir)
.args(args) .args(["rev-parse", refname])
.status() .output()
.await .await
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; .with_context(|| format!("git rev-parse {refname} in {}", dir.display()))?;
Ok(st.success()) if !out.status.success() {
bail!(
"git rev-parse {refname} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
/// Plant a lightweight tag at `target`. Errors if the tag already
/// exists — we want loud failures on id reuse, not silent
/// overwrites.
#[allow(dead_code)]
pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> {
git(dir, &["tag", name, target]).await
}
/// Plant an annotated tag with `body` as the message. Used for
/// `failed/<id>` (body = build error) and `denied/<id>` (body =
/// operator note). Multi-line bodies handled via stdin so we don't
/// have to escape anything.
#[allow(dead_code)]
pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> {
use tokio::io::AsyncWriteExt;
let mut child = git_command()
.current_dir(dir)
.args(["tag", "-a", name, target, "-F", "-"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| format!("spawn git tag -a {name} in {}", dir.display()))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(body.as_bytes())
.await
.context("write tag body to git stdin")?;
// Drop closes stdin so git can finish reading.
drop(stdin);
}
let out = child.wait_with_output().await.context("wait git tag -a")?;
if !out.status.success() {
bail!(
"git tag -a {name} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// Replace working tree + index with the tree at `target` without
/// moving HEAD. `applied/main` stays pointing at the last known-good
/// `deployed/*` while we let `nixos-container update` evaluate the
/// candidate. On build failure callers reset back to HEAD; on
/// success they fast-forward main to `target`.
#[allow(dead_code)]
pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> {
git(dir, &["read-tree", "--reset", "-u", target]).await
}
/// Hard-set a ref to `target`. Used to fast-forward `refs/heads/main`
/// to the just-deployed proposal commit. Uses `update-ref`, not
/// `branch -f`, so it works regardless of where HEAD currently sits.
#[allow(dead_code)]
pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> {
git(dir, &["update-ref", refname, target]).await
} }
/// Write a systemd drop-in for `container@<container>.service` that applies /// Write a systemd drop-in for `container@<container>.service` that applies
@ -475,11 +607,22 @@ async fn systemd_daemon_reload() -> Result<()> {
/// `containers.hm1nd.bindMounts."/agents"`. /// `containers.hm1nd.bindMounts."/agents"`.
pub const CONTAINER_MANAGER_AGENTS_MOUNT: &str = "/agents"; pub const CONTAINER_MANAGER_AGENTS_MOUNT: &str = "/agents";
/// Where the manager sees the applied trees of every agent, read-only.
/// Manager runs `git fetch /applied/<n>/.git refs/tags/*:refs/tags/applied/*`
/// to learn what hive-c0re deployed (or rejected, or failed to
/// build); the RO bind makes accidental writes impossible from
/// inside the container.
pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
/// The on-host root that gets bind-mounted to `/agents` inside the manager. /// The on-host root that gets bind-mounted to `/agents` inside the manager.
/// Hard-coded to match `AGENT_STATE_ROOT` in coordinator.rs (kept duplicated /// Hard-coded to match `AGENT_STATE_ROOT` in coordinator.rs (kept duplicated
/// here so lifecycle stays usable as a leaf module). /// here so lifecycle stays usable as a leaf module).
const HOST_AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; const HOST_AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
/// On-host applied repo root, mirrored RO into the manager. Matches
/// `APPLIED_STATE_ROOT` in coordinator.rs.
const HOST_APPLIED_ROOT: &str = "/var/lib/hyperhive/applied";
fn set_nspawn_flags( fn set_nspawn_flags(
container: &str, container: &str,
runtime_dir: &Path, runtime_dir: &Path,
@ -497,11 +640,22 @@ fn set_nspawn_flags(
if container == MANAGER_NAME { if container == MANAGER_NAME {
// Manager edits sub-agent proposed/ repos and its own. RW so it can // Manager edits sub-agent proposed/ repos and its own. RW so it can
// git-commit. Sub-agents see only their own /run/hive socket and // git-commit. Sub-agents see only their own /run/hive socket and
// /root/.claude (no /agents). // /root/.claude (no /agents or /applied).
//
// /applied is a separate RO mount of the hive-c0re-only applied
// repos so the manager can `git fetch /applied/<n>/.git
// refs/tags/*:refs/tags/applied/*` to mirror deployed/failed/
// denied tags into its proposed clones and diff against
// what's actually deployed. RO bind makes destructive git
// plumbing inside the container unable to corrupt applied.
use std::fmt::Write as _; use std::fmt::Write as _;
let _ = write!( let _ = write!(
binds, binds,
" --bind={HOST_AGENTS_ROOT}:{CONTAINER_MANAGER_AGENTS_MOUNT}" " --bind={HOST_AGENTS_ROOT}:{CONTAINER_MANAGER_AGENTS_MOUNT}",
);
let _ = write!(
binds,
" --bind-ro={HOST_APPLIED_ROOT}:{CONTAINER_MANAGER_APPLIED_MOUNT}",
); );
} }
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\""); let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");

View file

@ -258,9 +258,9 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
} }
ManagerRequest::RequestApplyCommit { agent, commit_ref } => { ManagerRequest::RequestApplyCommit { agent, commit_ref } => {
tracing::info!(%agent, %commit_ref, "manager: request_apply_commit"); tracing::info!(%agent, %commit_ref, "manager: request_apply_commit");
match coord.approvals.submit(agent, commit_ref) { match submit_apply_commit(coord, agent, commit_ref).await {
Ok(id) => { Ok((id, sha)) => {
tracing::info!(%id, %agent, %commit_ref, "approval queued"); tracing::info!(%id, %agent, manager_ref = %commit_ref, %sha, "approval queued + proposal tag planted");
ManagerResponse::Ok ManagerResponse::Ok
} }
Err(e) => ManagerResponse::Err { Err(e) => ManagerResponse::Err {
@ -271,6 +271,66 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
} }
} }
/// Submit-time half of the apply flow: queue the approval row, then
/// fetch the manager's commit from the proposed repo into applied and
/// pin it as `refs/tags/proposal/<id>`. From this point on the manager
/// repo is irrelevant for this approval — even if the manager amends
/// or force-pushes, the canonical sha hive-c0re will eventually
/// approve/deny lives in applied's object DB.
///
/// If anything fails after the row is inserted (sha missing in
/// proposed, fs error, git plumbing crash) we mark the row failed and
/// surface the error to the manager. We don't try to roll the row
/// back — the failure is part of the audit trail.
async fn submit_apply_commit(
coord: &Arc<Coordinator>,
agent: &str,
commit_ref: &str,
) -> anyhow::Result<(i64, String)> {
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent);
let applied_dir = crate::coordinator::Coordinator::agent_applied_dir(agent);
if !proposed_dir.exists() {
anyhow::bail!(
"proposed repo missing for agent '{agent}' (expected at {})",
proposed_dir.display()
);
}
if !applied_dir.join(".git").exists() {
anyhow::bail!(
"applied repo at {} is uninitialised — spawn the agent first",
applied_dir.display()
);
}
let id = coord
.approvals
.submit(agent, commit_ref)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
let tag = format!("proposal/{id}");
let sha = match crate::lifecycle::git_fetch_to_tag(
&applied_dir,
&proposed_dir,
commit_ref,
&tag,
)
.await
{
Ok(s) => s,
Err(e) => {
// Surface the failure on the approval row so the
// dashboard reflects it instead of leaving a phantom
// pending entry. The note doubles as the operator-visible
// explanation of why the approval can't be approved.
let _ = coord.approvals.mark_failed(id, &format!("{e:#}"));
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
}
};
coord
.approvals
.set_fetched_sha(id, &sha)
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
Ok((id, sha))
}
/// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then /// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then
/// try to resolve the question with `[expired]`. If the operator (or /// try to resolve the question with `[expired]`. If the operator (or
/// any other path) already answered it, `answer()` returns Err and /// any other path) already answered it, `answer()` returns Err and

View file

@ -83,6 +83,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
agent: name.clone(), agent: name.clone(),
ok: true, ok: true,
note: None, note: None,
sha: None,
}); });
} }
Err(e) => { Err(e) => {
@ -92,6 +93,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
agent: name.clone(), agent: name.clone(),
ok: false, ok: false,
note: Some(format!("{e:#}")), note: Some(format!("{e:#}")),
sha: None,
}); });
return Err(e); return Err(e);
} }
@ -144,7 +146,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostResponse::success() HostResponse::success()
} }
HostRequest::Deny { id } => { HostRequest::Deny { id } => {
actions::deny(&coord, *id, None)?; actions::deny(&coord, *id, None).await?;
HostResponse::success() HostResponse::success()
} }
}) })

View file

@ -65,8 +65,18 @@ pub struct Approval {
pub agent: String, pub agent: String,
#[serde(default)] #[serde(default)]
pub kind: ApprovalKind, pub kind: ApprovalKind,
/// For `ApplyCommit`: the git sha to apply. For `Spawn`: empty. /// For `ApplyCommit`: the git sha the manager submitted. For `Spawn`:
/// empty. Note that this is the manager's *claimed* ref — the
/// canonical, hive-c0re-vouched sha after the proposal fetch lives
/// in `fetched_sha`.
pub commit_ref: String, pub commit_ref: String,
/// The sha hive-c0re fetched from the proposed repo into applied at
/// submission time, then tagged `proposal/<id>`. Stable for the
/// lifetime of the approval — manager amends in proposed don't
/// change what gets built. Only set for `ApplyCommit` after the
/// successful fetch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetched_sha: Option<String>,
pub requested_at: i64, pub requested_at: i64,
pub status: ApprovalStatus, pub status: ApprovalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -236,6 +246,18 @@ pub enum HelperEvent {
status: ApprovalStatus, status: ApprovalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>, note: Option<String>,
/// Canonical sha hive-c0re fetched into applied at submission
/// time. `git show <sha>` against `/agents/<n>/applied.git`
/// inside the manager container yields the exact tree being
/// referenced.
#[serde(default, skip_serializing_if = "Option::is_none")]
sha: Option<String>,
/// Terminal tag name in the applied repo for this approval —
/// `deployed/<id>`, `failed/<id>`, or `denied/<id>` (and
/// `approved/<id>` for the rare bare-approval case where
/// no underlying action runs).
#[serde(default, skip_serializing_if = "Option::is_none")]
tag: Option<String>,
}, },
/// A new container was spawned (post-approval or via the admin CLI /// A new container was spawned (post-approval or via the admin CLI
/// bypass path). `ok=false` means the spawn failed. /// bypass path). `ok=false` means the spawn failed.
@ -244,6 +266,10 @@ pub enum HelperEvent {
ok: bool, ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>, note: Option<String>,
/// Sha of the `deployed/0` commit seeded by hive-c0re on
/// first spawn (Some on success, None on failure).
#[serde(default, skip_serializing_if = "Option::is_none")]
sha: Option<String>,
}, },
/// A container was rebuilt (auto-update on flake rev change, or a /// A container was rebuilt (auto-update on flake rev change, or a
/// manual rebuild from CLI/dashboard). /// manual rebuild from CLI/dashboard).
@ -252,6 +278,18 @@ pub enum HelperEvent {
ok: bool, ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>, note: Option<String>,
/// Sha that ended up at `deployed/<id>` on success, or the
/// proposal sha that just got tagged `failed/<id>` on
/// failure. None for the (rare) rebuild path that doesn't go
/// through an approval (e.g. `auto_update::rebuild_agent`
/// reapplying the existing main).
#[serde(default, skip_serializing_if = "Option::is_none")]
sha: Option<String>,
/// `deployed/<id>` or `failed/<id>` for approval-driven
/// rebuilds; None for auto-update / dashboard rebuilds that
/// don't change the deployed commit.
#[serde(default, skip_serializing_if = "Option::is_none")]
tag: Option<String>,
}, },
/// A sub-agent's container was stopped (the systemd unit is down; /// A sub-agent's container was stopped (the systemd unit is down;
/// persistent state is unchanged). /// persistent state is unchanged).