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
- **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).
- **What state survives destroy / purge / restart?**
[`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
structural manager that everything hangs under. Hierarchy is built
explicitly: an agent that requests a sub-agent gets a
requester-as-parent edge written at its `init_config` approval (so
`alice` spawned `bob` above), and the operator can reparent any
agent, including the bootstrap container (`ruth`) — it's just another
root. The manager is reparentable like any other agent; there's no
explicitly: an agent gets a parent edge written before its first spawn,
or the operator reparents it afterwards (so `bob` above sits under
`alice`). Any agent is reparentable, the bootstrap container (`ruth`)
included — it's just another root. The manager is reparentable like any other agent; there's no
"structurally root" carve-out. Its privileges live on its MCP socket,
not its tree position (see _Manager special-casing today_ below).
@ -63,10 +62,11 @@ where system-level facts live.
install that hasn't synced yet).
- **Reconcile** — runs alongside the periodic meta/flake regeneration.
New agents default to root unless they already carry an explicit
parent edge from an `init_config` approval; Reconcile preserves existing entries
(including operator overrides); removed agents drop.
Agents that are approved but not yet spawned keep their edge too, so
it survives the gap until the container actually appears.
parent edge written before their first spawn; Reconcile preserves
existing entries (including operator overrides); removed agents drop.
Agents whose config repo exists but that haven't spawned yet keep
their edge too, so it survives the gap until the container actually
appears.
- **Inject** — hive-c0re exposes each container's parent (if any) to its own
environment as `HIVE_PARENT`, so the harness / system-prompt
renderer can see it.
@ -90,7 +90,6 @@ umount-old / mount-new / restart-cascade step.
| operation | who can do it |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `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 |
| `get_logs` (any 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
key, and nixos-container name are all `ruth` (container `h-ruth`).
`hive-c0re` spawns it directly at boot if missing, with no operator
approval step — every other agent goes through `request_init_config`
→ approval. Topology-wise, `ruth` is still just another root agent.
approval step — every other agent goes through a `Spawn` approval.
Topology-wise, `ruth` is still just another root agent.
- **Wire-protocol** — the privileged `Request` variants
(`RequestInitConfig`; `Kill` / `Start` / `Restart` / `Update`;
(`Kill` / `Start` / `Restart` / `Update`;
`GetLogs`; `RequestUpdateMetaInputs`) — marked `*(privileged)*` in
`hive-core-agent-sock`'s unified `Request` enum — are reachable only
from the manager's socket flavour today. Planned rule for each is in the
table above ("any agent, child added under self" for init-config,
"any ancestor" for lifecycle/logs); `RequestUpdateMetaInputs` stays
table above ("any ancestor" for lifecycle/logs);
`RequestUpdateMetaInputs` stays
a root-only capability even post-milestone, not a topology rule.
One exception: `Wake` (inject a `from: <X>` message into the
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 —
the agent stays on its last-good config, no recovery action needed
from you.
- **New agent** (`InitConfig` then `Spawn`) — creating a brand-new
agent is two approvals. `InitConfig` creates the config repo and
seeds it from a template; `Spawn` creates the container from that
config. Tailoring the template first isn't a separate mechanism —
it's the config-change flow above, a PR you review like any other. Every later change goes through the config-change flow
above — there's no repeat "spawn" for an existing agent.
- **New agent** (`Spawn`) — the one approval in creating a brand-new
agent. Its config repo is scaffolded first, outside the approval
queue, by the swarm controller's `InitAgentConfigRepo` job
(`POST /api/agents`); `Spawn` then creates the container from that
config. Tailoring what the template seeded isn't a separate
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
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
@ -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
without it has nothing of its own to withdraw.
`InitConfig` approvals create a brand-new agent's config repo. On
approve, hive-c0re seeds it with a default `agent.nix` template and
pushes a todo (`push_todo_submitter`) into the submitting agent's
in-container store. The operator then **spawns** the agent (the
`Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the
A brand-new agent's config repo is created outside this queue, by the
swarm controller's `InitAgentConfigRepo` job, which seeds it with a
default `agent.nix` template. The operator then **spawns** the agent
(the `Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the
container from that config.
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)
`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
kind-specific payload carrier.
@ -169,13 +170,11 @@ kind-specific payload carrier.
`hivectl agent <name> request-create` CLI). The host-level `HostRequest::Spawn`
variant bypasses the approval queue entirely — privileged-context use
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`
seeds its config repo, the submitting agent customises it, then the
operator spawns to create the container. Subsequent config changes go
through a `MergeConfigPr` PR.
- `InitConfig``commit_ref` is empty; the variant just gates
"seed the proposed repo with the default template" against
operator approval. Step 1 of the two-step spawn flow above.
`hivectl agent <name> create`). This is the **canonical first-spawn**: the
swarm controller's `InitAgentConfigRepo` job seeds the agent's config
repo, it gets customised through a PR, then the operator spawns to
create the container. Subsequent config changes go through a
`MergeConfigPr` PR.
- `UpdateMetaInputs``commit_ref` stores the JSON-encoded inputs
array (`"[]"` = all inputs, `"[\"nixpkgs\"]"` = just nixpkgs,
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` |
| `UpdateMetaInputs` | `meta_update` (`MetaLock` + rebuild fan-out) | `approval` |
| `Spawn` | `spawn` (`Create → WriteDropin → Reconcile`) | `approval` |
| `InitConfig` | — runs inline (sub-second git seed) | — |
| `SchedulePrompt` | — runs inline (single sqlite insert) | — |
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
live dial of the target agent's in-container todo socket (same
`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
approval rows that predate the submitter column fall back to the
root agent. Variants (`hive_sh4re::manager::HelperEvent`):
@ -625,7 +623,7 @@ root agent. Variants (`hive_sh4re::manager::HelperEvent`):
no approval required.
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
they deliver via `push_todo`/`push_todo_submitter` (see above) instead
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
`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
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,
or the dashboard `↻ R3BU1LD` button when the lock didn't move). When set,
`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)
actually live now.
- `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`.
- `scheduled_prompts` — recurring + one-shot prompt queue.
`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
boundary a convention rather than a permission.
⚠️ Not to be confused with the seeding done when an `InitConfig`
approval resolves: that writes the child's initial config repo as
**hive-c0re, against the host path**, and `read_only` on a bind
⚠️ Not to be confused with the config-repo seeding hive-c0re does at
spawn (`lifecycle::setup_proposed`): that writes the child's initial
config repo as **hive-c0re, against the host path**, and `read_only` on a bind
constrains writers *inside* a container only. The two are unrelated —
conflating them can lead you to reason your way into thinking this
mount should be writable when it shouldn't.
@ -443,7 +443,7 @@ nothing is automigrated: existing agents keep their plain dirs
until an explicit opt-in upgrade.
- **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
hive-priv (`EnsureAgentSubvolume`) to `btrfs subvolume create` 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
Sub-agent creation goes through the approval queue — ruth proposes, the
operator approves, the container builds. From ruth's own turn (inside
the container, via MCP tools):
Sub-agent creation is an operator action — agents have no tool for it.
Two steps:
```
# Step 1: initialise a new agent's config repo
request_init_config(name: "iris")
# → operator approves → config_ready event lands in the inbox
# Step 1: scaffold the new agent's config repo. The swarm controller's
# InitAgentConfigRepo job does this (POST /api/agents), seeding
# /agents/iris/config/agent.nix from the default template.
# Step 2: edit /agents/iris/config/agent.nix and commit it. Then the
# operator spawns iris (dashboard ◆ R3QU3ST SP4WN / Spawn approval),
# which builds + starts the container from that config.
# Step 2: edit /agents/iris/config/agent.nix and commit it. Then spawn
# iris from the dashboard (◆ R3QU3ST SP4WN / Spawn approval), which
# builds + starts the container from that config.
# Later config changes: open a PR on agent-configs/iris (hive-forge);
# 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` |
| `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)* |
| `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)* |
| `diagnostics` | `get_logs` *(privileged)* |
| `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
Config changes and new-agent spawns route through the operator
approval queue. Topology-enforced the same way.
Meta-flake input bumps route through the operator approval queue.
### `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`
approval; on operator approve, hive-c0re seeds the proposed config
repo at `/agents/<name>/config/agent.nix` with a default template and
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
Config changes on an existing agent 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`.
`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?)`
Queue an approval to run `nix flake update [inputs...]` on the meta
@ -83,15 +65,14 @@ agents after the approval resolves.
## Boundary summary
| Operation | Requires approval? | Scope |
| --------------------------------------- | ------------------ | ---------------------------------- |
| `kill` / `start` / `restart` / `update` | No | Own subtree |
| `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) |
| Operation | Requires approval? | Scope |
| --------------------------------------- | ------------------ | ---------------------------- |
| `kill` / `start` / `restart` / `update` | No | Own subtree |
| `list_containers` | No | Own subtree, caller included |
| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) |
## See also
- [`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.

View file

@ -66,7 +66,7 @@ object with an `event` discriminant field). The **submitting agent**
tool group for its own subtree) receives `container_crash`,
`needs_update`, and `approval_resolved` this way. The remaining, lower-urgency lifecycle
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
(`Coordinator::push_todo`/`push_todo_submitter`, `subsystem = "core"`),
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`) |
| `update_meta_inputs` | `↻` | `meta-update` | — |
| `schedule_prompt` | `⏱` | `schedule` | — |
| `init_config` | `⊕` | `init` | — |
| `spawn` | `⊕` | `spawn` | — |
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
only when `forge_present` and `pr_number` is set). The config diff
lives on the forge PR itself — no inline diff side-panel.
- `init_config` / `spawn`: a one-line "container will be created"
note instead.
- `spawn`: a one-line "container will be created" note instead.
- **decision actions**`◆ APPR0VE` and `DENY`. Deny pops a
`prompt()` for an optional reason carried to the submitting agent as
`HelperEvent::ApprovalResolved.note`.

View file

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

View file

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

View file

@ -97,20 +97,6 @@ pub struct CompactArgs {
// 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)]
pub struct KillArgs {
/// Sub-agent name (without the `h-` container prefix).

View file

@ -26,9 +26,8 @@ mod render;
pub use args::{
AckUntilArgs, AgentGetLooseEndsArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs,
CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs,
GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestInitConfigArgs,
RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs,
UpdateMetaInputsArgs,
GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestSchedulePromptArgs,
RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs,
};
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
@ -710,47 +709,6 @@ impl AgentServer {
.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
// is granted to this agent. hive-c0re enforces the topology check
// 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 {
warnings: vec!["name is reserved".to_owned(), "second thing".to_owned()],
}),
"request_init_config",
"init_config approval queued for forge".to_owned(),
"request_update_meta_inputs",
"update_meta_inputs approval queued".to_owned(),
);
// The operation HAPPENED — dropping the success line would read as a
// 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("⚠️ second thing"));
}
@ -659,7 +659,7 @@ mod tests {
// warning marker would pass the test above.
let out = format_ack(
Ok(hive_core_agent_sock::Response::Ok),
"request_init_config",
"request_update_meta_inputs",
"queued".to_owned(),
);
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.
- **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`.
- **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).
- **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.
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:
- `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.
- `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:

