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).
|
state machine, `flake.lock` validation).
|
||||||
- **What state survives destroy / purge / restart?** →
|
- **What state survives destroy / purge / restart?** →
|
||||||
[`agent-lifecycle/persistence.md`](agent-lifecycle/persistence.md).
|
[`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).
|
[`agent-lifecycle/agent-hierarchy.md`](agent-lifecycle/agent-hierarchy.md).
|
||||||
- **How does claude get its prompt, and what tools does it have?** →
|
- **How does claude get its prompt, and what tools does it have?** →
|
||||||
[`turn-loop/`](turn-loop/README.md) — the loop, binary shape, turn
|
[`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 -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
Every agent has a place in an operator-editable parent/child tree, used
|
Agents are a **flat set**, with no parent/child tree: the `parent` field
|
||||||
to scope which agents can manage which others. This doc covers how
|
`topology.json` used to carry is gone, along with every mechanism that
|
||||||
hive-c0re stores and edits the tree today, the rules that are meant to run on top
|
read it. The capability store scopes which agents can manage which
|
||||||
of it once enforcement is finished, and where the manager still gets
|
others; a tree position no longer scopes anything.
|
||||||
special-cased in the meantime, as a tracked cleanup.
|
|
||||||
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- 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`:
|
`flake.nix`, at `/var/lib/hyperhive/meta/topology.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
["alice", "bob", "ruth"]
|
||||||
"ruth": null,
|
|
||||||
"alice": null,
|
|
||||||
"bob": "alice"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`null` = root-level agent. New agents **default to root** — there is no
|
One entry per agent the hive knows about, in name order. The file
|
||||||
structural manager that everything hangs under. The operator builds
|
carries no per-agent value any more, and encodes no ordering or
|
||||||
hierarchy explicitly: an agent gets a parent edge written before its
|
grouping — it answers exactly one question, _which agents exist,_ and
|
||||||
first spawn, or the operator reparents it afterwards (so `bob` above sits under
|
`topology::all_agents` is the only reader that matters.
|
||||||
`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).
|
|
||||||
|
|
||||||
### 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`
|
### Reading the legacy format
|
||||||
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> }`.
|
|
||||||
|
|
||||||
All three go through the same validation, which refuses:
|
`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
|
||||||
- unknown `child` / `new_parent` (typo guard),
|
the change reads the same roster rather than an empty one. An empty
|
||||||
- self-parenting,
|
roster costs more than a cosmetic gap: every capability holder loses its
|
||||||
- cycles (a bounded ancestor walk — moving the manager under one of
|
mounts until the next reconcile pass writes the array form.
|
||||||
its own descendants is the only real safety concern here, and it's
|
|
||||||
caught the same way as any other agent).
|
|
||||||
|
|
||||||
Setting a parent to its current value is a no-op (no disk write). A
|
|
||||||
successful change triggers an immediate rescan, so connected dashboard
|
|
||||||
viewers see the tree repaint without polling.
|
|
||||||
|
|
||||||
### Why meta, not per-agent `agent.nix`
|
### Why meta, not per-agent `agent.nix`
|
||||||
|
|
||||||
An agent shouldn't be able to claim a parent without that parent's
|
An agent shouldn't be able to add itself to a set that governs who may
|
||||||
consent, and operator-driven re-parenting shouldn't require touching
|
reach its state dir. The roster IS a system-level fact; meta is where
|
||||||
the moved agent's config. Topology IS a system-level concern; meta is
|
system-level facts live.
|
||||||
where system-level facts live.
|
|
||||||
|
|
||||||
### How `topology.json` gets updated
|
### How `topology.json` gets updated
|
||||||
|
|
||||||
- **Read** — parsed into an agent→parent map; a missing or unparsable
|
- **Read** — parsed into a set of names; a missing or unparsable file
|
||||||
file degrades safely to "every agent is root" (covers a fresh
|
degrades safely to "no agents" (covers a fresh install that hasn't
|
||||||
install that hasn't synced yet).
|
synced yet).
|
||||||
- **Reconcile** — runs alongside the periodic meta/flake regeneration.
|
- **Reconcile** — runs alongside the periodic meta/flake regeneration.
|
||||||
New agents default to root unless they already carry an explicit
|
Adds newly-spawned agents, drops removed ones. Reconcile keeps agents
|
||||||
parent edge written before their first spawn; Reconcile preserves
|
whose config repo exists but that haven't spawned yet, so the gap until
|
||||||
existing entries (including operator overrides); removed agents drop.
|
the container appears doesn't churn the file.
|
||||||
Agents whose config repo exists but that haven't spawned yet keep
|
|
||||||
their edge too, so it survives the gap until the container actually
|
No write API and no operator verb reach this file. Reconcile derives it
|
||||||
appears.
|
from which agents exist, so the next pass overwrites a hand edit.
|
||||||
- **Inject** — hive-c0re exposes each container's parent (if any) to its own
|
|
||||||
environment as `HIVE_PARENT`, so the harness / system-prompt
|
|
||||||
renderer can see it.
|
|
||||||
- **Surface** — every rescan re-reads `topology.json` and populates
|
|
||||||
`ContainerView.parent`, which the dashboard renders as a tree.
|
|
||||||
|
|
||||||
See `hive-c0re/src/agent_config/topology.rs` and `hive-c0re/src/meta.rs`'s
|
See `hive-c0re/src/agent_config/topology.rs` and `hive-c0re/src/meta.rs`'s
|
||||||
module docs for the exact call chain.
|
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
|
Recorded so a reader who finds one of these in an old branch, an issue
|
||||||
(`root`) gets `/var/lib/hyperhive/agents` bind-mounted at `/agents` in
|
thread or a stale comment knows each one went away rather than moved:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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 |
|
<!-- vale write-good.Passive = NO -->
|
||||||
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
"Ancestor" walks `ContainerView.parent` chains; a visited-set guards against
|
The last row is the one with teeth: an agent that used to reach a child's
|
||||||
cycles at dispatch time (a malformed `topology.json` can't lock
|
state dir by virtue of being its parent no longer reaches it at all
|
||||||
the dispatcher into a loop).
|
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
|
## 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**
|
**manager (`ruth`) still gets some hard-coded special treatment**
|
||||||
other agents don't:
|
other agents don't:
|
||||||
|
|
||||||
|
|
@ -113,13 +101,12 @@ other agents don't:
|
||||||
key, and nixos-container name are all `ruth` (container `h-ruth`).
|
key, and nixos-container name are all `ruth` (container `h-ruth`).
|
||||||
`hive-c0re` spawns it directly at boot if missing, with no operator
|
`hive-c0re` spawns it directly at boot if missing, with no operator
|
||||||
approval step — every other agent goes through a `Spawn` approval.
|
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
|
- **Wire-protocol** — the privileged `Request` variants
|
||||||
(`Kill` / `Start` / `Restart` / `Update`; `GetLogs`) — marked
|
(`Kill` / `Start` / `Restart` / `Update`; `GetLogs`) — marked
|
||||||
`*(privileged)*` in `hive-core-agent-sock`'s unified `Request` enum —
|
`*(privileged)*` in `hive-core-agent-sock`'s unified `Request` enum —
|
||||||
are reachable only from the manager's socket flavour today. Planned
|
are reachable only from the manager's socket flavour today; each is
|
||||||
rule for each is in the table above ("any ancestor" for
|
planned to become a capability check. One exception: `Wake` (inject a `from: <X>` message into the
|
||||||
lifecycle/logs). One exception: `Wake` (inject a `from: <X>` message into the
|
|
||||||
caller's own inbox) isn't really privileged — every per-agent daemon
|
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
|
(for example `hive-forge-notify`) needs it, and sub-agents already have the
|
||||||
equivalent on their own socket.
|
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
|
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
|
real config change is a PR from a clone), plus RO mounts for
|
||||||
`/applied` (diff against what's deployed) and `/meta` (system-wide
|
`/applied` (diff against what's deployed) and `/meta` (system-wide
|
||||||
deploy log). Planned: each agent gets RW to `/agents/<descendant>/`
|
deploy log). That grant is the `ManageRootAgent` capability now, and
|
||||||
for just its own subtree — the manager's full-forest RW becomes the
|
ruth holds it; no name check remains. hive-c0re will
|
||||||
"root's subtree is everything" case of that same rule. hive-c0re will
|
|
||||||
gate RO `/meta` access on a "meta read" capability; no agent-facing
|
gate RO `/meta` access on a "meta read" capability; no agent-facing
|
||||||
path writes `flake.lock` any more — `request_update_meta_inputs` was
|
path writes `flake.lock` any more — `request_update_meta_inputs` was
|
||||||
removed, leaving the operator dashboard's `POST
|
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),
|
visibility (manager sees hive-wide, sub-agents only their own),
|
||||||
`destroy` refusing to act on the manager, and crash-watch skipping
|
`destroy` refusing to act on the manager, and crash-watch skipping
|
||||||
the manager (it autorestarts via systemd instead of going through
|
the manager (it autorestarts via systemd instead of going through
|
||||||
the crash-watch loop). Each is planned to become an
|
the crash-watch loop). Each is planned to become a capability
|
||||||
ancestor/descendant check instead of a manager-name check — see the
|
check instead of a manager-name check — see the
|
||||||
module docs for `loose_ends.rs`, `stores/broker.rs`, `actions.rs`,
|
module docs for `loose_ends.rs`, `stores/broker.rs`, `actions.rs`,
|
||||||
and `workers/crash_watch.rs` for the current owner-check logic in
|
and `workers/crash_watch.rs` for the current owner-check logic in
|
||||||
each. (The harness handles reminder cancellation fully in-agent — see
|
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
|
None of the above is a stable interface — treat the module doc
|
||||||
comments as the source of truth for exactly which checks exist today.
|
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
|
## Harness systemd unit shape
|
||||||
|
|
||||||
One harness serve binary (`hive-agent`, with its `hive-agent-mcp`
|
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
|
## Cross-references
|
||||||
|
|
||||||
- Milestone: "Agent privileges and sub-agents" (tracked internally)
|
- 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)
|
- Audit table source: milestone comment (tracked internally)
|
||||||
- Operator/agent trust boundary (orthogonal axis): [`boundary.md`](../trust-boundary/boundary.md)
|
- 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
|
### Submitting agent's view of config repos
|
||||||
|
|
||||||
Every parent agent's container has its **direct children's** config
|
An agent holding `ManageRootAgent` has every other agent's config repo
|
||||||
repos bind-mounted **read-only** (topology-driven:
|
bind-mounted **read-only** (`hive-c0re/src/lifecycle/host_config.rs`
|
||||||
`hive-c0re/src/lifecycle/host_config.rs` calls `bind_child_agent_dirs`
|
calls `bind_child_agent_dirs` for each entry in
|
||||||
for each entry in
|
`topology::all_agents()`). it's a copy to *read* another agent's
|
||||||
`topology::children_of(agent_name)`). it's a copy to *read* a child's
|
current config — not an editing surface. An agent without the
|
||||||
current config — not an editing surface.
|
capability sees no other agent's config at all.
|
||||||
|
|
||||||
An agent with the `approvals` tool group submits a change the same way
|
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
|
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
|
the operator review and approve it. By design, no second, mount-shaped
|
||||||
path reaches the same file without the review.
|
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-turn-stats.sqlite` — per-turn timing stats.
|
||||||
- `hyperhive-model` — single-line model name override file.
|
- `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
|
An agent holding the `ManageRootAgent` capability gets every other
|
||||||
**read-write** and its `config` dir **read-only**
|
agent's `state` dir bind-mounted **read-write** and its `config` dir
|
||||||
(`bind_child_agent_dirs` in `lifecycle/host_config.rs`). The RW on
|
**read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`).
|
||||||
`state` is deliberate, not an oversight: a parent manages its children,
|
The RW on `state` is deliberate, not an oversight: the holder recovers
|
||||||
which includes writing into a child's state for recovery (for example seeding
|
other agents, which includes writing into their state (for example
|
||||||
notes, clearing a stuck sentinel) as well as reading it.
|
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
|
material — `bash-tasks/`, the turn-stats and event sqlite dbs — and
|
||||||
nothing argues for a parent reading it, let alone writing it. hive-c0re
|
nothing argues for anyone else reading it, let alone writing it.
|
||||||
reads a child's harness dir **directly on the host** when it wants
|
hive-c0re reads a harness dir **directly on the host** when it wants
|
||||||
those stats, which needs no mount into the parent.
|
those stats, which needs no mount into another container.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
**`config` is read-only, including for the parent.** A config change is
|
**`config` is read-only, including for the holder.** A config change is
|
||||||
a PR on the child's config repo, made from a clone and merged after
|
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
|
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
|
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
|
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 -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
⚠️ Don't confuse it with the config-repo seeding hive-c0re does at
|
⚠️ 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
|
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 —
|
constrains writers *inside* a container only. The two are unrelated —
|
||||||
conflating them can lead you to reason your way into thinking this
|
conflating them can lead you to reason your way into thinking this
|
||||||
mount should be writable when it shouldn't.
|
mount should be writable when it shouldn't.
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
Per-child isolation still holds: a container only ever has its *own*
|
Isolation still holds by default: a container has its *own* dirs and,
|
||||||
dirs plus its direct children's bind-mounted, never a sibling's.
|
unless it holds the capability, nothing else.
|
||||||
|
|
||||||
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
||||||
applied repo. Tracks `flake.nix` (module-only boilerplate; never
|
applied repo. Tracks `flake.nix` (module-only boilerplate; never
|
||||||
|
|
@ -387,13 +392,11 @@ Contents:
|
||||||
`nixosConfigurations.<n>` output per agent. `flake.lock` is the
|
`nixosConfigurations.<n>` output per agent. `flake.lock` is the
|
||||||
canonical "what's deployed where." The git log is the deploy
|
canonical "what's deployed where." The git log is the deploy
|
||||||
audit trail (one commit per successful deploy or hyperhive bump).
|
audit trail (one commit per successful deploy or hyperhive bump).
|
||||||
- `topology.json` — parent/child agent graph
|
- `topology.json` — the agent roster (`["alice", "bob", "ruth"]`).
|
||||||
(`{ "alice": "root", "bob": "alice", "root": null }`).
|
Written by `topology::reconcile` on every meta sync; read by
|
||||||
Written by `topology::apply_set_parent` (the pure move-validating
|
`topology::all_agents`, which is the set the `ManageRootAgent`
|
||||||
transform) via `meta::bulk_commit_topology` (the committer — see the
|
capability grants mounts over. Carried a `parent` per agent in the
|
||||||
`Reparent` node in [`docs/scheduler/coordinator.md`](../scheduler/coordinator.md)); read by
|
legacy format; the reader still accepts that shape and keeps its keys.
|
||||||
the dashboard, the renderer, and `<parent>` / `<children>` recipient
|
|
||||||
resolution.
|
|
||||||
- `tool-groups.json` — per-agent MCP tool group grants
|
- `tool-groups.json` — per-agent MCP tool group grants
|
||||||
(`{ "alice": ["messaging", "inbox", "execution"] }`). Written by
|
(`{ "alice": ["messaging", "inbox", "execution"] }`). Written by
|
||||||
`tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env
|
`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
|
### 8 · Useful host commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Roster: all agents, status, rev, parent, pending reminders
|
# Roster: all agents, status, rev, pending reminders
|
||||||
hivectl list-agents
|
hivectl list-agents
|
||||||
|
|
||||||
# Restart a stuck container (no rebuild)
|
# 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`).
|
(`socket_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
||||||
- `operator` — the human at the dashboard. Messages accumulate in the
|
- `operator` — the human at the dashboard. Messages accumulate in the
|
||||||
inbox view; no agent ever `recv`'s them.
|
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
|
`<parent>` and `<children>` were two more, resolved against a
|
||||||
broker stores the *resolved* labels as the message recipients — the
|
`topology.json` parent field. That field and both sentinels no longer
|
||||||
dashboard and recv side see the real routes. The sentinels are purely
|
exist: address `operator` where you would have said
|
||||||
send-time addressing conveniences.
|
`<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
|
## Wire protocol
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ Cheap — no build slot:
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
| Node | Behavior |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build |
|
||||||
|
|
@ -80,7 +80,6 @@ Cheap — no build slot:
|
||||||
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
|
|
@ -100,7 +99,7 @@ container build:
|
||||||
|
|
||||||
- **The deploy window** (`Resource::MetaWindow`): a global, capacity-1 queue
|
- **The deploy window** (`Resource::MetaWindow`): a global, capacity-1 queue
|
||||||
resource declared by every node kind that mutates the meta repo — `MetaSync`,
|
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
|
`DeployWindow` — the deploy subtree's root, which holds it across every
|
||||||
phase below it (it declares `Resource::MetaWindow`). Two meta
|
phase below it (it declares `Resource::MetaWindow`). Two meta
|
||||||
mutations can therefore never interleave, so no commit lands inside another
|
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
|
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;
|
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)
|
plus Reconcile(a) for every drifted agent (all ONE DAG)
|
||||||
reparent(moves): Reparent(moves) (no rebuild — topology.json is read live)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Notable collapses:
|
Notable collapses:
|
||||||
|
|
@ -229,7 +227,7 @@ resources are free. Resources:
|
||||||
(`SetWanted` is a store write, not a container op, but takes the lease anyway
|
(`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
|
so a power-op DAG's intent write + reconcile is atomic — two racing ops can't
|
||||||
clobber intent before either reconciles.) **Lease-exempt**: `MetaSync`,
|
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
|
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
|
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`,
|
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`
|
1. **Config path** — when _any_ marker is stale, submit one `Boot`
|
||||||
DAG: a `MetaLock` (hyperhive input bump, non-fatal) that grows an in-DAG
|
DAG: a `MetaLock` (hyperhive input bump, non-fatal) that grows an in-DAG
|
||||||
`Rebuild` subgraph for each stale agent whose `wanted = Up` (topology-sorted,
|
`Rebuild` subgraph for each stale agent whose `wanted = Up` (name-sorted). Stale but wanted-offline agents get no boot-time nix work — their
|
||||||
parents first). Stale but wanted-offline agents get no boot-time nix work — their
|
|
||||||
rebuild happens on their next start (the start submit path upgrades a
|
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
|
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
|
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/approvals.md` — approval flow + scheduled prompts
|
||||||
- `docs/agent-lifecycle/persistence.md` — SQLite schema, state-dir layout
|
- `docs/agent-lifecycle/persistence.md` — SQLite schema, state-dir layout
|
||||||
- `docs/process/conventions.md` — wire protocol, recipient sentinels
|
- `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 kill`↴](#hivectl-agent-kill)
|
||||||
* [`hivectl agent destroy`↴](#hivectl-agent-destroy)
|
* [`hivectl agent destroy`↴](#hivectl-agent-destroy)
|
||||||
* [`hivectl agent rebuild`↴](#hivectl-agent-rebuild)
|
* [`hivectl agent rebuild`↴](#hivectl-agent-rebuild)
|
||||||
* [`hivectl agent set-parent`↴](#hivectl-agent-set-parent)
|
|
||||||
* [`hivectl agent set-limits`↴](#hivectl-agent-set-limits)
|
* [`hivectl agent set-limits`↴](#hivectl-agent-set-limits)
|
||||||
* [`hivectl agent choom`↴](#hivectl-agent-choom)
|
* [`hivectl agent choom`↴](#hivectl-agent-choom)
|
||||||
* [`hivectl agent watch`↴](#hivectl-agent-watch)
|
* [`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
|
* `kill` — Hard-stop this managed container
|
||||||
* `destroy` — Tear down this sub-agent container, keeping its state by default. No undo
|
* `destroy` — Tear down this sub-agent container, keeping its state by default. No undo
|
||||||
* `rebuild` — Apply pending config to this managed container
|
* `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
|
* `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
|
* `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
|
* `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`
|
## `hivectl agent set-limits`
|
||||||
|
|
||||||
Declare this agent's CPU/memory limits, overriding the hive-wide defaults.
|
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 —
|
`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`
|
(`running` / `stopped`, plus ` paused` / ` needs-login` / ` needs-update`
|
||||||
when set — `paused` is orthogonal to running, see below);
|
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
|
REV is the first 12 chars of the agent's locked config sha; REMIND is the
|
||||||
place in the topology tree (`-` for a root agent); REMIND is the count of
|
count of pending reminders. It reuses the same per-agent aggregation the dashboard
|
||||||
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
|
renders, so the CLI roster and the web UI never drift. `--json` emits the
|
||||||
raw rows instead of the table.
|
raw rows instead of the table.
|
||||||
|
|
||||||
`agent <name> restart` is the manual equivalent of the MCP `restart`
|
`agent <name> restart` is the manual equivalent of the MCP `restart`
|
||||||
tool — useful when you need to kick a container from the host without
|
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) —
|
the top-level `hivectl restart --agents` (or `--agent <name>` repeated) —
|
||||||
it rides a single DAG and reports per-target failures at the end rather
|
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`
|
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
|
Plugin install failures aren't fatal: each entry comes back as a
|
||||||
human-readable failure string that gets routed via
|
human-readable failure string that gets routed via
|
||||||
`Surface::send_to_parent` to the agent's topology parent (the
|
`Surface::send_to_operator` to the operator.
|
||||||
broker resolves `<parent>` per `topology::resolve_recipient`; root
|
|
||||||
agents and the manager fall through to operator).
|
|
||||||
|
|
||||||
### Turn outcomes
|
### 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(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(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(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
|
`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
|
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)`.
|
`recv(max?)`, `ack_until(up_to)`.
|
||||||
|
|
||||||
- `send` — message a peer (logical name) or the operator
|
- `send` — message a peer (logical name) or the operator
|
||||||
(`to: "operator"`). Use `to: "<parent>"` to address the topology
|
(`to: "operator"`). Optional `in_reply_to: i64` links the
|
||||||
parent without hardcoding the label; the broker resolves the
|
|
||||||
sentinel at delivery time. Optional `in_reply_to: i64` links the
|
|
||||||
message to a prior id for thread rendering. Per-agent
|
message to a prior id for thread rendering. Per-agent
|
||||||
`services.hyperhive.agent.allowedRecipients` (default: empty = unrestricted) limits
|
`services.hyperhive.agent.allowedRecipients` (default: empty = unrestricted) limits
|
||||||
which names `send` accepts — useful for sandboxing: set
|
which names `send` accepts — useful for sandboxing: set
|
||||||
`[ "operator" ]` to restrict a sub-agent to operator messages only
|
`[ "operator" ]` to restrict a sub-agent to operator messages only.
|
||||||
(the topology parent is always reachable regardless of this list —
|
The operator stays reachable regardless of this list, so an agent can
|
||||||
that carve-out is structural, keyed on parent relationship, not name).
|
always report a block.
|
||||||
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
|
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
|
||||||
(default 1, cap 5) drains up to N rows. `recv` prefixes each returned row
|
(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
|
with `[msg #<id>]` (broker row id; note the highest id seen, then pass
|
||||||
|
|
|
||||||
|
|
@ -56,19 +56,10 @@ surfaces, not tab panes.
|
||||||
|
|
||||||
## SW4RM tab
|
## SW4RM tab
|
||||||
|
|
||||||
**C0NTAINERS** — live containers rendered as a depth-first
|
**C0NTAINERS** — live containers as a flat list, one row per
|
||||||
tree using `ContainerView.parent` (populated by
|
container, sorted alphabetically by agent name. No row nests under
|
||||||
`hive-c0re/src/agent_config/topology.rs` — don't confuse it with
|
another, none carries an indent or a tree glyph, and every container the
|
||||||
`hive-c0re/src/dashboard/topology.rs`, which only holds the
|
rescan reports gets a row. Pulsing red banner at the top
|
||||||
set-parent endpoints).
|
|
||||||
ASCII tree glyphs (`├─`, `└─`, `│ ` continuation columns) prefix
|
|
||||||
each container's row, showing the agent parent/child hierarchy.
|
|
||||||
When every container has `parent = null`
|
|
||||||
(flat topology) the tree collapses to a plain list with no
|
|
||||||
glyphs. The tree sorts children alphabetically within each parent;
|
|
||||||
roots likewise. The tree tolerates cycles in the parent graph —
|
|
||||||
it appends orphaned containers (not reachable from any root)
|
|
||||||
as roots so no agent disappears. Pulsing red banner at the top
|
|
||||||
of this section if any two sub-agents hash to the same port
|
of this section if any two sub-agents hash to the same port
|
||||||
(`port_conflicts` from `/api/state`): the operator must rename
|
(`port_conflicts` from `/api/state`): the operator must rename
|
||||||
one of them and rebuild. `lifecycle::{spawn,rebuild}` also
|
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.
|
summaries are sticky (select to dismiss) so they aren't missed.
|
||||||
|
|
||||||
Every destructive run-state action (`ST0P`, `R3ST4RT`, `R3BU1LD`,
|
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.
|
the per-agent `⋮` menu and the bulk selection bar.
|
||||||
|
|
||||||
**Graceful stop** — the `■ ST0P` confirm dialog (per-agent and
|
**Graceful stop** — the `■ ST0P` confirm dialog (per-agent and
|
||||||
|
|
@ -985,25 +976,11 @@ identically.)
|
||||||
|
|
||||||
### Topology tree
|
### Topology tree
|
||||||
|
|
||||||
See **SW4RM tab** above for the parent/child derivation, sibling
|
Nothing nests. `swarm.js::buildAgentTree` returns every container at
|
||||||
sort order, and cycle-safety rules (`swarm.js::buildAgentTree` walks
|
depth 0 in alphabetical order, and the row builder emits no prefix
|
||||||
`ContainerView.parent`) — this section covers only how the tree is
|
column for a depth-0 row — so a container row carries no indent, no
|
||||||
*drawn*.
|
joint and no continuation bar, and the SW4RM list reads as a flat
|
||||||
|
roster. The `.tree-prefix` lane rules in `dashboard.css` match nothing.
|
||||||
The per-row prefix column (`.tree-prefix`) is **DOM-painted, not
|
|
||||||
text-glyph-painted**: each indent lane is its own positioned `<span>`
|
|
||||||
so CSS can draw full-height vertical bars that bridge the gap between
|
|
||||||
sibling rows. Plain text box-drawing characters (`├─`, `└─`, `│ `)
|
|
||||||
would only paint one text-line tall and leave visible breaks between
|
|
||||||
the taller-than-one-line container cards, so CSS borders draw the
|
|
||||||
bars instead: a continuation bar runs the full height of an
|
|
||||||
ancestor's still-open subtree, and the joint at a row's own depth is
|
|
||||||
`├` (more siblings below) or `└` (last sibling — the line stops at
|
|
||||||
the row's icon midline). Exact lane widths and positioning live in
|
|
||||||
`swarm.js`'s tree-prefix rendering and its paired CSS rules — not
|
|
||||||
reproduced here since they're tuned in pixel units and will drift.
|
|
||||||
When every container is at depth 0 (no `parent` set) these rules are
|
|
||||||
all no-ops and the layout reads like a plain flat list.
|
|
||||||
|
|
||||||
## Selection bar
|
## Selection bar
|
||||||
|
|
||||||
|
|
@ -1032,25 +1009,9 @@ frosted-mauve bar slides up from the bottom of the viewport
|
||||||
- `▶ R3SUM3` — paused agents only
|
- `▶ R3SUM3` — paused agents only
|
||||||
- `↻ R3BU1LD` — always available
|
- `↻ R3BU1LD` — always available
|
||||||
- `DESTR0Y` / `PURG3` — 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 -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
Both write `topology.json` and re-emit a container snapshot so
|
That list is the whole set — the bar offers no other bulk action.
|
||||||
the tree repaints without a page reload.
|
|
||||||
- **`✕ clear`** button + `Esc` key clear the entire selection.
|
- **`✕ clear`** button + `Esc` key clear the entire selection.
|
||||||
|
|
||||||
Every render prunes stale selections (agents destroyed while
|
Every render prunes stale selections (agents destroyed while
|
||||||
|
|
|
||||||
|
|
@ -1384,27 +1384,6 @@ body.dashboard-shell.has-selection {
|
||||||
padding-bottom: 4.5em;
|
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
|
/* ST4TS moved to its own page (`/stats.html`): the
|
||||||
window selector / summary chips / bars live in stats.css, and the
|
window selector / summary chips / bars live in stats.css, and the
|
||||||
shared `.hive-stats-table` moved to common.css (the SYST3M ›
|
shared `.hive-stats-table` moved to common.css (the SYST3M ›
|
||||||
|
|
|
||||||
|
|
@ -952,154 +952,6 @@ export function renderSelectionBar(containers) {
|
||||||
confirm: (names) =>
|
confirm: (names) =>
|
||||||
`PURGE ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
|
`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) {
|
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
|
||||||
|
|
@ -1147,21 +999,13 @@ function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
|
||||||
// (rebuild_queue dedups but other endpoints don't); the loop is
|
// (rebuild_queue dedups but other endpoints don't); the loop is
|
||||||
// short — bulk selections are typically a handful of agents.
|
// short — bulk selections are typically a handful of agents.
|
||||||
//
|
//
|
||||||
// Two URL shapes:
|
// `opts.action` is a path prefix and the agent name gets appended
|
||||||
// - `opts.action` is a path prefix and the agent name gets
|
// (lifecycle endpoints: /start/<name>, /rebuild/<name>). `opts.body`
|
||||||
// appended (lifecycle endpoints: /start/<name>, /rebuild/<name>).
|
// is a static object applied to every POST.
|
||||||
// `opts.body` is a static object applied to every POST.
|
|
||||||
// - `opts.perAgentBodyFor(name)` is set: `opts.action` is the
|
|
||||||
// full URL (no name appended) and the per-agent body comes
|
|
||||||
// from the callback. Used by /api/topology/set-parent, where
|
|
||||||
// the agent name is a body field rather than a URL component.
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const body = opts.perAgentBodyFor
|
const body = new URLSearchParams(opts.body || {});
|
||||||
? new URLSearchParams(opts.perAgentBodyFor(name))
|
const url =
|
||||||
: new URLSearchParams(opts.body || {});
|
opts.action +
|
||||||
const url = opts.perAgentBodyFor
|
|
||||||
? opts.action
|
|
||||||
: opts.action +
|
|
||||||
encodeURIComponent(name) +
|
encodeURIComponent(name) +
|
||||||
(graceful ? "?graceful=true" : "");
|
(graceful ? "?graceful=true" : "");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,23 @@
|
||||||
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
|
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
|
||||||
|
|
||||||
/// Enforce the per-agent send allow-list. Returns `Ok` when the
|
/// Enforce the per-agent send allow-list. Returns `Ok` when the
|
||||||
/// recipient is permitted (no list configured, `<parent>` sentinel
|
/// recipient is permitted (no list configured, the operator, or `to` is
|
||||||
/// always allowed, or `to` is in the list); returns `Err(refusal)`
|
/// in the list); returns `Err(refusal)` with a claude-readable string
|
||||||
/// with a claude-readable string when blocked — the harness surfaces
|
/// when blocked — the harness surfaces the refusal as the tool result so
|
||||||
/// the refusal as the tool result so claude knows the message didn't
|
/// claude knows the message didn't land and can react (e.g. route to the
|
||||||
/// land and can react (e.g. route via `<parent>` instead).
|
/// operator instead).
|
||||||
pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||||
if to == hive_sh4re::manager::PARENT_RECIPIENT {
|
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||||
// Always allow `<parent>` — the allow-list constrains peer
|
// Always allow the operator — the allow-list constrains peer
|
||||||
// chatter, not the structural reporting line; the operator
|
// chatter, not the reporting line out, and an agent with no way
|
||||||
// can rewire who the parent IS via `set_parent` without
|
// to say "I am stuck" is an agent that fails silently.
|
||||||
// having to remember to update the per-agent allow-list.
|
//
|
||||||
// The broker resolves the sentinel to the real parent label
|
// This bypass used to be spelled `<parent>`, which the broker
|
||||||
// on the host side per topology.json (falls back to `operator`
|
// resolved per `topology.json` and which fell back to `operator`
|
||||||
// for root agents).
|
// 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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
|
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!(
|
Err(format!(
|
||||||
"send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \
|
"send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \
|
||||||
(configured in agent.nix). Allowed: {allow:?}. Your structural \
|
(configured in agent.nix). Allowed: {allow:?}. The operator is always \
|
||||||
parent is always reachable — route through `send(to: \"{}\", …)` \
|
reachable — route through `send(to: \"{}\", …)` if you need to reach \
|
||||||
if you need to reach someone outside the allow-list.",
|
someone outside the allow-list.",
|
||||||
hive_sh4re::manager::PARENT_RECIPIENT
|
hive_sh4re::manager::OPERATOR_RECIPIENT
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
|
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
|
||||||
/// log. Both agents and the manager receive `ContainerCrash`,
|
/// 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
|
/// identical. Quiet no-op when `from` isn't
|
||||||
/// `SYSTEM_SENDER`.
|
/// `SYSTEM_SENDER`.
|
||||||
fn log_system_event(bus: &Bus, from: &str, body: &str) {
|
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
|
/// Body string for the turn-failure notification we route to the
|
||||||
/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified
|
/// operator on `TurnError::Failed`. Reads the hive-qualified
|
||||||
/// identity so the receiver sees `agent@hive` rather than relying on
|
/// identity so the receiver sees `agent@hive` rather than relying on
|
||||||
/// the caller threading a `label` through every turn-handling layer.
|
/// the caller threading a `label` through every turn-handling layer.
|
||||||
/// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a
|
/// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a
|
||||||
|
|
@ -278,10 +278,15 @@ trait Surface {
|
||||||
/// fallback. Same shape as `graceful_stop_complete`.
|
/// fallback. Same shape as `graceful_stop_complete`.
|
||||||
fn pause_acknowledged(socket: &Path) -> impl Future<Output = ()>;
|
fn pause_acknowledged(socket: &Path) -> impl Future<Output = ()>;
|
||||||
|
|
||||||
/// Send a message addressed to `<parent>` (broker resolves the
|
/// Send a message addressed to the operator. The reporting line out
|
||||||
/// sentinel via `topology::parent_of` at delivery time; root
|
/// of a container: this is where a turn failure or a plugin-install
|
||||||
/// agents/manager fall through to operator).
|
/// failure surfaces when nothing inside the harness can act on it.
|
||||||
fn send_to_parent(socket: &Path, body: String) -> impl Future<Output = ()>;
|
///
|
||||||
|
/// 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
|
/// Long-poll the broker for the next message. Wraps the
|
||||||
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
||||||
|
|
@ -367,11 +372,11 @@ impl Surface for AgentSurface {
|
||||||
(threads, reminders)
|
(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>(
|
let res = hive_sock_client::request::<_, Response>(
|
||||||
socket,
|
socket,
|
||||||
&Request::Send {
|
&Request::Send {
|
||||||
to: hive_sh4re::manager::PARENT_RECIPIENT.into(),
|
to: hive_sh4re::manager::OPERATOR_RECIPIENT.into(),
|
||||||
body,
|
body,
|
||||||
in_reply_to: None,
|
in_reply_to: None,
|
||||||
},
|
},
|
||||||
|
|
@ -379,7 +384,7 @@ impl Surface for AgentSurface {
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Err(e) = res {
|
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?;
|
let files = turn::TurnFiles::prepare(socket, &label).await?;
|
||||||
// Plugin install failures come back as a Vec<String> — route each
|
// Plugin install failures come back as a Vec<String> — route each
|
||||||
// through `<parent>` via the `send_to_parent` failure-notify path.
|
// through the `send_to_operator` failure-notify path.
|
||||||
// The broker resolves `<parent>` per `topology::parent_of`;
|
|
||||||
// root agents fall through to operator.
|
|
||||||
for failure in plugins::install_configured().await {
|
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
|
// 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
|
// 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`
|
/// 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/
|
/// 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-
|
/// 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
|
/// `handle_turn` itself under clippy's line-count lint — no behavior
|
||||||
/// change from when this lived inline.
|
/// change from when this lived inline.
|
||||||
async fn handle_turn_error_recovery<S: Surface>(
|
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;
|
S::requeue_inflight(socket).await;
|
||||||
}
|
}
|
||||||
if let Err(turn::TurnError::Failed(e)) = outcome {
|
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`.
|
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
||||||
/// Returns a list of human-readable failure messages so the caller can
|
/// Returns a list of human-readable failure messages so the caller can
|
||||||
/// route them through their own per-role surface (turn-failure-style
|
/// route them through their own per-role surface (turn-failure-style
|
||||||
/// notification, see `Surface::send_to_parent`). Wire-agnostic: the
|
/// notification, see `Surface::send_to_operator`). Wire-agnostic: the
|
||||||
/// caller picks the recipient via the same `<parent>` sentinel that
|
/// caller picks the recipient, the same way failure-notify does.
|
||||||
/// failure-notify uses everywhere else.
|
|
||||||
pub async fn install_configured() -> Vec<String> {
|
pub async fn install_configured() -> Vec<String> {
|
||||||
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ pub enum TurnError {
|
||||||
/// rate-limit path — NOT a crash.
|
/// rate-limit path — NOT a crash.
|
||||||
ApiStall,
|
ApiStall,
|
||||||
/// A hard failure with no recovery — the serve loop escalates it to the
|
/// A hard failure with no recovery — the serve loop escalates it to the
|
||||||
/// parent (`send_to_parent`).
|
/// operator (`send_to_operator`).
|
||||||
Failed(anyhow::Error),
|
Failed(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,26 @@
|
||||||
//! Agent topology storage — single source of truth for parent/child
|
//! Agent roster storage — the set of agent names the hive knows about.
|
||||||
//! relations in the hive. Persisted as a flat JSON map of `name →
|
//! Persisted as a JSON array at `/var/lib/hyperhive/meta/topology.json`,
|
||||||
//! parent name | null` at `/var/lib/hyperhive/meta/topology.json`,
|
//! alongside the meta `flake.nix`, so roster changes thread through the
|
||||||
//! alongside the meta `flake.nix`, so topology changes thread through
|
//! same git commit log as deploys.
|
||||||
//! the same git commit log as deploys.
|
|
||||||
//!
|
//!
|
||||||
//! Broader-than-your-own-children bind-mount grants are **not** stored
|
//! **There is no hierarchy here any more.** The file used to be a map of
|
||||||
//! here: they hang off the `ManageRootAgent` capability in
|
//! `name → parent | null` and this module owned the parent/child tree that
|
||||||
//! `capabilities.json`. See `lifecycle::set_nspawn_flags`.
|
//! 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
|
//! Broader-than-your-own bind-mount grants are **not** stored here: they
|
||||||
//! enforcement semantics: `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`.
|
//! hang off the `ManageRootAgent` capability in `capabilities.json`. See
|
||||||
//! `<parent>` sentinel resolution (delivered by [`resolve_recipient`]):
|
//! `lifecycle::set_nspawn_flags`.
|
||||||
//! `docs/process/conventions.md::Recipient sentinels`.
|
|
||||||
//!
|
|
||||||
//! ## Graph representation
|
|
||||||
//!
|
|
||||||
//! The on-disk format stays as a flat JSON map `name → parent | null`
|
|
||||||
//! (small, git-diffable). In-memory, cycle detection uses a [`petgraph`]
|
|
||||||
//! directed graph where each edge runs
|
|
||||||
//! **parent → child**. This replaces the ad-hoc bounded walks that existed
|
|
||||||
//! before: petgraph's `is_cyclic_directed`
|
|
||||||
//! is correct for graphs of any depth (no 32-hop ceiling) and well-tested.
|
|
||||||
//! The graph is built on demand from the flat map; it is not cached across
|
|
||||||
//! calls (the map is small and disk I/O dominates anyway).
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use petgraph::algo::is_cyclic_directed;
|
|
||||||
use petgraph::graph::{DiGraph, NodeIndex};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
const TOPOLOGY_FILE: &str = "topology.json";
|
const TOPOLOGY_FILE: &str = "topology.json";
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -37,55 +28,44 @@ pub fn topology_path() -> PathBuf {
|
||||||
crate::paths::meta_root().join(TOPOLOGY_FILE)
|
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),
|
/// 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.
|
/// 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
|
/// Returns an empty set when the file is absent or unparsable. Safe
|
||||||
/// treat that as "no recorded parents", which falls back to every agent
|
/// degradation for fresh installs that haven't run through
|
||||||
/// being root-level. Safe degradation for fresh installs that haven't
|
/// `meta::sync_agents` yet.
|
||||||
/// run through `meta::sync_agents` yet.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn read() -> BTreeMap<String, Option<String>> {
|
pub fn read() -> BTreeSet<String> {
|
||||||
let path = topology_path();
|
let path = topology_path();
|
||||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
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`
|
/// Every agent the roster knows about, in name order. This is the set
|
||||||
/// entry has `name` as their parent. Reads the map once and scans all
|
|
||||||
/// entries; cheap enough for the fan-out path (one disk read per send
|
|
||||||
/// to `<children>`).
|
|
||||||
#[must_use]
|
|
||||||
pub fn children_of(name: &str) -> Vec<String> {
|
|
||||||
children_of_in(&read(), name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure form of [`children_of`] for unit tests.
|
|
||||||
#[must_use]
|
|
||||||
pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Vec<String> {
|
|
||||||
topo.iter()
|
|
||||||
.filter_map(|(agent, parent)| {
|
|
||||||
if parent.as_deref() == Some(name) {
|
|
||||||
Some(agent.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every agent the topology knows about, in name order. This is the set
|
|
||||||
/// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
|
/// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
|
||||||
/// bind-mounted, and it is deliberately unfiltered: that capability means
|
/// bind-mounted, and it is deliberately unfiltered: that capability means
|
||||||
/// "may manage any agent", so the set is all of them.
|
/// "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]
|
#[must_use]
|
||||||
pub fn all_agents() -> Vec<String> {
|
pub fn all_agents() -> Vec<String> {
|
||||||
all_agents_in(&read())
|
all_agents_in(&read())
|
||||||
|
|
@ -93,86 +73,15 @@ pub fn all_agents() -> Vec<String> {
|
||||||
|
|
||||||
/// Pure form of [`all_agents`] for unit tests.
|
/// Pure form of [`all_agents`] for unit tests.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn all_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
|
pub fn all_agents_in(topo: &BTreeSet<String>) -> Vec<String> {
|
||||||
topo.keys().cloned().collect()
|
topo.iter().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a magic recipient sentinel (currently just
|
/// Persist the roster. Sorted JSON output (`BTreeSet` iterates in key
|
||||||
/// [`hive_sh4re::manager::PARENT_RECIPIENT`]) to a real broker recipient at
|
/// order) keeps git diffs minimal across re-writes. Best-effort —
|
||||||
/// send time. Returns an owned `String` so callers can plug it
|
|
||||||
/// straight into [`crate::broker::Broker::send`] without
|
|
||||||
/// borrow-juggling around the temporary lookup.
|
|
||||||
///
|
|
||||||
/// Rules + rationale: `docs/process/conventions.md::Recipient sentinels`.
|
|
||||||
/// Fast path: ordinary recipient names short-circuit before any
|
|
||||||
/// disk read — only `<parent>` triggers `read()` on `topology.json`.
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolve_recipient(sender: &str, to: &str) -> String {
|
|
||||||
// Early exit: only sentinel recipients need topology lookup. This
|
|
||||||
// keeps the cost of a normal `send` at one string comparison.
|
|
||||||
if to != hive_sh4re::manager::PARENT_RECIPIENT {
|
|
||||||
return to.to_owned();
|
|
||||||
}
|
|
||||||
resolve_recipient_in(&read(), sender, to)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure form of [`resolve_recipient`] taking the topology map
|
|
||||||
/// explicitly. Split out so unit tests can exercise the sentinel
|
|
||||||
/// rules without writing a `topology.json` to disk.
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolve_recipient_in(
|
|
||||||
topo: &BTreeMap<String, Option<String>>,
|
|
||||||
sender: &str,
|
|
||||||
to: &str,
|
|
||||||
) -> String {
|
|
||||||
if to == hive_sh4re::manager::PARENT_RECIPIENT {
|
|
||||||
topo.get(sender)
|
|
||||||
.cloned()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or_else(|| hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned())
|
|
||||||
} else {
|
|
||||||
to.to_owned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an in-memory petgraph directed graph from the topology map.
|
|
||||||
///
|
|
||||||
/// Edges run **parent → child** so that:
|
|
||||||
/// - `children_of(name)` = outgoing neighbours of `name`'s node
|
|
||||||
/// - cycle detection = `is_cyclic_directed` after a speculative edge insert
|
|
||||||
///
|
|
||||||
/// Returns the graph and a `BTreeMap<name → NodeIndex>` for O(log n)
|
|
||||||
/// name-to-node lookups. Both are local to each call site — the graph is
|
|
||||||
/// not cached. Hive topologies are small (< ~100 nodes); building on demand
|
|
||||||
/// is dominated by the surrounding disk read.
|
|
||||||
#[must_use]
|
|
||||||
fn build_graph(
|
|
||||||
topo: &BTreeMap<String, Option<String>>,
|
|
||||||
) -> (DiGraph<String, ()>, BTreeMap<String, NodeIndex>) {
|
|
||||||
let mut graph: DiGraph<String, ()> = DiGraph::new();
|
|
||||||
let mut idx: BTreeMap<String, NodeIndex> = BTreeMap::new();
|
|
||||||
|
|
||||||
// Add one node per agent.
|
|
||||||
for name in topo.keys() {
|
|
||||||
let ni = graph.add_node(name.clone());
|
|
||||||
idx.insert(name.clone(), ni);
|
|
||||||
}
|
|
||||||
// Add parent→child edges.
|
|
||||||
for (name, parent_opt) in topo {
|
|
||||||
if let Some(parent) = parent_opt
|
|
||||||
&& let (Some(&p_idx), Some(&c_idx)) = (idx.get(parent), idx.get(name.as_str()))
|
|
||||||
{
|
|
||||||
graph.add_edge(p_idx, c_idx, ());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(graph, idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the topology map. Sorted JSON output (`BTreeMap` is sorted by
|
|
||||||
/// key) keeps git diffs minimal across re-writes. Best-effort —
|
|
||||||
/// returns an `io::Error` so callers can decide whether a failure
|
/// returns an `io::Error` so callers can decide whether a failure
|
||||||
/// should abort their op (`sync_agents`, `RequestSetParent`) or just log.
|
/// should abort their op (`sync_agents`) or just log.
|
||||||
pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()> {
|
pub fn write(topology: &BTreeSet<String>) -> std::io::Result<()> {
|
||||||
let path = topology_path();
|
let path = topology_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(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"))
|
std::fs::write(&path, format!("{text}\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the default topology for a fresh install: every agent is a
|
/// Reconcile `topology.json` against the current agent set. Adds any agent
|
||||||
/// root (parent = null). There is no structural "manager" — agents
|
/// missing from the file; removes entries for agents no longer present.
|
||||||
/// arrange themselves via explicit parent edges, written by the operator
|
/// Returns true when the file changed and should be re-committed by the
|
||||||
/// through the dashboard / `RequestSetParent` API.
|
/// caller.
|
||||||
/// Used by `meta::sync_agents` on first call to seed `topology.json`.
|
|
||||||
///
|
|
||||||
/// As soon as an explicit write lands (dashboard / `RequestSetParent`
|
|
||||||
/// API), this seeding stops touching pre-existing entries —
|
|
||||||
/// `sync_agents` only adds rows for newly-spawned agents against
|
|
||||||
/// whatever the operator has configured.
|
|
||||||
#[must_use]
|
|
||||||
#[allow(
|
|
||||||
dead_code,
|
|
||||||
reason = "kept for the dashboard / RequestSetParent write API; \
|
|
||||||
`sync_agents` does its own seeding today"
|
|
||||||
)]
|
|
||||||
pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>> {
|
|
||||||
let mut out = BTreeMap::new();
|
|
||||||
for name in agent_names {
|
|
||||||
out.insert(name.clone(), None);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure validation + apply for [`crate::meta::bulk_commit_topology`]. Splits off so tests
|
|
||||||
/// can exercise the rules (cycle / unknown) on an in-memory
|
|
||||||
/// `BTreeMap` without touching the on-disk `topology.json`. Returns
|
|
||||||
/// either the post-move map (caller writes it back) or a
|
|
||||||
/// user-readable error string.
|
|
||||||
///
|
|
||||||
/// The manager is reparentable like any other agent — its special
|
|
||||||
/// powers come from the privileged MCP socket, not its tree
|
|
||||||
/// position. The cycle walk below covers "moving X under its own
|
|
||||||
/// descendant" for the manager as much as any other agent.
|
|
||||||
/// `docs/agent-lifecycle/agent-hierarchy.md::Reparenting` has the rationale.
|
|
||||||
pub fn apply_set_parent(
|
|
||||||
topo: &BTreeMap<String, Option<String>>,
|
|
||||||
child: &str,
|
|
||||||
new_parent: Option<&str>,
|
|
||||||
) -> Result<BTreeMap<String, Option<String>>, String> {
|
|
||||||
if !topo.contains_key(child) {
|
|
||||||
return Err(format!("unknown agent: {child}"));
|
|
||||||
}
|
|
||||||
if let Some(p) = new_parent {
|
|
||||||
if !topo.contains_key(p) {
|
|
||||||
return Err(format!("unknown parent: {p}"));
|
|
||||||
}
|
|
||||||
if p == child {
|
|
||||||
return Err("an agent cannot be its own parent".to_owned());
|
|
||||||
}
|
|
||||||
// Cycle check via petgraph: build the current graph, speculatively
|
|
||||||
// insert the proposed parent→child edge, then test for cycles with
|
|
||||||
// `is_cyclic_directed`. This replaces the earlier ad-hoc 32-hop
|
|
||||||
// ancestor walk — petgraph is correct for any tree depth and the
|
|
||||||
// algorithm is well-tested.
|
|
||||||
let (mut graph, idx) = build_graph(topo);
|
|
||||||
if let (Some(&p_ni), Some(&c_ni)) = (idx.get(p), idx.get(child)) {
|
|
||||||
graph.add_edge(p_ni, c_ni, ());
|
|
||||||
if is_cyclic_directed(&graph) {
|
|
||||||
return Err(format!(
|
|
||||||
"cycle: {p} is in {child}'s subtree (would create a loop)"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut next = topo.clone();
|
|
||||||
next.insert(child.to_owned(), new_parent.map(str::to_owned));
|
|
||||||
Ok(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconcile `topology.json` against the current agent set. Adds an
|
|
||||||
/// entry (default: parent = null — a new agent with no declared parent
|
|
||||||
/// is its own root) for any agent missing from the file; removes
|
|
||||||
/// entries for agents no longer
|
|
||||||
/// present. Existing entries are preserved as-is — operator/manager
|
|
||||||
/// choices stick across regenerations. Returns true when the file
|
|
||||||
/// changed and should be re-committed by the caller.
|
|
||||||
///
|
///
|
||||||
/// `pending` lists agents that have a provisioned proposed config repo
|
/// `pending` lists agents that have a provisioned proposed config repo
|
||||||
/// but no container yet (provisioned, not yet spawned). They are KEPT
|
/// but no container yet (provisioned, not yet spawned). They are KEPT
|
||||||
/// (not dropped) so an explicit parent edge written before the first
|
/// (not dropped) so an agent that exists on disk but has never booted is
|
||||||
/// spawn survives until the first apply-commit, but they are NOT seeded with a
|
/// still a name the hive knows about.
|
||||||
/// default parent here — that happens when the container actually spawns
|
|
||||||
/// and the name moves into `agent_names`.
|
|
||||||
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
||||||
let (next, changed) = apply_reconcile(&read(), agent_names, pending);
|
let (next, changed) = apply_reconcile(&read(), agent_names, pending);
|
||||||
if changed {
|
if changed {
|
||||||
|
|
@ -274,32 +108,25 @@ pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents
|
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents,
|
||||||
/// at their default position, drops entries for agents that are neither
|
/// drops entries for agents that are neither live nor pending-init, and
|
||||||
/// live nor pending-init, and reports whether anything changed.
|
/// reports whether anything changed.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn apply_reconcile(
|
pub fn apply_reconcile(
|
||||||
current: &BTreeMap<String, Option<String>>,
|
current: &BTreeSet<String>,
|
||||||
agent_names: &[String],
|
agent_names: &[String],
|
||||||
pending: &[String],
|
pending: &[String],
|
||||||
) -> (BTreeMap<String, Option<String>>, bool) {
|
) -> (BTreeSet<String>, bool) {
|
||||||
let mut next = current.clone();
|
let mut next = current.clone();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for name in agent_names {
|
for name in agent_names {
|
||||||
if !next.contains_key(name) {
|
if next.insert(name.clone()) {
|
||||||
// A new agent with no declared parent defaults to root
|
|
||||||
// (parent = null). An agent placed under a parent carries an
|
|
||||||
// explicit edge written before its first spawn, so it never
|
|
||||||
// hits this default — only spawns with no declared parent do,
|
|
||||||
// and those are roots. No agent is structurally privileged
|
|
||||||
// here: "root-ness" is just a null parent.
|
|
||||||
next.insert(name.clone(), None);
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let known: std::collections::HashSet<&String> =
|
let known: std::collections::HashSet<&String> =
|
||||||
agent_names.iter().chain(pending.iter()).collect();
|
agent_names.iter().chain(pending.iter()).collect();
|
||||||
next.retain(|name, _| {
|
next.retain(|name| {
|
||||||
let keep = known.contains(name);
|
let keep = known.contains(name);
|
||||||
if !keep {
|
if !keep {
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
@ -311,301 +138,94 @@ pub fn apply_reconcile(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::{BTreeSet, OnDisk, all_agents_in, apply_reconcile};
|
||||||
|
|
||||||
#[test]
|
fn roster_three() -> BTreeSet<String> {
|
||||||
fn default_seed_makes_every_agent_root() {
|
["alice", "bob", "carol"]
|
||||||
// No structural manager: every agent defaults to root (null
|
.into_iter()
|
||||||
// parent). Explicit edges are layered on later.
|
.map(str::to_owned)
|
||||||
let agents = vec![
|
.collect()
|
||||||
"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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_seed_handles_empty_input() {
|
fn apply_reconcile_adds_missing_live_agent() {
|
||||||
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.
|
|
||||||
let live = vec![
|
let live = vec![
|
||||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||||
"newbie".to_owned(),
|
"newbie".to_owned(),
|
||||||
];
|
];
|
||||||
let (next, changed) = apply_reconcile(&BTreeMap::new(), &live, &[]);
|
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]);
|
||||||
assert!(changed);
|
assert!(changed);
|
||||||
assert_eq!(next.get("newbie"), Some(&None));
|
assert!(next.contains("newbie"));
|
||||||
assert_eq!(next.get(crate::lifecycle::MANAGER_NAME), Some(&None));
|
assert!(next.contains(crate::lifecycle::MANAGER_NAME));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_reconcile_drops_vanished_agent() {
|
fn apply_reconcile_drops_vanished_agent() {
|
||||||
let live = vec![
|
let live = vec!["alice".to_owned()];
|
||||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
|
||||||
"alice".to_owned(),
|
|
||||||
];
|
|
||||||
// carol + bob are gone from the live set and not pending.
|
|
||||||
let (next, changed) = apply_reconcile(&topo_three_level(), &live, &[]);
|
|
||||||
assert!(changed);
|
assert!(changed);
|
||||||
assert!(!next.contains_key("bob"));
|
assert!(!next.contains("bob"));
|
||||||
assert!(!next.contains_key("carol"));
|
assert!(!next.contains("carol"));
|
||||||
assert!(next.contains_key("alice"));
|
assert!(next.contains("alice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_reconcile_keeps_pending_init_agent_edge() {
|
fn apply_reconcile_keeps_pending_init_agent() {
|
||||||
// `dora` was placed under alice (edge present) but has no
|
// `dora` was provisioned but has no container yet, so it's absent
|
||||||
// container yet, so it's absent from the live set. It must NOT
|
// from the live set. It must NOT be dropped.
|
||||||
// be dropped, and its alice-parent edge must be preserved (not
|
let mut roster = roster_three();
|
||||||
// re-seeded under the manager).
|
roster.insert("dora".to_owned());
|
||||||
let mut topo = topo_three_level();
|
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
||||||
topo.insert("dora".to_owned(), Some("alice".to_owned()));
|
|
||||||
let live = vec![
|
|
||||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
|
||||||
"alice".to_owned(),
|
|
||||||
"bob".to_owned(),
|
|
||||||
"carol".to_owned(),
|
|
||||||
];
|
|
||||||
let pending = vec!["dora".to_owned()];
|
let 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!(!changed, "no change expected: {next:?}");
|
||||||
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
|
assert!(next.contains("dora"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_recipient_passes_through_ordinary_names() {
|
fn apply_reconcile_is_a_noop_when_already_in_sync() {
|
||||||
let topo = topo_three_level();
|
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
||||||
// Real labels, broadcast, and the operator literal all
|
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
|
||||||
// shortcut through unchanged — no resolution magic.
|
assert!(!changed);
|
||||||
assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice");
|
assert_eq!(next, roster_three());
|
||||||
assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*");
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_agents_in_returns_every_name_sorted() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_recipient_in(&topo, "bob", hive_sh4re::manager::OPERATOR_RECIPIENT),
|
all_agents_in(&roster_three()),
|
||||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
vec!["alice", "bob", "carol"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() {
|
fn all_agents_in_empty_roster_returns_empty() {
|
||||||
let topo = topo_three_level();
|
assert!(all_agents_in(&BTreeSet::new()).is_empty());
|
||||||
// bob's parent is alice → `<parent>` from bob goes to alice.
|
}
|
||||||
assert_eq!(
|
|
||||||
resolve_recipient_in(&topo, "bob", hive_sh4re::manager::PARENT_RECIPIENT),
|
/// The upgrade path. A hive whose `topology.json` still carries the
|
||||||
"alice"
|
/// legacy `name → parent` map must read as the same roster, parent
|
||||||
);
|
/// values discarded — otherwise the first read after the upgrade hands
|
||||||
// alice's parent is the manager — same one-hop rewrite.
|
/// `ManageRootAgent` holders an empty mount set.
|
||||||
assert_eq!(
|
#[test]
|
||||||
resolve_recipient_in(&topo, "alice", hive_sh4re::manager::PARENT_RECIPIENT),
|
fn a_pre_4472_parent_map_reads_as_its_key_set() {
|
||||||
crate::lifecycle::MANAGER_NAME
|
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]
|
#[test]
|
||||||
fn resolve_recipient_falls_back_to_operator_for_root_agent() {
|
fn a_roster_array_round_trips() {
|
||||||
let topo = topo_three_level();
|
let raw = serde_json::to_string(&roster_three()).expect("serialises");
|
||||||
// Manager is structurally root (parent = None) → `<parent>`
|
let parsed: OnDisk = serde_json::from_str(&raw).expect("array parses");
|
||||||
// resolves to the operator (the "no parent → tell mara"
|
let names = match parsed {
|
||||||
// fallback documented in conventions.md).
|
OnDisk::Roster(n) => n,
|
||||||
assert_eq!(
|
OnDisk::WithParents(m) => m.into_keys().collect(),
|
||||||
resolve_recipient_in(
|
};
|
||||||
&topo,
|
assert_eq!(names, roster_three());
|
||||||
crate::lifecycle::MANAGER_NAME,
|
|
||||||
hive_sh4re::manager::PARENT_RECIPIENT
|
|
||||||
),
|
|
||||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_recipient_falls_back_to_operator_for_unknown_sender() {
|
|
||||||
// Sender absent from topology entirely — defensive fallback
|
|
||||||
// covers the race window where an agent's spawn has registered
|
|
||||||
// its socket but the meta-flake `sync_agents` hasn't yet added
|
|
||||||
// its row.
|
|
||||||
let topo = topo_three_level();
|
|
||||||
assert_eq!(
|
|
||||||
resolve_recipient_in(&topo, "nobody", hive_sh4re::manager::PARENT_RECIPIENT),
|
|
||||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn children_of_in_returns_direct_descendants() {
|
|
||||||
let topo = topo_three_level();
|
|
||||||
// alice's children: bob, carol.
|
|
||||||
let mut children = children_of_in(&topo, "alice");
|
|
||||||
children.sort();
|
|
||||||
assert_eq!(children, vec!["bob", "carol"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn children_of_in_manager_returns_root_level_agents() {
|
|
||||||
let topo = topo_three_level();
|
|
||||||
// Only alice's parent is manager; bob+carol are under alice.
|
|
||||||
let children = children_of_in(&topo, crate::lifecycle::MANAGER_NAME);
|
|
||||||
assert_eq!(children, vec!["alice"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn children_of_in_leaf_returns_empty() {
|
|
||||||
let topo = topo_three_level();
|
|
||||||
// bob and carol have no children.
|
|
||||||
assert!(children_of_in(&topo, "bob").is_empty());
|
|
||||||
assert!(children_of_in(&topo, "carol").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn children_of_in_unknown_sender_returns_empty() {
|
|
||||||
let topo = topo_three_level();
|
|
||||||
assert!(children_of_in(&topo, "nobody").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn all_agents_in_returns_every_name() {
|
|
||||||
let topo = topo_three_level();
|
|
||||||
let mut all = all_agents_in(&topo);
|
|
||||||
all.sort();
|
|
||||||
let mut expected = vec![crate::lifecycle::MANAGER_NAME, "alice", "bob", "carol"];
|
|
||||||
expected.sort_unstable();
|
|
||||||
assert_eq!(all, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The set behind the `ManageRootAgent` mount grant must not depend on
|
|
||||||
/// `parent`: a capability holder manages an agent whether or not that
|
|
||||||
/// agent sits under someone. This is the assertion the old
|
|
||||||
/// `top_level_agents_in` could not have made.
|
|
||||||
#[test]
|
|
||||||
fn all_agents_in_includes_parented_agents() {
|
|
||||||
let mut topo = BTreeMap::new();
|
|
||||||
topo.insert("alice".to_owned(), Some("bob".to_owned()));
|
|
||||||
topo.insert("bob".to_owned(), None);
|
|
||||||
let mut all = all_agents_in(&topo);
|
|
||||||
all.sort();
|
|
||||||
assert_eq!(all, vec!["alice", "bob"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn all_agents_in_empty_topo_returns_empty() {
|
|
||||||
let topo = BTreeMap::new();
|
|
||||||
assert!(all_agents_in(&topo).is_empty());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,14 +49,6 @@ pub struct ContainerView {
|
||||||
/// for this agent's input.
|
/// for this agent's input.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub deployed_sha: Option<String>,
|
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
|
/// The Claude model the agent's harness is currently using, read from
|
||||||
/// `state/hyperhive-harness.json["active_model"]`. `None` when the
|
/// `state/hyperhive-harness.json["active_model"]`. `None` when the
|
||||||
/// agent has never started a turn or the field is absent. Only
|
/// 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,
|
needs_login: v.needs_login,
|
||||||
deployed_sha: v.deployed_sha,
|
deployed_sha: v.deployed_sha,
|
||||||
pending_reminders: 0,
|
pending_reminders: 0,
|
||||||
parent: v.parent,
|
|
||||||
paused: v.paused,
|
paused: v.paused,
|
||||||
active_model: v.active_model,
|
active_model: v.active_model,
|
||||||
status_text: v.status_text,
|
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> {
|
pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec<ContainerView> {
|
||||||
let raw = lifecycle::list().await.unwrap_or_default();
|
let raw = lifecycle::list().await.unwrap_or_default();
|
||||||
let locked = read_meta_locked_revs();
|
let locked = read_meta_locked_revs();
|
||||||
// Pull the topology map once and look up each agent's parent below.
|
// Read once per scan rather than re-read for every container on every
|
||||||
// Empty / absent topology.json → every agent root-level (safe
|
// SSE scan.
|
||||||
// degradation for fresh installs that haven't run sync_agents yet).
|
|
||||||
let topology = crate::topology::read();
|
|
||||||
// Same once-per-scan treatment as the topology map: the override file
|
|
||||||
// is read here and resolved per agent below, rather than re-read for
|
|
||||||
// every container on every SSE scan.
|
|
||||||
let limits = crate::resource_limits::read();
|
let limits = crate::resource_limits::read();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for c in &raw {
|
for c in &raw {
|
||||||
|
|
@ -189,7 +175,6 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec<ContainerView>
|
||||||
let needs_update =
|
let needs_update =
|
||||||
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await;
|
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 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
|
// One `systemctl` call for both facts: `unit_state` is the same
|
||||||
// shell-out `is_running` makes, minus `--quiet`. Asking twice would
|
// shell-out `is_running` makes, minus `--quiet`. Asking twice would
|
||||||
// double the per-agent subprocess count on every SSE scan.
|
// 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_update,
|
||||||
needs_login,
|
needs_login,
|
||||||
deployed_sha,
|
deployed_sha,
|
||||||
parent,
|
|
||||||
active_model,
|
active_model,
|
||||||
status_text,
|
status_text,
|
||||||
status_set_at,
|
status_set_at,
|
||||||
|
|
@ -547,7 +531,6 @@ mod tests {
|
||||||
needs_update: true,
|
needs_update: true,
|
||||||
needs_login: false,
|
needs_login: false,
|
||||||
deployed_sha: Some("abc123def456".to_owned()),
|
deployed_sha: Some("abc123def456".to_owned()),
|
||||||
parent: Some("bob".to_owned()),
|
|
||||||
active_model: Some("claude-opus".to_owned()),
|
active_model: Some("claude-opus".to_owned()),
|
||||||
status_text: Some("shipping".to_owned()),
|
status_text: Some("shipping".to_owned()),
|
||||||
status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()),
|
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_update);
|
||||||
assert!(!row.needs_login);
|
assert!(!row.needs_login);
|
||||||
assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456"));
|
assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456"));
|
||||||
assert_eq!(row.parent.as_deref(), Some("bob"));
|
|
||||||
assert!(row.paused);
|
assert!(row.paused);
|
||||||
assert_eq!(row.active_model.as_deref(), Some("claude-opus"));
|
assert_eq!(row.active_model.as_deref(), Some("claude-opus"));
|
||||||
assert_eq!(row.status_text.as_deref(), Some("shipping"));
|
assert_eq!(row.status_text.as_deref(), Some("shipping"));
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue