Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d547ae58e6 | ||
|
|
336ed5a010 | ||
|
|
179f873722 | ||
|
|
d94bc2188d |
43 changed files with 415 additions and 1790 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 — agent hierarchy and privilege?** →
|
||||
- **Who can do what to whom — the agent roster 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,109 +1,97 @@
|
|||
# Agent hierarchy & privileges
|
||||
# Agent roster & privileges
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
## Where the tree lives
|
||||
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.
|
||||
|
||||
Topology lives in the hive-c0re-owned **meta repo**, alongside
|
||||
## Where the roster lives
|
||||
|
||||
The roster lives in the hive-c0re-owned **meta repo**, alongside
|
||||
`flake.nix`, at `/var/lib/hyperhive/meta/topology.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ruth": null,
|
||||
"alice": null,
|
||||
"bob": "alice"
|
||||
}
|
||||
["alice", "bob", "ruth"]
|
||||
```
|
||||
|
||||
`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).
|
||||
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.
|
||||
|
||||
### Reparenting
|
||||
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._
|
||||
|
||||
- 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> }`.
|
||||
### Reading the legacy format
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
### Why meta, not per-agent `agent.nix`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### How `topology.json` gets updated
|
||||
|
||||
- **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).
|
||||
- **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).
|
||||
- **Reconcile** — runs alongside the periodic meta/flake regeneration.
|
||||
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.
|
||||
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.
|
||||
|
||||
See `hive-c0re/src/agent_config/topology.rs` and `hive-c0re/src/meta.rs`'s
|
||||
module docs for the exact call chain.
|
||||
|
||||
### Current limitation: state-dir visibility lags topology
|
||||
## What the parent field used to do
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
## Planned topology semantics (once ancestor-based enforcement lands)
|
||||
| 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 |
|
||||
|
||||
| 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 |
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
"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).
|
||||
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 -->
|
||||
|
||||
## Manager special-casing today
|
||||
|
||||
Enforcement of the ancestor rules above isn't fully wired yet, so the
|
||||
Capability enforcement isn't fully wired yet, so the
|
||||
**manager (`ruth`) still gets some hard-coded special treatment**
|
||||
other agents don't:
|
||||
|
||||
|
|
@ -113,13 +101,12 @@ 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.
|
||||
Topology-wise, `ruth` is still just another root agent.
|
||||
Roster-wise, `ruth` is just another entry.
|
||||
- **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. Planned
|
||||
rule for each is in the table above ("any ancestor" for
|
||||
lifecycle/logs). One exception: `Wake` (inject a `from: <X>` message into the
|
||||
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
|
||||
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.
|
||||
|
|
@ -128,9 +115,8 @@ 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). 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
|
||||
deploy log). That grant is the `ManageRootAgent` capability now, and
|
||||
ruth holds it; no name check remains. 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
|
||||
|
|
@ -150,8 +136,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 an
|
||||
ancestor/descendant check instead of a manager-name check — see the
|
||||
the crash-watch loop). Each is planned to become a capability
|
||||
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
|
||||
|
|
@ -162,18 +148,6 @@ 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`
|
||||
|
|
@ -240,6 +214,5 @@ 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
|
||||
|
||||
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 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.
|
||||
|
||||
An agent with the `approvals` tool group submits a change the same way
|
||||
it makes any other change: **clone the child's config repo from the
|
||||
it makes any other change: **clone the target agent'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,24 +335,29 @@ Under `/var/lib/hyperhive/agents/<name>/`:
|
|||
- `hyperhive-turn-stats.sqlite` — per-turn timing stats.
|
||||
- `hyperhive-model` — single-line model name override file.
|
||||
|
||||
### Parent access to child state
|
||||
### Cross-agent access to state
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**`harness` isn't mounted at all.** It holds the child's own runtime
|
||||
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
|
||||
material — `bash-tasks/`, the turn-stats and event sqlite dbs — and
|
||||
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.
|
||||
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.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
**`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
|
||||
**`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
|
||||
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
|
||||
|
|
@ -361,15 +366,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 child's initial
|
||||
spawn (`lifecycle::setup_proposed`): that writes the agent'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 -->
|
||||
|
||||
Per-child isolation still holds: a container only ever has its *own*
|
||||
dirs plus its direct children's bind-mounted, never a sibling's.
|
||||
Isolation still holds by default: a container has its *own* dirs and,
|
||||
unless it holds the capability, nothing else.
|
||||
|
||||
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
||||
applied repo. Tracks `flake.nix` (module-only boilerplate; never
|
||||
|
|
@ -387,13 +392,11 @@ 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` — 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.
|
||||
- `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.
|
||||
- `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, parent, pending reminders
|
||||
# Roster: all agents, status, rev, pending reminders
|
||||
hivectl list-agents
|
||||
|
||||
# Restart a stuck container (no rebuild)
|
||||
|
|
|
|||
|
|
@ -87,24 +87,13 @@ 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.
|
||||
|
||||
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.
|
||||
`<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.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
|
|
|
|||
|
|
@ -55,37 +55,36 @@ Cheap — no build slot:
|
|||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| Node | Behavior |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation |
|
||||
| `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/<id>` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction |
|
||||
| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build |
|
||||
| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#<name>` ref resolves. Store/meta-only, no container yet |
|
||||
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
|
||||
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
|
||||
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
|
||||
| `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped |
|
||||
| `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the DAG's `EmitRebuilt` tail node emits the `Rebuilt` manager event, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace |
|
||||
| `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal`→`Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ |
|
||||
| `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn |
|
||||
| `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way |
|
||||
| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here |
|
||||
| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` |
|
||||
| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims |
|
||||
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
|
||||
| `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s |
|
||||
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
|
||||
| `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/<id>`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree |
|
||||
| `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent |
|
||||
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
|
||||
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
|
||||
| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots |
|
||||
| `Reparent` | `set-parent` / `set-parent-bulk`: apply every `(child, new_parent)` move under one `META_LOCK` commit (`meta::bulk_commit_topology`), send the per-agent move notifications, rescan + diff-emit. Agentless like `MetaLock` — a bulk move can span multiple agents, and a reparent touches the meta repo, not any one container. `moves` is typed `(Ident, Option<Ident>)` pairs, not raw strings. Rides the existing `Template::MetaUpdate` variant rather than a dedicated one — it's internal-only (never reaches the graph wire), so the stand-in only affects `terminal_hook` dispatch (resolves to no hook either way) and history-retention bucketing |
|
||||
| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
|
||||
| `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node |
|
||||
| `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless |
|
||||
| `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop |
|
||||
| `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless |
|
||||
| Node | Behavior |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation |
|
||||
| `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/<id>` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction |
|
||||
| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build |
|
||||
| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#<name>` ref resolves. Store/meta-only, no container yet |
|
||||
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
|
||||
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
|
||||
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
|
||||
| `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped |
|
||||
| `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the DAG's `EmitRebuilt` tail node emits the `Rebuilt` manager event, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace |
|
||||
| `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal`→`Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ |
|
||||
| `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn |
|
||||
| `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way |
|
||||
| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here |
|
||||
| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` |
|
||||
| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims |
|
||||
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
|
||||
| `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s |
|
||||
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
|
||||
| `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/<id>`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree |
|
||||
| `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent |
|
||||
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
|
||||
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
|
||||
| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots |
|
||||
| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
|
||||
| `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node |
|
||||
| `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless |
|
||||
| `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop |
|
||||
| `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless |
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
@ -100,7 +99,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`, `Reparent`, `Provision`'s agent registration, and
|
||||
`MetaLock`, `WritePermFile`, `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
|
||||
|
|
@ -156,7 +155,6 @@ 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:
|
||||
|
|
@ -229,7 +227,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`, `Reparent` — they
|
||||
`Prebuild`, `Provision`, `MetaLock`, `WritePermFile` — 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`,
|
||||
|
|
@ -374,8 +372,7 @@ 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` (topology-sorted,
|
||||
parents first). Stale but wanted-offline agents get no boot-time nix work — their
|
||||
`Rebuild` subgraph for each stale agent whose `wanted = Up` (name-sorted). 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
|
||||
|
|
@ -584,4 +581,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` — topology and parent/child relations
|
||||
- `docs/agent-lifecycle/agent-hierarchy.md` — the agent roster and privilege
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ 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)
|
||||
|
|
@ -351,7 +350,6 @@ 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
|
||||
|
|
@ -464,19 +462,6 @@ 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,18 +153,17 @@ 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 PARENT REMIND`. STATUS collapses the health flags
|
||||
`NAME STATUS REV 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; 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
|
||||
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
|
||||
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 the agent hierarchy. For more than one agent at once, use
|
||||
going through an agent at all. 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,9 +111,7 @@ 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_parent` to the agent's topology parent (the
|
||||
broker resolves `<parent>` per `topology::resolve_recipient`; root
|
||||
agents and the manager fall through to operator).
|
||||
`Surface::send_to_operator` to the operator.
|
||||
|
||||
### Turn outcomes
|
||||
|
||||
|
|
@ -128,7 +126,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 `<parent>` via `send_to_parent` |
|
||||
| `Err(Failed(err))` | route `[system] \`<qualified-label>\` claude turn failed:\n<err>` to `operator` via `send_to_operator` |
|
||||
|
||||
`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,15 +29,13 @@ are opt-in via the P3RM1SS10NS tab.
|
|||
`recv(max?)`, `ack_until(up_to)`.
|
||||
|
||||
- `send` — message a peer (logical name) or the operator
|
||||
(`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
|
||||
(`to: "operator"`). 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 topology parent is always reachable regardless of this list —
|
||||
that carve-out is structural, keyed on parent relationship, not name).
|
||||
`[ "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.
|
||||
- `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,19 +56,10 @@ surfaces, not tab panes.
|
|||
|
||||
## SW4RM tab
|
||||
|
||||
**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
|
||||
**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
|
||||
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
|
||||
|
|
@ -961,7 +952,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`, `M0V3`) routes through `themedConfirm`, on both
|
||||
`DESTR0Y`, `PURG3`) routes through `themedConfirm`, on both
|
||||
the per-agent `⋮` menu and the bulk selection bar.
|
||||
|
||||
**Graceful stop** — the `■ ST0P` confirm dialog (per-agent and
|
||||
|
|
@ -985,25 +976,11 @@ identically.)
|
|||
|
||||
### Topology tree
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Selection bar
|
||||
|
||||
|
|
@ -1032,25 +1009,9 @@ 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 -->
|
||||
|
||||
Both write `topology.json` and re-emit a container snapshot so
|
||||
the tree repaints without a page reload.
|
||||
That list is the whole set — the bar offers no other bulk action.
|
||||
- **`✕ clear`** button + `Esc` key clear the entire selection.
|
||||
|
||||
Every render prunes stale selections (agents destroyed while
|
||||
|
|
|
|||
|
|
@ -1384,27 +1384,6 @@ 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,154 +952,6 @@ 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) {
|
||||
|
|
@ -1147,23 +999,15 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
// `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.
|
||||
for (const name of names) {
|
||||
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" : "");
|
||||
const body = new URLSearchParams(opts.body || {});
|
||||
const url =
|
||||
opts.action +
|
||||
encodeURIComponent(name) +
|
||||
(graceful ? "?graceful=true" : "");
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -12,20 +12,23 @@
|
|||
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, `<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).
|
||||
/// 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).
|
||||
pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||
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).
|
||||
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.
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
|
||||
|
|
@ -50,9 +53,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:?}. 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
|
||||
(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
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
/// reparent notifications, and friends; the parse and log path is
|
||||
/// 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
|
||||
/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified
|
||||
/// Body string for the turn-failure notification we route to the
|
||||
/// operator 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,10 +278,15 @@ trait Surface {
|
|||
/// fallback. Same shape as `graceful_stop_complete`.
|
||||
fn pause_acknowledged(socket: &Path) -> 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 = ()>;
|
||||
/// 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 = ()>;
|
||||
|
||||
/// Long-poll the broker for the next message. Wraps the
|
||||
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
||||
|
|
@ -367,11 +372,11 @@ impl Surface for AgentSurface {
|
|||
(threads, reminders)
|
||||
}
|
||||
|
||||
async fn send_to_parent(socket: &Path, body: String) {
|
||||
async fn send_to_operator(socket: &Path, body: String) {
|
||||
let res = hive_sock_client::request::<_, Response>(
|
||||
socket,
|
||||
&Request::Send {
|
||||
to: hive_sh4re::manager::PARENT_RECIPIENT.into(),
|
||||
to: hive_sh4re::manager::OPERATOR_RECIPIENT.into(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
},
|
||||
|
|
@ -379,7 +384,7 @@ impl Surface for AgentSurface {
|
|||
)
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(error = ?e, "failed to notify parent of turn failure");
|
||||
tracing::warn!(error = ?e, "failed to notify the operator of turn failure");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -519,11 +524,9 @@ 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 `<parent>` via the `send_to_parent` failure-notify path.
|
||||
// The broker resolves `<parent>` per `topology::parent_of`;
|
||||
// root agents fall through to operator.
|
||||
// through the `send_to_operator` failure-notify path.
|
||||
for failure in plugins::install_configured().await {
|
||||
S::send_to_parent(socket, failure).await;
|
||||
S::send_to_operator(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
|
||||
|
|
@ -1004,7 +1007,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 parent on a hard failure). Split out purely to keep
|
||||
/// found, notify the operator 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>(
|
||||
|
|
@ -1063,6 +1066,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_parent(socket, format_turn_failure(e)).await;
|
||||
S::send_to_operator(socket, format_turn_failure(e)).await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,9 +98,8 @@ 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_parent`). Wire-agnostic: the
|
||||
/// caller picks the recipient via the same `<parent>` sentinel that
|
||||
/// failure-notify uses everywhere else.
|
||||
/// notification, see `Surface::send_to_operator`). Wire-agnostic: the
|
||||
/// caller picks the recipient, the same way failure-notify does.
|
||||
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
|
||||
/// parent (`send_to_parent`).
|
||||
/// operator (`send_to_operator`).
|
||||
Failed(anyhow::Error),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,26 @@
|
|||
//! 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.
|
||||
//! 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.
|
||||
//!
|
||||
//! 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`.
|
||||
//! **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`]).
|
||||
//!
|
||||
//! 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).
|
||||
//! 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`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use petgraph::algo::is_cyclic_directed;
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const TOPOLOGY_FILE: &str = "topology.json";
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -37,55 +28,44 @@ pub fn topology_path() -> PathBuf {
|
|||
crate::paths::meta_root().join(TOPOLOGY_FILE)
|
||||
}
|
||||
|
||||
/// Snapshot of the topology map. Read on every `container_view::build_all`
|
||||
/// 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`
|
||||
/// 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 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.
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn read() -> BTreeMap<String, Option<String>> {
|
||||
pub fn read() -> BTreeSet<String> {
|
||||
let path = topology_path();
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
return BTreeMap::new();
|
||||
return BTreeSet::new();
|
||||
};
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
match serde_json::from_str::<OnDisk>(&raw) {
|
||||
Ok(OnDisk::Roster(names)) => names,
|
||||
Ok(OnDisk::WithParents(map)) => map.into_keys().collect(),
|
||||
Err(_) => BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Every agent the roster 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())
|
||||
|
|
@ -93,86 +73,15 @@ pub fn all_agents() -> Vec<String> {
|
|||
|
||||
/// Pure form of [`all_agents`] for unit tests.
|
||||
#[must_use]
|
||||
pub fn all_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
|
||||
topo.keys().cloned().collect()
|
||||
pub fn all_agents_in(topo: &BTreeSet<String>) -> Vec<String> {
|
||||
topo.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// 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 —
|
||||
/// Persist the roster. Sorted JSON output (`BTreeSet` iterates in key
|
||||
/// order) 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`, `RequestSetParent`) or just log.
|
||||
pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()> {
|
||||
/// should abort their op (`sync_agents`) or just log.
|
||||
pub fn write(topology: &BTreeSet<String>) -> std::io::Result<()> {
|
||||
let path = topology_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
|
@ -182,90 +91,15 @@ pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()>
|
|||
std::fs::write(&path, format!("{text}\n"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
///
|
||||
/// `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 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`.
|
||||
/// (not dropped) so an agent that exists on disk but has never booted is
|
||||
/// still a name the hive knows about.
|
||||
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
||||
let (next, changed) = apply_reconcile(&read(), agent_names, pending);
|
||||
if changed {
|
||||
|
|
@ -274,32 +108,25 @@ pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<
|
|||
Ok(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.
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn apply_reconcile(
|
||||
current: &BTreeMap<String, Option<String>>,
|
||||
current: &BTreeSet<String>,
|
||||
agent_names: &[String],
|
||||
pending: &[String],
|
||||
) -> (BTreeMap<String, Option<String>>, bool) {
|
||||
) -> (BTreeSet<String>, bool) {
|
||||
let mut next = current.clone();
|
||||
let mut changed = false;
|
||||
for name in agent_names {
|
||||
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);
|
||||
if next.insert(name.clone()) {
|
||||
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;
|
||||
|
|
@ -311,301 +138,94 @@ pub fn apply_reconcile(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{BTreeSet, OnDisk, all_agents_in, apply_reconcile};
|
||||
|
||||
#[test]
|
||||
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(),
|
||||
"bob".to_owned(),
|
||||
];
|
||||
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));
|
||||
fn roster_three() -> BTreeSet<String> {
|
||||
["alice", "bob", "carol"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
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_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_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 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!(
|
||||
next.get(crate::lifecycle::MANAGER_NAME),
|
||||
Some(&Some("peer".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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.
|
||||
fn apply_reconcile_adds_missing_live_agent() {
|
||||
let live = vec![
|
||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||
"newbie".to_owned(),
|
||||
];
|
||||
let (next, changed) = apply_reconcile(&BTreeMap::new(), &live, &[]);
|
||||
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]);
|
||||
assert!(changed);
|
||||
assert_eq!(next.get("newbie"), Some(&None));
|
||||
assert_eq!(next.get(crate::lifecycle::MANAGER_NAME), Some(&None));
|
||||
assert!(next.contains("newbie"));
|
||||
assert!(next.contains(crate::lifecycle::MANAGER_NAME));
|
||||
}
|
||||
|
||||
#[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, &[]);
|
||||
let live = vec!["alice".to_owned()];
|
||||
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
|
||||
assert!(changed);
|
||||
assert!(!next.contains_key("bob"));
|
||||
assert!(!next.contains_key("carol"));
|
||||
assert!(next.contains_key("alice"));
|
||||
assert!(!next.contains("bob"));
|
||||
assert!(!next.contains("carol"));
|
||||
assert!(next.contains("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(),
|
||||
];
|
||||
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(&topo, &live, &pending);
|
||||
let (next, changed) = apply_reconcile(&roster, &live, &pending);
|
||||
assert!(!changed, "no change expected: {next:?}");
|
||||
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
|
||||
assert!(next.contains("dora"));
|
||||
}
|
||||
|
||||
#[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", "*"), "*");
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_agents_in_returns_every_name_sorted() {
|
||||
assert_eq!(
|
||||
resolve_recipient_in(&topo, "bob", hive_sh4re::manager::OPERATOR_RECIPIENT),
|
||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
||||
all_agents_in(&roster_three()),
|
||||
vec!["alice", "bob", "carol"]
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
#[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());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,14 +49,6 @@ 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
|
||||
|
|
@ -121,7 +113,6 @@ 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,
|
||||
|
|
@ -164,13 +155,8 @@ 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();
|
||||
// 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.
|
||||
// Read once per scan 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 {
|
||||
|
|
@ -189,7 +175,6 @@ 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.
|
||||
|
|
@ -252,7 +237,6 @@ 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,
|
||||
|
|
@ -547,7 +531,6 @@ 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()),
|
||||
|
|
@ -563,7 +546,6 @@ 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"));
|
||||
|
|
|
|||
|
|
@ -870,81 +870,6 @@ impl Coordinator {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply topology reparent(s) + fan the resulting notifications out to
|
||||
/// the affected agents. Applies all moves under a single `META_LOCK`
|
||||
/// acquisition (one git commit — a single-move call is just a
|
||||
/// one-element slice) then, for each move that actually changed
|
||||
/// topology, drops a one-line system message into the inbox of:
|
||||
///
|
||||
/// 1. The **old parent** (if any) — `"{child} moved out of your
|
||||
/// subtree to {new_parent_or_root}"`.
|
||||
/// 2. The **new parent** (if any) — `"{child} just moved into
|
||||
/// your subtree (was previously under
|
||||
/// {old_parent_or_root})"`.
|
||||
/// 3. The **moved agent** — `"your parent changed from
|
||||
/// {old_parent_or_root} to {new_parent_or_root}"`.
|
||||
///
|
||||
/// `_or_root` resolves to the literal string `"<root>"` when the
|
||||
/// slot is `None`, keeping the wording consistent with the
|
||||
/// `<parent>` sentinel's "root → operator" routing (see
|
||||
/// `docs/process/conventions.md::Recipient sentinels`). The
|
||||
/// notifications fire as ordinary broker messages with
|
||||
/// `from = hive_sh4re::manager::SYSTEM_SENDER` so the dashboard renders
|
||||
/// them under the existing system-source styling.
|
||||
///
|
||||
/// First validation failure aborts the whole batch with no disk writes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates any error returned by [`crate::meta::bulk_commit_topology`]
|
||||
/// (validation failure or topology-file write error).
|
||||
pub async fn reparent_bulk_with_notify(
|
||||
self: &Arc<Self>,
|
||||
moves: &[(&str, Option<&str>)],
|
||||
) -> std::result::Result<(), String> {
|
||||
if moves.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// bulk_commit_topology applies all set_parent calls under one lock
|
||||
// and returns (child, old_parent) for every move that changed.
|
||||
let changed = crate::meta::bulk_commit_topology(moves).await?;
|
||||
|
||||
// Send per-agent notifications for each changed move.
|
||||
for (child, old_parent) in &changed {
|
||||
// Find the new parent from the moves slice.
|
||||
let new_parent = moves
|
||||
.iter()
|
||||
.find(|(c, _)| *c == child)
|
||||
.and_then(|(_, np)| *np);
|
||||
let old_label = old_parent.as_deref().unwrap_or("<root>");
|
||||
let new_label = new_parent.unwrap_or("<root>");
|
||||
if let Some(op) = old_parent.as_deref() {
|
||||
let _ = self.broker.send(&hive_sh4re::inbox::Message {
|
||||
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER),
|
||||
to: op.to_owned(),
|
||||
body: format!("{child} moved out of your subtree to {new_label}"),
|
||||
in_reply_to: None,
|
||||
});
|
||||
}
|
||||
if let Some(np) = new_parent {
|
||||
let _ = self.broker.send(&hive_sh4re::inbox::Message {
|
||||
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER),
|
||||
to: np.to_owned(),
|
||||
body: format!(
|
||||
"{child} just moved into your subtree (was previously under {old_label})"
|
||||
),
|
||||
in_reply_to: None,
|
||||
});
|
||||
}
|
||||
let _ = self
|
||||
.broker
|
||||
.send_coalescing_reparent(child, old_label, new_label);
|
||||
}
|
||||
|
||||
self.rescan_containers_and_emit().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only snapshot of the last cached container view. Used by
|
||||
/// `/api/state` to cold-load page-open clients without re-running
|
||||
/// `nixos-container list` themselves; the
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ use crate::lifecycle;
|
|||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||
(name = "topology", description = "operator-driven agent reparenting"),
|
||||
(name = "webhook", description = "forgejo webhook receivers"),
|
||||
)
|
||||
)]
|
||||
|
|
@ -72,7 +71,6 @@ mod schedules;
|
|||
mod state_files;
|
||||
mod state_snapshot;
|
||||
mod tombstones;
|
||||
mod topology;
|
||||
mod webhook;
|
||||
|
||||
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
|
||||
|
|
@ -161,8 +159,6 @@ pub async fn serve(
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(topology::post_set_parent))
|
||||
.routes(routes!(topology::post_set_parent_bulk))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
|
|
@ -402,8 +398,6 @@ mod router_build_probe {
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(topology::post_set_parent))
|
||||
.routes(routes!(topology::post_set_parent_bulk))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
|
|
|
|||
|
|
@ -805,7 +805,6 @@ mod tests {
|
|||
needs_update: false,
|
||||
needs_login: false,
|
||||
deployed_sha: None,
|
||||
parent: None,
|
||||
active_model: None,
|
||||
status_text: None,
|
||||
status_set_at: None,
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
//! Topology (set-parent) endpoints for the dashboard.
|
||||
//!
|
||||
//! Operator-driven agent reparenting — single (`/api/topology/set-parent`,
|
||||
//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array →
|
||||
//! one git commit). Both submit a `NodeKind::Reparent` DAG to the job
|
||||
//! queue (fire-and-forget, like every other queue-backed op — the
|
||||
//! dashboard tree repaints off the queue's own snapshot/rescan once the
|
||||
//! commit lands, same as a rebuild or restart). The executor delegates to
|
||||
//! `Coordinator::reparent_bulk_with_notify`, which wraps
|
||||
//! `crate::meta::bulk_commit_topology` with the move-notification messages
|
||||
//! and the `ContainerView` rescan.
|
||||
|
||||
use axum::{
|
||||
extract::{Form, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
|
||||
use super::{AppState, error_problem};
|
||||
|
||||
/// `POST /api/topology/set-parent` body. `child` is required.
|
||||
/// `new_parent` may be:
|
||||
/// - absent or empty / whitespace-only → promote to root,
|
||||
/// - non-empty → new parent's logical name.
|
||||
///
|
||||
/// (The CLI surface gates "no parent specified" behind an explicit
|
||||
/// `--root` flag for safety; the HTTP surface is permissive
|
||||
/// because the dashboard form encodes "no value" as the empty
|
||||
/// string for the optional radio-group input.)
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct SetParentForm {
|
||||
child: String,
|
||||
new_parent: Option<String>,
|
||||
}
|
||||
|
||||
/// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
|
||||
/// `new_parent`: absent/null/empty-string all mean "promote to root".
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct SetParentBulkEntry {
|
||||
child: String,
|
||||
#[serde(default)]
|
||||
new_parent: Option<String>,
|
||||
}
|
||||
|
||||
/// Operator-driven parent move.
|
||||
///
|
||||
/// Form fields: `child` (required, agent name), `new_parent`
|
||||
/// (optional — empty / absent string ⇒ promote to root). Refuses
|
||||
/// cycles and unknown agents (surfaced async on the job view — this
|
||||
/// handler only validates the identifiers, not the move itself). The
|
||||
/// manager is reparentable like any other agent — its privileges come
|
||||
/// from the privileged MCP socket, not its tree position. Submitting
|
||||
/// re-emits the queue snapshot immediately so the dashboard shows the
|
||||
/// queued move without a refresh; the tree itself repaints once the
|
||||
/// commit lands.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/topology/set-parent",
|
||||
responses(
|
||||
(status = 200, description = "reparent queued", body = String),
|
||||
(status = 400, description = "missing/invalid child or new_parent identifier"),
|
||||
),
|
||||
tag = "topology"
|
||||
)]
|
||||
pub(super) async fn post_set_parent(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<SetParentForm>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let child = form.child.trim().to_owned();
|
||||
if child.is_empty() {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail("set-parent: `child` required"));
|
||||
}
|
||||
let child = hive_types::Ident::parse(&child)
|
||||
.map_err(|e| error_problem(&format!("set-parent: `child` {e}")))?;
|
||||
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
|
||||
// forms submit the empty string for a "no value" radio button,
|
||||
// so this is the ergonomic encoding.
|
||||
let new_parent = form
|
||||
.new_parent
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(hive_types::Ident::parse)
|
||||
.transpose()
|
||||
.map_err(|e| error_problem(&format!("set-parent: `new_parent` {e}")))?;
|
||||
tracing::info!(
|
||||
child = %child,
|
||||
new_parent = ?new_parent,
|
||||
"operator: set-parent via dashboard"
|
||||
);
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::reparent(b, vec![(child, new_parent)]);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
/// Move multiple agents in a
|
||||
/// single request, producing **one** git commit.
|
||||
///
|
||||
/// JSON body: `[{"child":"name", "new_parent":"target-or-null"}, ...]`.
|
||||
/// Empty array is a no-op (200 OK). First identifier that fails to
|
||||
/// parse aborts the whole batch before anything is submitted — a
|
||||
/// partially-invalid bulk move never reaches the queue.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/topology/set-parent-bulk",
|
||||
responses(
|
||||
(status = 200, description = "reparents queued", body = String),
|
||||
(status = 400, description = "an invalid child identifier in the batch"),
|
||||
),
|
||||
tag = "topology"
|
||||
)]
|
||||
pub(super) async fn post_set_parent_bulk(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
if body.is_empty() {
|
||||
return Ok((StatusCode::OK, "ok").into_response());
|
||||
}
|
||||
// Collect into `Result<_, String>` first, not `ProblemDetails` directly —
|
||||
// clippy::result_large_err flags a ~232-byte `Err` variant threaded
|
||||
// through this closure's `?`. `String` is small enough to satisfy the
|
||||
// lint; the single `map_err` below promotes it to a `ProblemDetails`
|
||||
// once, after the fallible collect.
|
||||
let moves = body
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let child = hive_types::Ident::parse(e.child.trim())
|
||||
.map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?;
|
||||
let new_parent = e
|
||||
.new_parent
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(hive_types::Ident::parse)
|
||||
.transpose()
|
||||
.map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?;
|
||||
Ok((child, new_parent))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
.map_err(|e| error_problem(&e))?;
|
||||
let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect();
|
||||
tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard");
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::reparent(b, moves);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ pub(super) async fn run_node(
|
|||
kind: &NodeKind,
|
||||
) -> (super::JobBuilder, Result<()>) {
|
||||
// The agent this node targets rides the payload — empty for the agentless
|
||||
// kinds (`MetaLock`, `Reparent`), which never read it.
|
||||
// kinds (`MetaLock`), which never read it.
|
||||
let agent = kind.agent();
|
||||
// Every arm is `Result<()>`; the three that grow work declare into `builder`
|
||||
// *synchronously*, after their own awaits have finished. Borrowing `&builder`
|
||||
|
|
@ -120,7 +120,6 @@ pub(super) async fn run_node(
|
|||
// takes it directly instead of re-matching the kind behind a `bail!`
|
||||
// that could never fire.
|
||||
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
|
||||
NodeKind::Reparent { moves } => run_reparent(coord, moves).await,
|
||||
NodeKind::MergeVerify { approval_id, .. } => {
|
||||
run_merge_verify(coord, *approval_id, id).await
|
||||
}
|
||||
|
|
@ -815,33 +814,6 @@ async fn run_write_perm_file(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply the node's `(child, new_parent)` moves as one `META_LOCK`-fused
|
||||
/// commit (`Coordinator::reparent_bulk_with_notify`, which already handles
|
||||
/// both the single- and bulk-move case, sends the per-agent move
|
||||
/// notifications, and rescans + diff-emits the container tree). Runs under
|
||||
/// the deploy window (it declares `Resource::MetaWindow`), same reasoning as
|
||||
/// `run_write_perm_file`: a topology commit landing inside another node's
|
||||
/// staged deploy window would sweep the staged lock into its commit.
|
||||
async fn run_reparent(
|
||||
coord: &Arc<Coordinator>,
|
||||
moves: &[(hive_types::Ident, Option<hive_types::Ident>)],
|
||||
) -> Result<()> {
|
||||
let refs: Vec<(&str, Option<&str>)> = moves
|
||||
.iter()
|
||||
.map(|(child, parent)| {
|
||||
(
|
||||
child.as_str(),
|
||||
parent.as_ref().map(hive_types::Ident::as_str),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
coord
|
||||
.reparent_bulk_with_notify(&refs)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deploy phase 1 — drift gate, fetch, eval-verify. Mutates nothing, so a
|
||||
/// failure here cancel-cascades the rest of the subtree with the forge and the
|
||||
/// applied repo exactly as they were.
|
||||
|
|
@ -890,7 +862,9 @@ async fn run_deploy_tail(
|
|||
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
|
||||
/// `inputs` or any input under `hyperhive` → every container;
|
||||
/// otherwise just the agents named by `agent-<name>` inputs.
|
||||
/// Topology-sorted so parents rebuild before their children.
|
||||
/// Sorted by name — the parent field the old depth sort keyed on is
|
||||
/// gone, and with every agent a root that sort already reduced to
|
||||
/// this.
|
||||
///
|
||||
/// `inputs` is the caller-supplied flake-input-name list (the dashboard's
|
||||
/// `POST /api/meta-update` form field, operator-supplied but not
|
||||
|
|
@ -918,8 +892,7 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> {
|
|||
} else {
|
||||
touched_agents
|
||||
};
|
||||
let topo = crate::topology::read();
|
||||
crate::auto_update::topology_sort(&mut names, &topo);
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,12 +102,6 @@ pub enum NodeKind {
|
|||
WriteDropin { agent: String },
|
||||
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`.
|
||||
WritePermFile { agent: String, payload: PermPayload },
|
||||
/// Topology move(s) — `set-parent` (len 1) or `set-parent-bulk` (len N) —
|
||||
/// as a single queue node. `moves` is typed `(Ident, Option<Ident>)`
|
||||
/// pairs, applied in order under one `META_LOCK` acquisition.
|
||||
Reparent {
|
||||
moves: Vec<(hive_types::Ident, Option<hive_types::Ident>)>,
|
||||
},
|
||||
/// Group root of the approval-deploy (`MergeConfigPr`) subtree — the
|
||||
/// **brace** that owns the deploy window. See _Braces_ and _Approvals_.
|
||||
DeployWindow { agent: String, approval_id: i64 },
|
||||
|
|
@ -203,7 +197,6 @@ impl hive_jobq_wire::WireNode for NodeKind {
|
|||
impl NodeKind {
|
||||
/// The agent this node targets, or `""` for agentless kinds
|
||||
/// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent,
|
||||
/// [`NodeKind::Reparent`] which can span multiple agents, and
|
||||
/// [`NodeKind::ResolveApproval`] which acts on an approval row).
|
||||
#[must_use]
|
||||
pub fn agent(&self) -> &str {
|
||||
|
|
@ -236,7 +229,6 @@ impl NodeKind {
|
|||
| NodeKind::EmitRebuilt { agent, .. }
|
||||
| NodeKind::SetWanted { agent, .. } => agent,
|
||||
NodeKind::MetaLock { .. }
|
||||
| NodeKind::Reparent { .. }
|
||||
| NodeKind::ResolveApproval { .. }
|
||||
| NodeKind::ForgeSweep
|
||||
| NodeKind::MatrixSweep
|
||||
|
|
|
|||
|
|
@ -597,27 +597,6 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>, approval_id: Optio
|
|||
}
|
||||
}
|
||||
|
||||
/// Topology move(s) as a single-node DAG. `moves` is `(child, new_parent)`
|
||||
/// pairs — len 1 for `set-parent`, len N for `set-parent-bulk`, applied
|
||||
/// uniformly by the one [`NodeKind::Reparent`] node (which holds the global
|
||||
/// meta window for its duration, same precedent as [`NodeKind::WritePermFile`]).
|
||||
/// No rebuild subgraph: `topology.json` is read live by every consumer
|
||||
/// (dashboard tree, `<parent>`/`<children>` sentinel routing, permission
|
||||
/// checks), so a parent move needs no container rebuild to take effect.
|
||||
/// No transient pill either — the node is agentless (no lease to hang one
|
||||
/// off of) and near-instant. No tail node: the write is the whole effect.
|
||||
///
|
||||
/// Returns the single node's guid so a caller can wait on it.
|
||||
pub fn reparent(
|
||||
builder: &JobBuilder,
|
||||
moves: Vec<(hive_types::Ident, Option<hive_types::Ident>)>,
|
||||
) -> hive_jobq::NodeGuid {
|
||||
builder
|
||||
.node(NodeKind::Reparent { moves })
|
||||
.needs(Resource::MetaWindow)
|
||||
.guid()
|
||||
}
|
||||
|
||||
// The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree`
|
||||
// as ONE `Boot` DAG (a sweep `MetaLock` root that grows rebuild subgraphs
|
||||
// in-DAG, plus a `Reconcile` root per drifted agent) — no anchor node and no
|
||||
|
|
|
|||
|
|
@ -49,10 +49,6 @@ fn insert_named(
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn ident(s: &str) -> hive_types::Ident {
|
||||
hive_types::Ident::parse(s).expect("valid test ident")
|
||||
}
|
||||
|
||||
fn rebuild(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
|
||||
templates::rebuild(builder, agent, true)
|
||||
}
|
||||
|
|
@ -186,14 +182,6 @@ fn node_of(q: &JobQueue, kind: &str) -> hive_jobq::NodeId {
|
|||
found.pop().expect("checked above")
|
||||
}
|
||||
|
||||
/// The payload of the one node of `kind`, for assertions about what a node
|
||||
/// *carries* rather than how it is wired.
|
||||
fn payload_of(q: &JobQueue, kind: &str) -> NodeKind {
|
||||
let id = node_of(q, kind);
|
||||
let sched = q.sched().lock().expect("job_queue mutex poisoned");
|
||||
sched.graph().node(id).expect("node exists").payload.clone()
|
||||
}
|
||||
|
||||
/// Kinds of every node still `Pending` — the nodes that could yet run.
|
||||
/// Stronger than asking the scheduler what is *ready right now*: a node
|
||||
/// blocked on a dep is not ready but is very much still alive.
|
||||
|
|
@ -1785,48 +1773,3 @@ fn perm_change_shape_prefixes_rebuild_chain() {
|
|||
"the perm write prefixes an otherwise ordinary rebuild chain"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_shape_is_a_lone_agentless_meta_window_node() {
|
||||
// Single-move `set-parent` shape: one node, no rebuild subgraph (no
|
||||
// container rebuild needed for a parent move), agentless like
|
||||
// `MetaLock`, and it must declare the meta window — a topology commit
|
||||
// must not land inside another node's staged deploy window.
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::reparent(builder, vec![(ident("alice"), Some(ident("bob")))]);
|
||||
});
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![row("reparent", None, &[])],
|
||||
"one node, no rebuild subgraph"
|
||||
);
|
||||
let node = node_of(&q, "reparent");
|
||||
assert_eq!(
|
||||
declared_resources(&q, node),
|
||||
vec![Resource::MetaWindow],
|
||||
"a topology commit must declare the same MetaWindow as WritePermFile, \
|
||||
and nothing else — no lease (agentless), no build slot (no nix work)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_bulk_shape_carries_every_move_on_one_node() {
|
||||
// `set-parent-bulk`: still ONE node (one git commit, `moves.len() > 1`),
|
||||
// not one node per move — bulk atomicity across every move in the
|
||||
// request is the reason a single node was chosen in the first place.
|
||||
let moves = vec![(ident("alice"), Some(ident("bob"))), (ident("carol"), None)];
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::reparent(builder, moves.clone());
|
||||
});
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![row("reparent", None, &[])],
|
||||
"one node for the whole request, not one per move"
|
||||
);
|
||||
let NodeKind::Reparent { moves: got } = payload_of(&q, "reparent") else {
|
||||
panic!("expected a Reparent node");
|
||||
};
|
||||
assert_eq!(got, moves, "every move rides the single node");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
|
|||
/// clone is where a config change is staged, so it can hold a proposal
|
||||
/// that is still under review or was rejected outright — mounting it shows
|
||||
/// an agent a config which does not govern it. Both mounts (an agent's own
|
||||
/// and a parent's view of a child's) go through here so they cannot drift.
|
||||
/// and a `ManageRootAgent` holder's view of another agent's) go through
|
||||
/// here so they cannot drift.
|
||||
///
|
||||
/// Never empty under a live container: `provision_container` runs
|
||||
/// `setup_applied` before `create_only` makes the container at all.
|
||||
|
|
@ -86,26 +87,30 @@ fn config_bind_source(name: &str) -> PathBuf {
|
|||
}
|
||||
|
||||
/// Append bind flags for `child`'s state and config dirs into `binds`.
|
||||
/// See docs/agent-lifecycle/persistence.md ("Parent access to child state") for what a
|
||||
/// parent may touch and why. Creates missing host-side directories so
|
||||
/// nspawn doesn't refuse to start; missing dirs are non-fatal.
|
||||
/// See docs/agent-lifecycle/persistence.md ("Cross-agent access to state")
|
||||
/// for what the holder may touch and why. Creates missing host-side
|
||||
/// directories so nspawn doesn't refuse to start; missing dirs are
|
||||
/// non-fatal.
|
||||
///
|
||||
/// The only caller left is the `ManageRootAgent` grant; the parent/child
|
||||
/// tree that handed every agent its own children here is gone.
|
||||
///
|
||||
/// **Three dirs, three different answers** — the uniformity of the
|
||||
/// original loop is what hid that:
|
||||
///
|
||||
/// - `state` is **read-write**: a parent reads and writes a child's notes
|
||||
/// to recover it, which is the one case that needs to work while the
|
||||
/// child is down.
|
||||
/// - `config` is **read-only**, and read-only for the *parent* is the
|
||||
/// point: a config change is a PR against the child's repo on the
|
||||
/// - `state` is **read-write**: the holder reads and writes another
|
||||
/// agent's notes to recover it, which is the one case that needs to
|
||||
/// work while that agent is down.
|
||||
/// - `config` is **read-only**, and read-only for the *holder* is the
|
||||
/// point: a config change is a PR against the other agent's repo on the
|
||||
/// forge, reviewed and merged, never an edit in place. A writable mount
|
||||
/// here is a second path to the same file that skips the review — the
|
||||
/// boundary would then be a convention rather than a permission.
|
||||
/// - `harness` is **absent entirely**. It holds the child's own runtime
|
||||
/// - `harness` is **absent entirely**. It holds that agent's own runtime
|
||||
/// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of
|
||||
/// which a parent has a stated reason to read, let alone write.
|
||||
/// which the holder has a stated reason to read, let alone write.
|
||||
/// hive-c0re still reads it directly on the host (`stats::hive_stats`),
|
||||
/// which needs no bind mount into the parent.
|
||||
/// which needs no bind mount into anyone else's container.
|
||||
///
|
||||
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
|
||||
/// the `config` flag and must not be read as a reason to widen it: that runs
|
||||
|
|
@ -316,21 +321,12 @@ async fn set_nspawn_flags(
|
|||
read_only: true,
|
||||
});
|
||||
|
||||
// Topology-driven child mounts: every direct child of this agent gets
|
||||
// its state dir RW and its config dir RO. See `bind_child_agent_dirs`
|
||||
// for why each is what it is.
|
||||
let direct_children = crate::topology::children_of(agent_name);
|
||||
for child in &direct_children {
|
||||
bind_child_agent_dirs(child, &mut binds);
|
||||
}
|
||||
|
||||
// `ManageRootAgent` capability: additionally mount *every* agent in
|
||||
// the hive as a virtual child. Enables recovery — the holder can
|
||||
// update another agent's config even when that agent is down. Also
|
||||
// grants RO access to /applied and /meta.
|
||||
// `ManageRootAgent` capability: mount *every* agent in the hive as a
|
||||
// virtual child. Enables recovery — the holder can update another
|
||||
// agent's config even when that agent is down. Also grants RO access
|
||||
// to /applied and /meta.
|
||||
//
|
||||
// ⚠️ "every agent" reads as a widening next to the `children_of`
|
||||
// mounts above, so: it is the definition of this capability, not an
|
||||
// ⚠️ "every agent" is the definition of this capability, not an
|
||||
// accident of how the set is computed. The grant used to hang off a
|
||||
// `can_manage_top_level_agents` role and cover `top_level_agents()`
|
||||
// — i.e. `parent.is_none()` — which was "everything outside the
|
||||
|
|
@ -338,13 +334,18 @@ async fn set_nspawn_flags(
|
|||
// parentless, so that set *was* every agent anyway; the capability
|
||||
// now says so out loud instead of deriving it from a field that no
|
||||
// longer discriminates.
|
||||
//
|
||||
// This is the *only* cross-agent mount left. Dropping the topology
|
||||
// parent field took with it the unconditional grant every
|
||||
// agent used to get over its own direct children — so an agent with
|
||||
// no capability now sees its own dirs and nothing else.
|
||||
if crate::capabilities::has_cap(agent_name, Capability::ManageRootAgent) {
|
||||
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
|
||||
// `agent_state_dir/state` and `config_bind_source` is shared, so
|
||||
// binding the holder as its own virtual child reproduced the two
|
||||
// own-dir mounts pushed above, byte for byte.
|
||||
for other in crate::topology::all_agents() {
|
||||
if other != agent_name && !direct_children.contains(&other) {
|
||||
if other != agent_name {
|
||||
bind_child_agent_dirs(&other, &mut binds);
|
||||
}
|
||||
}
|
||||
|
|
@ -416,10 +417,11 @@ mod tests {
|
|||
binds
|
||||
}
|
||||
|
||||
/// The boundary, asserted as a whole rather than per-dir: a parent
|
||||
/// sees a child's `state` and `config`, and nothing else.
|
||||
/// The boundary, asserted as a whole rather than per-dir: a
|
||||
/// `ManageRootAgent` holder sees another agent's `state` and
|
||||
/// `config`, and nothing else.
|
||||
#[test]
|
||||
fn parent_sees_only_child_state_and_config() {
|
||||
fn holder_sees_only_other_agents_state_and_config() {
|
||||
let paths: Vec<String> = child_binds()
|
||||
.into_iter()
|
||||
.map(|b| b.container_path)
|
||||
|
|
@ -448,21 +450,21 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The regression this exists for. `harness` holds the child's own
|
||||
/// runtime material and was only ever mounted because one loop
|
||||
/// The regression this exists for. `harness` holds the other agent's
|
||||
/// own runtime material and was only ever mounted because one loop
|
||||
/// treated all three dirs alike — re-adding it to that loop is a
|
||||
/// one-word change that nothing else would catch.
|
||||
#[test]
|
||||
fn parent_never_sees_a_child_harness_dir() {
|
||||
fn a_holder_never_sees_another_agents_harness_dir() {
|
||||
for bind in child_binds() {
|
||||
assert!(
|
||||
!bind.container_path.contains("harness"),
|
||||
"child harness must not be bound into a parent: {}",
|
||||
"another agent's harness must not be bound in: {}",
|
||||
bind.container_path
|
||||
);
|
||||
assert!(
|
||||
!bind.host_path.contains("harness"),
|
||||
"child harness must not be bound into a parent: {}",
|
||||
"another agent's harness must not be bound in: {}",
|
||||
bind.host_path
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,11 +143,8 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
let ca_touched = materialise_ca_files(&dir, &ca_files)?;
|
||||
|
||||
// Reconcile topology.json against the live agent set — adds
|
||||
// entries for newly-spawned agents (default: manager as parent,
|
||||
// manager itself as root) and drops removed agents. Operator
|
||||
// overrides via the write API are preserved because reconcile
|
||||
// only fills in missing entries. Idempotent; when nothing changed
|
||||
// the file isn't touched.
|
||||
// newly-spawned agents and drops removed ones. Idempotent; when
|
||||
// nothing changed the file isn't touched.
|
||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
|
||||
.into_iter()
|
||||
|
|
@ -573,85 +570,6 @@ pub async fn commit_perms(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies every `(child, new_parent)` move under a single `META_LOCK`
|
||||
/// acquisition and creates **one** git commit for all of them — a
|
||||
/// single-move call is just a one-element slice, so there's no separate
|
||||
/// non-batch entry point. Moves are applied in the order given; the first
|
||||
/// validation error short-circuits the whole batch. True atomic write: all
|
||||
/// moves are pre-validated against a cumulative in-memory state with
|
||||
/// [`crate::topology::apply_set_parent`] before anything touches disk, then
|
||||
/// [`crate::topology::write`] is called exactly once. If any move fails
|
||||
/// validation the topology file is never modified.
|
||||
///
|
||||
/// The multi-move commit message uses `moves[0].1` as the destination label.
|
||||
/// This is intentional: the dashboard bulk-move UI always sends a single
|
||||
/// destination for all selected agents, so the message is always accurate in
|
||||
/// practice.
|
||||
///
|
||||
/// Returns a `Vec` of `(child, old_parent)` pairs for every move that
|
||||
/// actually changed the topology (idempotent same-parent moves are skipped),
|
||||
/// so the caller can send targeted notifications.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `String` error if any move fails validation (cycle, unknown
|
||||
/// agent, etc.) or if the topology file cannot be written. Git-commit failure
|
||||
/// is logged as a warning and does not propagate — `sync_agents` will recover.
|
||||
pub async fn bulk_commit_topology(
|
||||
moves: &[(&str, Option<&str>)],
|
||||
) -> std::result::Result<Vec<(String, Option<String>)>, String> {
|
||||
if moves.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let _guard = META_LOCK.lock().await;
|
||||
// Snapshot parents before any writes so we can compute the diff.
|
||||
let topo_before = crate::topology::read();
|
||||
// Validate all moves against a cumulative in-memory state -- no disk
|
||||
// writes yet; first error aborts with the topology file untouched.
|
||||
let mut next = topo_before.clone();
|
||||
for (child, new_parent) in moves {
|
||||
next = crate::topology::apply_set_parent(&next, child, *new_parent)?;
|
||||
}
|
||||
// Only flush to disk if something actually changed.
|
||||
if next != topo_before {
|
||||
crate::topology::write(&next).map_err(|e| format!("{e:#}"))?;
|
||||
}
|
||||
// Commit the whole batch as one git operation.
|
||||
let dir = crate::paths::meta_root();
|
||||
let commit_msg = if moves.len() == 1 {
|
||||
let (child, new_parent) = moves[0];
|
||||
format!("topology: {} → {}", child, new_parent.unwrap_or("<root>"))
|
||||
} else {
|
||||
let names: Vec<&str> = moves.iter().map(|(c, _)| *c).collect();
|
||||
let dest = moves[0].1.unwrap_or("<root>");
|
||||
format!(
|
||||
"topology: move {} agents → {} ({})",
|
||||
moves.len(),
|
||||
dest,
|
||||
names.join(", ")
|
||||
)
|
||||
};
|
||||
let stage = async {
|
||||
git(&dir, &["add", "topology.json"]).await?;
|
||||
if paths_dirty(&dir, &["topology.json"]).await? {
|
||||
git_commit_paths(&dir, &commit_msg, &["topology.json"]).await?;
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
if let Err(e) = stage.await {
|
||||
tracing::warn!(error = ?e, "bulk_commit_topology: topology written but git commit failed (sync_agents will recover)");
|
||||
}
|
||||
// Return (child, old_parent) for each move that changed state.
|
||||
let changed = moves
|
||||
.iter()
|
||||
.filter_map(|(child, new_parent)| {
|
||||
let old = topo_before.get(*child).cloned().flatten();
|
||||
(old.as_deref() != *new_parent).then_some((child.to_string(), old))
|
||||
})
|
||||
.collect();
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \
|
||||
|
|
@ -1163,7 +1081,7 @@ where
|
|||
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
|
||||
);
|
||||
out.push_str(
|
||||
r#" let
|
||||
|
|
@ -1172,7 +1090,6 @@ where
|
|||
else hyperhive.nixosConfigurations.agent-base;
|
||||
input = inputs."agent-${name}";
|
||||
service = "hive-agent";
|
||||
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
|
||||
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
|
||||
capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; };
|
||||
in
|
||||
|
|
@ -1353,7 +1270,7 @@ where
|
|||
}
|
||||
out.push_str(
|
||||
r" };
|
||||
systemd.services.${service}.environment = parentEnv // toolGroupsEnv // capabilitiesEnv // {
|
||||
systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // {
|
||||
HIVE_PORT = toString port;
|
||||
HIVE_LABEL = name;
|
||||
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
||||
|
|
@ -1399,19 +1316,10 @@ where
|
|||
nixosConfigurations = {
|
||||
"#,
|
||||
);
|
||||
// Pull the topology map once and look up each agent's parent. An
|
||||
// empty / absent topology.json yields `parent = null` for everyone
|
||||
// (every container at root). `meta::sync_agents` seeds the file
|
||||
// on first run with manager as root + everyone else under manager.
|
||||
let topology = crate::topology::read();
|
||||
let tool_groups_map = crate::tool_groups::read();
|
||||
let capabilities_map = crate::capabilities::read();
|
||||
let resource_limits_map = crate::resource_limits::read();
|
||||
for spec in agents {
|
||||
let parent_attr = topology
|
||||
.get(&spec.name)
|
||||
.and_then(|p| p.as_ref())
|
||||
.map_or_else(|| "null".to_owned(), |p| format!("\"{p}\""));
|
||||
// Emit `toolGroups = "group1,group2"` when the operator has
|
||||
// explicitly configured groups for this agent. Absent entry = null
|
||||
// = harness falls back to AGENT_DEFAULT (no env var emitted,
|
||||
|
|
@ -1451,12 +1359,11 @@ where
|
|||
.map_or_else(|| "null".to_owned(), |b| b.to_string());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
|
||||
spec.name,
|
||||
spec.name,
|
||||
if spec.is_manager { "true" } else { "false" },
|
||||
spec.port,
|
||||
parent_attr,
|
||||
tool_groups_attr,
|
||||
capabilities_attr,
|
||||
memory_max_attr,
|
||||
|
|
|
|||
|
|
@ -207,26 +207,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
actions::deny(&coord, *id, None)?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::SetParent { child, new_parent } => {
|
||||
tracing::info!(%child, ?new_parent, "set_parent");
|
||||
// Fire-and-forget, like every other queue-backed op:
|
||||
// submit returns a DAG id immediately, the caller polls
|
||||
// `QueueDag` (`hivectl`'s wait/progress loop) for the
|
||||
// outcome instead of blocking here on the commit.
|
||||
let inserted = coord.job_queue.insert_job(|b| {
|
||||
vec![crate::job_queue::templates::reparent(
|
||||
b,
|
||||
vec![(child.clone(), new_parent.clone())],
|
||||
)]
|
||||
});
|
||||
match inserted {
|
||||
Ok(ids) => {
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
HostResponse::queued(ids.into_iter().map(hive_jobq::NodeId::get).collect())
|
||||
}
|
||||
Err(e) => HostResponse::error(format!("queue reparent: {e}")),
|
||||
}
|
||||
}
|
||||
HostRequest::SetResourceLimits {
|
||||
name,
|
||||
cpu_quota,
|
||||
|
|
|
|||
|
|
@ -712,36 +712,9 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fan out one message to each recipient in `targets`. Skips the sender
|
||||
/// itself. Returns a list of `"<agent>: <error>"` strings for any delivery
|
||||
/// failures (empty = all good).
|
||||
pub(crate) fn fan_out_send(
|
||||
coord: &Arc<Coordinator>,
|
||||
from: &str,
|
||||
body: &str,
|
||||
in_reply_to: Option<i64>,
|
||||
targets: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
for target in targets {
|
||||
if target == from {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = coord.broker.send(&Message {
|
||||
from: hive_sh4re::manager::trusted_sender(from),
|
||||
to: target.clone(),
|
||||
body: body.to_owned(),
|
||||
in_reply_to,
|
||||
}) {
|
||||
errors.push(format!("{target}: {e}"));
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// Common Send handler shared between dispatch arms. Applies the
|
||||
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / children fan-out
|
||||
/// (`to == "<children>"`) / unicast through their respective broker calls.
|
||||
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through
|
||||
/// their respective broker calls.
|
||||
/// `pub(crate)` so `dispatch_shared` can use it across both socket paths.
|
||||
pub(crate) fn handle_send(
|
||||
coord: &Arc<Coordinator>,
|
||||
|
|
@ -763,50 +736,30 @@ pub(crate) fn handle_send(
|
|||
}
|
||||
};
|
||||
}
|
||||
// `<children>`: fan out to every direct descendant of the sender per
|
||||
// topology.json. Bypasses the allow-list check — structural fan-out
|
||||
// targets are never user-listed peers. No-op (returns Ok) for leaf
|
||||
// agents that have no children.
|
||||
if to == hive_sh4re::manager::CHILDREN_RECIPIENT {
|
||||
let children = crate::topology::children_of(agent);
|
||||
let errors = fan_out_send(coord, agent, body, in_reply_to, &children);
|
||||
return if errors.is_empty() {
|
||||
Response::Ok
|
||||
} else {
|
||||
Response::Err {
|
||||
message: format!("children fan-out failed for agents: {}", errors.join(", ")),
|
||||
}
|
||||
};
|
||||
}
|
||||
// Resolve magic-recipient sentinels (`<parent>`) against topology.json;
|
||||
// no-op for ordinary names. Lets agents address structural roles without
|
||||
// learning the label — runtime reparenting propagates for free. See
|
||||
// `docs/process/conventions.md::Recipient sentinels`.
|
||||
let resolved = crate::topology::resolve_recipient(agent, to);
|
||||
// Validate that the resolved recipient is a known local agent or the
|
||||
// Validate that the recipient is a known local agent or the
|
||||
// special "operator" recipient. Without this check a typo in `to`
|
||||
// silently queues a message nobody will ever read.
|
||||
//
|
||||
// Cross-hive messaging (`name@hive` qualified names) is not routed
|
||||
// through the broker — use the Matrix MCP tools for that instead.
|
||||
if resolved.contains('@') {
|
||||
if to.contains('@') {
|
||||
return Response::Err {
|
||||
message: format!(
|
||||
"send failed: cross-hive recipient `{resolved}` is not supported \
|
||||
"send failed: cross-hive recipient `{to}` is not supported \
|
||||
via the broker — use Matrix MCP tools for cross-hive messaging"
|
||||
),
|
||||
};
|
||||
}
|
||||
if resolved != hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||
if to != hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||
// A name that doesn't parse as an Ident can't be a local agent, so
|
||||
// it collapses into the same "unknown recipient" error as a valid
|
||||
// name with no state dir.
|
||||
let exists = hive_types::Ident::parse(&resolved)
|
||||
let exists = hive_types::Ident::parse(to)
|
||||
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
|
||||
if !exists {
|
||||
return Response::Err {
|
||||
message: format!(
|
||||
"send failed: unknown recipient `{resolved}` \
|
||||
"send failed: unknown recipient `{to}` \
|
||||
(no agent with that name exists on this hive)"
|
||||
),
|
||||
};
|
||||
|
|
@ -814,7 +767,7 @@ pub(crate) fn handle_send(
|
|||
}
|
||||
match coord.broker.send(&Message {
|
||||
from: hive_sh4re::manager::trusted_sender(agent),
|
||||
to: resolved,
|
||||
to: to.to_owned(),
|
||||
body: body.to_owned(),
|
||||
in_reply_to,
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@ mod tests {
|
|||
needs_update: false,
|
||||
needs_login,
|
||||
deployed_sha: None,
|
||||
parent: None,
|
||||
active_model: None,
|
||||
status_text: None,
|
||||
status_set_at: None,
|
||||
|
|
|
|||
|
|
@ -355,83 +355,6 @@ impl Broker {
|
|||
Ok(u64::try_from(n.max(0)).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Send a "your parent changed from X to Y" notification to `child`,
|
||||
/// coalescing with any existing undelivered one so that multiple moves
|
||||
/// while the agent is offline collapse into a single message spanning
|
||||
/// the full arc (e.g. A→B then B→C becomes "your parent changed from A to C").
|
||||
///
|
||||
/// If an undelivered system reparent notification for `child` already
|
||||
/// exists, its body is updated in-place preserving the original "from"
|
||||
/// label. If none exists, a fresh message is inserted with `old_label`
|
||||
/// as the source.
|
||||
pub fn send_coalescing_reparent(
|
||||
&self,
|
||||
child: &str,
|
||||
old_label: &str,
|
||||
new_label: &str,
|
||||
) -> Result<()> {
|
||||
const PREFIX: &str = "your parent changed from ";
|
||||
const SEPARATOR: &str = " to ";
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = Utc::now().timestamp();
|
||||
let existing: Option<(i64, String)> = conn
|
||||
.query_row(
|
||||
"SELECT id, body FROM messages
|
||||
WHERE recipient = ?1
|
||||
AND sender = ?2
|
||||
AND body LIKE ?3
|
||||
AND delivered_at IS NULL
|
||||
AND acked_at IS NULL
|
||||
LIMIT 1",
|
||||
params![
|
||||
child,
|
||||
hive_sh4re::manager::SYSTEM_SENDER,
|
||||
format!("{PREFIX}%")
|
||||
],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((row_id, old_body)) = existing {
|
||||
// Preserve the original "from" label from the earlier notification.
|
||||
let original_from = old_body
|
||||
.strip_prefix(PREFIX)
|
||||
.and_then(|rest| rest.split(SEPARATOR).next())
|
||||
.unwrap_or(old_label);
|
||||
let new_body = format!("{PREFIX}{original_from}{SEPARATOR}{new_label}");
|
||||
conn.execute(
|
||||
"UPDATE messages SET body = ?1, sent_at = ?2 WHERE id = ?3",
|
||||
params![new_body, now, row_id],
|
||||
)?;
|
||||
drop(conn);
|
||||
let _ = self.events.send(MessageEvent::Sent {
|
||||
id: row_id,
|
||||
from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(),
|
||||
to: child.to_owned(),
|
||||
body: new_body,
|
||||
at: now,
|
||||
in_reply_to: None,
|
||||
});
|
||||
} else {
|
||||
let body = format!("{PREFIX}{old_label}{SEPARATOR}{new_label}");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to)
|
||||
VALUES (?1, ?2, ?3, ?4, NULL)",
|
||||
params![hive_sh4re::manager::SYSTEM_SENDER, child, body, now],
|
||||
)?;
|
||||
let row_id = conn.last_insert_rowid();
|
||||
drop(conn);
|
||||
let _ = self.events.send(MessageEvent::Sent {
|
||||
id: row_id,
|
||||
from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(),
|
||||
to: child.to_owned(),
|
||||
body,
|
||||
at: now,
|
||||
in_reply_to: None,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Long-poll variant of `recv_batch`: returns immediately if any
|
||||
/// row is pending (popping up to `max`); otherwise waits up to
|
||||
/// `timeout` for the broker to emit a `Sent { to: recipient }`
|
||||
|
|
|
|||
|
|
@ -250,44 +250,6 @@ fn seed_manager_capabilities() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sort `names` in-place so parents precede their children in the topology.
|
||||
/// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last,
|
||||
/// alphabetically within their tier. Stable within each depth tier.
|
||||
pub fn topology_sort(
|
||||
names: &mut [String],
|
||||
topo: &std::collections::BTreeMap<String, Option<String>>,
|
||||
) {
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
// Build depth map using owned clones so the borrow on `names` is released
|
||||
// before the sort_by mutable borrow.
|
||||
let name_set: Vec<String> = names.to_vec();
|
||||
let mut depth: HashMap<String, usize> = HashMap::new();
|
||||
let mut queue: VecDeque<String> = VecDeque::new();
|
||||
// Seed roots: entries with no parent, or names not present in topo at all.
|
||||
for name in &name_set {
|
||||
if topo.get(name).is_none_or(Option::is_none) {
|
||||
depth.insert(name.clone(), 0);
|
||||
queue.push_back(name.clone());
|
||||
}
|
||||
}
|
||||
// BFS to assign depths to children.
|
||||
while let Some(parent) = queue.pop_front() {
|
||||
let d = depth[&parent] + 1;
|
||||
for name in &name_set {
|
||||
let is_child = topo.get(name).and_then(|p| p.as_deref()) == Some(parent.as_str());
|
||||
if is_child && !depth.contains_key(name) {
|
||||
depth.insert(name.clone(), d);
|
||||
queue.push_back(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_by(|a, b| {
|
||||
let da = depth.get(a).copied().unwrap_or(usize::MAX);
|
||||
let db = depth.get(b).copied().unwrap_or(usize::MAX);
|
||||
da.cmp(&db).then(a.cmp(b))
|
||||
});
|
||||
}
|
||||
|
||||
/// Boot reconcile (see the module doc): classify every agent by rev
|
||||
/// freshness + persisted `wanted` intent, submit one `Boot` DAG
|
||||
/// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale
|
||||
|
|
@ -305,16 +267,16 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
|||
|
||||
let current_rev = current_flake_rev(&coord.hyperhive_flake);
|
||||
|
||||
// Resolve container names to logical agent names, then sort by
|
||||
// topology depth so parents are always rebuilt before their
|
||||
// children. Root agents (depth 0) go first; agents absent from
|
||||
// the topology file sort last (stable, alphabetical within tier).
|
||||
// Resolve container names to logical agent names, then sort. The
|
||||
// parent field this used to depth-sort by is gone; with no
|
||||
// hierarchy left to respect, alphabetical is the whole order — and it
|
||||
// is exactly what the depth sort already produced once every agent
|
||||
// was a root.
|
||||
let mut logical_names: Vec<String> = containers
|
||||
.iter()
|
||||
.filter_map(|c| c.strip_prefix(AGENT_PREFIX).map(str::to_owned))
|
||||
.collect();
|
||||
let topo = crate::topology::read();
|
||||
topology_sort(&mut logical_names, &topo);
|
||||
logical_names.sort();
|
||||
|
||||
// Classify. `get_or_seed` doubles as the one-time migration: an
|
||||
// agent without an `agent_power` row is seeded from its observed
|
||||
|
|
@ -374,8 +336,8 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
|||
// Rebuild running agents first. All fanout entries are wanted=Up;
|
||||
// among them, warm the live/serving agents onto the fresh config before the
|
||||
// stopped-but-wanted-up ones so the scarce build slots hit uptime-critical
|
||||
// agents first. Stable sort keeps topology order (parents before children)
|
||||
// within each running/stopped group. The `drifted` reconciles aren't sorted
|
||||
// agents first. Stable sort keeps the alphabetical order within each
|
||||
// running/stopped group. The `drifted` reconciles aren't sorted
|
||||
// — they hold no build slot and run concurrently, so their order is moot.
|
||||
fanout.sort_by_key(|(_, running)| !running);
|
||||
let fanout: Vec<String> = fanout.into_iter().map(|(name, _)| name).collect();
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ pub enum HostRequest {
|
|||
/// credentials into the existing state tree.
|
||||
AgentExists { name: Ident },
|
||||
/// List managed agents with their full status + technical state
|
||||
/// (running / needs-login / needs-update / deployed sha / parent /
|
||||
/// (running / needs-login / needs-update / deployed sha /
|
||||
/// pending reminders) — the `hivectl list-agents` roster view.
|
||||
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
|
||||
AgentStatus,
|
||||
|
|
@ -247,14 +247,6 @@ pub enum HostRequest {
|
|||
Approve { id: i64 },
|
||||
/// Deny a pending request by id.
|
||||
Deny { id: i64 },
|
||||
/// Move an agent in the topology tree. `new_parent = None`
|
||||
/// promotes the agent to root, `Some(name)` sets a new parent.
|
||||
/// Validation rules + bind-mount caveat documented in
|
||||
/// `docs/agent-lifecycle/agent-hierarchy.md::Reparenting`.
|
||||
SetParent {
|
||||
child: Ident,
|
||||
new_parent: Option<Ident>,
|
||||
},
|
||||
/// Declare an agent's CPU/memory overrides for the per-container
|
||||
/// systemd drop-in, persisted to `meta/resource-limits.json`.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -53,9 +53,6 @@ pub struct AgentStatusRow {
|
|||
/// paused while stopped, and pause survives a restart.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
/// Parent in the topology tree. `None` marks a root-level agent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
/// The Claude model the agent's harness is currently using. Mirrors
|
||||
/// `container_view::ContainerView::active_model`: `None` when the
|
||||
/// agent has never started a turn, the field is absent from its
|
||||
|
|
|
|||
|
|
@ -17,23 +17,6 @@ pub const MANAGER_AGENT: &str = "ruth";
|
|||
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
|
||||
pub const OPERATOR_RECIPIENT: &str = "operator";
|
||||
|
||||
/// Reserved magic recipient — `send(to: "<parent>", ...)` is rewritten
|
||||
/// by hive-c0re at delivery time to whoever `topology::parent_of(sender)`
|
||||
/// returns, or to [`OPERATOR_RECIPIENT`] when the sender is a root agent
|
||||
/// (no parent). Lets agents address their parent without hardcoding the
|
||||
/// label, so runtime reparenting requires no agent-side restart. The
|
||||
/// angle brackets are not valid in agent names (validators reject
|
||||
/// `<`/`>`), so this name can never collide with a real recipient.
|
||||
pub const PARENT_RECIPIENT: &str = "<parent>";
|
||||
|
||||
/// Reserved magic recipient — `send(to: "<children>", ...)` fans out to
|
||||
/// every agent whose direct parent (per `topology.json`) is the sender.
|
||||
/// Lets a sub-manager nudge its subtree without enumerating labels at
|
||||
/// call-time; topology changes propagate for free. The angle brackets
|
||||
/// are structurally safe — agent name validation rejects `<`/`>`.
|
||||
/// Delivers to an empty set (no-op) for leaf agents that have no children.
|
||||
pub const CHILDREN_RECIPIENT: &str = "<children>";
|
||||
|
||||
/// Sender hive-c0re uses for events it pushes into the manager's inbox.
|
||||
/// Manager harness recognises this and parses the body as a `HelperEvent`.
|
||||
pub const SYSTEM_SENDER: &str = "system";
|
||||
|
|
@ -113,9 +96,7 @@ pub struct SchedulePromptPayload {
|
|||
|
||||
#[cfg(test)]
|
||||
mod reserved_name_tests {
|
||||
use super::{
|
||||
CHILDREN_RECIPIENT, MANAGER_AGENT, OPERATOR_RECIPIENT, PARENT_RECIPIENT, SYSTEM_SENDER,
|
||||
};
|
||||
use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER};
|
||||
use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name};
|
||||
|
||||
/// The blacklist as nix rendered it for this test run.
|
||||
|
|
@ -182,24 +163,6 @@ mod reserved_name_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The other half, and the reason the test above is not vacuous: these
|
||||
/// sentinels are *unreachable* as agent names because the charset
|
||||
/// rejects them, so they are correctly absent from the list. If a
|
||||
/// charset change ever made one parseable, it would become a real
|
||||
/// collision and this test is what notices.
|
||||
#[test]
|
||||
fn bracketed_recipients_cannot_be_agent_names() {
|
||||
let owned = reserved();
|
||||
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
for sentinel in [PARENT_RECIPIENT, CHILDREN_RECIPIENT] {
|
||||
assert!(
|
||||
Ident::parse(sentinel).is_err(),
|
||||
"{sentinel:?} parses as an ident now — it is reachable as an agent name and must be reserved"
|
||||
);
|
||||
assert!(!is_reserved_name(sentinel, &reserved));
|
||||
}
|
||||
}
|
||||
|
||||
/// `ruth` is a real agent, not a protocol literal, so it is not
|
||||
/// reserved: a second agent wanting the name is a *taken* name, which
|
||||
/// the roster check answers. Recorded as a test so the distinction is
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! `hivectl agent <name>` — everything scoped to ONE managed agent:
|
||||
//! container lifecycle over the host admin socket (restart/pause/resume/
|
||||
//! spawn/kill/destroy/rebuild/set-parent/set-limits/choom/watch), plus the
|
||||
//! spawn/kill/destroy/rebuild/set-limits/choom/watch), plus the
|
||||
//! `quota` and `subvol` groups, whose handlers live in their own modules.
|
||||
//! `agents_list` (`hivectl list-agents`) is the one genuinely hive-wide
|
||||
//! read that lives in this module too since it shares the same daemon
|
||||
|
|
@ -172,15 +172,14 @@ pub(crate) async fn agents_list(socket: &Path, json: bool) -> Result<()> {
|
|||
}
|
||||
s
|
||||
};
|
||||
let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"];
|
||||
let table: Vec<[String; 5]> = rows
|
||||
let headers = ["NAME", "STATUS", "REV", "REMIND"];
|
||||
let table: Vec<[String; 4]> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
[
|
||||
r.name.clone(),
|
||||
status_of(r),
|
||||
r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()),
|
||||
r.parent.clone().unwrap_or_else(|| "-".to_owned()),
|
||||
if r.pending_reminders > 0 {
|
||||
r.pending_reminders.to_string()
|
||||
} else {
|
||||
|
|
@ -291,18 +290,6 @@ pub(crate) async fn run_agent(socket: &Path, name: &str, cmd: AgentCmd) -> Resul
|
|||
let name = crate::util::parse_ident(name)?;
|
||||
render(crate::client::request(socket, HostRequest::Rebuild { name }).await?)
|
||||
}
|
||||
AgentCmd::SetParent { parent, root } => {
|
||||
let child = crate::util::parse_ident(name)?;
|
||||
let new_parent = if root {
|
||||
None
|
||||
} else {
|
||||
parent.map(|p| crate::util::parse_ident(&p)).transpose()?
|
||||
};
|
||||
render(
|
||||
crate::client::request(socket, HostRequest::SetParent { child, new_parent })
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
AgentCmd::SetLimits {
|
||||
cpu_quota,
|
||||
memory_max,
|
||||
|
|
|
|||
|
|
@ -507,16 +507,6 @@ pub enum AgentCmd {
|
|||
},
|
||||
/// Apply pending config to this managed container.
|
||||
Rebuild,
|
||||
/// Move this agent in the topology tree — under a new parent, or to
|
||||
/// root.
|
||||
SetParent {
|
||||
/// New parent agent name. Mutually exclusive with `--root`.
|
||||
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
|
||||
parent: Option<String>,
|
||||
/// Promote this agent to root (no parent).
|
||||
#[arg(long)]
|
||||
root: bool,
|
||||
},
|
||||
/// Declare this agent's CPU/memory limits, overriding the hive-wide
|
||||
/// defaults.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -151,9 +151,9 @@ in
|
|||
Place the file inside the agent's bind-mounted **harness** dir (e.g.
|
||||
`/agents/<name>/harness/openrouter.env`, `$HYPERHIVE_HARNESS_DIR`),
|
||||
not `state/` — `harness/` survives container rebuilds exactly like
|
||||
`state/` does, but is never bind-mounted into a parent agent's
|
||||
container (unlike `state/`, which a parent gets read-write for child
|
||||
recovery — see `docs/agent-lifecycle/persistence.md`'s "Parent access to child
|
||||
`state/` does, but is never bind-mounted into another agent's
|
||||
container (unlike `state/`, which a `ManageRootAgent` holder gets
|
||||
read-write for recovery — see `docs/agent-lifecycle/persistence.md`'s "Cross-agent access to
|
||||
state"), so this credential is reachable by nothing but this agent
|
||||
and the host. Permissions should be `0600`, owned by the agent's
|
||||
unix user. Loaded with a leading `-` (optional `EnvironmentFile`),
|
||||
|
|
|
|||
|
|
@ -183,20 +183,21 @@ in
|
|||
mkdir -p "$(dirname "$marker")"
|
||||
: > "$marker"
|
||||
# Scope state + harness chowns to THIS container's own dirs only.
|
||||
# The glob `/agents/*/state` also matches child-agent state dirs that
|
||||
# are bind-mounted into parent containers, which would clobber the
|
||||
# ownership those dirs' own activation scripts set — producing
|
||||
# intermittent EACCES for the child agent's harness between a parent
|
||||
# rebuild and the child's next activation. Config dirs are kept broad
|
||||
# because the parent legitimately owns child proposed-config repos.
|
||||
# The glob `/agents/*/state` also matches other agents' state dirs
|
||||
# bind-mounted into a `ManageRootAgent` holder's container, which
|
||||
# would clobber the ownership those dirs' own activation scripts set
|
||||
# — producing intermittent EACCES for that agent's harness between
|
||||
# the holder's rebuild and its own next activation. Config dirs are
|
||||
# kept broad because the holder legitimately owns the proposed-config
|
||||
# repos it edits.
|
||||
if [ -d "/agents/$userName/state" ]; then
|
||||
chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "/agents/$userName/harness" ]; then
|
||||
chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true
|
||||
fi
|
||||
# The proposed-config repo is RW-mounted into the editing (parent/
|
||||
# manager) agent and owned by it; hive-c0re only pulls from it. Heal
|
||||
# The proposed-config repo is RW-mounted into the editing agent and
|
||||
# owned by it; hive-c0re only pulls from it. Heal
|
||||
# it to this user too — same as state/harness. In an agent's own
|
||||
# container its config is RO-mounted, so the chown there just fails
|
||||
# harmlessly (|| true).
|
||||
|
|
|
|||
|
|
@ -60,11 +60,7 @@
|
|||
# against hive names, in `swarm-otel.nix`'s own `reservedOwners`.
|
||||
"swarm"
|
||||
]
|
||||
# Deliberately absent, and both are load-bearing omissions:
|
||||
#
|
||||
# `<parent>` / `<children>` — routing recipients the ident charset already
|
||||
# rejects, so no name can ever equal them; listing them would imply a guard
|
||||
# that never fires.
|
||||
# Deliberately absent, and it is a load-bearing omission:
|
||||
#
|
||||
# `ruth` — a real agent, not a literal. A second agent wanting that name is a
|
||||
# name that is TAKEN, which the roster answers, not this list.
|
||||
|
|
|
|||
Loading…
Reference in a new issue