View file

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

View file

@ -12,11 +12,10 @@ use hive_sh4re::manager::HelperEvent;
use crate::coordinator::Coordinator;
use crate::lifecycle;
/// Approve a pending request. Marks the approval row durably, then
/// either runs the work inline (`InitConfig`, sub-second git ops) or
/// submits it to the job queue so the dashboard POST returns
/// immediately while the long-running pipeline runs off-thread
/// (operator no longer blocks on a 30-90s spinner for `MergeConfigPr`).
/// Approve a pending request. Marks the approval row durably, then submits
/// the work to the job queue so the dashboard POST returns immediately while
/// the long-running pipeline runs off-thread (operator no longer blocks on a
/// 30-90s spinner for `MergeConfigPr`).
///
/// Dispatch:
/// - `MergeConfigPr` → a `DeployWindow` DAG (`MergeVerify → DeployApply →
@ -24,7 +23,6 @@ use crate::lifecycle;
/// resource-holding root; ~30-90s)
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
/// - `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
/// [`resolve_approval_dag`] when the DAG settles terminal.
@ -38,15 +36,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
"approval: dispatching",
);
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 => {
// Inputs JSON-encoded into commit_ref by the manager's
// 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;
}
/// 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(
coord: &Coordinator,
approval: &hive_sh4re::approvals::Approval,
@ -727,28 +673,12 @@ async fn finish_approval(
note: note.clone(),
description: approval.description.clone(),
});
// For spawn/rebuild/init_config approvals, also surface the underlying
// action so the manager knows whether the lifecycle step succeeded.
// The ApprovalResolved event already carries the same `ok` signal but
// For spawn/rebuild approvals, also surface the underlying action so the
// manager knows whether the lifecycle step succeeded. The
// ApprovalResolved event already carries the same `ok` signal but
// separating it lets the manager react to the lifecycle change
// without having to special-case approvals.
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 => {
let summary = if ok {
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
/// root (parent = null). There is no structural "manager" — agents
/// arrange themselves via explicit parent edges (an agent-requested
/// sub-agent gets a requester-as-parent edge at `init_config`; the
/// operator reparents via the dashboard / `RequestSetParent` API).
/// arrange themselves via explicit parent edges, written by the operator
/// through the dashboard / `RequestSetParent` API.
/// Used by `meta::sync_agents` on first call to seed `topology.json`.
///
/// As soon as an explicit write lands (dashboard / `RequestSetParent`
@ -298,53 +297,6 @@ pub fn apply_set_parent(
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
/// entry (default: parent = null — a new agent with no declared parent
/// 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
/// changed and should be re-committed by the caller.
///
/// `pending` lists agents that have an operator-approved proposed config
/// repo but no container yet (init'd, not yet spawned). They are KEPT
/// (not dropped) so the parent edge written at `InitConfig` approval
/// survives until the first apply-commit, but they are NOT seeded with a
/// `pending` lists agents that have a provisioned proposed config repo
/// but no container yet (provisioned, not yet spawned). They are KEPT
/// (not dropped) so an explicit parent edge written before the first
/// spawn survives until the first apply-commit, but they are NOT seeded with a
/// default parent here and NOT added to roles — 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> {
@ -384,12 +336,11 @@ pub fn apply_reconcile(
for name in agent_names {
if !next.contains_key(name) {
// A new agent with no declared parent defaults to root
// (parent = null). Agent-requested sub-agents always carry an
// explicit requester-as-parent edge (written at init_config
// approval), so they never hit this default — only
// user/operator-initiated spawns do, and those are roots. No
// agent is structurally privileged here: "root-ness" is just
// a null parent.
// (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;
}
@ -502,8 +453,7 @@ mod tests {
#[test]
fn default_seed_makes_every_agent_root() {
// No structural manager: every agent defaults to root (null
// parent). Explicit edges (init_config / dashboard) are layered
// on later.
// parent). Explicit edges are layered on later.
let agents = vec![
"alice".to_owned(),
crate::lifecycle::MANAGER_NAME.to_owned(),
@ -630,42 +580,6 @@ mod tests {
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]
fn apply_reconcile_adds_missing_live_agent_as_root() {
// A live agent with no prior topology entry defaults to root
@ -696,7 +610,7 @@ mod tests {
#[test]
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
// be dropped, and its alice-parent edge must be preserved (not
// re-seeded under the manager).

View file

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

View file

@ -83,13 +83,9 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
approvals
.into_iter()
.filter(|a| {
// Spawn and InitConfig approvals are for not-yet-existent agents;
// the proposed dir is supposed to be missing.
if matches!(
a.kind,
hive_sh4re::approvals::ApprovalKind::Spawn
| hive_sh4re::approvals::ApprovalKind::InitConfig
) {
// A Spawn approval is for a not-yet-existent agent; the proposed
// dir is supposed to be missing, so absence is not orphanhood.
if a.kind == hive_sh4re::approvals::ApprovalKind::Spawn {
return true;
}
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,
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 {
id: a.id,
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`),
/// which needs no bind mount into the parent.
///
/// ⚠️ The seeding done at `InitConfig` approval is **not** affected by 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
/// repo and wires its forge remote inline), and `read_only` on a bind
/// constrains writers *inside* the container only.
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
/// the `config` flag and must not be read as a reason to widen it: that runs
/// as hive-c0re against the host path (see `lifecycle::setup_proposed`), and
/// `read_only` on a bind constrains writers *inside* the container only.
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let Ok(child) = hive_types::Ident::parse(child) else {
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
/// secrets stay private regardless of the directory mode. Idempotent: existing
/// 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<()> {
use std::io;
if !claude_dir.exists() {
@ -208,9 +206,9 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
Ok(())
}
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
/// dirs without calling the full `spawn`. Also creates the sibling `harness/`
/// dir so the first harness startup can write its sqlite files immediately.
/// Create the per-agent state dir if missing. Also creates the sibling
/// `harness/` dir so the first harness startup can write its sqlite files
/// immediately.
pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
if !notes_dir.exists() {
std::fs::create_dir_all(notes_dir)

View file

@ -1,6 +1,5 @@
//! Config-approval request handlers: `RequestInitConfig` /
//! `RequestUpdateMetaInputs`, plus the shared submit helpers
//! (`submit_init_config` / `submit_merge_config_pr`).
//! Config-approval request handlers: `RequestUpdateMetaInputs`, plus the
//! shared submit helper `submit_merge_config_pr`.
//!
//! `submit_merge_config_pr` is called from the dashboard webhook handler
//! (`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 super::require_new_child;
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
/// carrying the JSON-encoded input list in `commit_ref` (no git commit
/// 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)
}
/// 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 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::{
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::Update { name } => handle_update(coord, agent, name),
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
// hive-wide `"*"` sweep require `QueryAgentState`.
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
/// descendant resolve freely (a parent sees its subtree, the root sees all);
/// 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`
//! / `RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or
//! Approval queue. Requests are submitted by the manager
//! (`RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or
//! the operator (`Spawn`); the user approves/denies via the host admin CLI;
//! 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
/// when the sha is already known at submission time (e.g. `MergeConfigPr`
/// fetches the PR head before inserting), making the insert + sha-set
/// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn` /
/// `InitConfig`).
/// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn`).
pub fn submit_kind(
&self,
agent: &str,
@ -380,7 +379,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
let kind: String = row.get(2)?;
let kind = match kind.as_str() {
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt,
"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> {
Ok(match s {
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt,
"merge_config_pr" => ApprovalKind::MergeConfigPr,
@ -454,31 +451,6 @@ mod tests {
(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]
fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp();
@ -493,7 +465,7 @@ mod tests {
.unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None)
.unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None)
db.submit_kind("c", ApprovalKind::UpdateMetaInputs, "[]", None, "c", None)
.unwrap();
let pending = db.pending().expect("pending");
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)* 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).
Kill { name: String },
/// *(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
/// (under the default `agent.nix` template).
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
/// the resulting lock changes.
UpdateMetaInputs,

View file

@ -24,7 +24,7 @@ pub enum ToolGroup {
Inbox,
/// `kill`, `start`, `restart`, `update` - *(privileged)*
Lifecycle,
/// `request_init_config`, `request_update_meta_inputs` - *(privileged)*
/// `request_update_meta_inputs` - *(privileged)*
Approvals,
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
/// `edit_schedule`, `list_schedules` - *(privileged)*
@ -60,7 +60,7 @@ impl ToolGroup {
Self::Meta => &["get_agent_meta"],
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
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 => &[
"request_schedule_prompt",
"fire_schedule_now",
@ -167,7 +167,7 @@ impl ToolGroup {
"kill, start, restart, update, list_containers — container lifecycle (privileged)"
}
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 => {
"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:
- **`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.
- **`power.rs`** — restart/start/stop at the container level.
- **`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
# a rebuild of a binary. Read by:
#
# - `host-modules/hive-c0re/environment.nix` -> the env var, for the
# `request_init_config` path every hive uses
# - `host-modules/swarm-controller.nix` -> the same var, for the
# swarm-level `create_agent` path
# - `host-modules/swarm-controller.nix` -> the env var, for the
# swarm-level `create_agent` path — the only name check that runs today
# - `host-modules/hive-c0re/environment.nix` -> the same var. hive-c0re
# 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
# a hive name and an agent name are checked against ONE list
# - `nix/checks.nix` -> exported into `cargo test`,
# which is what keeps the message layer's sentinels from drifting away
# from this file
# which keeps the message layer's sentinels from drifting from this file
#
# 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.