Compare commits
43 changed files with 1790 additions and 415 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ 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 |
|
||||
|
|
@ -80,6 +80,7 @@ Cheap — no build slot:
|
|||
| `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 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ›
|
||||
|
|
|
|||
|
|
@ -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,13 +1147,21 @@ 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 +
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue