diff --git a/docs/README.md b/docs/README.md index b20b3458..163fcd63 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,7 +30,7 @@ declarations. state machine, `flake.lock` validation). - **What state survives destroy / purge / restart?** → [`agent-lifecycle/persistence.md`](agent-lifecycle/persistence.md). -- **Who can do what to whom — the agent roster and privilege?** → +- **Who can do what to whom — agent hierarchy and privilege?** → [`agent-lifecycle/agent-hierarchy.md`](agent-lifecycle/agent-hierarchy.md). - **How does claude get its prompt, and what tools does it have?** → [`turn-loop/`](turn-loop/README.md) — the loop, binary shape, turn diff --git a/docs/agent-lifecycle/agent-hierarchy.md b/docs/agent-lifecycle/agent-hierarchy.md index aeddb0fd..a84b235c 100644 --- a/docs/agent-lifecycle/agent-hierarchy.md +++ b/docs/agent-lifecycle/agent-hierarchy.md @@ -1,97 +1,109 @@ -# Agent roster & privileges +# Agent hierarchy & privileges -Agents are a **flat set**, with no parent/child tree: the `parent` field -`topology.json` used to carry is gone, along with every mechanism that -read it. The capability store scopes which agents can manage which -others; a tree position no longer scopes anything. +Every agent has a place in an operator-editable parent/child tree, used +to scope which agents can manage which others. This doc covers how +hive-c0re stores and edits the tree today, the rules that are meant to run on top +of it once enforcement is finished, and where the manager still gets +special-cased in the meantime, as a tracked cleanup. -This doc covers what the roster file is now, what the removal took with -it, and where the manager still gets special-cased, as a tracked -cleanup. +## Where the tree lives -## Where the roster lives - -The roster lives in the hive-c0re-owned **meta repo**, alongside +Topology lives in the hive-c0re-owned **meta repo**, alongside `flake.nix`, at `/var/lib/hyperhive/meta/topology.json`: ```json -["alice", "bob", "ruth"] +{ + "ruth": null, + "alice": null, + "bob": "alice" +} ``` -One entry per agent the hive knows about, in name order. The file -carries no per-agent value any more, and encodes no ordering or -grouping — it answers exactly one question, _which agents exist,_ and -`topology::all_agents` is the only reader that matters. +`null` = root-level agent. New agents **default to root** — there is no +structural manager that everything hangs under. The operator builds +hierarchy explicitly: an agent gets a parent edge written before its +first spawn, or the operator reparents it afterwards (so `bob` above sits under +`alice`). Any agent is reparentable, the bootstrap container (`ruth`) +included — it's just another root. The manager is reparentable like any other agent; there's no +"structurally root" carve-out. Its privileges live on its MCP socket, +not its tree position (see _Manager special-casing today_ below). -That reader is a permission boundary: the set it returns is what an agent -holding the `ManageRootAgent` capability gets bind-mounted (each other -agent's `state` read-write and `config` read-only; never `harness`). An -agent holding no capability sees its own dirs and nothing else. See -[`persistence.md`](persistence.md)'s _Cross-agent access to state._ +### Reparenting -### Reading the legacy format +- CLI: `hivectl agent set-parent --parent ` (or `--root` + to promote). Pass exactly one of `--parent` / `--root`. +- Dashboard: `POST /api/topology/set-parent` (form fields `child`, + optional `new_parent` — absent / empty ⇒ promote to root). +- Wire: `HostRequest::SetParent { child, new_parent: Option }`. -`topology.json` used to be a map of `name → parent | null`. The reader -still accepts that shape and keeps its keys, so a hive upgrading across -the change reads the same roster rather than an empty one. An empty -roster costs more than a cosmetic gap: every capability holder loses its -mounts until the next reconcile pass writes the array form. +All three go through the same validation, which refuses: + +- unknown `child` / `new_parent` (typo guard), +- self-parenting, +- cycles (a bounded ancestor walk — moving the manager under one of + its own descendants is the only real safety concern here, and it's + caught the same way as any other agent). + +Setting a parent to its current value is a no-op (no disk write). A +successful change triggers an immediate rescan, so connected dashboard +viewers see the tree repaint without polling. ### Why meta, not per-agent `agent.nix` -An agent shouldn't be able to add itself to a set that governs who may -reach its state dir. The roster IS a system-level fact; meta is where -system-level facts live. +An agent shouldn't be able to claim a parent without that parent's +consent, and operator-driven re-parenting shouldn't require touching +the moved agent's config. Topology IS a system-level concern; meta is +where system-level facts live. ### How `topology.json` gets updated -- **Read** — parsed into a set of names; a missing or unparsable file - degrades safely to "no agents" (covers a fresh install that hasn't - synced yet). +- **Read** — parsed into an agent→parent map; a missing or unparsable + file degrades safely to "every agent is root" (covers a fresh + install that hasn't synced yet). - **Reconcile** — runs alongside the periodic meta/flake regeneration. - Adds newly-spawned agents, drops removed ones. Reconcile keeps agents - whose config repo exists but that haven't spawned yet, so the gap until - the container appears doesn't churn the file. - -No write API and no operator verb reach this file. Reconcile derives it -from which agents exist, so the next pass overwrites a hand edit. + New agents default to root unless they already carry an explicit + parent edge written before their first spawn; Reconcile preserves + existing entries (including operator overrides); removed agents drop. + Agents whose config repo exists but that haven't spawned yet keep + their edge too, so it survives the gap until the container actually + appears. +- **Inject** — hive-c0re exposes each container's parent (if any) to its own + environment as `HIVE_PARENT`, so the harness / system-prompt + renderer can see it. +- **Surface** — every rescan re-reads `topology.json` and populates + `ContainerView.parent`, which the dashboard renders as a tree. See `hive-c0re/src/agent_config/topology.rs` and `hive-c0re/src/meta.rs`'s module docs for the exact call chain. -## What the parent field used to do +### Current limitation: state-dir visibility lags topology -Recorded so a reader who finds one of these in an old branch, an issue -thread or a stale comment knows each one went away rather than moved: +Reparenting today is purely a JSON edit. Only the top-level manager +(`root`) gets `/var/lib/hyperhive/agents` bind-mounted at `/agents` in +its container, so sub-agents don't yet see their would-be children's +state dirs. Once sub-manager bind mounts land alongside capability +enforcement, reparenting will grow a companion +umount-old / mount-new / restart-cascade step. -| gone | what replaced it | -| -------------------------------------------- | --------------------------------------------------- | -| `` recipient sentinel | address `operator` directly | -| `` fan-out recipient | nothing — name the recipients, or broadcast to `*` | -| `hivectl agent set-parent` | nothing | -| `POST /api/topology/set-parent{,-bulk}` | nothing | -| `HostRequest::SetParent` | nothing | -| `NodeKind::Reparent` and its DAG template | nothing | -| `HIVE_PARENT` on the container | nothing — no consumer ever read it | -| every agent's grant over its direct children | the `ManageRootAgent` capability, for every agent | -| rebuild ordering by topology depth | alphabetical, which the depth sort already produced | +## Planned topology semantics (once ancestor-based enforcement lands) - +| operation | who can do it | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| config change via forge PR (any descendant's config) | any ancestor | +| moderate reminders (cancel any open thread of a descendant) | any ancestor | +| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else | -The last row is the one with teeth: an agent that used to reach a child's -state dir by virtue of being its parent no longer reaches it at all -unless it holds `ManageRootAgent`. That narrowing is the intended -consequence of removing the field, not a side effect of it. - - +"Ancestor" walks `ContainerView.parent` chains; a visited-set guards against +cycles at dispatch time (a malformed `topology.json` can't lock +the dispatcher into a loop). ## Manager special-casing today -Capability enforcement isn't fully wired yet, so the +Enforcement of the ancestor rules above isn't fully wired yet, so the **manager (`ruth`) still gets some hard-coded special treatment** other agents don't: @@ -101,12 +113,13 @@ other agents don't: key, and nixos-container name are all `ruth` (container `h-ruth`). `hive-c0re` spawns it directly at boot if missing, with no operator approval step — every other agent goes through a `Spawn` approval. - Roster-wise, `ruth` is just another entry. + Topology-wise, `ruth` is still just another root agent. - **Wire-protocol** — the privileged `Request` variants (`Kill` / `Start` / `Restart` / `Update`; `GetLogs`) — marked `*(privileged)*` in `hive-core-agent-sock`'s unified `Request` enum — - are reachable only from the manager's socket flavour today; each is - planned to become a capability check. One exception: `Wake` (inject a `from: ` message into the + are reachable only from the manager's socket flavour today. Planned + rule for each is in the table above ("any ancestor" for + lifecycle/logs). One exception: `Wake` (inject a `from: ` message into the caller's own inbox) isn't really privileged — every per-agent daemon (for example `hive-forge-notify`) needs it, and sub-agents already have the equivalent on their own socket. @@ -115,8 +128,9 @@ other agents don't: manage any agent's state dir — config isn't authored there, since a real config change is a PR from a clone), plus RO mounts for `/applied` (diff against what's deployed) and `/meta` (system-wide - deploy log). That grant is the `ManageRootAgent` capability now, and - ruth holds it; no name check remains. hive-c0re will + deploy log). Planned: each agent gets RW to `/agents//` + for just its own subtree — the manager's full-forest RW becomes the + "root's subtree is everything" case of that same rule. hive-c0re will gate RO `/meta` access on a "meta read" capability; no agent-facing path writes `flake.lock` any more — `request_update_meta_inputs` was removed, leaving the operator dashboard's `POST @@ -136,8 +150,8 @@ manager-only overrides exist across `hive-c0re` today: loose-ends visibility (manager sees hive-wide, sub-agents only their own), `destroy` refusing to act on the manager, and crash-watch skipping the manager (it autorestarts via systemd instead of going through -the crash-watch loop). Each is planned to become a capability -check instead of a manager-name check — see the +the crash-watch loop). Each is planned to become an +ancestor/descendant check instead of a manager-name check — see the module docs for `loose_ends.rs`, `stores/broker.rs`, `actions.rs`, and `workers/crash_watch.rs` for the current owner-check logic in each. (The harness handles reminder cancellation fully in-agent — see @@ -148,6 +162,18 @@ the note on `CancelLooseEndKind::Reminder` in None of the above is a stable interface — treat the module doc comments as the source of truth for exactly which checks exist today. +## Future work: sub-agents inside the same container + +When enabled for an agent, it will be able to spawn temporary +"sub-agents" that run inside its own container — lighter than a full +nspawn agent. Open questions, not yet wired: + +- Inherit caps from parent, or take an explicit narrower set? +- Survive container restart, or always ephemeral? +- Inbox: separate from parent, or shared? +- Filesystem: share parent's `/state` RW, or a sub-dir? +- Identity: distinct broker recipient name, or address the parent? + ## Harness systemd unit shape One harness serve binary (`hive-agent`, with its `hive-agent-mcp` @@ -214,5 +240,6 @@ uid 0 and have the setuid bit set." ## Cross-references - Milestone: "Agent privileges and sub-agents" (tracked internally) +- Dashboard render: "show agent topology in container list" (tracked internally) - Audit table source: milestone comment (tracked internally) - Operator/agent trust boundary (orthogonal axis): [`boundary.md`](../trust-boundary/boundary.md) diff --git a/docs/agent-lifecycle/approvals.md b/docs/agent-lifecycle/approvals.md index 43e0a272..bfdcca48 100644 --- a/docs/agent-lifecycle/approvals.md +++ b/docs/agent-lifecycle/approvals.md @@ -483,15 +483,15 @@ approval card. See `docs/web-ui/dashboard.md`. ### Submitting agent's view of config repos -An agent holding `ManageRootAgent` has every other agent's config repo -bind-mounted **read-only** (`hive-c0re/src/lifecycle/host_config.rs` -calls `bind_child_agent_dirs` for each entry in -`topology::all_agents()`). it's a copy to *read* another agent's -current config — not an editing surface. An agent without the -capability sees no other agent's config at all. +Every parent agent's container has its **direct children's** config +repos bind-mounted **read-only** (topology-driven: +`hive-c0re/src/lifecycle/host_config.rs` calls `bind_child_agent_dirs` +for each entry in +`topology::children_of(agent_name)`). it's a copy to *read* a child's +current config — not an editing surface. An agent with the `approvals` tool group submits a change the same way -it makes any other change: **clone the target agent's config repo from the +it makes any other change: **clone the child's config repo from the forge into its own state dir, commit on a branch, open a PR**, and let the operator review and approve it. By design, no second, mount-shaped path reaches the same file without the review. diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index da4e4db5..e908efdd 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -335,29 +335,24 @@ Under `/var/lib/hyperhive/agents//`: - `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-model` — single-line model name override file. -### Cross-agent access to state +### Parent access to child state -An agent holding the `ManageRootAgent` capability gets every other -agent's `state` dir bind-mounted **read-write** and its `config` dir -**read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`). -The RW on `state` is deliberate, not an oversight: the holder recovers -other agents, which includes writing into their state (for example -seeding notes, clearing a stuck sentinel) as well as reading it. +A parent agent gets each direct child's `state` dir bind-mounted +**read-write** and its `config` dir **read-only** +(`bind_child_agent_dirs` in `lifecycle/host_config.rs`). The RW on +`state` is deliberate, not an oversight: a parent manages its children, +which includes writing into a child's state for recovery (for example seeding +notes, clearing a stuck sentinel) as well as reading it. -This is the **only** cross-agent mount. Dropping the topology parent -field took with it the unconditional grant every agent used to -get over its own direct children — an agent holding no capability now -sees its own dirs and nothing else. - -**`harness` isn't mounted at all.** It holds that agent's own runtime +**`harness` isn't mounted at all.** It holds the child's own runtime material — `bash-tasks/`, the turn-stats and event sqlite dbs — and -nothing argues for anyone else reading it, let alone writing it. -hive-c0re reads a harness dir **directly on the host** when it wants -those stats, which needs no mount into another container. +nothing argues for a parent reading it, let alone writing it. hive-c0re +reads a child's harness dir **directly on the host** when it wants +those stats, which needs no mount into the parent. -**`config` is read-only, including for the holder.** A config change is -a PR on that agent's config repo, made from a clone and merged after +**`config` is read-only, including for the parent.** A config change is +a PR on the child's config repo, made from a clone and merged after review — so the bind-mounted `config` dir is a *copy to read*, never a tree anyone edits in place. Mounting it writable would leave a second path to the same file that skips the review entirely, which makes the @@ -366,15 +361,15 @@ boundary a convention rather than a permission. ⚠️ Don't confuse it with the config-repo seeding hive-c0re does at -spawn (`lifecycle::setup_proposed`): that writes the agent's initial +spawn (`lifecycle::setup_proposed`): that writes the child's initial config repo as **hive-c0re, against the host path**, and `read_only` on a bind constrains writers *inside* a container only. The two are unrelated — conflating them can lead you to reason your way into thinking this mount should be writable when it shouldn't. -Isolation still holds by default: a container has its *own* dirs and, -unless it holds the capability, nothing else. +Per-child isolation still holds: a container only ever has its *own* +dirs plus its direct children's bind-mounted, never a sibling's. Under `/var/lib/hyperhive/applied//` — the hive-c0re-only applied repo. Tracks `flake.nix` (module-only boilerplate; never @@ -392,11 +387,13 @@ Contents: `nixosConfigurations.` output per agent. `flake.lock` is the canonical "what's deployed where." The git log is the deploy audit trail (one commit per successful deploy or hyperhive bump). -- `topology.json` — the agent roster (`["alice", "bob", "ruth"]`). - Written by `topology::reconcile` on every meta sync; read by - `topology::all_agents`, which is the set the `ManageRootAgent` - capability grants mounts over. Carried a `parent` per agent in the - legacy format; the reader still accepts that shape and keeps its keys. +- `topology.json` — parent/child agent graph + (`{ "alice": "root", "bob": "alice", "root": null }`). + Written by `topology::apply_set_parent` (the pure move-validating + transform) via `meta::bulk_commit_topology` (the committer — see the + `Reparent` node in [`docs/scheduler/coordinator.md`](../scheduler/coordinator.md)); read by + the dashboard, the renderer, and `` / `` recipient + resolution. - `tool-groups.json` — per-agent MCP tool group grants (`{ "alice": ["messaging", "inbox", "execution"] }`). Written by `tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index af3290d3..ea23be35 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -270,7 +270,7 @@ See [`approvals.md`](../agent-lifecycle/approvals.md) for the full flow. ### 8 · Useful host commands ```bash -# Roster: all agents, status, rev, pending reminders +# Roster: all agents, status, rev, parent, pending reminders hivectl list-agents # Restart a stuck container (no rebuild) diff --git a/docs/process/conventions.md b/docs/process/conventions.md index cdc6d558..fbd8e565 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -87,13 +87,24 @@ angle-bracket and asterisk shapes below are structurally safe. (`socket_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. +- `` — 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. +- `` — fan-out to every direct descendant of the sender per + `topology.json`. Resolved in `socket_server::handle_send` via + `topology::children_of(sender)`: it delivers one message to each + child, bypassing the allow-list check (structural fan-out targets are + never user-listed peers). No-op for leaf agents (returns `Ok` when the + child set is empty). Lets a sub-manager nudge its subtree without + enumerating labels. -`` and `` were two more, resolved against a -`topology.json` parent field. That field and both sentinels no longer -exist: address `operator` where you would have said -``, and name the recipients (or broadcast to `*`) where you -would have said ``. Nothing rewrites a recipient at send time -any more — what an agent passes is what the broker stores. +When a `` or `` send resolves to real recipients, the +broker stores the *resolved* labels as the message recipients — the +dashboard and recv side see the real routes. The sentinels are purely +send-time addressing conveniences. ## Wire protocol diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index b5265d10..34fcf982 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -55,36 +55,37 @@ Cheap — no build slot: -| Node | Behavior | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation | -| `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction | -| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build | -| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#` ref resolves. Store/meta-only, no container yet | -| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop | -| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down | -| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up | -| `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped | -| `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the DAG's `EmitRebuilt` tail node emits the `Rebuilt` manager event, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace | -| `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal`→`Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ | -| `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn | -| `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way | -| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here | -| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` | -| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims | -| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step | -| `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s | -| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles | -| `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree | -| `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent | -| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome | -| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload | -| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots | -| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless | -| `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node | -| `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless | -| `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop | -| `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless | +| Node | Behavior | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation | +| `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction | +| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build | +| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#` ref resolves. Store/meta-only, no container yet | +| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop | +| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down | +| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up | +| `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped | +| `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the DAG's `EmitRebuilt` tail node emits the `Rebuilt` manager event, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace | +| `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal`→`Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ | +| `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn | +| `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way | +| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here | +| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` | +| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims | +| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step | +| `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s | +| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles | +| `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree | +| `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent | +| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome | +| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload | +| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots | +| `Reparent` | `set-parent` / `set-parent-bulk`: apply every `(child, new_parent)` move under one `META_LOCK` commit (`meta::bulk_commit_topology`), send the per-agent move notifications, rescan + diff-emit. Agentless like `MetaLock` — a bulk move can span multiple agents, and a reparent touches the meta repo, not any one container. `moves` is typed `(Ident, Option)` pairs, not raw strings. Rides the existing `Template::MetaUpdate` variant rather than a dedicated one — it's internal-only (never reaches the graph wire), so the stand-in only affects `terminal_hook` dispatch (resolves to no hook either way) and history-retention bucketing | +| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless | +| `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node | +| `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless | +| `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop | +| `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless | @@ -99,7 +100,7 @@ container build: - **The deploy window** (`Resource::MetaWindow`): a global, capacity-1 queue resource declared by every node kind that mutates the meta repo — `MetaSync`, - `MetaLock`, `WritePermFile`, `Provision`'s agent registration, and + `MetaLock`, `WritePermFile`, `Reparent`, `Provision`'s agent registration, and `DeployWindow` — the deploy subtree's root, which holds it across every phase below it (it declares `Resource::MetaWindow`). Two meta mutations can therefore never interleave, so no commit lands inside another @@ -155,6 +156,7 @@ perm-change(a): WritePermFile(a) → «rebuild subgraph» meta-update(inp): MetaLock(inp) →(in-DAG) «rebuild subgraph» per affected agent boot: (if any rev marker stale) MetaLock(hyperhive) →(in-DAG) «rebuild subgraph» per stale agent; plus Reconcile(a) for every drifted agent (all ONE DAG) +reparent(moves): Reparent(moves) (no rebuild — topology.json is read live) ``` Notable collapses: @@ -227,7 +229,7 @@ resources are free. Resources: (`SetWanted` is a store write, not a container op, but takes the lease anyway so a power-op DAG's intent write + reconcile is atomic — two racing ops can't clobber intent before either reconciles.) **Lease-exempt**: `MetaSync`, - `Prebuild`, `Provision`, `MetaLock`, `WritePermFile` — they + `Prebuild`, `Provision`, `MetaLock`, `WritePermFile`, `Reparent` — they touch the store / meta, not the running container, which is exactly why a stop can land while another DAG's prebuild is still building. Also exempt, for a different reason, are the rebuild subtree's own members (`StopForUpdate`, `Swap`, @@ -372,7 +374,8 @@ vs the current flake path) and persisted `wanted` intent, then: 1. **Config path** — when _any_ marker is stale, submit one `Boot` DAG: a `MetaLock` (hyperhive input bump, non-fatal) that grows an in-DAG - `Rebuild` subgraph for each stale agent whose `wanted = Up` (name-sorted). Stale but wanted-offline agents get no boot-time nix work — their + `Rebuild` subgraph for each stale agent whose `wanted = Up` (topology-sorted, + parents first). Stale but wanted-offline agents get no boot-time nix work — their rebuild happens on their next start (the start submit path upgrades a stale start to rebuild+start), which is also why the lock bump runs even when every stale agent is offline: those later start-upgrades must build @@ -581,4 +584,4 @@ which is almost never desired. Leave off on non-x86 hosts. - `docs/agent-lifecycle/approvals.md` — approval flow + scheduled prompts - `docs/agent-lifecycle/persistence.md` — SQLite schema, state-dir layout - `docs/process/conventions.md` — wire protocol, recipient sentinels -- `docs/agent-lifecycle/agent-hierarchy.md` — the agent roster and privilege +- `docs/agent-lifecycle/agent-hierarchy.md` — topology and parent/child relations diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 18881495..38cd6602 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -31,6 +31,7 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agent kill`↴](#hivectl-agent-kill) * [`hivectl agent destroy`↴](#hivectl-agent-destroy) * [`hivectl agent rebuild`↴](#hivectl-agent-rebuild) +* [`hivectl agent set-parent`↴](#hivectl-agent-set-parent) * [`hivectl agent set-limits`↴](#hivectl-agent-set-limits) * [`hivectl agent choom`↴](#hivectl-agent-choom) * [`hivectl agent watch`↴](#hivectl-agent-watch) @@ -350,6 +351,7 @@ Everything here targets a single named agent (`hivectl agent foo restart`, `hive * `kill` — Hard-stop this managed container * `destroy` — Tear down this sub-agent container, keeping its state by default. No undo * `rebuild` — Apply pending config to this managed container +* `set-parent` — Move this agent in the topology tree — under a new parent, or to root * `set-limits` — Declare this agent's CPU/memory limits, overriding the hive-wide defaults * `choom` — Open an interactive Claude session inside this agent's container * `watch` — Follow this agent's live turn/tool-call event stream from the CLI @@ -462,6 +464,19 @@ Apply pending config to this managed container +## `hivectl agent set-parent` + +Move this agent in the topology tree — under a new parent, or to root + +**Usage:** `hivectl agent set-parent [OPTIONS]` + +###### **Options:** + +* `--parent ` — New parent agent name. Mutually exclusive with `--root` +* `--root` — Promote this agent to root (no parent) + + + ## `hivectl agent set-limits` Declare this agent's CPU/memory limits, overriding the hive-wide defaults. diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 409d462b..5a40ddb0 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -153,17 +153,18 @@ hivectl agent iris resume # let it drive turns again, draining what que ``` `list-agents` prints a padded table with one row per managed agent — -`NAME STATUS REV REMIND`. STATUS collapses the health flags +`NAME STATUS REV PARENT REMIND`. STATUS collapses the health flags (`running` / `stopped`, plus ` paused` / ` needs-login` / ` needs-update` when set — `paused` is orthogonal to running, see below); -REV is the first 12 chars of the agent's locked config sha; REMIND is the -count of pending reminders. It reuses the same per-agent aggregation the dashboard +REV is the first 12 chars of the agent's locked config sha; PARENT is its +place in the topology tree (`-` for a root agent); REMIND is the count of +pending reminders. It reuses the same per-agent aggregation the dashboard renders, so the CLI roster and the web UI never drift. `--json` emits the raw rows instead of the table. `agent restart` is the manual equivalent of the MCP `restart` tool — useful when you need to kick a container from the host without -going through an agent at all. For more than one agent at once, use +going through the agent hierarchy. For more than one agent at once, use the top-level `hivectl restart --agents` (or `--agent ` repeated) — it rides a single DAG and reports per-target failures at the end rather than aborting mid-run, instead of shelling out to `agent restart` diff --git a/docs/turn-loop/README.md b/docs/turn-loop/README.md index 03fa27ac..6ef2233e 100644 --- a/docs/turn-loop/README.md +++ b/docs/turn-loop/README.md @@ -111,7 +111,9 @@ merge work. Plugin install failures aren't fatal: each entry comes back as a human-readable failure string that gets routed via -`Surface::send_to_operator` to the operator. +`Surface::send_to_parent` to the agent's topology parent (the +broker resolves `` per `topology::resolve_recipient`; root +agents and the manager fall through to operator). ### Turn outcomes @@ -126,7 +128,7 @@ else a `TurnError`) drives the post-claude branch: | `Err(AuthFailed)` | emit `needs_login_idle` sentinel, requeue inflight, park in `wait_for_login` | | `Err(SessionNotFound)` | resume + create self-heal both missed ("shouldn't happen"); requeue inflight so the next turn creates fresh — no status park, message not dropped | | `Err(ApiStall)` | idle watchdog killed claude after `HIVE_TURN_IDLE_SECS` (default 600) of output silence; sleep `HIVE_STALL_SLEEP_SECS` (default 60), requeue inflight, status back to `online` | -| `Err(Failed(err))` | route `[system] \`\` claude turn failed:\n` to `operator` via `send_to_operator` | +| `Err(Failed(err))` | route `[system] \`\` claude turn failed:\n` to `` via `send_to_parent` | `ApiStall` catches an Anthropic API stall — a multi-retry connection storm where the stream goes silent for minutes. The idle watchdog lives in `hive-claude`'s diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index c54589c7..22b0a00a 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -29,13 +29,15 @@ are opt-in via the P3RM1SS10NS tab. `recv(max?)`, `ack_until(up_to)`. - `send` — message a peer (logical name) or the operator - (`to: "operator"`). Optional `in_reply_to: i64` links the + (`to: "operator"`). Use `to: ""` to address the topology + parent without hardcoding the label; the broker resolves the + sentinel at delivery time. Optional `in_reply_to: i64` links the message to a prior id for thread rendering. Per-agent `services.hyperhive.agent.allowedRecipients` (default: empty = unrestricted) limits which names `send` accepts — useful for sandboxing: set - `[ "operator" ]` to restrict a sub-agent to operator messages only. - The operator stays reachable regardless of this list, so an agent can - always report a block. + `[ "operator" ]` to restrict a sub-agent to operator messages only + (the topology parent is always reachable regardless of this list — + that carve-out is structural, keyed on parent relationship, not name). - `recv` — drain inbox. Always an immediate peek, never blocks. `max` (default 1, cap 5) drains up to N rows. `recv` prefixes each returned row with `[msg #]` (broker row id; note the highest id seen, then pass diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 9990aa9b..488117b8 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -56,10 +56,19 @@ surfaces, not tab panes. ## SW4RM tab -**C0NTAINERS** — live containers as a flat list, one row per -container, sorted alphabetically by agent name. No row nests under -another, none carries an indent or a tree glyph, and every container the -rescan reports gets a row. Pulsing red banner at the top +**C0NTAINERS** — live containers rendered as a depth-first +tree using `ContainerView.parent` (populated by +`hive-c0re/src/agent_config/topology.rs` — don't confuse it with +`hive-c0re/src/dashboard/topology.rs`, which only holds the +set-parent endpoints). +ASCII tree glyphs (`├─`, `└─`, `│ ` continuation columns) prefix +each container's row, showing the agent parent/child hierarchy. +When every container has `parent = null` +(flat topology) the tree collapses to a plain list with no +glyphs. The tree sorts children alphabetically within each parent; +roots likewise. The tree tolerates cycles in the parent graph — +it appends orphaned containers (not reachable from any root) +as roots so no agent disappears. 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 @@ -952,7 +961,7 @@ Three primitives, all built on the `openDialog` core: summaries are sticky (select to dismiss) so they aren't missed. Every destructive run-state action (`ST0P`, `R3ST4RT`, `R3BU1LD`, -`DESTR0Y`, `PURG3`) routes through `themedConfirm`, on both +`DESTR0Y`, `PURG3`, `M0V3`) routes through `themedConfirm`, on both the per-agent `⋮` menu and the bulk selection bar. **Graceful stop** — the `■ ST0P` confirm dialog (per-agent and @@ -976,11 +985,25 @@ identically.) ### Topology tree -Nothing nests. `swarm.js::buildAgentTree` returns every container at -depth 0 in alphabetical order, and the row builder emits no prefix -column for a depth-0 row — so a container row carries no indent, no -joint and no continuation bar, and the SW4RM list reads as a flat -roster. The `.tree-prefix` lane rules in `dashboard.css` match nothing. +See **SW4RM tab** above for the parent/child derivation, sibling +sort order, and cycle-safety rules (`swarm.js::buildAgentTree` walks +`ContainerView.parent`) — this section covers only how the tree is +*drawn*. + +The per-row prefix column (`.tree-prefix`) is **DOM-painted, not +text-glyph-painted**: each indent lane is its own positioned `` +so CSS can draw full-height vertical bars that bridge the gap between +sibling rows. Plain text box-drawing characters (`├─`, `└─`, `│ `) +would only paint one text-line tall and leave visible breaks between +the taller-than-one-line container cards, so CSS borders draw the +bars instead: a continuation bar runs the full height of an +ancestor's still-open subtree, and the joint at a row's own depth is +`├` (more siblings below) or `└` (last sibling — the line stops at +the row's icon midline). Exact lane widths and positioning live in +`swarm.js`'s tree-prefix rendering and its paired CSS rules — not +reproduced here since they're tuned in pixel units and will drift. +When every container is at depth 0 (no `parent` set) these rules are +all no-ops and the layout reads like a plain flat list. ## Selection bar @@ -1009,9 +1032,25 @@ frosted-mauve bar slides up from the bottom of the viewport - `▶ R3SUM3` — paused agents only - `↻ R3BU1LD` — always available - `DESTR0Y` / `PURG3` — always available + - `⇡ M0V3 → ROOT` — promote selected agents to top-level + (parent = null); disabled when all selected are already at root. + hive-c0re's `topology::set_parent` refuses moves it can't satisfy + (for example a move that would create a cycle) and the refusal surfaces + in the failure roll-up. + - `⇢ M0V3 → [select]` — inline picker available for any + selection size. The dropdown lists every container that isn't IN + the selection itself nor a descendant of any selected agent + (client-side BFS cycle prevention across the whole batch; the + hive-c0re re-checks per-agent). On submit: + - **single agent** → `POST /api/topology/set-parent` + (form-encoded `child=&new_parent=`) + - **multiple agents** → `POST /api/topology/set-parent-bulk` + (JSON `[{ child, new_parent }]`; all moves land in a single + `topology.json` commit instead of one per agent) - That list is the whole set — the bar offers no other bulk action. + Both write `topology.json` and re-emit a container snapshot so + the tree repaints without a page reload. - **`✕ clear`** button + `Esc` key clear the entire selection. Every render prunes stale selections (agents destroyed while diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 71106782..93552780 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1384,6 +1384,27 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; } +.move-picker { + display: inline-flex; + align-items: center; + gap: 0.3em; + margin-left: 0.6em; +} +.move-picker-select { + font-family: inherit; + font-size: 0.75em; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--purple); + border-radius: 2px; + padding: 0.1em 0.3em; + max-width: 16em; +} +.move-picker-select:disabled { + opacity: 0.5; + cursor: default; +} + /* ST4TS moved to its own page (`/stats.html`): the window selector / summary chips / bars live in stats.css, and the shared `.hive-stats-table` moved to common.css (the SYST3M › diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index 69c77bab..6906b348 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -952,6 +952,154 @@ export function renderSelectionBar(containers) { confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers, config history, claude creds, and notes are all WIPED. no undo.`, }); + + // Move agent(s) in the topology tree — selecting an option in the + // M0V3 dropdown immediately confirms + executes the move. "(no parent)" + // promotes to root (empty new_parent on the backend). Cycle-safe: + // dropdown filters out self and descendants on the client side; the + // backend rechecks via `topology::set_parent`. + // + // Backend: POST /api/topology/set-parent (dashboard.rs), + // form-encoded `child=&new_parent=`. Re-emits + // container snapshots on success so the tree repaints without a + // separate refresh. + addMoveActions(actions, selected, containers); +} + +// Render the M0V3 picker in the selection bar. Selecting any real option +// (including "(no parent)") immediately fires a confirm + POST — no +// separate button. Backend `topology::set_parent` refuses invalid moves +// and the refusal surfaces in the alert roll-up. +function addMoveActions(parent, selected, containers) { + const candidates = validReparentCandidates(selected, containers); + const wrap = el("span", { class: "move-picker" }); + const selectTitle = + selected.length === 1 + ? `change ${selected[0].name}'s parent` + : `change ${selected.length} agents' parent`; + const sel = el("select", { class: "move-picker-select", title: selectTitle }); + sel.append(el("option", { value: "" }, "⇢ M0V3 →")); + // "(no parent)" -> empty new_parent on the backend (promotes to root). + sel.append(el("option", { value: "__root__" }, "(no parent)")); + for (const name of candidates) { + sel.append(el("option", { value: name }, name)); + } + sel.addEventListener("change", async () => { + if (sel.selectedIndex === 0) return; + const newParent = sel.value === "__root__" ? "" : sel.value; + const newParentLabel = sel.value === "__root__" ? "(no parent)" : sel.value; + const names = selected.map((c) => c.name); + const promptMsg = + names.length === 1 + ? `move ${names[0]} → ${newParentLabel}?` + : `move ${names.length} agents (${names.join(", ")}) → ${newParentLabel}?`; + if (!(await themedConfirm({ message: promptMsg, danger: true }))) { + sel.selectedIndex = 0; + return; + } + sel.disabled = true; + const failures = []; + if (names.length === 1) { + // Single agent — use the form-encoded endpoint (backwards compat). + try { + const body = new URLSearchParams({ + child: names[0], + new_parent: newParent, + }); + const resp = await fetch("/api/topology/set-parent", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + redirect: "manual", + }); + const ok = + resp.ok || + resp.type === "opaqueredirect" || + (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ""); + failures.push( + `${names[0]}: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`, + ); + } + } catch (err) { + failures.push(`${names[0]}: ${err}`); + } + } else { + // Multiple agents — use the bulk endpoint so all moves land in + // a single git commit instead of one per agent. + try { + const payload = names.map((n) => ({ + child: n, + new_parent: newParent || null, + })); + const resp = await fetch("/api/topology/set-parent-bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "manual", + }); + const ok = + resp.ok || + resp.type === "opaqueredirect" || + (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ""); + failures.push( + `bulk: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`, + ); + } + } catch (err) { + failures.push(`bulk: ${err}`); + } + } + sel.disabled = false; + sel.selectedIndex = 0; + if (failures.length) { + themedToast( + `M0V3 completed with ${failures.length} failure${failures.length === 1 ? "" : "s"}:\n\n` + + failures.join("\n"), + { type: "error", duration: 0 }, + ); + } + }); + wrap.append(sel); + parent.append(wrap); +} + +// Filter the dashboard's container list to those that are valid +// re-parent targets for the `selected` agents: anyone who isn't IN +// the selection itself, isn't a descendant of any selected agent +// (cycle prevention across the whole batch). The backend re-checks +// per-agent via `topology::set_parent`; this client-side filter is +// purely UX so the operator can't pick an obviously-invalid option. +function validReparentCandidates(selected, containers) { + // Build child map once. + const childrenOf = new Map(); + for (const c of containers) { + const p = c.parent || null; + if (!childrenOf.has(p)) childrenOf.set(p, []); + childrenOf.get(p).push(c.name); + } + // Union descendant set across every selected agent (each agent's + // descendants AND itself). + const blocked = new Set(); + for (const t of selected) { + const queue = [t.name]; + blocked.add(t.name); + while (queue.length) { + const n = queue.shift(); + for (const child of childrenOf.get(n) || []) { + if (blocked.has(child)) continue; + blocked.add(child); + queue.push(child); + } + } + } + return containers + .filter((c) => !blocked.has(c.name)) + .map((c) => c.name) + .sort(); } function addBulkButton(parent, btnClass, label, enabled, selected, opts) { @@ -999,15 +1147,23 @@ function addBulkButton(parent, btnClass, label, enabled, selected, opts) { // (rebuild_queue dedups but other endpoints don't); the loop is // short — bulk selections are typically a handful of agents. // - // `opts.action` is a path prefix and the agent name gets appended - // (lifecycle endpoints: /start/, /rebuild/). `opts.body` - // is a static object applied to every POST. + // Two URL shapes: + // - `opts.action` is a path prefix and the agent name gets + // appended (lifecycle endpoints: /start/, /rebuild/). + // `opts.body` is a static object applied to every POST. + // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the + // full URL (no name appended) and the per-agent body comes + // from the callback. Used by /api/topology/set-parent, where + // the agent name is a body field rather than a URL component. for (const name of names) { - const body = new URLSearchParams(opts.body || {}); - const url = - opts.action + - encodeURIComponent(name) + - (graceful ? "?graceful=true" : ""); + const body = opts.perAgentBodyFor + ? new URLSearchParams(opts.perAgentBodyFor(name)) + : new URLSearchParams(opts.body || {}); + const url = opts.perAgentBodyFor + ? opts.action + : opts.action + + encodeURIComponent(name) + + (graceful ? "?graceful=true" : ""); try { const resp = await fetch(url, { method: "POST", diff --git a/hive-agent-mcp/src/send_allow.rs b/hive-agent-mcp/src/send_allow.rs index 8bfc0e1f..64681a47 100644 --- a/hive-agent-mcp/src/send_allow.rs +++ b/hive-agent-mcp/src/send_allow.rs @@ -12,23 +12,20 @@ const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; /// Enforce the per-agent send allow-list. Returns `Ok` when the -/// recipient is permitted (no list configured, the operator, or `to` is -/// in the list); returns `Err(refusal)` with a claude-readable string -/// when blocked — the harness surfaces the refusal as the tool result so -/// claude knows the message didn't land and can react (e.g. route to the -/// operator instead). +/// recipient is permitted (no list configured, `` sentinel +/// always allowed, or `to` is in the list); returns `Err(refusal)` +/// with a claude-readable string when blocked — the harness surfaces +/// the refusal as the tool result so claude knows the message didn't +/// land and can react (e.g. route via `` instead). pub fn check_send_allowed(to: &str) -> Result<(), String> { - if to == hive_sh4re::manager::OPERATOR_RECIPIENT { - // Always allow the operator — the allow-list constrains peer - // chatter, not the reporting line out, and an agent with no way - // to say "I am stuck" is an agent that fails silently. - // - // This bypass used to be spelled ``, which the broker - // resolved per `topology.json` and which fell back to `operator` - // for a root agent. The parent field is gone now, so every - // agent is what that fallback called a root — the exemption is - // now written as the name it always resolved to. Same reachable - // set, one fewer indirection. + if to == hive_sh4re::manager::PARENT_RECIPIENT { + // Always allow `` — the allow-list constrains peer + // chatter, not the structural reporting line; the operator + // can rewire who the parent IS via `set_parent` without + // having to remember to update the per-agent allow-list. + // The broker resolves the sentinel to the real parent label + // on the host side per topology.json (falls back to `operator` + // for root agents). return Ok(()); } let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { @@ -53,9 +50,9 @@ pub fn check_send_allowed(to: &str) -> Result<(), String> { } Err(format!( "send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \ - (configured in agent.nix). Allowed: {allow:?}. The operator is always \ - reachable — route through `send(to: \"{}\", …)` if you need to reach \ - someone outside the allow-list.", - hive_sh4re::manager::OPERATOR_RECIPIENT + (configured in agent.nix). Allowed: {allow:?}. Your structural \ + parent is always reachable — route through `send(to: \"{}\", …)` \ + if you need to reach someone outside the allow-list.", + hive_sh4re::manager::PARENT_RECIPIENT )) } diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index 064f79cd..3b1e2e8c 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -104,7 +104,7 @@ async fn main() -> Result<()> { /// Surface a `SYSTEM_SENDER` message in the live event bus + tracing /// log. Both agents and the manager receive `ContainerCrash`, -/// notifications, and friends; the parse and log path is +/// reparent notifications, and friends; the parse and log path is /// identical. Quiet no-op when `from` isn't /// `SYSTEM_SENDER`. fn log_system_event(bus: &Bus, from: &str, body: &str) { @@ -122,8 +122,8 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) { }); } -/// Body string for the turn-failure notification we route to the -/// operator on `TurnError::Failed`. Reads the hive-qualified +/// Body string for the turn-failure notification we route to +/// `` on `TurnError::Failed`. Reads the hive-qualified /// identity so the receiver sees `agent@hive` rather than relying on /// the caller threading a `label` through every turn-handling layer. /// Falls back to `` when `HIVE_LABEL` is missing so a @@ -278,15 +278,10 @@ trait Surface { /// fallback. Same shape as `graceful_stop_complete`. fn pause_acknowledged(socket: &Path) -> impl Future; - /// Send a message addressed to the operator. The reporting line out - /// of a container: this is where a turn failure or a plugin-install - /// failure surfaces when nothing inside the harness can act on it. - /// - /// Was `` while the hierarchy existed, a sentinel the broker resolved per - /// `topology.json` and which already fell through to `operator` for a - /// root agent. With the parent field gone every agent takes that - /// branch, so the recipient is written out rather than resolved. - fn send_to_operator(socket: &Path, body: String) -> impl Future; + /// Send a message addressed to `` (broker resolves the + /// sentinel via `topology::parent_of` at delivery time; root + /// agents/manager fall through to operator). + fn send_to_parent(socket: &Path, body: String) -> impl Future; /// Long-poll the broker for the next message. Wraps the /// `Messages`/empty/error trichotomy in `RecvOutcome` so the @@ -372,11 +367,11 @@ impl Surface for AgentSurface { (threads, reminders) } - async fn send_to_operator(socket: &Path, body: String) { + async fn send_to_parent(socket: &Path, body: String) { let res = hive_sock_client::request::<_, Response>( socket, &Request::Send { - to: hive_sh4re::manager::OPERATOR_RECIPIENT.into(), + to: hive_sh4re::manager::PARENT_RECIPIENT.into(), body, in_reply_to: None, }, @@ -384,7 +379,7 @@ impl Surface for AgentSurface { ) .await; if let Err(e) = res { - tracing::warn!(error = ?e, "failed to notify the operator of turn failure"); + tracing::warn!(error = ?e, "failed to notify parent of turn failure"); } } @@ -524,9 +519,11 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { } let files = turn::TurnFiles::prepare(socket, &label).await?; // Plugin install failures come back as a Vec — route each - // through the `send_to_operator` failure-notify path. + // through `` via the `send_to_parent` failure-notify path. + // The broker resolves `` per `topology::parent_of`; + // root agents fall through to operator. for failure in plugins::install_configured().await { - S::send_to_operator(socket, failure).await; + S::send_to_parent(socket, failure).await; } // The forge notification poller used to be spawned here. It is its own // process now (`hive-forge-notify`, its own systemd unit) so a harness @@ -1007,7 +1004,7 @@ async fn handle_turn( /// The non-happy-path half of `handle_turn`: react to each `TurnError` /// variant the turn could have failed with (park-and-retry on rate-limit/ /// stall/auth, requeue-for-a-fresh-turn on prompt-too-long/session-not- -/// found, notify the operator on a hard failure). Split out purely to keep +/// found, notify the parent on a hard failure). Split out purely to keep /// `handle_turn` itself under clippy's line-count lint — no behavior /// change from when this lived inline. async fn handle_turn_error_recovery( @@ -1066,6 +1063,6 @@ async fn handle_turn_error_recovery( S::requeue_inflight(socket).await; } if let Err(turn::TurnError::Failed(e)) = outcome { - S::send_to_operator(socket, format_turn_failure(e)).await; + S::send_to_parent(socket, format_turn_failure(e)).await; } } diff --git a/hive-agent/src/plugins.rs b/hive-agent/src/plugins.rs index 958a6977..3928ee89 100644 --- a/hive-agent/src/plugins.rs +++ b/hive-agent/src/plugins.rs @@ -98,8 +98,9 @@ async fn update_marketplaces() { /// Install every plugin in `/etc/hyperhive/claude-plugins.json`. /// Returns a list of human-readable failure messages so the caller can /// route them through their own per-role surface (turn-failure-style -/// notification, see `Surface::send_to_operator`). Wire-agnostic: the -/// caller picks the recipient, the same way failure-notify does. +/// notification, see `Surface::send_to_parent`). Wire-agnostic: the +/// caller picks the recipient via the same `` sentinel that +/// failure-notify uses everywhere else. pub async fn install_configured() -> Vec { let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else { return Vec::new(); diff --git a/hive-agent/src/turn.rs b/hive-agent/src/turn.rs index 9254fc06..69929dc5 100644 --- a/hive-agent/src/turn.rs +++ b/hive-agent/src/turn.rs @@ -193,7 +193,7 @@ pub enum TurnError { /// rate-limit path — NOT a crash. ApiStall, /// A hard failure with no recovery — the serve loop escalates it to the - /// operator (`send_to_operator`). + /// parent (`send_to_parent`). Failed(anyhow::Error), } diff --git a/hive-c0re/src/agent_config/topology.rs b/hive-c0re/src/agent_config/topology.rs index d4dfbff2..894f7e53 100644 --- a/hive-c0re/src/agent_config/topology.rs +++ b/hive-c0re/src/agent_config/topology.rs @@ -1,26 +1,35 @@ -//! Agent roster storage — the set of agent names the hive knows about. -//! Persisted as a JSON array at `/var/lib/hyperhive/meta/topology.json`, -//! alongside the meta `flake.nix`, so roster changes thread through the -//! same git commit log as deploys. +//! Agent topology storage — single source of truth for parent/child +//! relations in the hive. Persisted as a flat JSON map of `name → +//! parent name | null` at `/var/lib/hyperhive/meta/topology.json`, +//! alongside the meta `flake.nix`, so topology changes thread through +//! the same git commit log as deploys. //! -//! **There is no hierarchy here any more.** The file used to be a map of -//! `name → parent | null` and this module owned the parent/child tree that -//! fed `` / `` routing, the reparenting API and the -//! dashboard's tree view. All of that is gone; what the file is -//! *for* now is the one thing that survived the removal — naming every -//! agent, which is the set a -//! [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets -//! bind-mounted ([`all_agents`]). +//! Broader-than-your-own-children bind-mount grants are **not** stored +//! here: they hang off the `ManageRootAgent` capability in +//! `capabilities.json`. See `lifecycle::set_nspawn_flags`. //! -//! Broader-than-your-own bind-mount grants are **not** stored here: they -//! hang off the `ManageRootAgent` capability in `capabilities.json`. See -//! `lifecycle::set_nspawn_flags`. +//! Format, rationale, read/reconcile/inject/surface flow, and target +//! enforcement semantics: `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`. +//! `` sentinel resolution (delivered by [`resolve_recipient`]): +//! `docs/process/conventions.md::Recipient sentinels`. +//! +//! ## Graph representation +//! +//! The on-disk format stays as a flat JSON map `name → parent | null` +//! (small, git-diffable). In-memory, cycle detection uses a [`petgraph`] +//! directed graph where each edge runs +//! **parent → child**. This replaces the ad-hoc bounded walks that existed +//! before: petgraph's `is_cyclic_directed` +//! is correct for graphs of any depth (no 32-hop ceiling) and well-tested. +//! The graph is built on demand from the flat map; it is not cached across +//! calls (the map is small and disk I/O dominates anyway). -use std::collections::BTreeSet; +use std::collections::BTreeMap; + +use petgraph::algo::is_cyclic_directed; +use petgraph::graph::{DiGraph, NodeIndex}; use std::path::PathBuf; -use serde::Deserialize; - const TOPOLOGY_FILE: &str = "topology.json"; #[must_use] @@ -28,44 +37,55 @@ pub fn topology_path() -> PathBuf { crate::paths::meta_root().join(TOPOLOGY_FILE) } -/// On-disk shapes [`read`] accepts. The array is what [`write()`] emits; the -/// map is the legacy `name → parent | null` format, kept readable so a -/// hive that upgrades across this change keeps its roster instead of -/// blanking it until the next `reconcile` pass — and a blank roster is not -/// a cosmetic gap, it is every `ManageRootAgent` holder losing its mounts -/// for the length of that window. -#[derive(Deserialize)] -#[serde(untagged)] -enum OnDisk { - Roster(BTreeSet), - /// The value was the parent name; only the keys carry over. - WithParents(std::collections::BTreeMap>), -} - -/// Snapshot of the agent roster. Read on every `container_view::build_all` +/// Snapshot of the topology map. Read on every `container_view::build_all` /// and every `render_flake` call. The file is small (one line per agent), /// so we re-read rather than caching — keeps the source of truth on disk. /// -/// Returns an empty set when the file is absent or unparsable. Safe -/// degradation for fresh installs that haven't run through -/// `meta::sync_agents` yet. +/// Returns an empty map when the file is absent or unparsable; callers +/// treat that as "no recorded parents", which falls back to every agent +/// being root-level. Safe degradation for fresh installs that haven't +/// run through `meta::sync_agents` yet. #[must_use] -pub fn read() -> BTreeSet { +pub fn read() -> BTreeMap> { let path = topology_path(); let Ok(raw) = std::fs::read_to_string(&path) else { - return BTreeSet::new(); + return BTreeMap::new(); }; - match serde_json::from_str::(&raw) { - Ok(OnDisk::Roster(names)) => names, - Ok(OnDisk::WithParents(map)) => map.into_keys().collect(), - Err(_) => BTreeSet::new(), - } + serde_json::from_str(&raw).unwrap_or_default() } -/// Every agent the roster knows about, in name order. This is the set +/// Return the direct children of `name` — agents whose `topology.json` +/// entry has `name` as their parent. Reads the map once and scans all +/// entries; cheap enough for the fan-out path (one disk read per send +/// to ``). +#[must_use] +pub fn children_of(name: &str) -> Vec { + children_of_in(&read(), name) +} + +/// Pure form of [`children_of`] for unit tests. +#[must_use] +pub fn children_of_in(topo: &BTreeMap>, name: &str) -> Vec { + topo.iter() + .filter_map(|(agent, parent)| { + if parent.as_deref() == Some(name) { + Some(agent.clone()) + } else { + None + } + }) + .collect() +} + +/// Every agent the topology knows about, in name order. This is the set /// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets /// bind-mounted, and it is deliberately unfiltered: that capability means /// "may manage any agent", so the set is all of them. +/// +/// It replaces a `top_level_agents()` that selected `parent.is_none()`. +/// With the hierarchy removed every agent is parentless, so the old +/// predicate already matched everything — keeping it would have hidden an +/// all-agents grant behind a filter that no longer filters. #[must_use] pub fn all_agents() -> Vec { all_agents_in(&read()) @@ -73,15 +93,86 @@ pub fn all_agents() -> Vec { /// Pure form of [`all_agents`] for unit tests. #[must_use] -pub fn all_agents_in(topo: &BTreeSet) -> Vec { - topo.iter().cloned().collect() +pub fn all_agents_in(topo: &BTreeMap>) -> Vec { + topo.keys().cloned().collect() } -/// Persist the roster. Sorted JSON output (`BTreeSet` iterates in key -/// order) keeps git diffs minimal across re-writes. Best-effort — +/// Resolve a magic recipient sentinel (currently just +/// [`hive_sh4re::manager::PARENT_RECIPIENT`]) to a real broker recipient at +/// send time. Returns an owned `String` so callers can plug it +/// straight into [`crate::broker::Broker::send`] without +/// borrow-juggling around the temporary lookup. +/// +/// Rules + rationale: `docs/process/conventions.md::Recipient sentinels`. +/// Fast path: ordinary recipient names short-circuit before any +/// disk read — only `` triggers `read()` on `topology.json`. +#[must_use] +pub fn resolve_recipient(sender: &str, to: &str) -> String { + // Early exit: only sentinel recipients need topology lookup. This + // keeps the cost of a normal `send` at one string comparison. + if to != hive_sh4re::manager::PARENT_RECIPIENT { + return to.to_owned(); + } + resolve_recipient_in(&read(), sender, to) +} + +/// Pure form of [`resolve_recipient`] taking the topology map +/// explicitly. Split out so unit tests can exercise the sentinel +/// rules without writing a `topology.json` to disk. +#[must_use] +pub fn resolve_recipient_in( + topo: &BTreeMap>, + sender: &str, + to: &str, +) -> String { + if to == hive_sh4re::manager::PARENT_RECIPIENT { + topo.get(sender) + .cloned() + .flatten() + .unwrap_or_else(|| hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned()) + } else { + to.to_owned() + } +} + +/// Build an in-memory petgraph directed graph from the topology map. +/// +/// Edges run **parent → child** so that: +/// - `children_of(name)` = outgoing neighbours of `name`'s node +/// - cycle detection = `is_cyclic_directed` after a speculative edge insert +/// +/// Returns the graph and a `BTreeMap` for O(log n) +/// name-to-node lookups. Both are local to each call site — the graph is +/// not cached. Hive topologies are small (< ~100 nodes); building on demand +/// is dominated by the surrounding disk read. +#[must_use] +fn build_graph( + topo: &BTreeMap>, +) -> (DiGraph, BTreeMap) { + let mut graph: DiGraph = DiGraph::new(); + let mut idx: BTreeMap = BTreeMap::new(); + + // Add one node per agent. + for name in topo.keys() { + let ni = graph.add_node(name.clone()); + idx.insert(name.clone(), ni); + } + // Add parent→child edges. + for (name, parent_opt) in topo { + if let Some(parent) = parent_opt + && let (Some(&p_idx), Some(&c_idx)) = (idx.get(parent), idx.get(name.as_str())) + { + graph.add_edge(p_idx, c_idx, ()); + } + } + (graph, idx) +} + +/// Persist the topology map. Sorted JSON output (`BTreeMap` is sorted by +/// key) keeps git diffs minimal across re-writes. Best-effort — /// returns an `io::Error` so callers can decide whether a failure -/// should abort their op (`sync_agents`) or just log. -pub fn write(topology: &BTreeSet) -> std::io::Result<()> { +/// should abort their op (`sync_agents`, `RequestSetParent`) or just log. +pub fn write(topology: &BTreeMap>) -> std::io::Result<()> { let path = topology_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -91,15 +182,90 @@ pub fn write(topology: &BTreeSet) -> std::io::Result<()> { std::fs::write(&path, format!("{text}\n")) } -/// Reconcile `topology.json` against the current agent set. Adds any agent -/// missing from the file; removes entries for agents no longer present. -/// Returns true when the file changed and should be re-committed by the -/// caller. +/// Compute the default topology for a fresh install: every agent is a +/// root (parent = null). There is no structural "manager" — agents +/// arrange themselves via explicit parent edges, written by the operator +/// through the dashboard / `RequestSetParent` API. +/// Used by `meta::sync_agents` on first call to seed `topology.json`. +/// +/// As soon as an explicit write lands (dashboard / `RequestSetParent` +/// API), this seeding stops touching pre-existing entries — +/// `sync_agents` only adds rows for newly-spawned agents against +/// whatever the operator has configured. +#[must_use] +#[allow( + dead_code, + reason = "kept for the dashboard / RequestSetParent write API; \ + `sync_agents` does its own seeding today" +)] +pub fn default_seed(agent_names: &[String]) -> BTreeMap> { + let mut out = BTreeMap::new(); + for name in agent_names { + out.insert(name.clone(), None); + } + out +} + +/// Pure validation + apply for [`crate::meta::bulk_commit_topology`]. Splits off so tests +/// can exercise the rules (cycle / unknown) on an in-memory +/// `BTreeMap` without touching the on-disk `topology.json`. Returns +/// either the post-move map (caller writes it back) or a +/// user-readable error string. +/// +/// The manager is reparentable like any other agent — its special +/// powers come from the privileged MCP socket, not its tree +/// position. The cycle walk below covers "moving X under its own +/// descendant" for the manager as much as any other agent. +/// `docs/agent-lifecycle/agent-hierarchy.md::Reparenting` has the rationale. +pub fn apply_set_parent( + topo: &BTreeMap>, + child: &str, + new_parent: Option<&str>, +) -> Result>, String> { + if !topo.contains_key(child) { + return Err(format!("unknown agent: {child}")); + } + if let Some(p) = new_parent { + if !topo.contains_key(p) { + return Err(format!("unknown parent: {p}")); + } + if p == child { + return Err("an agent cannot be its own parent".to_owned()); + } + // Cycle check via petgraph: build the current graph, speculatively + // insert the proposed parent→child edge, then test for cycles with + // `is_cyclic_directed`. This replaces the earlier ad-hoc 32-hop + // ancestor walk — petgraph is correct for any tree depth and the + // algorithm is well-tested. + let (mut graph, idx) = build_graph(topo); + if let (Some(&p_ni), Some(&c_ni)) = (idx.get(p), idx.get(child)) { + graph.add_edge(p_ni, c_ni, ()); + if is_cyclic_directed(&graph) { + return Err(format!( + "cycle: {p} is in {child}'s subtree (would create a loop)" + )); + } + } + } + let mut next = topo.clone(); + next.insert(child.to_owned(), new_parent.map(str::to_owned)); + Ok(next) +} + +/// Reconcile `topology.json` against the current agent set. Adds an +/// entry (default: parent = null — a new agent with no declared parent +/// is its own root) for any agent missing from the file; removes +/// entries for agents no longer +/// present. Existing entries are preserved as-is — operator/manager +/// choices stick across regenerations. Returns true when the file +/// changed and should be re-committed by the caller. /// /// `pending` lists agents that have a provisioned proposed config repo /// but no container yet (provisioned, not yet spawned). They are KEPT -/// (not dropped) so an agent that exists on disk but has never booted is -/// still a name the hive knows about. +/// (not dropped) so an explicit parent edge written before the first +/// spawn survives until the first apply-commit, but they are NOT seeded with a +/// default parent here — that happens when the container actually spawns +/// and the name moves into `agent_names`. pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result { let (next, changed) = apply_reconcile(&read(), agent_names, pending); if changed { @@ -108,25 +274,32 @@ pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result< Ok(changed) } -/// Pure form of [`reconcile`] for unit tests. Adds missing live agents, -/// drops entries for agents that are neither live nor pending-init, and -/// reports whether anything changed. +/// Pure form of [`reconcile`] for unit tests. Adds missing live agents +/// at their default position, drops entries for agents that are neither +/// live nor pending-init, and reports whether anything changed. #[must_use] pub fn apply_reconcile( - current: &BTreeSet, + current: &BTreeMap>, agent_names: &[String], pending: &[String], -) -> (BTreeSet, bool) { +) -> (BTreeMap>, bool) { let mut next = current.clone(); let mut changed = false; for name in agent_names { - if next.insert(name.clone()) { + if !next.contains_key(name) { + // A new agent with no declared parent defaults to root + // (parent = null). An agent placed under a parent carries an + // explicit edge written before its first spawn, so it never + // hits this default — only spawns with no declared parent do, + // and those are roots. No agent is structurally privileged + // here: "root-ness" is just a null parent. + next.insert(name.clone(), None); changed = true; } } let known: std::collections::HashSet<&String> = agent_names.iter().chain(pending.iter()).collect(); - next.retain(|name| { + next.retain(|name, _| { let keep = known.contains(name); if !keep { changed = true; @@ -138,94 +311,301 @@ pub fn apply_reconcile( #[cfg(test)] mod tests { - use super::{BTreeSet, OnDisk, all_agents_in, apply_reconcile}; - - fn roster_three() -> BTreeSet { - ["alice", "bob", "carol"] - .into_iter() - .map(str::to_owned) - .collect() - } + use super::*; #[test] - fn apply_reconcile_adds_missing_live_agent() { - let live = vec![ + fn default_seed_makes_every_agent_root() { + // No structural manager: every agent defaults to root (null + // parent). Explicit edges are layered on later. + let agents = vec![ + "alice".to_owned(), crate::lifecycle::MANAGER_NAME.to_owned(), - "newbie".to_owned(), + "bob".to_owned(), ]; - let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]); - assert!(changed); - assert!(next.contains("newbie")); - assert!(next.contains(crate::lifecycle::MANAGER_NAME)); + let seed = default_seed(&agents); + assert_eq!(seed.get(crate::lifecycle::MANAGER_NAME), Some(&None)); + assert_eq!(seed.get("alice"), Some(&None)); + assert_eq!(seed.get("bob"), Some(&None)); } #[test] - fn apply_reconcile_drops_vanished_agent() { - let live = vec!["alice".to_owned()]; - let (next, changed) = apply_reconcile(&roster_three(), &live, &[]); - assert!(changed); - assert!(!next.contains("bob")); - assert!(!next.contains("carol")); - assert!(next.contains("alice")); + fn default_seed_handles_empty_input() { + let seed = default_seed(&[]); + assert!(seed.is_empty()); + } + + fn topo_three_level() -> BTreeMap> { + let mut m = BTreeMap::new(); + m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); + m.insert( + "alice".to_owned(), + Some(crate::lifecycle::MANAGER_NAME.to_owned()), + ); + m.insert("bob".to_owned(), Some("alice".to_owned())); + m.insert("carol".to_owned(), Some("alice".to_owned())); + m } #[test] - fn apply_reconcile_keeps_pending_init_agent() { - // `dora` was provisioned but has no container yet, so it's absent - // from the live set. It must NOT be dropped. - let mut roster = roster_three(); - roster.insert("dora".to_owned()); - let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; - let pending = vec!["dora".to_owned()]; - let (next, changed) = apply_reconcile(&roster, &live, &pending); - assert!(!changed, "no change expected: {next:?}"); - assert!(next.contains("dora")); + fn apply_set_parent_promotes_to_root() { + let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap(); + assert_eq!(next.get("alice"), Some(&None)); } #[test] - fn apply_reconcile_is_a_noop_when_already_in_sync() { - let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; - let (next, changed) = apply_reconcile(&roster_three(), &live, &[]); - assert!(!changed); - assert_eq!(next, roster_three()); + fn apply_set_parent_reparents_under_sibling_subtree() { + // bob and carol both under alice; move carol under bob. + let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap(); + assert_eq!(next.get("carol"), Some(&Some("bob".to_owned()))); } #[test] - fn all_agents_in_returns_every_name_sorted() { + fn apply_set_parent_allows_manager_move() { + // The manager is reparentable like any other agent (its + // privileges live on the MCP socket, not its tree position). + // Build a topo with an unrelated root-level agent `peer` so + // moving the manager under it doesn't trip the cycle walk + // (every non-manager agent in topo_three_level descends from + // the manager, so that fixture can't exercise a legal + // manager move). + let mut topo = BTreeMap::new(); + topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); + topo.insert("peer".to_owned(), None); + let next = apply_set_parent(&topo, crate::lifecycle::MANAGER_NAME, Some("peer")) + .expect("manager move should succeed"); assert_eq!( - all_agents_in(&roster_three()), - vec!["alice", "bob", "carol"] + next.get(crate::lifecycle::MANAGER_NAME), + Some(&Some("peer".to_owned())) ); } #[test] - fn all_agents_in_empty_roster_returns_empty() { - assert!(all_agents_in(&BTreeSet::new()).is_empty()); - } - - /// The upgrade path. A hive whose `topology.json` still carries the - /// legacy `name → parent` map must read as the same roster, parent - /// values discarded — otherwise the first read after the upgrade hands - /// `ManageRootAgent` holders an empty mount set. - #[test] - fn a_pre_4472_parent_map_reads_as_its_key_set() { - let raw = r#"{"alice": "bob", "bob": null, "carol": "bob"}"#; - let parsed: OnDisk = serde_json::from_str(raw).expect("legacy map parses"); - let names = match parsed { - OnDisk::Roster(n) => n, - OnDisk::WithParents(m) => m.into_keys().collect(), - }; - assert_eq!(names, roster_three()); + fn apply_set_parent_refuses_manager_under_own_descendant() { + // Moving the manager under `bob` (who already lives under + // `alice` who lives under the manager) would close the loop. + // The general cycle walk catches this; no separate manager + // guard needed. + let err = apply_set_parent( + &topo_three_level(), + crate::lifecycle::MANAGER_NAME, + Some("bob"), + ) + .unwrap_err(); + assert!(err.contains("cycle"), "err = {err}"); } #[test] - fn a_roster_array_round_trips() { - let raw = serde_json::to_string(&roster_three()).expect("serialises"); - let parsed: OnDisk = serde_json::from_str(&raw).expect("array parses"); - let names = match parsed { - OnDisk::Roster(n) => n, - OnDisk::WithParents(m) => m.into_keys().collect(), - }; - assert_eq!(names, roster_three()); + fn apply_set_parent_refuses_unknown_child() { + let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err(); + assert!(err.contains("unknown agent"), "err = {err}"); + } + + #[test] + fn apply_set_parent_refuses_unknown_parent() { + let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err(); + assert!(err.contains("unknown parent"), "err = {err}"); + } + + #[test] + fn apply_set_parent_refuses_self() { + let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err(); + assert!(err.contains("own parent"), "err = {err}"); + } + + #[test] + fn apply_set_parent_refuses_cycle() { + // bob's parent is alice; trying to make alice's parent = + // bob would close the loop alice → bob → alice. + let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err(); + assert!(err.contains("cycle"), "err = {err}"); + } + + #[test] + fn apply_set_parent_refuses_deep_cycle() { + // Three-deep chain: manager → alice → bob → carol. Moving + // alice under carol would create the loop alice → carol → bob → alice. + let mut topo = BTreeMap::new(); + topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); + topo.insert( + "alice".to_owned(), + Some(crate::lifecycle::MANAGER_NAME.to_owned()), + ); + topo.insert("bob".to_owned(), Some("alice".to_owned())); + topo.insert("carol".to_owned(), Some("bob".to_owned())); + let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err(); + assert!(err.contains("cycle"), "err = {err}"); + } + + #[test] + fn apply_set_parent_is_idempotent_noop() { + // bob is already under alice — same value returned. + let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap(); + assert_eq!(next, topo_three_level()); + } + + #[test] + fn apply_reconcile_adds_missing_live_agent_as_root() { + // A live agent with no prior topology entry defaults to root + // (null parent) — no structural manager to hang it under. + let live = vec![ + crate::lifecycle::MANAGER_NAME.to_owned(), + "newbie".to_owned(), + ]; + let (next, changed) = apply_reconcile(&BTreeMap::new(), &live, &[]); + assert!(changed); + assert_eq!(next.get("newbie"), Some(&None)); + assert_eq!(next.get(crate::lifecycle::MANAGER_NAME), Some(&None)); + } + + #[test] + fn apply_reconcile_drops_vanished_agent() { + let live = vec![ + crate::lifecycle::MANAGER_NAME.to_owned(), + "alice".to_owned(), + ]; + // carol + bob are gone from the live set and not pending. + let (next, changed) = apply_reconcile(&topo_three_level(), &live, &[]); + assert!(changed); + assert!(!next.contains_key("bob")); + assert!(!next.contains_key("carol")); + assert!(next.contains_key("alice")); + } + + #[test] + fn apply_reconcile_keeps_pending_init_agent_edge() { + // `dora` was placed under alice (edge present) but has no + // container yet, so it's absent from the live set. It must NOT + // be dropped, and its alice-parent edge must be preserved (not + // re-seeded under the manager). + let mut topo = topo_three_level(); + topo.insert("dora".to_owned(), Some("alice".to_owned())); + let live = vec![ + crate::lifecycle::MANAGER_NAME.to_owned(), + "alice".to_owned(), + "bob".to_owned(), + "carol".to_owned(), + ]; + let pending = vec!["dora".to_owned()]; + let (next, changed) = apply_reconcile(&topo, &live, &pending); + assert!(!changed, "no change expected: {next:?}"); + assert_eq!(next.get("dora"), Some(&Some("alice".to_owned()))); + } + + #[test] + fn resolve_recipient_passes_through_ordinary_names() { + let topo = topo_three_level(); + // Real labels, broadcast, and the operator literal all + // shortcut through unchanged — no resolution magic. + assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice"); + assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*"); + assert_eq!( + resolve_recipient_in(&topo, "bob", hive_sh4re::manager::OPERATOR_RECIPIENT), + hive_sh4re::manager::OPERATOR_RECIPIENT + ); + } + + #[test] + fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() { + let topo = topo_three_level(); + // bob's parent is alice → `` from bob goes to alice. + assert_eq!( + resolve_recipient_in(&topo, "bob", hive_sh4re::manager::PARENT_RECIPIENT), + "alice" + ); + // alice's parent is the manager — same one-hop rewrite. + assert_eq!( + resolve_recipient_in(&topo, "alice", hive_sh4re::manager::PARENT_RECIPIENT), + crate::lifecycle::MANAGER_NAME + ); + } + + #[test] + fn resolve_recipient_falls_back_to_operator_for_root_agent() { + let topo = topo_three_level(); + // Manager is structurally root (parent = None) → `` + // resolves to the operator (the "no parent → tell mara" + // fallback documented in conventions.md). + assert_eq!( + resolve_recipient_in( + &topo, + crate::lifecycle::MANAGER_NAME, + hive_sh4re::manager::PARENT_RECIPIENT + ), + hive_sh4re::manager::OPERATOR_RECIPIENT + ); + } + + #[test] + fn resolve_recipient_falls_back_to_operator_for_unknown_sender() { + // Sender absent from topology entirely — defensive fallback + // covers the race window where an agent's spawn has registered + // its socket but the meta-flake `sync_agents` hasn't yet added + // its row. + let topo = topo_three_level(); + assert_eq!( + resolve_recipient_in(&topo, "nobody", hive_sh4re::manager::PARENT_RECIPIENT), + hive_sh4re::manager::OPERATOR_RECIPIENT + ); + } + + #[test] + fn children_of_in_returns_direct_descendants() { + let topo = topo_three_level(); + // alice's children: bob, carol. + let mut children = children_of_in(&topo, "alice"); + children.sort(); + assert_eq!(children, vec!["bob", "carol"]); + } + + #[test] + fn children_of_in_manager_returns_root_level_agents() { + let topo = topo_three_level(); + // Only alice's parent is manager; bob+carol are under alice. + let children = children_of_in(&topo, crate::lifecycle::MANAGER_NAME); + assert_eq!(children, vec!["alice"]); + } + + #[test] + fn children_of_in_leaf_returns_empty() { + let topo = topo_three_level(); + // bob and carol have no children. + assert!(children_of_in(&topo, "bob").is_empty()); + assert!(children_of_in(&topo, "carol").is_empty()); + } + + #[test] + fn children_of_in_unknown_sender_returns_empty() { + let topo = topo_three_level(); + assert!(children_of_in(&topo, "nobody").is_empty()); + } + + #[test] + fn all_agents_in_returns_every_name() { + let topo = topo_three_level(); + let mut all = all_agents_in(&topo); + all.sort(); + let mut expected = vec![crate::lifecycle::MANAGER_NAME, "alice", "bob", "carol"]; + expected.sort_unstable(); + assert_eq!(all, expected); + } + + /// The set behind the `ManageRootAgent` mount grant must not depend on + /// `parent`: a capability holder manages an agent whether or not that + /// agent sits under someone. This is the assertion the old + /// `top_level_agents_in` could not have made. + #[test] + fn all_agents_in_includes_parented_agents() { + let mut topo = BTreeMap::new(); + topo.insert("alice".to_owned(), Some("bob".to_owned())); + topo.insert("bob".to_owned(), None); + let mut all = all_agents_in(&topo); + all.sort(); + assert_eq!(all, vec!["alice", "bob"]); + } + + #[test] + fn all_agents_in_empty_topo_returns_empty() { + let topo = BTreeMap::new(); + assert!(all_agents_in(&topo).is_empty()); } } diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 88bc56cb..a037f131 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -49,6 +49,14 @@ pub struct ContainerView { /// for this agent's input. #[serde(skip_serializing_if = "Option::is_none")] pub deployed_sha: Option, + /// Name of this agent's parent in the agent hierarchy. `None` + /// marks the agent as root-level; the dashboard renders it without + /// indentation. Sourced from `meta/topology.json` (single source of + /// truth, hive-c0re-owned) — NOT from per-agent agent.nix, because + /// an agent shouldn't be able to unilaterally declare its own place + /// in the tree. See `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, /// The Claude model the agent's harness is currently using, read from /// `state/hyperhive-harness.json["active_model"]`. `None` when the /// agent has never started a turn or the field is absent. Only @@ -113,6 +121,7 @@ impl From for hive_sh4re::container::AgentStatusRow { needs_login: v.needs_login, deployed_sha: v.deployed_sha, pending_reminders: 0, + parent: v.parent, paused: v.paused, active_model: v.active_model, status_text: v.status_text, @@ -155,8 +164,13 @@ fn agent_url_for_domain(domain: &str, name: &str) -> String { pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec { let raw = lifecycle::list().await.unwrap_or_default(); let locked = read_meta_locked_revs(); - // Read once per scan rather than re-read for every container on every - // SSE scan. + // Pull the topology map once and look up each agent's parent below. + // Empty / absent topology.json → every agent root-level (safe + // degradation for fresh installs that haven't run sync_agents yet). + let topology = crate::topology::read(); + // Same once-per-scan treatment as the topology map: the override file + // is read here and resolved per agent below, rather than re-read for + // every container on every SSE scan. let limits = crate::resource_limits::read(); let mut out = Vec::new(); for c in &raw { @@ -175,6 +189,7 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec let needs_update = crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await; let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned()); + let parent = topology.get(logical.as_str()).cloned().flatten(); // One `systemctl` call for both facts: `unit_state` is the same // shell-out `is_running` makes, minus `--quiet`. Asking twice would // double the per-agent subprocess count on every SSE scan. @@ -237,6 +252,7 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec needs_update, needs_login, deployed_sha, + parent, active_model, status_text, status_set_at, @@ -531,6 +547,7 @@ mod tests { needs_update: true, needs_login: false, deployed_sha: Some("abc123def456".to_owned()), + parent: Some("bob".to_owned()), active_model: Some("claude-opus".to_owned()), status_text: Some("shipping".to_owned()), status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()), @@ -546,6 +563,7 @@ mod tests { assert!(row.needs_update); assert!(!row.needs_login); assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456")); + assert_eq!(row.parent.as_deref(), Some("bob")); assert!(row.paused); assert_eq!(row.active_model.as_deref(), Some("claude-opus")); assert_eq!(row.status_text.as_deref(), Some("shipping")); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 35da2088..2daeafb9 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -870,6 +870,81 @@ impl Coordinator { } } + /// Apply topology reparent(s) + fan the resulting notifications out to + /// the affected agents. Applies all moves under a single `META_LOCK` + /// acquisition (one git commit — a single-move call is just a + /// one-element slice) then, for each move that actually changed + /// topology, drops a one-line system message into the inbox of: + /// + /// 1. The **old parent** (if any) — `"{child} moved out of your + /// subtree to {new_parent_or_root}"`. + /// 2. The **new parent** (if any) — `"{child} just moved into + /// your subtree (was previously under + /// {old_parent_or_root})"`. + /// 3. The **moved agent** — `"your parent changed from + /// {old_parent_or_root} to {new_parent_or_root}"`. + /// + /// `_or_root` resolves to the literal string `""` when the + /// slot is `None`, keeping the wording consistent with the + /// `` sentinel's "root → operator" routing (see + /// `docs/process/conventions.md::Recipient sentinels`). The + /// notifications fire as ordinary broker messages with + /// `from = hive_sh4re::manager::SYSTEM_SENDER` so the dashboard renders + /// them under the existing system-source styling. + /// + /// First validation failure aborts the whole batch with no disk writes. + /// + /// # Errors + /// + /// Propagates any error returned by [`crate::meta::bulk_commit_topology`] + /// (validation failure or topology-file write error). + pub async fn reparent_bulk_with_notify( + self: &Arc, + moves: &[(&str, Option<&str>)], + ) -> std::result::Result<(), String> { + if moves.is_empty() { + return Ok(()); + } + // bulk_commit_topology applies all set_parent calls under one lock + // and returns (child, old_parent) for every move that changed. + let changed = crate::meta::bulk_commit_topology(moves).await?; + + // Send per-agent notifications for each changed move. + for (child, old_parent) in &changed { + // Find the new parent from the moves slice. + let new_parent = moves + .iter() + .find(|(c, _)| *c == child) + .and_then(|(_, np)| *np); + let old_label = old_parent.as_deref().unwrap_or(""); + let new_label = new_parent.unwrap_or(""); + if let Some(op) = old_parent.as_deref() { + let _ = self.broker.send(&hive_sh4re::inbox::Message { + from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER), + to: op.to_owned(), + body: format!("{child} moved out of your subtree to {new_label}"), + in_reply_to: None, + }); + } + if let Some(np) = new_parent { + let _ = self.broker.send(&hive_sh4re::inbox::Message { + from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER), + to: np.to_owned(), + body: format!( + "{child} just moved into your subtree (was previously under {old_label})" + ), + in_reply_to: None, + }); + } + let _ = self + .broker + .send_coalescing_reparent(child, old_label, new_label); + } + + self.rescan_containers_and_emit().await; + Ok(()) + } + /// Read-only snapshot of the last cached container view. Used by /// `/api/state` to cold-load page-open clients without re-running /// `nixos-container list` themselves; the diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index f44d130f..86da1de5 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -47,6 +47,7 @@ use crate::lifecycle; (name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_snapshot", description = "cold-load dashboard snapshot"), (name = "tombstones", description = "purge of retained state for destroyed agents"), + (name = "topology", description = "operator-driven agent reparenting"), (name = "webhook", description = "forgejo webhook receivers"), ) )] @@ -71,6 +72,7 @@ mod schedules; mod state_files; mod state_snapshot; mod tombstones; +mod topology; mod webhook; // Run after lock bumps by the job queue (`job_queue/exec.rs`); the view @@ -159,6 +161,8 @@ pub async fn serve( .routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_raw)) + .routes(routes!(topology::post_set_parent)) + .routes(routes!(topology::post_set_parent_bulk)) .routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::get_capabilities)) @@ -398,6 +402,8 @@ mod router_build_probe { .routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_raw)) + .routes(routes!(topology::post_set_parent)) + .routes(routes!(topology::post_set_parent_bulk)) .routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::get_capabilities)) diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index bd14e9d1..6e11ae06 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -805,6 +805,7 @@ mod tests { needs_update: false, needs_login: false, deployed_sha: None, + parent: None, active_model: None, status_text: None, status_set_at: None, diff --git a/hive-c0re/src/dashboard/topology.rs b/hive-c0re/src/dashboard/topology.rs new file mode 100644 index 00000000..37ec5045 --- /dev/null +++ b/hive-c0re/src/dashboard/topology.rs @@ -0,0 +1,165 @@ +//! Topology (set-parent) endpoints for the dashboard. +//! +//! Operator-driven agent reparenting — single (`/api/topology/set-parent`, +//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array → +//! one git commit). Both submit a `NodeKind::Reparent` DAG to the job +//! queue (fire-and-forget, like every other queue-backed op — the +//! dashboard tree repaints off the queue's own snapshot/rescan once the +//! commit lands, same as a rebuild or restart). The executor delegates to +//! `Coordinator::reparent_bulk_with_notify`, which wraps +//! `crate::meta::bulk_commit_topology` with the move-notification messages +//! and the `ContainerView` rescan. + +use axum::{ + extract::{Form, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use utoipa::ToSchema; + +use problem_details::ProblemDetails; + +use super::{AppState, error_problem}; + +/// `POST /api/topology/set-parent` body. `child` is required. +/// `new_parent` may be: +/// - absent or empty / whitespace-only → promote to root, +/// - non-empty → new parent's logical name. +/// +/// (The CLI surface gates "no parent specified" behind an explicit +/// `--root` flag for safety; the HTTP surface is permissive +/// because the dashboard form encodes "no value" as the empty +/// string for the optional radio-group input.) +#[derive(Deserialize, ToSchema)] +pub(super) struct SetParentForm { + child: String, + new_parent: Option, +} + +/// One entry in a `POST /api/topology/set-parent-bulk` JSON array. +/// `new_parent`: absent/null/empty-string all mean "promote to root". +#[derive(Deserialize, ToSchema)] +pub(super) struct SetParentBulkEntry { + child: String, + #[serde(default)] + new_parent: Option, +} + +/// Operator-driven parent move. +/// +/// Form fields: `child` (required, agent name), `new_parent` +/// (optional — empty / absent string ⇒ promote to root). Refuses +/// cycles and unknown agents (surfaced async on the job view — this +/// handler only validates the identifiers, not the move itself). The +/// manager is reparentable like any other agent — its privileges come +/// from the privileged MCP socket, not its tree position. Submitting +/// re-emits the queue snapshot immediately so the dashboard shows the +/// queued move without a refresh; the tree itself repaints once the +/// commit lands. +#[utoipa::path( + post, + path = "/api/topology/set-parent", + responses( + (status = 200, description = "reparent queued", body = String), + (status = 400, description = "missing/invalid child or new_parent identifier"), + ), + tag = "topology" +)] +pub(super) async fn post_set_parent( + State(state): State, + Form(form): Form, +) -> Result { + let child = form.child.trim().to_owned(); + if child.is_empty() { + return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail("set-parent: `child` required")); + } + let child = hive_types::Ident::parse(&child) + .map_err(|e| error_problem(&format!("set-parent: `child` {e}")))?; + // Empty / whitespace-only `new_parent` ⇒ promote to root. Web + // forms submit the empty string for a "no value" radio button, + // so this is the ergonomic encoding. + let new_parent = form + .new_parent + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(hive_types::Ident::parse) + .transpose() + .map_err(|e| error_problem(&format!("set-parent: `new_parent` {e}")))?; + tracing::info!( + child = %child, + new_parent = ?new_parent, + "operator: set-parent via dashboard" + ); + state + .coord + .job_queue + .insert_job(|b| { + crate::job_queue::templates::reparent(b, vec![(child, new_parent)]); + Vec::new() + }) + .expect("template-declared shapes are acyclic"); + state.coord.emit_rebuild_queue_snapshot(); + Ok((StatusCode::OK, "ok").into_response()) +} + +/// Move multiple agents in a +/// single request, producing **one** git commit. +/// +/// JSON body: `[{"child":"name", "new_parent":"target-or-null"}, ...]`. +/// Empty array is a no-op (200 OK). First identifier that fails to +/// parse aborts the whole batch before anything is submitted — a +/// partially-invalid bulk move never reaches the queue. +#[utoipa::path( + post, + path = "/api/topology/set-parent-bulk", + responses( + (status = 200, description = "reparents queued", body = String), + (status = 400, description = "an invalid child identifier in the batch"), + ), + tag = "topology" +)] +pub(super) async fn post_set_parent_bulk( + State(state): State, + axum::Json(body): axum::Json>, +) -> Result { + if body.is_empty() { + return Ok((StatusCode::OK, "ok").into_response()); + } + // Collect into `Result<_, String>` first, not `ProblemDetails` directly — + // clippy::result_large_err flags a ~232-byte `Err` variant threaded + // through this closure's `?`. `String` is small enough to satisfy the + // lint; the single `map_err` below promotes it to a `ProblemDetails` + // once, after the fallible collect. + let moves = body + .iter() + .map(|e| { + let child = hive_types::Ident::parse(e.child.trim()) + .map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?; + let new_parent = e + .new_parent + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(hive_types::Ident::parse) + .transpose() + .map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?; + Ok((child, new_parent)) + }) + .collect::, String>>() + .map_err(|e| error_problem(&e))?; + let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect(); + tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard"); + state + .coord + .job_queue + .insert_job(|b| { + crate::job_queue::templates::reparent(b, moves); + Vec::new() + }) + .expect("template-declared shapes are acyclic"); + state.coord.emit_rebuild_queue_snapshot(); + Ok((StatusCode::OK, "ok").into_response()) +} diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index d7f82ce0..27309eba 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -60,7 +60,7 @@ pub(super) async fn run_node( kind: &NodeKind, ) -> (super::JobBuilder, Result<()>) { // The agent this node targets rides the payload — empty for the agentless - // kinds (`MetaLock`), which never read it. + // kinds (`MetaLock`, `Reparent`), which never read it. let agent = kind.agent(); // Every arm is `Result<()>`; the three that grow work declare into `builder` // *synchronously*, after their own awaits have finished. Borrowing `&builder` @@ -120,6 +120,7 @@ pub(super) async fn run_node( // takes it directly instead of re-matching the kind behind a `bail!` // that could never fire. NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await, + NodeKind::Reparent { moves } => run_reparent(coord, moves).await, NodeKind::MergeVerify { approval_id, .. } => { run_merge_verify(coord, *approval_id, id).await } @@ -814,6 +815,33 @@ async fn run_write_perm_file( Ok(()) } +/// Apply the node's `(child, new_parent)` moves as one `META_LOCK`-fused +/// commit (`Coordinator::reparent_bulk_with_notify`, which already handles +/// both the single- and bulk-move case, sends the per-agent move +/// notifications, and rescans + diff-emits the container tree). Runs under +/// the deploy window (it declares `Resource::MetaWindow`), same reasoning as +/// `run_write_perm_file`: a topology commit landing inside another node's +/// staged deploy window would sweep the staged lock into its commit. +async fn run_reparent( + coord: &Arc, + moves: &[(hive_types::Ident, Option)], +) -> Result<()> { + let refs: Vec<(&str, Option<&str>)> = moves + .iter() + .map(|(child, parent)| { + ( + child.as_str(), + parent.as_ref().map(hive_types::Ident::as_str), + ) + }) + .collect(); + coord + .reparent_bulk_with_notify(&refs) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(()) +} + /// Deploy phase 1 — drift gate, fetch, eval-verify. Mutates nothing, so a /// failure here cancel-cascades the rest of the subtree with the forge and the /// applied repo exactly as they were. @@ -862,9 +890,7 @@ async fn run_deploy_tail( /// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty /// `inputs` or any input under `hyperhive` → every container; /// otherwise just the agents named by `agent-` inputs. -/// Sorted by name — the parent field the old depth sort keyed on is -/// gone, and with every agent a root that sort already reduced to -/// this. +/// Topology-sorted so parents rebuild before their children. /// /// `inputs` is the caller-supplied flake-input-name list (the dashboard's /// `POST /api/meta-update` form field, operator-supplied but not @@ -892,7 +918,8 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec { } else { touched_agents }; - names.sort(); + let topo = crate::topology::read(); + crate::auto_update::topology_sort(&mut names, &topo); names } diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index eda0d9f5..0ee175b3 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -102,6 +102,12 @@ pub enum NodeKind { WriteDropin { agent: String }, /// Commit `tool-groups.json` / `capabilities.json` per its `payload`. WritePermFile { agent: String, payload: PermPayload }, + /// Topology move(s) — `set-parent` (len 1) or `set-parent-bulk` (len N) — + /// as a single queue node. `moves` is typed `(Ident, Option)` + /// pairs, applied in order under one `META_LOCK` acquisition. + Reparent { + moves: Vec<(hive_types::Ident, Option)>, + }, /// Group root of the approval-deploy (`MergeConfigPr`) subtree — the /// **brace** that owns the deploy window. See _Braces_ and _Approvals_. DeployWindow { agent: String, approval_id: i64 }, @@ -197,6 +203,7 @@ impl hive_jobq_wire::WireNode for NodeKind { impl NodeKind { /// The agent this node targets, or `""` for agentless kinds /// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, + /// [`NodeKind::Reparent`] which can span multiple agents, and /// [`NodeKind::ResolveApproval`] which acts on an approval row). #[must_use] pub fn agent(&self) -> &str { @@ -229,6 +236,7 @@ impl NodeKind { | NodeKind::EmitRebuilt { agent, .. } | NodeKind::SetWanted { agent, .. } => agent, NodeKind::MetaLock { .. } + | NodeKind::Reparent { .. } | NodeKind::ResolveApproval { .. } | NodeKind::ForgeSweep | NodeKind::MatrixSweep diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 64eb09f5..7e5575d4 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -597,6 +597,27 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec, approval_id: Optio } } +/// Topology move(s) as a single-node DAG. `moves` is `(child, new_parent)` +/// pairs — len 1 for `set-parent`, len N for `set-parent-bulk`, applied +/// uniformly by the one [`NodeKind::Reparent`] node (which holds the global +/// meta window for its duration, same precedent as [`NodeKind::WritePermFile`]). +/// No rebuild subgraph: `topology.json` is read live by every consumer +/// (dashboard tree, ``/`` sentinel routing, permission +/// checks), so a parent move needs no container rebuild to take effect. +/// No transient pill either — the node is agentless (no lease to hang one +/// off of) and near-instant. No tail node: the write is the whole effect. +/// +/// Returns the single node's guid so a caller can wait on it. +pub fn reparent( + builder: &JobBuilder, + moves: Vec<(hive_types::Ident, Option)>, +) -> hive_jobq::NodeGuid { + builder + .node(NodeKind::Reparent { moves }) + .needs(Resource::MetaWindow) + .guid() +} + // The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree` // as ONE `Boot` DAG (a sweep `MetaLock` root that grows rebuild subgraphs // in-DAG, plus a `Reconcile` root per drifted agent) — no anchor node and no diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index b18fd019..bbf06d45 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -49,6 +49,10 @@ fn insert_named( .collect() } +fn ident(s: &str) -> hive_types::Ident { + hive_types::Ident::parse(s).expect("valid test ident") +} + fn rebuild(builder: &JobBuilder, agent: &str) -> Vec { templates::rebuild(builder, agent, true) } @@ -182,6 +186,14 @@ fn node_of(q: &JobQueue, kind: &str) -> hive_jobq::NodeId { found.pop().expect("checked above") } +/// The payload of the one node of `kind`, for assertions about what a node +/// *carries* rather than how it is wired. +fn payload_of(q: &JobQueue, kind: &str) -> NodeKind { + let id = node_of(q, kind); + let sched = q.sched().lock().expect("job_queue mutex poisoned"); + sched.graph().node(id).expect("node exists").payload.clone() +} + /// Kinds of every node still `Pending` — the nodes that could yet run. /// Stronger than asking the scheduler what is *ready right now*: a node /// blocked on a dep is not ready but is very much still alive. @@ -1773,3 +1785,48 @@ fn perm_change_shape_prefixes_rebuild_chain() { "the perm write prefixes an otherwise ordinary rebuild chain" ); } + +#[test] +fn reparent_shape_is_a_lone_agentless_meta_window_node() { + // Single-move `set-parent` shape: one node, no rebuild subgraph (no + // container rebuild needed for a parent move), agentless like + // `MetaLock`, and it must declare the meta window — a topology commit + // must not land inside another node's staged deploy window. + let q = JobQueue::new(1); + insert(&q, |builder| { + templates::reparent(builder, vec![(ident("alice"), Some(ident("bob")))]); + }); + assert_eq!( + declared_shape(&q), + vec![row("reparent", None, &[])], + "one node, no rebuild subgraph" + ); + let node = node_of(&q, "reparent"); + assert_eq!( + declared_resources(&q, node), + vec![Resource::MetaWindow], + "a topology commit must declare the same MetaWindow as WritePermFile, \ + and nothing else — no lease (agentless), no build slot (no nix work)" + ); +} + +#[test] +fn reparent_bulk_shape_carries_every_move_on_one_node() { + // `set-parent-bulk`: still ONE node (one git commit, `moves.len() > 1`), + // not one node per move — bulk atomicity across every move in the + // request is the reason a single node was chosen in the first place. + let moves = vec![(ident("alice"), Some(ident("bob"))), (ident("carol"), None)]; + let q = JobQueue::new(1); + insert(&q, |builder| { + templates::reparent(builder, moves.clone()); + }); + assert_eq!( + declared_shape(&q), + vec![row("reparent", None, &[])], + "one node for the whole request, not one per move" + ); + let NodeKind::Reparent { moves: got } = payload_of(&q, "reparent") else { + panic!("expected a Reparent node"); + }; + assert_eq!(got, moves, "every move rides the single node"); +} diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 4bf1da77..88aa6d3e 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -77,8 +77,7 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; /// clone is where a config change is staged, so it can hold a proposal /// that is still under review or was rejected outright — mounting it shows /// an agent a config which does not govern it. Both mounts (an agent's own -/// and a `ManageRootAgent` holder's view of another agent's) go through -/// here so they cannot drift. +/// and a parent's view of a child's) go through here so they cannot drift. /// /// Never empty under a live container: `provision_container` runs /// `setup_applied` before `create_only` makes the container at all. @@ -87,30 +86,26 @@ fn config_bind_source(name: &str) -> PathBuf { } /// Append bind flags for `child`'s state and config dirs into `binds`. -/// See docs/agent-lifecycle/persistence.md ("Cross-agent access to state") -/// for what the holder may touch and why. Creates missing host-side -/// directories so nspawn doesn't refuse to start; missing dirs are -/// non-fatal. -/// -/// The only caller left is the `ManageRootAgent` grant; the parent/child -/// tree that handed every agent its own children here is gone. +/// See docs/agent-lifecycle/persistence.md ("Parent access to child state") for what a +/// parent may touch and why. Creates missing host-side directories so +/// nspawn doesn't refuse to start; missing dirs are non-fatal. /// /// **Three dirs, three different answers** — the uniformity of the /// original loop is what hid that: /// -/// - `state` is **read-write**: the holder reads and writes another -/// agent's notes to recover it, which is the one case that needs to -/// work while that agent is down. -/// - `config` is **read-only**, and read-only for the *holder* is the -/// point: a config change is a PR against the other agent's repo on the +/// - `state` is **read-write**: a parent reads and writes a child's notes +/// to recover it, which is the one case that needs to work while the +/// child is down. +/// - `config` is **read-only**, and read-only for the *parent* is the +/// point: a config change is a PR against the child's repo on the /// forge, reviewed and merged, never an edit in place. A writable mount /// here is a second path to the same file that skips the review — the /// boundary would then be a convention rather than a permission. -/// - `harness` is **absent entirely**. It holds that agent's own runtime +/// - `harness` is **absent entirely**. It holds the child's own runtime /// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of -/// which the holder has a stated reason to read, let alone write. +/// which a parent has a stated reason to read, let alone write. /// hive-c0re still reads it directly on the host (`stats::hive_stats`), -/// which needs no bind mount into anyone else's container. +/// which needs no bind mount into the parent. /// /// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by /// the `config` flag and must not be read as a reason to widen it: that runs @@ -321,12 +316,21 @@ async fn set_nspawn_flags( read_only: true, }); - // `ManageRootAgent` capability: mount *every* agent in the hive as a - // virtual child. Enables recovery — the holder can update another - // agent's config even when that agent is down. Also grants RO access - // to /applied and /meta. + // Topology-driven child mounts: every direct child of this agent gets + // its state dir RW and its config dir RO. See `bind_child_agent_dirs` + // for why each is what it is. + let direct_children = crate::topology::children_of(agent_name); + for child in &direct_children { + bind_child_agent_dirs(child, &mut binds); + } + + // `ManageRootAgent` capability: additionally mount *every* agent in + // the hive as a virtual child. Enables recovery — the holder can + // update another agent's config even when that agent is down. Also + // grants RO access to /applied and /meta. // - // ⚠️ "every agent" is the definition of this capability, not an + // ⚠️ "every agent" reads as a widening next to the `children_of` + // mounts above, so: it is the definition of this capability, not an // accident of how the set is computed. The grant used to hang off a // `can_manage_top_level_agents` role and cover `top_level_agents()` // — i.e. `parent.is_none()` — which was "everything outside the @@ -334,18 +338,13 @@ async fn set_nspawn_flags( // parentless, so that set *was* every agent anyway; the capability // now says so out loud instead of deriving it from a field that no // longer discriminates. - // - // This is the *only* cross-agent mount left. Dropping the topology - // parent field took with it the unconditional grant every - // agent used to get over its own direct children — so an agent with - // no capability now sees its own dirs and nothing else. if crate::capabilities::has_cap(agent_name, Capability::ManageRootAgent) { // Skipping self is a no-op, not a narrowing: `agent_notes_dir` is // `agent_state_dir/state` and `config_bind_source` is shared, so // binding the holder as its own virtual child reproduced the two // own-dir mounts pushed above, byte for byte. for other in crate::topology::all_agents() { - if other != agent_name { + if other != agent_name && !direct_children.contains(&other) { bind_child_agent_dirs(&other, &mut binds); } } @@ -417,11 +416,10 @@ mod tests { binds } - /// The boundary, asserted as a whole rather than per-dir: a - /// `ManageRootAgent` holder sees another agent's `state` and - /// `config`, and nothing else. + /// The boundary, asserted as a whole rather than per-dir: a parent + /// sees a child's `state` and `config`, and nothing else. #[test] - fn holder_sees_only_other_agents_state_and_config() { + fn parent_sees_only_child_state_and_config() { let paths: Vec = child_binds() .into_iter() .map(|b| b.container_path) @@ -450,21 +448,21 @@ mod tests { ); } - /// The regression this exists for. `harness` holds the other agent's - /// own runtime material and was only ever mounted because one loop + /// The regression this exists for. `harness` holds the child's own + /// runtime material and was only ever mounted because one loop /// treated all three dirs alike — re-adding it to that loop is a /// one-word change that nothing else would catch. #[test] - fn a_holder_never_sees_another_agents_harness_dir() { + fn parent_never_sees_a_child_harness_dir() { for bind in child_binds() { assert!( !bind.container_path.contains("harness"), - "another agent's harness must not be bound in: {}", + "child harness must not be bound into a parent: {}", bind.container_path ); assert!( !bind.host_path.contains("harness"), - "another agent's harness must not be bound in: {}", + "child harness must not be bound into a parent: {}", bind.host_path ); } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 3f26dd4b..e6c69200 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -143,8 +143,11 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let ca_touched = materialise_ca_files(&dir, &ca_files)?; // Reconcile topology.json against the live agent set — adds - // newly-spawned agents and drops removed ones. Idempotent; when - // nothing changed the file isn't touched. + // entries for newly-spawned agents (default: manager as parent, + // manager itself as root) and drops removed agents. Operator + // overrides via the write API are preserved because reconcile + // only fills in missing entries. Idempotent; when nothing changed + // the file isn't touched. let agent_names: Vec = agents.iter().map(|a| a.name.clone()).collect(); let pending: Vec = crate::coordinator::Coordinator::pending_init_names() .into_iter() @@ -570,6 +573,85 @@ pub async fn commit_perms( Ok(()) } +/// Applies every `(child, new_parent)` move under a single `META_LOCK` +/// acquisition and creates **one** git commit for all of them — a +/// single-move call is just a one-element slice, so there's no separate +/// non-batch entry point. Moves are applied in the order given; the first +/// validation error short-circuits the whole batch. True atomic write: all +/// moves are pre-validated against a cumulative in-memory state with +/// [`crate::topology::apply_set_parent`] before anything touches disk, then +/// [`crate::topology::write`] is called exactly once. If any move fails +/// validation the topology file is never modified. +/// +/// The multi-move commit message uses `moves[0].1` as the destination label. +/// This is intentional: the dashboard bulk-move UI always sends a single +/// destination for all selected agents, so the message is always accurate in +/// practice. +/// +/// Returns a `Vec` of `(child, old_parent)` pairs for every move that +/// actually changed the topology (idempotent same-parent moves are skipped), +/// so the caller can send targeted notifications. +/// +/// # Errors +/// +/// Returns a `String` error if any move fails validation (cycle, unknown +/// agent, etc.) or if the topology file cannot be written. Git-commit failure +/// is logged as a warning and does not propagate — `sync_agents` will recover. +pub async fn bulk_commit_topology( + moves: &[(&str, Option<&str>)], +) -> std::result::Result)>, String> { + if moves.is_empty() { + return Ok(vec![]); + } + let _guard = META_LOCK.lock().await; + // Snapshot parents before any writes so we can compute the diff. + let topo_before = crate::topology::read(); + // Validate all moves against a cumulative in-memory state -- no disk + // writes yet; first error aborts with the topology file untouched. + let mut next = topo_before.clone(); + for (child, new_parent) in moves { + next = crate::topology::apply_set_parent(&next, child, *new_parent)?; + } + // Only flush to disk if something actually changed. + if next != topo_before { + crate::topology::write(&next).map_err(|e| format!("{e:#}"))?; + } + // Commit the whole batch as one git operation. + let dir = crate::paths::meta_root(); + let commit_msg = if moves.len() == 1 { + let (child, new_parent) = moves[0]; + format!("topology: {} → {}", child, new_parent.unwrap_or("")) + } else { + let names: Vec<&str> = moves.iter().map(|(c, _)| *c).collect(); + let dest = moves[0].1.unwrap_or(""); + format!( + "topology: move {} agents → {} ({})", + moves.len(), + dest, + names.join(", ") + ) + }; + let stage = async { + git(&dir, &["add", "topology.json"]).await?; + if paths_dirty(&dir, &["topology.json"]).await? { + git_commit_paths(&dir, &commit_msg, &["topology.json"]).await?; + } + Ok::<_, anyhow::Error>(()) + }; + if let Err(e) = stage.await { + tracing::warn!(error = ?e, "bulk_commit_topology: topology written but git commit failed (sync_agents will recover)"); + } + // Return (child, old_parent) for each move that changed state. + let changed = moves + .iter() + .filter_map(|(child, new_parent)| { + let old = topo_before.get(*child).cloned().flatten(); + (old.as_deref() != *new_parent).then_some((child.to_string(), old)) + }) + .collect(); + Ok(changed) +} + #[allow( clippy::too_many_arguments, reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \ @@ -1081,7 +1163,7 @@ where let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!( out, - " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:" + " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:" ); out.push_str( r#" let @@ -1090,6 +1172,7 @@ where else hyperhive.nixosConfigurations.agent-base; input = inputs."agent-${name}"; service = "hive-agent"; + parentEnv = if parent == null then {} else { HIVE_PARENT = parent; }; toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; }; capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; }; in @@ -1270,7 +1353,7 @@ where } out.push_str( r" }; - systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // { + systemd.services.${service}.environment = parentEnv // toolGroupsEnv // capabilitiesEnv // { HIVE_PORT = toString port; HIVE_LABEL = name; HIVE_DASHBOARD_PORT = toString dashboardPort; @@ -1316,10 +1399,19 @@ where nixosConfigurations = { "#, ); + // Pull the topology map once and look up each agent's parent. An + // empty / absent topology.json yields `parent = null` for everyone + // (every container at root). `meta::sync_agents` seeds the file + // on first run with manager as root + everyone else under manager. + let topology = crate::topology::read(); let tool_groups_map = crate::tool_groups::read(); let capabilities_map = crate::capabilities::read(); let resource_limits_map = crate::resource_limits::read(); for spec in agents { + let parent_attr = topology + .get(&spec.name) + .and_then(|p| p.as_ref()) + .map_or_else(|| "null".to_owned(), |p| format!("\"{p}\"")); // Emit `toolGroups = "group1,group2"` when the operator has // explicitly configured groups for this agent. Absent entry = null // = harness falls back to AGENT_DEFAULT (no env var emitted, @@ -1359,11 +1451,12 @@ where .map_or_else(|| "null".to_owned(), |b| b.to_string()); let _ = writeln!( out, - " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};", + " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};", spec.name, spec.name, if spec.is_manager { "true" } else { "false" }, spec.port, + parent_attr, tool_groups_attr, capabilities_attr, memory_max_attr, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 0d0e3440..e9cbbf08 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -207,6 +207,26 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { actions::deny(&coord, *id, None)?; HostResponse::success() } + HostRequest::SetParent { child, new_parent } => { + tracing::info!(%child, ?new_parent, "set_parent"); + // Fire-and-forget, like every other queue-backed op: + // submit returns a DAG id immediately, the caller polls + // `QueueDag` (`hivectl`'s wait/progress loop) for the + // outcome instead of blocking here on the commit. + let inserted = coord.job_queue.insert_job(|b| { + vec![crate::job_queue::templates::reparent( + b, + vec![(child.clone(), new_parent.clone())], + )] + }); + match inserted { + Ok(ids) => { + coord.emit_rebuild_queue_snapshot(); + HostResponse::queued(ids.into_iter().map(hive_jobq::NodeId::get).collect()) + } + Err(e) => HostResponse::error(format!("queue reparent: {e}")), + } + } HostRequest::SetResourceLimits { name, cpu_quota, diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 85ed2523..1ce0c746 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -712,9 +712,36 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> { } } +/// Fan out one message to each recipient in `targets`. Skips the sender +/// itself. Returns a list of `": "` strings for any delivery +/// failures (empty = all good). +pub(crate) fn fan_out_send( + coord: &Arc, + from: &str, + body: &str, + in_reply_to: Option, + targets: &[String], +) -> Vec { + let mut errors = Vec::new(); + for target in targets { + if target == from { + continue; + } + if let Err(e) = coord.broker.send(&Message { + from: hive_sh4re::manager::trusted_sender(from), + to: target.clone(), + body: body.to_owned(), + in_reply_to, + }) { + errors.push(format!("{target}: {e}")); + } + } + errors +} + /// Common Send handler shared between dispatch arms. Applies the -/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through -/// their respective broker calls. +/// 4 KiB body cap, then routes broadcast (`to == "*"`) / children fan-out +/// (`to == ""`) / unicast through their respective broker calls. /// `pub(crate)` so `dispatch_shared` can use it across both socket paths. pub(crate) fn handle_send( coord: &Arc, @@ -736,30 +763,50 @@ pub(crate) fn handle_send( } }; } - // Validate that the recipient is a known local agent or the + // ``: fan out to every direct descendant of the sender per + // topology.json. Bypasses the allow-list check — structural fan-out + // targets are never user-listed peers. No-op (returns Ok) for leaf + // agents that have no children. + if to == hive_sh4re::manager::CHILDREN_RECIPIENT { + let children = crate::topology::children_of(agent); + let errors = fan_out_send(coord, agent, body, in_reply_to, &children); + return if errors.is_empty() { + Response::Ok + } else { + Response::Err { + message: format!("children fan-out failed for agents: {}", errors.join(", ")), + } + }; + } + // Resolve magic-recipient sentinels (``) against topology.json; + // no-op for ordinary names. Lets agents address structural roles without + // learning the label — runtime reparenting propagates for free. See + // `docs/process/conventions.md::Recipient sentinels`. + let resolved = crate::topology::resolve_recipient(agent, to); + // Validate that the resolved recipient is a known local agent or the // special "operator" recipient. Without this check a typo in `to` // silently queues a message nobody will ever read. // // Cross-hive messaging (`name@hive` qualified names) is not routed // through the broker — use the Matrix MCP tools for that instead. - if to.contains('@') { + if resolved.contains('@') { return Response::Err { message: format!( - "send failed: cross-hive recipient `{to}` is not supported \ + "send failed: cross-hive recipient `{resolved}` is not supported \ via the broker — use Matrix MCP tools for cross-hive messaging" ), }; } - if to != hive_sh4re::manager::OPERATOR_RECIPIENT { + if resolved != hive_sh4re::manager::OPERATOR_RECIPIENT { // A name that doesn't parse as an Ident can't be a local agent, so // it collapses into the same "unknown recipient" error as a valid // name with no state dir. - let exists = hive_types::Ident::parse(to) + let exists = hive_types::Ident::parse(&resolved) .is_ok_and(|id| crate::paths::agent_state_dir(&id).exists()); if !exists { return Response::Err { message: format!( - "send failed: unknown recipient `{to}` \ + "send failed: unknown recipient `{resolved}` \ (no agent with that name exists on this hive)" ), }; @@ -767,7 +814,7 @@ pub(crate) fn handle_send( } match coord.broker.send(&Message { from: hive_sh4re::manager::trusted_sender(agent), - to: to.to_owned(), + to: resolved, body: body.to_owned(), in_reply_to, }) { diff --git a/hive-c0re/src/stats/host_stats.rs b/hive-c0re/src/stats/host_stats.rs index 76b6e1a1..56459f0f 100644 --- a/hive-c0re/src/stats/host_stats.rs +++ b/hive-c0re/src/stats/host_stats.rs @@ -252,6 +252,7 @@ mod tests { needs_update: false, needs_login, deployed_sha: None, + parent: None, active_model: None, status_text: None, status_set_at: None, diff --git a/hive-c0re/src/stores/broker.rs b/hive-c0re/src/stores/broker.rs index f2df9265..7a4f54a0 100644 --- a/hive-c0re/src/stores/broker.rs +++ b/hive-c0re/src/stores/broker.rs @@ -355,6 +355,83 @@ impl Broker { Ok(u64::try_from(n.max(0)).unwrap_or(0)) } + /// Send a "your parent changed from X to Y" notification to `child`, + /// coalescing with any existing undelivered one so that multiple moves + /// while the agent is offline collapse into a single message spanning + /// the full arc (e.g. A→B then B→C becomes "your parent changed from A to C"). + /// + /// If an undelivered system reparent notification for `child` already + /// exists, its body is updated in-place preserving the original "from" + /// label. If none exists, a fresh message is inserted with `old_label` + /// as the source. + pub fn send_coalescing_reparent( + &self, + child: &str, + old_label: &str, + new_label: &str, + ) -> Result<()> { + const PREFIX: &str = "your parent changed from "; + const SEPARATOR: &str = " to "; + let conn = self.conn.lock().unwrap(); + let now = Utc::now().timestamp(); + let existing: Option<(i64, String)> = conn + .query_row( + "SELECT id, body FROM messages + WHERE recipient = ?1 + AND sender = ?2 + AND body LIKE ?3 + AND delivered_at IS NULL + AND acked_at IS NULL + LIMIT 1", + params![ + child, + hive_sh4re::manager::SYSTEM_SENDER, + format!("{PREFIX}%") + ], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + if let Some((row_id, old_body)) = existing { + // Preserve the original "from" label from the earlier notification. + let original_from = old_body + .strip_prefix(PREFIX) + .and_then(|rest| rest.split(SEPARATOR).next()) + .unwrap_or(old_label); + let new_body = format!("{PREFIX}{original_from}{SEPARATOR}{new_label}"); + conn.execute( + "UPDATE messages SET body = ?1, sent_at = ?2 WHERE id = ?3", + params![new_body, now, row_id], + )?; + drop(conn); + let _ = self.events.send(MessageEvent::Sent { + id: row_id, + from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(), + to: child.to_owned(), + body: new_body, + at: now, + in_reply_to: None, + }); + } else { + let body = format!("{PREFIX}{old_label}{SEPARATOR}{new_label}"); + conn.execute( + "INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to) + VALUES (?1, ?2, ?3, ?4, NULL)", + params![hive_sh4re::manager::SYSTEM_SENDER, child, body, now], + )?; + let row_id = conn.last_insert_rowid(); + drop(conn); + let _ = self.events.send(MessageEvent::Sent { + id: row_id, + from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(), + to: child.to_owned(), + body, + at: now, + in_reply_to: None, + }); + } + Ok(()) + } + /// Long-poll variant of `recv_batch`: returns immediately if any /// row is pending (popping up to `max`); otherwise waits up to /// `timeout` for the broker to emit a `Sent { to: recipient }` diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index bb510d7c..2e441ec0 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -250,6 +250,44 @@ fn seed_manager_capabilities() { } } +/// Sort `names` in-place so parents precede their children in the topology. +/// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last, +/// alphabetically within their tier. Stable within each depth tier. +pub fn topology_sort( + names: &mut [String], + topo: &std::collections::BTreeMap>, +) { + use std::collections::{HashMap, VecDeque}; + // Build depth map using owned clones so the borrow on `names` is released + // before the sort_by mutable borrow. + let name_set: Vec = names.to_vec(); + let mut depth: HashMap = HashMap::new(); + let mut queue: VecDeque = VecDeque::new(); + // Seed roots: entries with no parent, or names not present in topo at all. + for name in &name_set { + if topo.get(name).is_none_or(Option::is_none) { + depth.insert(name.clone(), 0); + queue.push_back(name.clone()); + } + } + // BFS to assign depths to children. + while let Some(parent) = queue.pop_front() { + let d = depth[&parent] + 1; + for name in &name_set { + let is_child = topo.get(name).and_then(|p| p.as_deref()) == Some(parent.as_str()); + if is_child && !depth.contains_key(name) { + depth.insert(name.clone(), d); + queue.push_back(name.clone()); + } + } + } + names.sort_by(|a, b| { + let da = depth.get(a).copied().unwrap_or(usize::MAX); + let db = depth.get(b).copied().unwrap_or(usize::MAX); + da.cmp(&db).then(a.cmp(b)) + }); +} + /// Boot reconcile (see the module doc): classify every agent by rev /// freshness + persisted `wanted` intent, submit one `Boot` DAG /// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale @@ -267,16 +305,16 @@ pub async fn run(coord: Arc) -> Result<()> { let current_rev = current_flake_rev(&coord.hyperhive_flake); - // Resolve container names to logical agent names, then sort. The - // parent field this used to depth-sort by is gone; with no - // hierarchy left to respect, alphabetical is the whole order — and it - // is exactly what the depth sort already produced once every agent - // was a root. + // Resolve container names to logical agent names, then sort by + // topology depth so parents are always rebuilt before their + // children. Root agents (depth 0) go first; agents absent from + // the topology file sort last (stable, alphabetical within tier). let mut logical_names: Vec = containers .iter() .filter_map(|c| c.strip_prefix(AGENT_PREFIX).map(str::to_owned)) .collect(); - logical_names.sort(); + let topo = crate::topology::read(); + topology_sort(&mut logical_names, &topo); // Classify. `get_or_seed` doubles as the one-time migration: an // agent without an `agent_power` row is seeded from its observed @@ -336,8 +374,8 @@ pub async fn run(coord: Arc) -> Result<()> { // Rebuild running agents first. All fanout entries are wanted=Up; // among them, warm the live/serving agents onto the fresh config before the // stopped-but-wanted-up ones so the scarce build slots hit uptime-critical - // agents first. Stable sort keeps the alphabetical order within each - // running/stopped group. The `drifted` reconciles aren't sorted + // agents first. Stable sort keeps topology order (parents before children) + // within each running/stopped group. The `drifted` reconciles aren't sorted // — they hold no build slot and run concurrently, so their order is moot. fanout.sort_by_key(|(_, running)| !running); let fanout: Vec = fanout.into_iter().map(|(name, _)| name).collect(); diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 2e9dcae0..496afed2 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -202,7 +202,7 @@ pub enum HostRequest { /// credentials into the existing state tree. AgentExists { name: Ident }, /// List managed agents with their full status + technical state - /// (running / needs-login / needs-update / deployed sha / + /// (running / needs-login / needs-update / deployed sha / parent / /// pending reminders) — the `hivectl list-agents` roster view. /// Reuses the dashboard's per-agent `ContainerView` aggregation. AgentStatus, @@ -247,6 +247,14 @@ pub enum HostRequest { Approve { id: i64 }, /// Deny a pending request by id. Deny { id: i64 }, + /// Move an agent in the topology tree. `new_parent = None` + /// promotes the agent to root, `Some(name)` sets a new parent. + /// Validation rules + bind-mount caveat documented in + /// `docs/agent-lifecycle/agent-hierarchy.md::Reparenting`. + SetParent { + child: Ident, + new_parent: Option, + }, /// Declare an agent's CPU/memory overrides for the per-container /// systemd drop-in, persisted to `meta/resource-limits.json`. /// diff --git a/hive-sh4re/src/container.rs b/hive-sh4re/src/container.rs index f928ad8b..33ce770d 100644 --- a/hive-sh4re/src/container.rs +++ b/hive-sh4re/src/container.rs @@ -53,6 +53,9 @@ pub struct AgentStatusRow { /// paused while stopped, and pause survives a restart. #[serde(default)] pub paused: bool, + /// Parent in the topology tree. `None` marks a root-level agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, /// The Claude model the agent's harness is currently using. Mirrors /// `container_view::ContainerView::active_model`: `None` when the /// agent has never started a turn, the field is absent from its diff --git a/hive-sh4re/src/manager.rs b/hive-sh4re/src/manager.rs index c21ed357..9323f2da 100644 --- a/hive-sh4re/src/manager.rs +++ b/hive-sh4re/src/manager.rs @@ -17,6 +17,23 @@ pub const MANAGER_AGENT: &str = "ruth"; /// dashboard's inbox view — they are never `recv`'d by an agent harness. pub const OPERATOR_RECIPIENT: &str = "operator"; +/// Reserved magic recipient — `send(to: "", ...)` is rewritten +/// by hive-c0re at delivery time to whoever `topology::parent_of(sender)` +/// returns, or to [`OPERATOR_RECIPIENT`] when the sender is a root agent +/// (no parent). Lets agents address their parent without hardcoding the +/// label, so runtime reparenting requires no agent-side restart. The +/// angle brackets are not valid in agent names (validators reject +/// `<`/`>`), so this name can never collide with a real recipient. +pub const PARENT_RECIPIENT: &str = ""; + +/// Reserved magic recipient — `send(to: "", ...)` fans out to +/// every agent whose direct parent (per `topology.json`) is the sender. +/// Lets a sub-manager nudge its subtree without enumerating labels at +/// call-time; topology changes propagate for free. The angle brackets +/// are structurally safe — agent name validation rejects `<`/`>`. +/// Delivers to an empty set (no-op) for leaf agents that have no children. +pub const CHILDREN_RECIPIENT: &str = ""; + /// Sender hive-c0re uses for events it pushes into the manager's inbox. /// Manager harness recognises this and parses the body as a `HelperEvent`. pub const SYSTEM_SENDER: &str = "system"; @@ -96,7 +113,9 @@ pub struct SchedulePromptPayload { #[cfg(test)] mod reserved_name_tests { - use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER}; + use super::{ + CHILDREN_RECIPIENT, MANAGER_AGENT, OPERATOR_RECIPIENT, PARENT_RECIPIENT, SYSTEM_SENDER, + }; use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name}; /// The blacklist as nix rendered it for this test run. @@ -163,6 +182,24 @@ mod reserved_name_tests { } } + /// The other half, and the reason the test above is not vacuous: these + /// sentinels are *unreachable* as agent names because the charset + /// rejects them, so they are correctly absent from the list. If a + /// charset change ever made one parseable, it would become a real + /// collision and this test is what notices. + #[test] + fn bracketed_recipients_cannot_be_agent_names() { + let owned = reserved(); + let reserved: Vec<&str> = owned.iter().map(String::as_str).collect(); + for sentinel in [PARENT_RECIPIENT, CHILDREN_RECIPIENT] { + assert!( + Ident::parse(sentinel).is_err(), + "{sentinel:?} parses as an ident now — it is reachable as an agent name and must be reserved" + ); + assert!(!is_reserved_name(sentinel, &reserved)); + } + } + /// `ruth` is a real agent, not a protocol literal, so it is not /// reserved: a second agent wanting the name is a *taken* name, which /// the roster check answers. Recorded as a test so the distinction is diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index 66c77215..a4eda3d0 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -1,6 +1,6 @@ //! `hivectl agent ` — everything scoped to ONE managed agent: //! container lifecycle over the host admin socket (restart/pause/resume/ -//! spawn/kill/destroy/rebuild/set-limits/choom/watch), plus the +//! spawn/kill/destroy/rebuild/set-parent/set-limits/choom/watch), plus the //! `quota` and `subvol` groups, whose handlers live in their own modules. //! `agents_list` (`hivectl list-agents`) is the one genuinely hive-wide //! read that lives in this module too since it shares the same daemon @@ -172,14 +172,15 @@ pub(crate) async fn agents_list(socket: &Path, json: bool) -> Result<()> { } s }; - let headers = ["NAME", "STATUS", "REV", "REMIND"]; - let table: Vec<[String; 4]> = rows + let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"]; + let table: Vec<[String; 5]> = rows .iter() .map(|r| { [ r.name.clone(), status_of(r), r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()), + r.parent.clone().unwrap_or_else(|| "-".to_owned()), if r.pending_reminders > 0 { r.pending_reminders.to_string() } else { @@ -290,6 +291,18 @@ pub(crate) async fn run_agent(socket: &Path, name: &str, cmd: AgentCmd) -> Resul let name = crate::util::parse_ident(name)?; render(crate::client::request(socket, HostRequest::Rebuild { name }).await?) } + AgentCmd::SetParent { parent, root } => { + let child = crate::util::parse_ident(name)?; + let new_parent = if root { + None + } else { + parent.map(|p| crate::util::parse_ident(&p)).transpose()? + }; + render( + crate::client::request(socket, HostRequest::SetParent { child, new_parent }) + .await?, + ) + } AgentCmd::SetLimits { cpu_quota, memory_max, diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index a4de9784..542c36e6 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -507,6 +507,16 @@ pub enum AgentCmd { }, /// Apply pending config to this managed container. Rebuild, + /// Move this agent in the topology tree — under a new parent, or to + /// root. + SetParent { + /// New parent agent name. Mutually exclusive with `--root`. + #[arg(long, conflicts_with = "root", required_unless_present = "root")] + parent: Option, + /// Promote this agent to root (no parent). + #[arg(long)] + root: bool, + }, /// Declare this agent's CPU/memory limits, overriding the hive-wide /// defaults. /// diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix index bfa2259f..b806ac33 100644 --- a/nix/agent-modules/agent-service.nix +++ b/nix/agent-modules/agent-service.nix @@ -151,9 +151,9 @@ in Place the file inside the agent's bind-mounted **harness** dir (e.g. `/agents//harness/openrouter.env`, `$HYPERHIVE_HARNESS_DIR`), not `state/` — `harness/` survives container rebuilds exactly like - `state/` does, but is never bind-mounted into another agent's - container (unlike `state/`, which a `ManageRootAgent` holder gets - read-write for recovery — see `docs/agent-lifecycle/persistence.md`'s "Cross-agent access to + `state/` does, but is never bind-mounted into a parent agent's + container (unlike `state/`, which a parent gets read-write for child + recovery — see `docs/agent-lifecycle/persistence.md`'s "Parent access to child state"), so this credential is reachable by nothing but this agent and the host. Permissions should be `0600`, owned by the agent's unix user. Loaded with a leading `-` (optional `EnvironmentFile`), diff --git a/nix/agent-modules/user.nix b/nix/agent-modules/user.nix index 52424e00..19f66a11 100644 --- a/nix/agent-modules/user.nix +++ b/nix/agent-modules/user.nix @@ -183,21 +183,20 @@ in mkdir -p "$(dirname "$marker")" : > "$marker" # Scope state + harness chowns to THIS container's own dirs only. - # The glob `/agents/*/state` also matches other agents' state dirs - # bind-mounted into a `ManageRootAgent` holder's container, which - # would clobber the ownership those dirs' own activation scripts set - # — producing intermittent EACCES for that agent's harness between - # the holder's rebuild and its own next activation. Config dirs are - # kept broad because the holder legitimately owns the proposed-config - # repos it edits. + # The glob `/agents/*/state` also matches child-agent state dirs that + # are bind-mounted into parent containers, which would clobber the + # ownership those dirs' own activation scripts set — producing + # intermittent EACCES for the child agent's harness between a parent + # rebuild and the child's next activation. Config dirs are kept broad + # because the parent legitimately owns child proposed-config repos. if [ -d "/agents/$userName/state" ]; then chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true fi if [ -d "/agents/$userName/harness" ]; then chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true fi - # The proposed-config repo is RW-mounted into the editing agent and - # owned by it; hive-c0re only pulls from it. Heal + # The proposed-config repo is RW-mounted into the editing (parent/ + # manager) agent and owned by it; hive-c0re only pulls from it. Heal # it to this user too — same as state/harness. In an agent's own # container its config is RO-mounted, so the chown there just fails # harmlessly (|| true). diff --git a/nix/reserved-names.nix b/nix/reserved-names.nix index bfc26857..95599b28 100644 --- a/nix/reserved-names.nix +++ b/nix/reserved-names.nix @@ -60,7 +60,11 @@ # against hive names, in `swarm-otel.nix`'s own `reservedOwners`. "swarm" ] -# Deliberately absent, and it is a load-bearing omission: +# Deliberately absent, and both are load-bearing omissions: +# +# `` / `` — routing recipients the ident charset already +# rejects, so no name can ever equal them; listing them would imply a guard +# that never fires. # # `ruth` — a real agent, not a literal. A second agent wanting that name is a # name that is TAKEN, which the roster answers, not this list.