Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
43 changed files with 1790 additions and 415 deletions

View file

@ -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

View file

@ -1,97 +1,109 @@
# Agent roster & privileges
# Agent hierarchy & privileges
<!-- vale write-good.Passive = NO -->
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.
<!-- vale write-good.Passive = YES -->
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 <child> set-parent --parent <new>` (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<String> }`.
`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 |
| -------------------------------------------- | --------------------------------------------------- |
| `<parent>` recipient sentinel | address `operator` directly |
| `<children>` fan-out recipient | nothing — name the recipients, or broadcast to `*` |
| `hivectl agent <name> 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)
<!-- vale write-good.Passive = NO -->
| 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.
<!-- vale write-good.Passive = YES -->
"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: <X>` 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: <X>` 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/<descendant>/`
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)

View file

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

View file

@ -335,29 +335,24 @@ Under `/var/lib/hyperhive/agents/<name>/`:
- `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.
<!-- vale write-good.Passive = NO -->
**`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.
<!-- vale write-good.Passive = NO -->
⚠️ 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.
<!-- vale write-good.Passive = YES -->
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/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never
@ -392,11 +387,13 @@ Contents:
`nixosConfigurations.<n>` 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 `<parent>` / `<children>` 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

View file

@ -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)

View file

@ -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.
- `<parent>` — the sender's parent per `topology.json`. Rewritten at
send time by `topology::resolve_recipient`: looks up
`parent_of(sender)` and falls back to `operator` when the sender is
a root agent (or absent from topology entirely). Lets agents address
their parent without learning the label, so runtime reparenting
propagates with zero agent-side restart.
- `<children>` — 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.
`<parent>` and `<children>` 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
`<parent>`, and name the recipients (or broadcast to `*`) where you
would have said `<children>`. Nothing rewrites a recipient at send time
any more — what an agent passes is what the broker stores.
When a `<children>` or `<parent>` 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

View file

@ -55,36 +55,37 @@ Cheap — no build slot:
<!-- vale write-good.Passive = NO -->
| 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/<id>` 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#<name>` 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/<id>`, 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/<id>` 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#<name>` 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/<id>`, 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<Ident>)` 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 |
<!-- vale write-good.Passive = YES -->
@ -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

View file

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

View file

@ -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 <name> 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 <name>` 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 <name> restart`

View file

@ -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 `<parent>` 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] \`<qualified-label>\` claude turn failed:\n<err>` to `operator` via `send_to_operator` |
| `Err(Failed(err))` | route `[system] \`<qualified-label>\` claude turn failed:\n<err>` to `<parent>` 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

View file

@ -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: "<parent>"` 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 #<id>]` (broker row id; note the highest id seen, then pass

View file

@ -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 `<span>`
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=<name>&new_parent=<target>`)
- **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)
<!-- vale write-good.Passive = YES -->
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

View file

@ -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 ›

View file

@ -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=<name>&new_parent=<target-or-empty>`. 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/<name>, /rebuild/<name>). `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/<name>, /rebuild/<name>).
// `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",

View file

@ -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, `<parent>` 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 `<parent>` 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 `<parent>`, 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 `<parent>` — 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
))
}

View file

@ -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
/// `<parent>` 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 `<unknown>` 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<Output = ()>;
/// 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 `<parent>` 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<Output = ()>;
/// Send a message addressed to `<parent>` (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<Output = ()>;
/// 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<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
}
let files = turn::TurnFiles::prepare(socket, &label).await?;
// Plugin install failures come back as a Vec<String> — route each
// through the `send_to_operator` failure-notify path.
// through `<parent>` via the `send_to_parent` failure-notify path.
// The broker resolves `<parent>` 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<S: Surface>(
/// 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<S: Surface>(
@ -1066,6 +1063,6 @@ async fn handle_turn_error_recovery<S: Surface>(
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;
}
}

View file

@ -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 `<parent>` sentinel that
/// failure-notify uses everywhere else.
pub async fn install_configured() -> Vec<String> {
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
return Vec::new();

View file

@ -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),
}

View file

@ -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 `<parent>` / `<children>` 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`.
//! `<parent>` 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<String>),
/// The value was the parent name; only the keys carry over.
WithParents(std::collections::BTreeMap<String, Option<String>>),
}
/// 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<String> {
pub fn read() -> BTreeMap<String, Option<String>> {
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::<OnDisk>(&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 `<children>`).
#[must_use]
pub fn children_of(name: &str) -> Vec<String> {
children_of_in(&read(), name)
}
/// Pure form of [`children_of`] for unit tests.
#[must_use]
pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Vec<String> {
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<String> {
all_agents_in(&read())
@ -73,15 +93,86 @@ pub fn all_agents() -> Vec<String> {
/// Pure form of [`all_agents`] for unit tests.
#[must_use]
pub fn all_agents_in(topo: &BTreeSet<String>) -> Vec<String> {
topo.iter().cloned().collect()
pub fn all_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
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 `<parent>` 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<String, Option<String>>,
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<name → NodeIndex>` 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<String, Option<String>>,
) -> (DiGraph<String, ()>, BTreeMap<String, NodeIndex>) {
let mut graph: DiGraph<String, ()> = DiGraph::new();
let mut idx: BTreeMap<String, NodeIndex> = 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<String>) -> std::io::Result<()> {
/// should abort their op (`sync_agents`, `RequestSetParent`) or just log.
pub fn write(topology: &BTreeMap<String, Option<String>>) -> 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<String>) -> 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<String, Option<String>> {
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<String, Option<String>>,
child: &str,
new_parent: Option<&str>,
) -> Result<BTreeMap<String, Option<String>>, 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<bool> {
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<String>,
current: &BTreeMap<String, Option<String>>,
agent_names: &[String],
pending: &[String],
) -> (BTreeSet<String>, bool) {
) -> (BTreeMap<String, Option<String>>, 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<String> {
["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<String, Option<String>> {
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 → `<parent>` 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) → `<parent>`
// 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());
}
}

View file

@ -49,6 +49,14 @@ pub struct ContainerView {
/// for this agent's input.
#[serde(skip_serializing_if = "Option::is_none")]
pub deployed_sha: Option<String>,
/// 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<String>,
/// 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<ContainerView> 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<ContainerView> {
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<ContainerView>
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<ContainerView>
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"));

Some files were not shown because too many files have changed in this diff Show more