refactor(hive-c0re): drop the request_init_config tool and InitConfig approval

swarm-controller's `InitAgentConfigRepo` node already covers config-repo
creation, so this deletes a duplicate rather than a capability; old
`init_config` rows are skipped by `collect_lenient` with no migration, by
operator decision.

Refs #4398
This commit is contained in:
atlas 2026-09-14 18:50:24 +02:00
commit a3b672d1d5
31 changed files with 134 additions and 601 deletions

View file

@ -26,7 +26,7 @@ declarations.
## Agent lifecycle ## Agent lifecycle
- **How do config changes flow from manager to operator to container?** - **How do config changes flow from manager to operator to container?**
[`agent-lifecycle/approvals.md`](agent-lifecycle/approvals.md) (two-step spawn, approval [`agent-lifecycle/approvals.md`](agent-lifecycle/approvals.md) (approval kinds, approval
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).

View file

@ -21,11 +21,10 @@ Topology lives in the hive-c0re-owned **meta repo**, alongside
`null` = root-level agent. New agents **default to root** — there is no `null` = root-level agent. New agents **default to root** — there is no
structural manager that everything hangs under. Hierarchy is built structural manager that everything hangs under. Hierarchy is built
explicitly: an agent that requests a sub-agent gets a explicitly: an agent gets a parent edge written before its first spawn,
requester-as-parent edge written at its `init_config` approval (so or the operator reparents it afterwards (so `bob` above sits under
`alice` spawned `bob` above), and the operator can reparent any `alice`). Any agent is reparentable, the bootstrap container (`ruth`)
agent, including the bootstrap container (`ruth`) — it's just another included — it's just another root. The manager is reparentable like any other agent; there's no
root. The manager is reparentable like any other agent; there's no
"structurally root" carve-out. Its privileges live on its MCP socket, "structurally root" carve-out. Its privileges live on its MCP socket,
not its tree position (see _Manager special-casing today_ below). not its tree position (see _Manager special-casing today_ below).
@ -63,10 +62,11 @@ where system-level facts live.
install that hasn't synced yet). install that hasn't 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 New agents default to root unless they already carry an explicit
parent edge from an `init_config` approval; Reconcile preserves existing entries parent edge written before their first spawn; Reconcile preserves
(including operator overrides); removed agents drop. existing entries (including operator overrides); removed agents drop.
Agents that are approved but not yet spawned keep their edge too, so Agents whose config repo exists but that haven't spawned yet keep
it survives the gap until the container actually appears. their edge too, so it survives the gap until the container actually
appears.
- **Inject** — hive-c0re exposes each container's parent (if any) to its own - **Inject** — hive-c0re exposes each container's parent (if any) to its own
environment as `HIVE_PARENT`, so the harness / system-prompt environment as `HIVE_PARENT`, so the harness / system-prompt
renderer can see it. renderer can see it.
@ -90,7 +90,6 @@ umount-old / mount-new / restart-cascade step.
| operation | who can do it | | operation | who can do it |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor | | `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
| `request_init_config` (spawn a new child) | any agent, child added under self |
| config change via forge PR (any descendant's config) | any ancestor | | config change via forge PR (any descendant's config) | any ancestor |
| `get_logs` (any descendant) | any ancestor | | `get_logs` (any descendant) | any ancestor |
| moderate reminders (cancel any open thread of a descendant) | any ancestor | | moderate reminders (cancel any open thread of a descendant) | any ancestor |
@ -110,15 +109,15 @@ other agents don't:
- **Naming/bootstrap** — the manager's broker recipient name, state-dir - **Naming/bootstrap** — the manager's broker recipient name, state-dir
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 `request_init_config` approval step — every other agent goes through a `Spawn` approval.
→ approval. Topology-wise, `ruth` is still just another root agent. Topology-wise, `ruth` is still just another root agent.
- **Wire-protocol** — the privileged `Request` variants - **Wire-protocol** — the privileged `Request` variants
(`RequestInitConfig`; `Kill` / `Start` / `Restart` / `Update`; (`Kill` / `Start` / `Restart` / `Update`;
`GetLogs`; `RequestUpdateMetaInputs`) — marked `*(privileged)*` in `GetLogs`; `RequestUpdateMetaInputs`) — marked `*(privileged)*` in
`hive-core-agent-sock`'s unified `Request` enum — are reachable only `hive-core-agent-sock`'s unified `Request` enum — are reachable only
from the manager's socket flavour today. Planned rule for each is in the from the manager's socket flavour today. Planned rule for each is in the
table above ("any agent, child added under self" for init-config, table above ("any ancestor" for lifecycle/logs);
"any ancestor" for lifecycle/logs); `RequestUpdateMetaInputs` stays `RequestUpdateMetaInputs` stays
a root-only capability even post-milestone, not a topology rule. a root-only capability even post-milestone, not a topology rule.
One exception: `Wake` (inject a `from: <X>` message into the 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

View file

@ -24,12 +24,14 @@ CLI) before it takes effect. What you'll see, and what to do with it:
anything in that chain fails, the change rolls back automatically — anything in that chain fails, the change rolls back automatically —
the agent stays on its last-good config, no recovery action needed the agent stays on its last-good config, no recovery action needed
from you. from you.
- **New agent** (`InitConfig` then `Spawn`) — creating a brand-new - **New agent** (`Spawn`) — the one approval in creating a brand-new
agent is two approvals. `InitConfig` creates the config repo and agent. Its config repo is scaffolded first, outside the approval
seeds it from a template; `Spawn` creates the container from that queue, by the swarm controller's `InitAgentConfigRepo` job
config. Tailoring the template first isn't a separate mechanism — (`POST /api/agents`); `Spawn` then creates the container from that
it's the config-change flow above, a PR you review like any other. Every later change goes through the config-change flow config. Tailoring what the template seeded isn't a separate
above — there's no repeat "spawn" for an existing agent. mechanism — it's the config-change flow above, a PR you review like
any other. Every later change goes through that flow — there's no
repeat "spawn" for an existing agent.
- **Meta/flake update** (`UpdateMetaInputs`) — an agent asked to bump - **Meta/flake update** (`UpdateMetaInputs`) — an agent asked to bump
one or more Nix flake inputs (or all of them). Approving runs the one or more Nix flake inputs (or all of them). Approving runs the
update and commits the lock change; it doesn't rebuild anything by update and commits the lock change; it doesn't rebuild anything by
@ -131,11 +133,10 @@ agent that lacks the `approvals` tool group: only an agent with that
group submits approvals (for its direct children), so an agent group submits approvals (for its direct children), so an agent
without it has nothing of its own to withdraw. without it has nothing of its own to withdraw.
`InitConfig` approvals create a brand-new agent's config repo. On A brand-new agent's config repo is created outside this queue, by the
approve, hive-c0re seeds it with a default `agent.nix` template and swarm controller's `InitAgentConfigRepo` job, which seeds it with a
pushes a todo (`push_todo_submitter`) into the submitting agent's default `agent.nix` template. The operator then **spawns** the agent
in-container store. The operator then **spawns** the agent (the (the `Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the
`Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the
container from that config. container from that config.
Changing what the template seeded isn't a special case: like every Changing what the template seeded isn't a special case: like every
@ -146,7 +147,7 @@ through the web UI or the forge.
### Approval kinds (wire shapes) ### Approval kinds (wire shapes)
`ApprovalKind` carries five variants; each maps to a different `ApprovalKind` carries four variants; each maps to a different
`commit_ref` encoding because that field is overloaded as the `commit_ref` encoding because that field is overloaded as the
kind-specific payload carrier. kind-specific payload carrier.
@ -169,13 +170,11 @@ kind-specific payload carrier.
`hivectl agent <name> request-create` CLI). The host-level `HostRequest::Spawn` `hivectl agent <name> request-create` CLI). The host-level `HostRequest::Spawn`
variant bypasses the approval queue entirely — privileged-context use variant bypasses the approval queue entirely — privileged-context use
only (operator on the host shell, test scripts, one-off recoveries; only (operator on the host shell, test scripts, one-off recoveries;
`hivectl agent <name> create`). This is the **canonical first-spawn**: a new agent's `InitConfig` `hivectl agent <name> create`). This is the **canonical first-spawn**: the
seeds its config repo, the submitting agent customises it, then the swarm controller's `InitAgentConfigRepo` job seeds the agent's config
operator spawns to create the container. Subsequent config changes go repo, it gets customised through a PR, then the operator spawns to
through a `MergeConfigPr` PR. create the container. Subsequent config changes go through a
- `InitConfig``commit_ref` is empty; the variant just gates `MergeConfigPr` PR.
"seed the proposed repo with the default template" against
operator approval. Step 1 of the two-step spawn flow above.
- `UpdateMetaInputs``commit_ref` stores the JSON-encoded inputs - `UpdateMetaInputs``commit_ref` stores the JSON-encoded inputs
array (`"[]"` = all inputs, `"[\"nixpkgs\"]"` = just nixpkgs, array (`"[]"` = all inputs, `"[\"nixpkgs\"]"` = just nixpkgs,
etc.). hive-c0re sets the `agent` field to the requesting root agent. etc.). hive-c0re sets the `agent` field to the requesting root agent.
@ -421,7 +420,6 @@ rather than run inline:
| `MergeConfigPr` | `rebuild` (`DeployWindow` root + `MergeVerify → DeployApply` + `DeployTail`) | `approval` | | `MergeConfigPr` | `rebuild` (`DeployWindow` root + `MergeVerify → DeployApply` + `DeployTail`) | `approval` |
| `UpdateMetaInputs` | `meta_update` (`MetaLock` + rebuild fan-out) | `approval` | | `UpdateMetaInputs` | `meta_update` (`MetaLock` + rebuild fan-out) | `approval` |
| `Spawn` | `spawn` (`Create → WriteDropin → Reconcile`) | `approval` | | `Spawn` | `spawn` (`Create → WriteDropin → Reconcile`) | `approval` |
| `InitConfig` | — runs inline (sub-second git seed) | — |
| `SchedulePrompt` | — runs inline (single sqlite insert) | — | | `SchedulePrompt` | — runs inline (single sqlite insert) | — |
The DAG carries the originating `approval_id`, surfaced on the node that The DAG carries the originating `approval_id`, surfaced on the node that
@ -604,7 +602,7 @@ as a regular `system` inbox message so it drives a normal claude turn.
through `Coordinator::push_todo`/`push_todo_submitter` instead, a direct through `Coordinator::push_todo`/`push_todo_submitter` instead, a direct
live dial of the target agent's in-container todo socket (same live dial of the target agent's in-container todo socket (same
`UpsertTodo` request in-container producers use); `finish_approval` fires `UpsertTodo` request in-container producers use); `finish_approval` fires
one of these too for `InitConfig`/`Spawn`/`MergeConfigPr`, *in addition to* one of these too for `Spawn`/`MergeConfigPr`, *in addition to*
the `ApprovalResolved` HelperEvent above, not instead of it. Legacy the `ApprovalResolved` HelperEvent above, not instead of it. Legacy
approval rows that predate the submitter column fall back to the approval rows that predate the submitter column fall back to the
root agent. Variants (`hive_sh4re::manager::HelperEvent`): root agent. Variants (`hive_sh4re::manager::HelperEvent`):
@ -625,7 +623,7 @@ root agent. Variants (`hive_sh4re::manager::HelperEvent`):
no approval required. no approval required.
The remaining lower-urgency lifecycle notices — `Rebuilt`, `Killed`, The remaining lower-urgency lifecycle notices — `Rebuilt`, `Killed`,
`Destroyed`, `NeedsLogin`, `LoggedIn`, `ConfigReady` — are "FYI, check `Destroyed`, `NeedsLogin`, `LoggedIn` — are "FYI, check
when convenient" events with no reason to drive an immediate turn, so when convenient" events with no reason to drive an immediate turn, so
they deliver via `push_todo`/`push_todo_submitter` (see above) instead they deliver via `push_todo`/`push_todo_submitter` (see above) instead
of `HelperEvent`: an `agent_todo_socket` push instead of a broker of `HelperEvent`: an `agent_todo_socket` push instead of a broker
@ -638,7 +636,7 @@ hive-c0re-vouched commit sha. Optional `tag` carries the deploy
bookkeeping tag — `deployed/<id>` on a successful build or bookkeeping tag — `deployed/<id>` on a successful build or
`failed/<id>` on a failed one, planted by the `MergeConfigPr` deploy. `failed/<id>` on a failed one, planted by the `MergeConfigPr` deploy.
Both fields are `Option`: `None` on the paths that don't deploy a new Both fields are `Option`: `None` on the paths that don't deploy a new
commit (spawn / init_config / meta-update / deny, and the autoupdate commit (spawn / meta-update / deny, and the autoupdate
sweep's `job_queue::templates::rebuild` reapplying the existing main, sweep's `job_queue::templates::rebuild` reapplying the existing main,
or the dashboard `↻ R3BU1LD` button when the lock didn't move). When set, or the dashboard `↻ R3BU1LD` button when the lock didn't move). When set,
`git show <sha>` against `/applied/<n>/.git` inside the `git show <sha>` against `/applied/<n>/.git` inside the

View file

@ -58,7 +58,7 @@ power-intent registry:
below](#state-dirs-per-agent) for where reminders (and todos) below](#state-dirs-per-agent) for where reminders (and todos)
actually live now. actually live now.
- `approvals` — the queue. `agent / kind (merge_config_pr | spawn | - `approvals` — the queue. `agent / kind (merge_config_pr | spawn |
init_config | update_meta_inputs | schedule_prompt) / update_meta_inputs | schedule_prompt) /
commit_ref / requested_at / status / resolved_at / note`. commit_ref / requested_at / status / resolved_at / note`.
- `scheduled_prompts` — recurring + one-shot prompt queue. - `scheduled_prompts` — recurring + one-shot prompt queue.
`owner / body / interval_seconds (NULL = one-shot) / `owner / body / interval_seconds (NULL = one-shot) /
@ -353,9 +353,9 @@ 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
boundary a convention rather than a permission. boundary a convention rather than a permission.
⚠️ Not to be confused with the seeding done when an `InitConfig` ⚠️ Not to be confused with the config-repo seeding hive-c0re does at
approval resolves: that writes the child's initial config repo as spawn (`lifecycle::setup_proposed`): that writes the child's initial
**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.
@ -443,7 +443,7 @@ nothing is automigrated: existing agents keep their plain dirs
until an explicit opt-in upgrade. until an explicit opt-in upgrade.
- **Creation:** `lifecycle::ensure_agent_state_subvolume` runs before - **Creation:** `lifecycle::ensure_agent_state_subvolume` runs before
hive-c0re creates the per-agent subdirs (spawn / rebuild / InitConfig). hive-c0re creates the per-agent subdirs (spawn / rebuild).
It skips the work when the root already exists; otherwise it asks It skips the work when the root already exists; otherwise it asks
hive-priv (`EnsureAgentSubvolume`) to `btrfs subvolume create` the hive-priv (`EnsureAgentSubvolume`) to `btrfs subvolume create` the
root when the FS is btrfs (`statfs` magic gate) and chown it to the root when the FS is btrfs (`statfs` magic gate) and chown it to the

View file

@ -247,18 +247,17 @@ a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`).
### 7 · Spawn sub-agents ### 7 · Spawn sub-agents
Sub-agent creation goes through the approval queue — ruth proposes, the Sub-agent creation is an operator action — agents have no tool for it.
operator approves, the container builds. From ruth's own turn (inside Two steps:
the container, via MCP tools):
``` ```
# Step 1: initialise a new agent's config repo # Step 1: scaffold the new agent's config repo. The swarm controller's
request_init_config(name: "iris") # InitAgentConfigRepo job does this (POST /api/agents), seeding
# → operator approves → config_ready event lands in the inbox # /agents/iris/config/agent.nix from the default template.
# Step 2: edit /agents/iris/config/agent.nix and commit it. Then the # Step 2: edit /agents/iris/config/agent.nix and commit it. Then spawn
# operator spawns iris (dashboard ◆ R3QU3ST SP4WN / Spawn approval), # iris from the dashboard (◆ R3QU3ST SP4WN / Spawn approval), which
# which builds + starts the container from that config. # builds + starts the container from that config.
# Later config changes: open a PR on agent-configs/iris (hive-forge); # Later config changes: open a PR on agent-configs/iris (hive-forge);
# the operator reviews + approves it — no MCP tool call. # the operator reviews + approves it — no MCP tool call.

View file

@ -311,7 +311,7 @@ binary flavor.
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` | | `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` |
| `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. | | `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. |
| `lifecycle` | `kill`, `start`, `restart`, `update`, `list_containers` *(privileged)* | | `lifecycle` | `kill`, `start`, `restart`, `update`, `list_containers` *(privileged)* |
| `approvals` | `request_init_config`, `request_update_meta_inputs` *(privileged)* | | `approvals` | `request_update_meta_inputs` *(privileged)* |
| `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* | | `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* |
| `diagnostics` | `get_logs` *(privileged)* | | `diagnostics` | `get_logs` *(privileged)* |
| `forge` | `create_repo` — create git repos through hive-c0re (operator-gated merge) | | `forge` | `create_repo` — create git repos through hive-c0re (operator-gated merge) |

View file

@ -39,38 +39,20 @@ one-row answer naming itself.
## `approvals` tool group ## `approvals` tool group
Config changes and new-agent spawns route through the operator Meta-flake input bumps route through the operator approval queue.
approval queue. Topology-enforced the same way.
### `request_init_config(name, description?)` Creating a new agent is **not** in this group — agents have no tool for
it. A new agent's config repo is scaffolded by the swarm controller's
`InitAgentConfigRepo` job (`POST /api/agents`, see
`swarm-controller/`), and the operator spawns the container from the
dashboard (`◆ R3QU3ST SP4WN` / `Spawn` approval, routed via
`HostRequest::RequestSpawn`).
Step 1 of spawning a new sub-agent. Queues an `InitConfig` Config changes on an existing agent go through a **forge PR** on the
approval; on operator approve, hive-c0re seeds the proposed config agent's `agent-configs/<name>` repo (queues a `MergeConfigPr` approval
repo at `/agents/<name>/config/agent.nix` with a default template and on open/update — no MCP tool involved), not a tool call. See
delivers a `config_ready` system event. Then edit `agent.nix`, commit,
and the operator **spawns** the agent (the dashboard `◆ R3QU3ST SP4WN`
button / `Spawn` approval, routed via `HostRequest::RequestSpawn`),
which creates the container from that config.
Subsequent config changes go through a **forge PR** on the agent's
`agent-configs/<name>` repo (queues a `MergeConfigPr` approval on
open/update — no MCP tool involved), not a tool call. See
`docs/agent-lifecycle/approvals.md`. `docs/agent-lifecycle/approvals.md`.
`name` must be either unused — in which case the caller becomes its
parent on approval — or an agent already in the caller's subtree whose
config is being re-seeded. The server refuses a name that exists outside
that subtree, so one agent can't hijack another's.
Only **direct** children's config repos are bind-mounted into a parent's
container, though (`bind_child_agent_dirs`, driven by
`topology::children_of`). The server accepts re-seeding an agent further
down the subtree, and that still leaves the caller without a local copy
to edit afterwards.
Fails if a proposed config repo for `name` already exists.
`name` is ≤ 9 characters.
### `request_update_meta_inputs(inputs?, description?)` ### `request_update_meta_inputs(inputs?, description?)`
Queue an approval to run `nix flake update [inputs...]` on the meta Queue an approval to run `nix flake update [inputs...]` on the meta
@ -83,15 +65,14 @@ agents after the approval resolves.
## Boundary summary ## Boundary summary
| Operation | Requires approval? | Scope | | Operation | Requires approval? | Scope |
| --------------------------------------- | ------------------ | ---------------------------------- | | --------------------------------------- | ------------------ | ---------------------------- |
| `kill` / `start` / `restart` / `update` | No | Own subtree | | `kill` / `start` / `restart` / `update` | No | Own subtree |
| `list_containers` | No | Own subtree, caller included | | `list_containers` | No | Own subtree, caller included |
| `request_init_config` | Yes (InitConfig) | Unused name, or one in own subtree | | `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) |
| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) |
## See also ## See also
- [`docs/agent-lifecycle/approvals.md`](../agent-lifecycle/approvals.md) — full approval flow, kinds, - [`docs/agent-lifecycle/approvals.md`](../agent-lifecycle/approvals.md) — full approval flow, kinds,
helper events (`config_ready`, `approval_resolved`), flake.lock helper events (`approval_resolved`), flake.lock
validation. validation.

View file

@ -66,7 +66,7 @@ object with an `event` discriminant field). The **submitting agent**
tool group for its own subtree) receives `container_crash`, tool group for its own subtree) receives `container_crash`,
`needs_update`, and `approval_resolved` this way. The remaining, lower-urgency lifecycle `needs_update`, and `approval_resolved` this way. The remaining, lower-urgency lifecycle
notices — `spawned`, `rebuilt`, `killed`, `destroyed`, `needs_login`, notices — `spawned`, `rebuilt`, `killed`, `destroyed`, `needs_login`,
`logged_in`, `config_ready` — skip the inbox entirely: they land as `logged_in` — skip the inbox entirely: they land as
todos on the submitting agent's in-container todo socket instead todos on the submitting agent's in-container todo socket instead
(`Coordinator::push_todo`/`push_todo_submitter`, `subsystem = "core"`), (`Coordinator::push_todo`/`push_todo_submitter`, `subsystem = "core"`),
which still wakes a turn (the todo-wake path — see [Turn which still wakes a turn (the todo-wake path — see [Turn

View file

@ -1060,7 +1060,6 @@ renderApprovals`) with three stacked sections:
| `merge_config_pr` | `⇒` | `merge-pr` | PR-head sha (`sha_short`) | | `merge_config_pr` | `⇒` | `merge-pr` | PR-head sha (`sha_short`) |
| `update_meta_inputs` | `↻` | `meta-update` | — | | `update_meta_inputs` | `↻` | `meta-update` | — |
| `schedule_prompt` | `⏱` | `schedule` | — | | `schedule_prompt` | `⏱` | `schedule` | — |
| `init_config` | `⊕` | `init` | — |
| `spawn` | `⊕` | `spawn` | — | | `spawn` | `⊕` | `spawn` | — |
The chip ticks live every second via a `data-requested-at` The chip ticks live every second via a `data-requested-at`
@ -1074,8 +1073,7 @@ renderApprovals`) with three stacked sections:
config PR into `agent-configs/<agent>/pulls/<pr_number>` (shown config PR into `agent-configs/<agent>/pulls/<pr_number>` (shown
only when `forge_present` and `pr_number` is set). The config diff only when `forge_present` and `pr_number` is set). The config diff
lives on the forge PR itself — no inline diff side-panel. lives on the forge PR itself — no inline diff side-panel.
- `init_config` / `spawn`: a one-line "container will be created" - `spawn`: a one-line "container will be created" note instead.
note instead.
- **decision actions**`◆ APPR0VE` and `DENY`. Deny pops a - **decision actions**`◆ APPR0VE` and `DENY`. Deny pops a
`prompt()` for an optional reason carried to the submitting agent as `prompt()` for an optional reason carried to the submitting agent as
`HelperEvent::ApprovalResolved.note`. `HelperEvent::ApprovalResolved.note`.

View file

@ -232,7 +232,6 @@ export function renderApprovals() {
const ul = el("ul", { class: "approvals" }); const ul = el("ul", { class: "approvals" });
for (const a of pending) { for (const a of pending) {
const isInit = a.kind === "init_config";
const isMergePr = a.kind === "merge_config_pr"; const isMergePr = a.kind === "merge_config_pr";
const isUpdateMeta = a.kind === "update_meta_inputs"; const isUpdateMeta = a.kind === "update_meta_inputs";
const isSchedule = a.kind === "schedule_prompt"; const isSchedule = a.kind === "schedule_prompt";
@ -262,9 +261,7 @@ export function renderApprovals() {
? "meta-update" ? "meta-update"
: isSchedule : isSchedule
? "schedule" ? "schedule"
: isInit : "spawn",
? "init"
: "spawn",
), ),
); );
if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short)); if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short));
@ -440,9 +437,7 @@ function renderApprovalHistory(root, history) {
? "meta-update" ? "meta-update"
: a.kind === "schedule_prompt" : a.kind === "schedule_prompt"
? "schedule" ? "schedule"
: a.kind === "init_config" : "spawn",
? "init"
: "spawn",
), ),
" ", " ",
); );

View file

@ -81,12 +81,7 @@ window.marked = marked;
for (const a of approvals) { for (const a of approvals) {
if (seenApprovals.has(a.id)) continue; if (seenApprovals.has(a.id)) continue;
seenApprovals.add(a.id); seenApprovals.add(a.id);
const verb = const verb = a.kind === "spawn" ? "spawn approval" : "config commit";
a.kind === "spawn"
? "spawn approval"
: a.kind === "init_config"
? "config-init approval"
: "config commit";
NOTIF.show( NOTIF.show(
"◆ approval #" + a.id, "◆ approval #" + a.id,
`${verb} for ${a.agent}`, `${verb} for ${a.agent}`,

View file

@ -97,20 +97,6 @@ pub struct CompactArgs {
// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics) // Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestInitConfigArgs {
/// New sub-agent name (≤9 chars). Queues an `InitConfig` approval; on
/// approval hive-c0re creates the child's config repo and seeds it with a
/// default `agent.nix`. Approving the follow-up `Spawn` creates the
/// container. Config changes — including the child's first — are PRs on
/// that repo, made from a clone, reviewed + approved by the operator;
/// `/agents/<name>/config` is a read-only copy, not an editing surface.
pub name: String,
/// Optional description shown on the dashboard approval card.
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct KillArgs { pub struct KillArgs {
/// Sub-agent name (without the `h-` container prefix). /// Sub-agent name (without the `h-` container prefix).

View file

@ -26,9 +26,8 @@ mod render;
pub use args::{ pub use args::{
AckUntilArgs, AgentGetLooseEndsArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs, AckUntilArgs, AgentGetLooseEndsArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs,
CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs,
GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestInitConfigArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestSchedulePromptArgs,
RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs,
UpdateMetaInputsArgs,
}; };
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
@ -710,47 +709,6 @@ impl AgentServer {
.await .await
} }
// IMPORTANT: this tool is only available when the `approvals` tool group
// is configured for the agent (`HIVE_TOOL_GROUPS` contains `approvals`).
// hive-c0re performs a topology check server-side: an unused `name` is
// accepted from any caller (the requester becomes its parent); an existing
// agent is accepted only from inside the caller's subtree.
#[tool(description = "Create a new agent's config repo and queue an \
`InitConfig` approval for the operator to review. Requires the `approvals` tool \
group. `name` must be either unused in which case you become its parent or an \
agent already in your subtree, whose config you are re-seeding. Fails if a \
config repo for that agent already exists. This tool **creates the repo** and \
nothing else: on approval hive-c0re seeds it with a default `agent.nix`, and \
approving the follow-up `Spawn` creates the container. \
Every config change the child's first one included goes through a PR on its \
config repo, made from a clone you take yourself, reviewed + approved by the \
operator. `/agents/<name>/config` is a **read-only copy** for reading a config, \
never an editing surface.")]
async fn request_init_config(
&self,
Parameters(args): Parameters<RequestInitConfigArgs>,
) -> String {
let log = format!("{args:?}");
let name = args.name.clone();
run_tool_envelope("request_init_config", log, async move {
let (resp, retries) = self
.dispatch(hive_core_agent_sock::Request::RequestInitConfig {
name: args.name,
description: args.description,
})
.await;
annotate_retries(
format_ack(
resp,
"request_init_config",
format!("init_config approval queued for {name}"),
),
retries,
)
})
.await
}
// IMPORTANT: this tool is only available when the `lifecycle` tool group // IMPORTANT: this tool is only available when the `lifecycle` tool group
// is granted to this agent. hive-c0re enforces the topology check // is granted to this agent. hive-c0re enforces the topology check
// server-side: the call is rejected unless `name` is in the caller's // server-side: the call is rejected unless `name` is in the caller's

View file

@ -643,12 +643,12 @@ mod tests {
Ok(hive_core_agent_sock::Response::OkWarn { Ok(hive_core_agent_sock::Response::OkWarn {
warnings: vec!["name is reserved".to_owned(), "second thing".to_owned()], warnings: vec!["name is reserved".to_owned(), "second thing".to_owned()],
}), }),
"request_init_config", "request_update_meta_inputs",
"init_config approval queued for forge".to_owned(), "update_meta_inputs approval queued".to_owned(),
); );
// The operation HAPPENED — dropping the success line would read as a // The operation HAPPENED — dropping the success line would read as a
// failure and invite a retry that queues a second approval. // failure and invite a retry that queues a second approval.
assert!(out.starts_with("init_config approval queued for forge")); assert!(out.starts_with("update_meta_inputs approval queued"));
assert!(out.contains("⚠️ name is reserved")); assert!(out.contains("⚠️ name is reserved"));
assert!(out.contains("⚠️ second thing")); assert!(out.contains("⚠️ second thing"));
} }
@ -659,7 +659,7 @@ mod tests {
// warning marker would pass the test above. // warning marker would pass the test above.
let out = format_ack( let out = format_ack(
Ok(hive_core_agent_sock::Response::Ok), Ok(hive_core_agent_sock::Response::Ok),
"request_init_config", "request_update_meta_inputs",
"queued".to_owned(), "queued".to_owned(),
); );
assert_eq!(out, "queued"); assert_eq!(out, "queued");

View file

@ -5,23 +5,23 @@ Tools (hyperhive surface). Full signature + behavior for each comes from the too
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at. - **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. - **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
- **Lifecycle** (_requires `lifecycle` tool group_, your own subtree — children, their children, and so on down, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Lifecycle** (_requires `lifecycle` tool group_, your own subtree — children, their children, and so on down, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`.
- **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_update_meta_inputs`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_update_meta_inputs`.
- **Scheduling** (_requires `scheduling` tool group_): `request_schedule_prompt` (queues an approval), `cancel_schedule`, `fire_schedule_now`, `edit_schedule`, `list_schedules` (these four don't need approval — you can manage schedules you own or that a sub-agent in your subtree owns). - **Scheduling** (_requires `scheduling` tool group_): `request_schedule_prompt` (queues an approval), `cancel_schedule`, `fire_schedule_now`, `edit_schedule`, `list_schedules` (these four don't need approval — you can manage schedules you own or that a sub-agent in your subtree owns).
- **Diagnostics**: `get_logs` (_requires `diagnostics` tool group_), `get_host_journal` (_requires `read_host_journal` capability_). - **Diagnostics**: `get_logs` (_requires `diagnostics` tool group_), `get_host_journal` (_requires `read_host_journal` capability_).
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — ask a peer agent with the `approvals` tool group, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
Your config repo is mounted **read-only** at `/agents/{label}/config/``agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line. Your config repo is mounted **read-only** at `/agents/{label}/config/``agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
Approval boundary: lifecycle ops on _existing_ agents in your subtree (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent starts with `request_init_config` (requires `approvals` tool group), then the operator spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which queues a `MergeConfigPr` approval on open/update. The operator only signs off on changes; you run the day-to-day. Approval boundary: lifecycle ops on _existing_ agents in your subtree (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which queues a `MergeConfigPr` approval on open/update. The operator only signs off on changes; you run the day-to-day.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes: Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes:
- `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval. - `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval.
- `container_crash` — restart with `start(name)`. If it crashes again, ask the operator. - `container_crash` — restart with `start(name)`. If it crashes again, ask the operator.
- `approval_resolved` — one of your own submitted approvals (`request_init_config`, `request_update_meta_inputs`, a scheduled prompt, …) was approved, denied, or failed; the body carries the resolution. - `approval_resolved` — one of your own submitted approvals (`request_update_meta_inputs`, a scheduled prompt, a config PR, …) was approved, denied, or failed; the body carries the resolution.
Lifecycle notices that don't need an immediate turn — a new agent spawned, its config repo seeded, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them. Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.
Durable knowledge: Durable knowledge:

View file

@ -646,8 +646,7 @@ fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String {
"mcp__hyperhive__kill" "mcp__hyperhive__kill"
| "mcp__hyperhive__restart" | "mcp__hyperhive__restart"
| "mcp__hyperhive__start" | "mcp__hyperhive__start"
| "mcp__hyperhive__update" | "mcp__hyperhive__update" => format!("{short} {}", sv(input, "name")),
| "mcp__hyperhive__request_init_config" => format!("{short} {}", sv(input, "name")),
"mcp__hyperhive__ack_until" => { "mcp__hyperhive__ack_until" => {
let up_to = input let up_to = input
.get("up_to") .get("up_to")

View file

@ -12,11 +12,10 @@ use hive_sh4re::manager::HelperEvent;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle; use crate::lifecycle;
/// Approve a pending request. Marks the approval row durably, then /// Approve a pending request. Marks the approval row durably, then submits
/// either runs the work inline (`InitConfig`, sub-second git ops) or /// the work to the job queue so the dashboard POST returns immediately while
/// submits it to the job queue so the dashboard POST returns /// the long-running pipeline runs off-thread (operator no longer blocks on a
/// immediately while the long-running pipeline runs off-thread /// 30-90s spinner for `MergeConfigPr`).
/// (operator no longer blocks on a 30-90s spinner for `MergeConfigPr`).
/// ///
/// Dispatch: /// Dispatch:
/// - `MergeConfigPr` → a `DeployWindow` DAG (`MergeVerify → DeployApply → /// - `MergeConfigPr` → a `DeployWindow` DAG (`MergeVerify → DeployApply →
@ -24,7 +23,6 @@ use crate::lifecycle;
/// resource-holding root; ~30-90s) /// resource-holding root; ~30-90s)
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion) /// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
/// - `Spawn` → a `Spawn` DAG (`Create → WriteDropin → Reconcile`) /// - `Spawn` → a `Spawn` DAG (`Create → WriteDropin → Reconcile`)
/// - `InitConfig` → inline (<1s; queue card would be noise)
/// ///
/// Every queued kind — deploys included — resolves its approval row via /// Every queued kind — deploys included — resolves its approval row via
/// [`resolve_approval_dag`] when the DAG settles terminal. /// [`resolve_approval_dag`] when the DAG settles terminal.
@ -38,15 +36,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
"approval: dispatching", "approval: dispatching",
); );
match approval.kind { match approval.kind {
ApprovalKind::InitConfig => {
// Sub-second git seed + forge-remote wire. Routing through
// the queue would surface a queue card that's gone before
// the operator's eyes refocus. Run inline.
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
}
ApprovalKind::UpdateMetaInputs => { ApprovalKind::UpdateMetaInputs => {
// Inputs JSON-encoded into commit_ref by the manager's // Inputs JSON-encoded into commit_ref by the manager's
// submit path — surface them on the DAG so the dashboard // submit path — surface them on the DAG so the dashboard
@ -638,49 +627,6 @@ async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
crate::dashboard::emit_tombstones_snapshot(coord).await; crate::dashboard::emit_tombstones_snapshot(coord).await;
} }
/// Inline (non-queued) handler for `ApprovalKind::InitConfig`. Just
/// seeds the proposed git repo + the per-agent dirs — sub-second
/// work that doesn't justify a queue card.
async fn run_approval_init_config(
coord: &Coordinator,
approval: hive_sh4re::approvals::Approval,
proposed_dir: std::path::PathBuf,
claude_dir: std::path::PathBuf,
notes_dir: std::path::PathBuf,
) -> Result<()> {
let result: Result<()> = async {
// Place the new child under its requesting parent (carried in
// commit_ref by submit_init_config). An empty commit_ref means
// no explicit parent was named (privileged manager socket, or an
// approval queued before the new-child feature) — write no edge
// and let `topology::reconcile` assign the default position on
// first spawn, so this path never names a specific root agent.
if !approval.commit_ref.is_empty() {
crate::topology::add_child(approval.agent.as_str(), &approval.commit_ref)
.map_err(|e| anyhow::anyhow!("topology add_child: {e}"))?;
}
// Create the agent's state root as a btrfs subvolume FIRST, before
// any dir-seed touches it. `ensure_agent_state_subvolume` is
// progressive ("root exists → skip"), and `setup_proposed` does a
// `create_dir_all` on the proposed-config path which would
// materialise the state root as a plain directory — after which
// the subvolume create is silently skipped and the agent never
// lands on a subvolume (no quota, no snapshot). Order matters.
lifecycle::ensure_agent_state_subvolume(approval.agent.as_str()).await?;
lifecycle::setup_proposed(&proposed_dir, approval.agent.as_str()).await?;
lifecycle::ensure_claude_dir(&claude_dir)?;
lifecycle::ensure_state_dir(&notes_dir)?;
Ok(())
}
.await;
if result.is_ok()
&& let Err(e) = crate::forge::ensure_meta_remote(approval.agent.as_str()).await
{
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed");
}
finish_approval(coord, &approval, result, None).await
}
async fn finish_approval( async fn finish_approval(
coord: &Coordinator, coord: &Coordinator,
approval: &hive_sh4re::approvals::Approval, approval: &hive_sh4re::approvals::Approval,
@ -727,28 +673,12 @@ async fn finish_approval(
note: note.clone(), note: note.clone(),
description: approval.description.clone(), description: approval.description.clone(),
}); });
// For spawn/rebuild/init_config approvals, also surface the underlying // For spawn/rebuild approvals, also surface the underlying action so the
// action so the manager knows whether the lifecycle step succeeded. // manager knows whether the lifecycle step succeeded. The
// The ApprovalResolved event already carries the same `ok` signal but // ApprovalResolved event already carries the same `ok` signal but
// separating it lets the manager react to the lifecycle change // separating it lets the manager react to the lifecycle change
// without having to special-case approvals. // without having to special-case approvals.
match approval.kind { match approval.kind {
ApprovalKind::InitConfig => {
if ok {
let _ = coord
.push_todo_submitter(
approval.id,
"core",
Some(format!("config_ready:{}", approval.agent)),
format!(
"agent '{}' config repo ready — edit + apply",
approval.agent
),
None,
)
.await;
}
}
ApprovalKind::Spawn => { ApprovalKind::Spawn => {
let summary = if ok { let summary = if ok {
format!("agent '{}' spawned", approval.agent) format!("agent '{}' spawned", approval.agent)

View file

@ -229,9 +229,8 @@ pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()>
/// Compute the default topology for a fresh install: every agent is a /// Compute the default topology for a fresh install: every agent is a
/// root (parent = null). There is no structural "manager" — agents /// root (parent = null). There is no structural "manager" — agents
/// arrange themselves via explicit parent edges (an agent-requested /// arrange themselves via explicit parent edges, written by the operator
/// sub-agent gets a requester-as-parent edge at `init_config`; the /// through the dashboard / `RequestSetParent` API.
/// operator reparents via the dashboard / `RequestSetParent` API).
/// Used by `meta::sync_agents` on first call to seed `topology.json`. /// Used by `meta::sync_agents` on first call to seed `topology.json`.
/// ///
/// As soon as an explicit write lands (dashboard / `RequestSetParent` /// As soon as an explicit write lands (dashboard / `RequestSetParent`
@ -298,53 +297,6 @@ pub fn apply_set_parent(
Ok(next) Ok(next)
} }
/// Declare a brand-new agent's parent edge before the agent exists in
/// the container set. Unlike [`crate::meta::bulk_commit_topology`] /
/// [`apply_set_parent`] (which reparent an entry that must already be
/// present), this inserts a fresh `child -> parent`
/// row. Used by the `InitConfig` approval to place a just-scaffolded
/// sub-agent under its requesting parent, so the edge is in place
/// before the first apply-commit spawns the container (and before
/// [`reconcile`] would otherwise default it to the manager).
///
/// Idempotent when the edge already exists. Refuses to clobber an entry
/// whose parent differs (one agent can't steal another's child) and
/// validates that `parent` is itself a known agent.
pub fn add_child(child: &str, parent: &str) -> Result<(), String> {
let current = read();
match apply_add_child(&current, child, parent)? {
Some(next) => write(&next).map_err(|e| format!("write topology.json: {e}")),
None => Ok(()),
}
}
/// Pure form of [`add_child`] for unit tests. Returns the post-insert
/// map (caller writes it back), `None` for an idempotent no-op (edge
/// already present), or an error string (unknown parent / name owned by
/// a different parent).
pub fn apply_add_child(
topo: &BTreeMap<String, Option<String>>,
child: &str,
parent: &str,
) -> Result<Option<BTreeMap<String, Option<String>>>, String> {
if !topo.contains_key(parent) {
return Err(format!("unknown parent: {parent}"));
}
match topo.get(child) {
Some(Some(p)) if p == parent => return Ok(None),
Some(existing) => {
return Err(format!(
"agent {child} already exists in topology (parent: {existing:?}); \
refusing to reparent via add_child"
));
}
None => {}
}
let mut next = topo.clone();
next.insert(child.to_owned(), Some(parent.to_owned()));
Ok(Some(next))
}
/// Reconcile `topology.json` against the current agent set. Adds an /// Reconcile `topology.json` against the current agent set. Adds an
/// entry (default: parent = null — a new agent with no declared parent /// entry (default: parent = null — a new agent with no declared parent
/// is its own root) for any agent missing from the file; removes /// is its own root) for any agent missing from the file; removes
@ -353,10 +305,10 @@ pub fn apply_add_child(
/// choices stick across regenerations. Returns true when the file /// choices stick across regenerations. Returns true when the file
/// changed and should be re-committed by the caller. /// changed and should be re-committed by the caller.
/// ///
/// `pending` lists agents that have an operator-approved proposed config /// `pending` lists agents that have a provisioned proposed config repo
/// repo but no container yet (init'd, not yet spawned). They are KEPT /// but no container yet (provisioned, not yet spawned). They are KEPT
/// (not dropped) so the parent edge written at `InitConfig` approval /// (not dropped) so an explicit parent edge written before the first
/// survives until the first apply-commit, but they are NOT seeded with a /// spawn survives until the first apply-commit, but they are NOT seeded with a
/// default parent here and NOT added to roles — that happens when the /// default parent here and NOT added to roles — that happens when the
/// container actually spawns and the name moves into `agent_names`. /// 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> {
@ -384,12 +336,11 @@ pub fn apply_reconcile(
for name in agent_names { for name in agent_names {
if !next.contains_key(name) { if !next.contains_key(name) {
// A new agent with no declared parent defaults to root // A new agent with no declared parent defaults to root
// (parent = null). Agent-requested sub-agents always carry an // (parent = null). An agent placed under a parent carries an
// explicit requester-as-parent edge (written at init_config // explicit edge written before its first spawn, so it never
// approval), so they never hit this default — only // hits this default — only spawns with no declared parent do,
// user/operator-initiated spawns do, and those are roots. No // and those are roots. No agent is structurally privileged
// agent is structurally privileged here: "root-ness" is just // here: "root-ness" is just a null parent.
// a null parent.
next.insert(name.clone(), None); next.insert(name.clone(), None);
changed = true; changed = true;
} }
@ -502,8 +453,7 @@ mod tests {
#[test] #[test]
fn default_seed_makes_every_agent_root() { fn default_seed_makes_every_agent_root() {
// No structural manager: every agent defaults to root (null // No structural manager: every agent defaults to root (null
// parent). Explicit edges (init_config / dashboard) are layered // parent). Explicit edges are layered on later.
// on later.
let agents = vec![ let agents = vec![
"alice".to_owned(), "alice".to_owned(),
crate::lifecycle::MANAGER_NAME.to_owned(), crate::lifecycle::MANAGER_NAME.to_owned(),
@ -630,42 +580,6 @@ mod tests {
assert_eq!(next, topo_three_level()); assert_eq!(next, topo_three_level());
} }
#[test]
fn apply_add_child_inserts_new_edge_under_parent() {
// alice spawns a brand-new child `dora`: the edge lands
// with alice as parent without disturbing the rest of the tree.
let next = apply_add_child(&topo_three_level(), "dora", "alice")
.unwrap()
.expect("brand-new edge should produce a map");
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
// existing entries untouched.
assert_eq!(next.get("bob"), Some(&Some("alice".to_owned())));
}
#[test]
fn apply_add_child_is_idempotent_when_edge_exists() {
// bob already under alice — re-init returns a no-op (None).
assert!(
apply_add_child(&topo_three_level(), "bob", "alice")
.unwrap()
.is_none()
);
}
#[test]
fn apply_add_child_refuses_unknown_parent() {
let err = apply_add_child(&topo_three_level(), "dora", "nobody").unwrap_err();
assert!(err.contains("unknown parent"), "err = {err}");
}
#[test]
fn apply_add_child_refuses_name_owned_by_other_parent() {
// bob lives under alice; the manager can't claim it via add_child.
let err = apply_add_child(&topo_three_level(), "bob", crate::lifecycle::MANAGER_NAME)
.unwrap_err();
assert!(err.contains("already exists"), "err = {err}");
}
#[test] #[test]
fn apply_reconcile_adds_missing_live_agent_as_root() { fn apply_reconcile_adds_missing_live_agent_as_root() {
// A live agent with no prior topology entry defaults to root // A live agent with no prior topology entry defaults to root
@ -696,7 +610,7 @@ mod tests {
#[test] #[test]
fn apply_reconcile_keeps_pending_init_agent_edge() { fn apply_reconcile_keeps_pending_init_agent_edge() {
// `dora` was init'd under alice (edge present) but has no // `dora` was placed under alice (edge present) but has no
// container yet, so it's absent from the live set. It must NOT // container yet, so it's absent from the live set. It must NOT
// be dropped, and its alice-parent edge must be preserved (not // be dropped, and its alice-parent edge must be preserved (not
// re-seeded under the manager). // re-seeded under the manager).

View file

@ -1512,8 +1512,8 @@ impl Coordinator {
/// Agents that have an operator-approved proposed config repo but /// Agents that have an operator-approved proposed config repo but
/// were never deployed — proposed `.git` exists, applied `.git` does /// were never deployed — proposed `.git` exists, applied `.git` does
/// not. Their `topology.json` parent edge (written at `InitConfig` /// not. Their `topology.json` parent edge (written before the first
/// approval) must survive `topology::reconcile` until the first /// spawn) must survive `topology::reconcile` until the first
/// apply-commit spawns the container. Distinct from tombstones, /// apply-commit spawns the container. Distinct from tombstones,
/// which have an applied repo from a prior deploy. /// which have an applied repo from a prior deploy.
#[must_use] #[must_use]

View file

@ -83,13 +83,9 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
approvals approvals
.into_iter() .into_iter()
.filter(|a| { .filter(|a| {
// Spawn and InitConfig approvals are for not-yet-existent agents; // A Spawn approval is for a not-yet-existent agent; the proposed
// the proposed dir is supposed to be missing. // dir is supposed to be missing, so absence is not orphanhood.
if matches!( if a.kind == hive_sh4re::approvals::ApprovalKind::Spawn {
a.kind,
hive_sh4re::approvals::ApprovalKind::Spawn
| hive_sh4re::approvals::ApprovalKind::InitConfig
) {
return true; return true;
} }
if Coordinator::agent_proposed_dir(&a.agent).exists() { if Coordinator::agent_proposed_dir(&a.agent).exists() {

View file

@ -449,16 +449,6 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
commit_ref: None, commit_ref: None,
requested_at: a.requested_at, requested_at: a.requested_at,
}, },
hive_sh4re::approvals::ApprovalKind::InitConfig => ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: "init_config",
sha_short: None,
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
},
hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView { hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView {
id: a.id, id: a.id,
agent: a.agent.to_string(), agent: a.agent.to_string(),

View file

@ -106,11 +106,10 @@ fn config_bind_source(name: &str) -> PathBuf {
/// hive-c0re still reads it directly on the host (`stats::hive_stats`), /// hive-c0re still reads it directly on the host (`stats::hive_stats`),
/// which needs no bind mount into the parent. /// which needs no bind mount into the parent.
/// ///
/// ⚠️ The seeding done at `InitConfig` approval is **not** affected by the /// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
/// `config` flag and must not be read as a reason to widen it: that runs /// the `config` flag and must not be read as a reason to widen it: that runs
/// as hive-c0re against the host path (see `actions.rs`, which seeds the /// as hive-c0re against the host path (see `lifecycle::setup_proposed`), and
/// repo and wires its forge remote inline), and `read_only` on a bind /// `read_only` on a bind constrains writers *inside* the container only.
/// constrains writers *inside* the container only.
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) { fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let Ok(child) = hive_types::Ident::parse(child) else { let Ok(child) = hive_types::Ident::parse(child) else {
tracing::warn!(%child, "skipping child bind: invalid agent name"); tracing::warn!(%child, "skipping child bind: invalid agent name");

View file

@ -171,8 +171,6 @@ pub async fn setup_applied(
/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so /// valid session; credential files inside (`.credentials.json` etc.) are 0600 so
/// secrets stay private regardless of the directory mode. Idempotent: existing /// secrets stay private regardless of the directory mode. Idempotent: existing
/// dirs are left untouched (an agent's OAuth tokens survive `destroy`/recreate). /// dirs are left untouched (an agent's OAuth tokens survive `destroy`/recreate).
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
/// dirs without calling the full `spawn`.
pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
use std::io; use std::io;
if !claude_dir.exists() { if !claude_dir.exists() {
@ -208,9 +206,9 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Public for the `InitConfig` approval path in `actions.rs` which seeds /// Create the per-agent state dir if missing. Also creates the sibling
/// dirs without calling the full `spawn`. Also creates the sibling `harness/` /// `harness/` dir so the first harness startup can write its sqlite files
/// dir so the first harness startup can write its sqlite files immediately. /// immediately.
pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
if !notes_dir.exists() { if !notes_dir.exists() {
std::fs::create_dir_all(notes_dir) std::fs::create_dir_all(notes_dir)

View file

@ -1,6 +1,5 @@
//! Config-approval request handlers: `RequestInitConfig` / //! Config-approval request handlers: `RequestUpdateMetaInputs`, plus the
//! `RequestUpdateMetaInputs`, plus the shared submit helpers //! shared submit helper `submit_merge_config_pr`.
//! (`submit_init_config` / `submit_merge_config_pr`).
//! //!
//! `submit_merge_config_pr` is called from the dashboard webhook handler //! `submit_merge_config_pr` is called from the dashboard webhook handler
//! (`dashboard::webhook`) — agents no longer need an MCP tool for config //! (`dashboard::webhook`) — agents no longer need an MCP tool for config
@ -11,70 +10,8 @@ use std::sync::Arc;
use hive_core_agent_sock::Response; use hive_core_agent_sock::Response;
use super::require_new_child;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
/// `RequestInitConfig` — queue an `InitConfig` approval for an agent. The
/// `name` must be brand-new (absent from the topology) or already in the
/// caller's subtree; the requester is recorded as the new agent's parent (the
/// root requesting a new agent → a top-level agent, matching reconcile's
/// default).
pub(super) fn handle_request_init_config(
coord: &Arc<Coordinator>,
agent: &str,
name: &str,
description: Option<String>,
) -> Response {
if let Some(err) = require_new_child(agent, name, "request_init_config for") {
return err;
}
tracing::info!(%agent, %name, "request_init_config");
// Warn, do not refuse: an agent already created under a colliding
// name must stay re-initialisable, so the refusal comes later, once
// the warning has had time to be seen.
//
// Checked HERE and not only in `swarm-controller::create_agent`:
// that daemon is opt-in and off on most hives, while this is the
// path the `request_init_config` tool takes on every hive. Guarding
// only the rarer one would have left the common flow exactly as
// unguarded as before.
//
// The blacklist itself comes from nix via `HIVE_RESERVED_NAMES`, so it
// stays a config change rather than a rebuild. An UNSET variable means
// this daemon was never told — which is not the same as "no name is
// reserved", and saying nothing there would be a check that reports
// clean because it could not run.
let raw = hive_types::reserved_names_raw();
let warnings = match raw.as_deref().map(hive_types::parse_reserved_names) {
None => {
tracing::error!(
var = hive_types::RESERVED_NAMES_ENV,
"request_init_config: reserved-name check could not run — variable not set"
);
vec![format!(
"the reserved-name check did not run: {} is unset, so {name:?} was accepted \
without being checked against the protocol literals",
hive_types::RESERVED_NAMES_ENV
)]
}
Some(reserved) if hive_types::is_reserved_name(name, &reserved) => {
tracing::warn!(%agent, %name, "request_init_config: reserved name");
vec![format!(
"agent name {name:?} is a reserved protocol name — messages from this agent will \
be indistinguishable from hyperhive's own; this will become an error"
)]
}
Some(_) => Vec::new(),
};
match submit_init_config(coord, name, Some(agent), description) {
Ok(_id) if warnings.is_empty() => Response::Ok,
Ok(_id) => Response::OkWarn { warnings },
Err(e) => Response::Err {
message: format!("{e:#}"),
},
}
}
/// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval /// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval
/// carrying the JSON-encoded input list in `commit_ref` (no git commit /// carrying the JSON-encoded input list in `commit_ref` (no git commit
/// is involved; the field is the payload the approval handler decodes). /// is involved; the field is the payload the approval handler decodes).
@ -214,58 +151,3 @@ pub(crate) async fn submit_merge_config_pr(
}); });
Ok(id) Ok(id)
} }
/// Queue an `InitConfig` approval for a brand-new agent whose config repo
/// does not yet exist. Shared between the manager and agent sockets.
///
/// `parent`, when `Some`, is the agent that will own the new child once
/// the operator approves: it is stashed in the approval's `commit_ref`
/// field (unused for `InitConfig` otherwise — same pattern
/// `UpdateMetaInputs` uses to carry its inputs JSON) and consumed in
/// `run_approval_init_config` to write the `child -> parent` topology
/// edge. Callers pass the requesting agent, so the requester becomes the
/// new agent's parent (the root requesting a new agent → a top-level agent,
/// matching `topology::reconcile`'s default). `None` writes no explicit
/// edge (reconcile-default placement) — retained for that fallback.
pub(crate) fn submit_init_config(
coord: &Arc<Coordinator>,
name: &str,
parent: Option<&str>,
description: Option<String>,
) -> anyhow::Result<i64> {
let agent = hive_types::Ident::parse(name)
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent);
if proposed_dir.join(".git").exists() {
anyhow::bail!(
"proposed config repo for '{name}' already exists at {} - \
nothing to init; config changes go through a forge PR on \
agent-configs/{name}",
proposed_dir.display()
);
}
let id = coord
.approvals
.submit_kind(
name,
hive_sh4re::approvals::ApprovalKind::InitConfig,
parent.unwrap_or(""),
description.as_deref(),
// `parent` is the requesting agent (becomes the new child's
// parent); it's also the submitter the approval events route
// back to. No declared parent = operator-initiated path.
parent.unwrap_or("operator"),
None, // no sha for InitConfig
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
tracing::info!(%id, %name, "init_config approval queued");
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: name,
approval_kind: "init_config",
sha_short: None,
description,
pr_number: None,
});
Ok(id)
}

View file

@ -30,7 +30,7 @@ pub(crate) use config_approvals::submit_merge_config_pr;
pub(crate) use schedules::filter_ghost_schedule_targets; pub(crate) use schedules::filter_ghost_schedule_targets;
pub use schedules::schedule_to_wire_public; pub use schedules::schedule_to_wire_public;
use config_approvals::{handle_request_init_config, handle_request_update_meta_inputs}; use config_approvals::handle_request_update_meta_inputs;
use lifecycle_handlers::{ use lifecycle_handlers::{
handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update, handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update,
}; };
@ -560,9 +560,6 @@ async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Respo
Request::Kill { name } => handle_kill(coord, agent, name).await, Request::Kill { name } => handle_kill(coord, agent, name).await,
Request::Update { name } => handle_update(coord, agent, name), Request::Update { name } => handle_update(coord, agent, name),
Request::ListDescendants => handle_list_descendants(coord, agent).await, Request::ListDescendants => handle_list_descendants(coord, agent).await,
Request::RequestInitConfig { name, description } => {
handle_request_init_config(coord, agent, name, description.clone())
}
// Agent-state queries: own subtree is free; other agents + the // Agent-state queries: own subtree is free; other agents + the
// hive-wide `"*"` sweep require `QueryAgentState`. // hive-wide `"*"` sweep require `QueryAgentState`.
Request::GetLooseEnds { agent: target } => { Request::GetLooseEnds { agent: target } => {
@ -695,46 +692,6 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
} }
} }
/// Topology guard for `request_init_config`, which may legitimately target a
/// child that does not exist *yet* (seeding a brand-new sub-agent's config
/// repo). The caller may act on a
/// `target` that is EITHER already in its subtree (re-init / config
/// update of an agent it owns) OR brand-new (absent from the topology
/// tree — the requester becomes its parent). A name that already
/// exists outside the caller's subtree is refused so
/// one agent can't hijack another's sub-tree.
///
/// Also re-runs the agent-name format check (a traversal / malformed name
/// could never be a descendant): a brand-new name now flows straight to
/// `submit_init_config`, which builds filesystem paths from it, so validate
/// before that.
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<Response> {
if let Err(reason) = hive_types::Ident::parse(target) {
return Some(Response::Err {
message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"),
});
}
// brand-new name (absent from topology) — requester becomes the parent on
// approval; allowed for any caller.
if !crate::topology::read().contains_key(target) {
return None;
}
// existing agent — allowed only if it's in the caller's subtree
// (re-init / config update of an agent the caller owns; the root owns
// every existing agent). Refuses an agent outside the caller's subtree
// so one agent can't hijack another's config.
if crate::topology::is_descendant_of(target, agent) {
None
} else {
Some(Response::Err {
message: format!(
"agent `{agent}` cannot {action} `{target}`: it already exists \
outside its subtree in the topology tree"
),
})
}
}
/// `GetLooseEnds` — read the target's loose ends. `None` / own / a subtree /// `GetLooseEnds` — read the target's loose ends. `None` / own / a subtree
/// descendant resolve freely (a parent sees its subtree, the root sees all); /// descendant resolve freely (a parent sees its subtree, the root sees all);
/// any other named agent needs `QueryAgentState`; `"*"` is a hive-wide sweep /// any other named agent needs `QueryAgentState`; `"*"` is a hive-wide sweep

View file

@ -1,5 +1,5 @@
//! Approval queue. Requests are submitted by the manager (`RequestInitConfig` //! Approval queue. Requests are submitted by the manager
//! / `RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or //! (`RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or
//! the operator (`Spawn`); the user approves/denies via the host admin CLI; //! the operator (`Spawn`); the user approves/denies via the host admin CLI;
//! on approval the host runs the corresponding action. //! on approval the host runs the corresponding action.
@ -74,8 +74,7 @@ impl Approvals {
/// Insert a new pending approval row. `fetched_sha` may be supplied /// Insert a new pending approval row. `fetched_sha` may be supplied
/// when the sha is already known at submission time (e.g. `MergeConfigPr` /// when the sha is already known at submission time (e.g. `MergeConfigPr`
/// fetches the PR head before inserting), making the insert + sha-set /// fetches the PR head before inserting), making the insert + sha-set
/// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn` / /// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn`).
/// `InitConfig`).
pub fn submit_kind( pub fn submit_kind(
&self, &self,
agent: &str, agent: &str,
@ -380,7 +379,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
let kind: String = row.get(2)?; let kind: String = row.get(2)?;
let kind = match kind.as_str() { let kind = match kind.as_str() {
"spawn" => ApprovalKind::Spawn, "spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt, "schedule_prompt" => ApprovalKind::SchedulePrompt,
"merge_config_pr" => ApprovalKind::MergeConfigPr, "merge_config_pr" => ApprovalKind::MergeConfigPr,
@ -434,7 +432,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
fn kind_from_str(s: &str) -> Result<ApprovalKind> { fn kind_from_str(s: &str) -> Result<ApprovalKind> {
Ok(match s { Ok(match s {
"spawn" => ApprovalKind::Spawn, "spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt, "schedule_prompt" => ApprovalKind::SchedulePrompt,
"merge_config_pr" => ApprovalKind::MergeConfigPr, "merge_config_pr" => ApprovalKind::MergeConfigPr,
@ -454,31 +451,6 @@ mod tests {
(dir, path, db) (dir, path, db)
} }
#[test]
fn init_config_approval_round_trips() {
// Regression test: an `init_config` row used to fail
// deserialization (row_to_approval matched only apply_commit +
// spawn), erroring out the whole `pending()` query — every
// approval then vanished from the dashboard.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind(
"bitburner",
ApprovalKind::InitConfig,
"",
Some("scaffold"),
"bitburner",
None,
)
.expect("submit init_config");
let pending = db
.pending()
.expect("pending() must not error on an init_config row");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, id);
assert!(matches!(pending[0].kind, ApprovalKind::InitConfig));
}
#[test] #[test]
fn mixed_kinds_all_listed() { fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp(); let (_dir, _path, db) = open_temp();
@ -493,7 +465,7 @@ mod tests {
.unwrap(); .unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None) db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None)
.unwrap(); .unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None) db.submit_kind("c", ApprovalKind::UpdateMetaInputs, "[]", None, "c", None)
.unwrap(); .unwrap();
let pending = db.pending().expect("pending"); let pending = db.pending().expect("pending");
assert_eq!(pending.len(), 3, "all three kinds must be visible"); assert_eq!(pending.len(), 3, "all three kinds must be visible");

View file

@ -154,13 +154,6 @@ pub enum Request {
}, },
// ---- privileged (manager socket only for now) --------------------------- // ---- privileged (manager socket only for now) ---------------------------
/// *(privileged)* Initialise a brand-new agent's proposed config repo
/// and queue an approval for the operator to review.
RequestInitConfig {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// *(privileged)* Stop a sub-agent (graceful). /// *(privileged)* Stop a sub-agent (graceful).
Kill { name: String }, Kill { name: String },
/// *(privileged)* Start a previously-stopped sub-agent container. /// *(privileged)* Start a previously-stopped sub-agent container.

View file

@ -49,12 +49,6 @@ pub enum ApprovalKind {
/// Create + start a new sub-agent container with the given name /// Create + start a new sub-agent container with the given name
/// (under the default `agent.nix` template). /// (under the default `agent.nix` template).
Spawn, Spawn,
/// Create an agent's config repo and seed it from the default
/// template (step 1 of the two-step spawn flow). Creating it is the
/// whole of this step — tailoring what the template seeded is not a
/// separate mechanism, it's a `MergeConfigPr` like every later
/// change.
InitConfig,
/// Run `nix flake update [inputs...]` on the meta flake and commit /// Run `nix flake update [inputs...]` on the meta flake and commit
/// the resulting lock changes. /// the resulting lock changes.
UpdateMetaInputs, UpdateMetaInputs,

View file

@ -24,7 +24,7 @@ pub enum ToolGroup {
Inbox, Inbox,
/// `kill`, `start`, `restart`, `update` - *(privileged)* /// `kill`, `start`, `restart`, `update` - *(privileged)*
Lifecycle, Lifecycle,
/// `request_init_config`, `request_update_meta_inputs` - *(privileged)* /// `request_update_meta_inputs` - *(privileged)*
Approvals, Approvals,
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, /// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
/// `edit_schedule`, `list_schedules` - *(privileged)* /// `edit_schedule`, `list_schedules` - *(privileged)*
@ -60,7 +60,7 @@ impl ToolGroup {
Self::Meta => &["get_agent_meta"], Self::Meta => &["get_agent_meta"],
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"], Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"],
Self::Approvals => &["request_init_config", "request_update_meta_inputs"], Self::Approvals => &["request_update_meta_inputs"],
Self::Scheduling => &[ Self::Scheduling => &[
"request_schedule_prompt", "request_schedule_prompt",
"fire_schedule_now", "fire_schedule_now",
@ -167,7 +167,7 @@ impl ToolGroup {
"kill, start, restart, update, list_containers — container lifecycle (privileged)" "kill, start, restart, update, list_containers — container lifecycle (privileged)"
} }
Self::Approvals => { Self::Approvals => {
"request_init_config, request_update_meta_inputs — config change flow (privileged)" "request_update_meta_inputs — operator-approved meta-flake input bumps (privileged)"
} }
Self::Scheduling => { Self::Scheduling => {
"request_schedule_prompt and related — operator-visible scheduled prompts (privileged)" "request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"

View file

@ -20,7 +20,7 @@ One module per subcommand family; `main.rs` is just the clap parse +
dispatch: dispatch:
- **`agents.rs`** — container lifecycle (start/stop/create/kill/rebuild/restart/…). - **`agents.rs`** — container lifecycle (start/stop/create/kill/rebuild/restart/…).
- **`approvals.rs`** — the config/init-config/meta-input approval queue. - **`approvals.rs`** — the config/spawn/meta-input approval queue.
- **`dag_progress.rs`** — rebuild-queue progress rendering. - **`dag_progress.rs`** — rebuild-queue progress rendering.
- **`power.rs`** — restart/start/stop at the container level. - **`power.rs`** — restart/start/stop at the container level.
- **`choom.rs`** — drop into an interactive claude session in a - **`choom.rs`** — drop into an interactive claude session in a

View file

@ -4,15 +4,15 @@
# (`HIVE_RESERVED_NAMES`), so keeping it current is a config change rather than # (`HIVE_RESERVED_NAMES`), so keeping it current is a config change rather than
# a rebuild of a binary. Read by: # a rebuild of a binary. Read by:
# #
# - `host-modules/hive-c0re/environment.nix` -> the env var, for the # - `host-modules/swarm-controller.nix` -> the env var, for the
# `request_init_config` path every hive uses # swarm-level `create_agent` path — the only name check that runs today
# - `host-modules/swarm-controller.nix` -> the same var, for the # - `host-modules/hive-c0re/environment.nix` -> the same var. hive-c0re
# swarm-level `create_agent` path # creates no agent, so nothing reads it there now; still handed over so a
# future host-side path is wired by construction, not by remembering to.
# - `host-modules/swarm-otel.nix` -> its `<owner>` assertion, so # - `host-modules/swarm-otel.nix` -> its `<owner>` assertion, so
# a hive name and an agent name are checked against ONE list # a hive name and an agent name are checked against ONE list
# - `nix/checks.nix` -> exported into `cargo test`, # - `nix/checks.nix` -> exported into `cargo test`,
# which is what keeps the message layer's sentinels from drifting away # which keeps the message layer's sentinels from drifting from this file
# from this file
# #
# A plain nix file rather than a module option because two of those readers are # A plain nix file rather than a module option because two of those readers are
# flake-level (`checks.nix`) and cannot see a NixOS option. # flake-level (`checks.nix`) and cannot see a NixOS option.