Watch
0
0
Fork
You've already forked hyperhive
0

Make agent creation swarm-only and refuse a name placed on another hive

swarm-controller's POST /api/agents now refuses (409) a name the swarm
has already placed on a different hive: a non-Destroyed declaration in
that hive's wanted state, or a SetAgentWanted node still queued for it.
The same name on the same hive is that agent being re-created and goes
through. A wanted state that cannot be read refuses (503/500) instead of
reading as "placed nowhere". Creations are serialised from that read to
the graph insert so two concurrent creations of one name cannot both
pass.

Hive-level creation is removed: hivectl `agent create` / `request-create`,
HostRequest::Spawn / RequestSpawn, the dashboard POST /api/request-spawn
route, and ApprovalKind::Spawn with its approve/resolve arms and the
approval-carrying `templates::spawn`. The swarm path (deploy request or
wanted-state sweep -> queue_first_deploy -> templates::first_deploy) used
none of them. Old `spawn` approval rows are skipped by collect_lenient,
as `init_config` rows were in a3b672d1.

policy.rs's comment on agent_object_name stated swarm-wide name
uniqueness as a fact; it now says where it is enforced and what that
check cannot see.

Refs #4396
This commit is contained in:
atlas 2026-09-29 15:11:57 +02:00
commit 5785c0024c
35 changed files with 376 additions and 434 deletions

View file

@ -100,7 +100,8 @@ 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 a `Spawn` approval. approval step — every other agent is created at swarm level
(`swarmctl agent create`).
Roster-wise, `ruth` is just another entry. Roster-wise, `ruth` is just another entry.
- **Wire-protocol** — the privileged `Request` variants - **Wire-protocol** — the privileged `Request` variants
(`Kill` / `Start` / `Restart` / `Update`; `GetLogs`) — marked (`Kill` / `Start` / `Restart` / `Update`; `GetLogs`) — marked

View file

@ -24,14 +24,6 @@ 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** (`Spawn`) — the one approval in creating a brand-new
agent. The swarm controller's `InitAgentConfigRepo` job
(`POST /api/agents`) scaffolds its config repo first, outside the
approval queue; `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 - **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
@ -133,11 +125,11 @@ 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.
The swarm controller's `InitAgentConfigRepo` job creates a brand-new Creating a brand-new agent isn't an approval: it's swarm-level
agent's config repo outside this queue, seeding it with a default (`swarmctl agent create`, `POST /api/agents` on the swarm controller),
`agent.nix` template. The operator then **spawns** the agent and no hive can originate an agent. The swarm controller's
(the `Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the `InitAgentConfigRepo` job seeds the agent's config repo with a default
container from that config. `agent.nix` template, then asks the target hive to deploy it.
Changing what the template seeded isn't a special case: like every Changing what the template seeded isn't a special case: like every
later change, it's a PR on that config repo (`MergeConfigPr`), made later change, it's a PR on that config repo (`MergeConfigPr`), made
@ -147,7 +139,7 @@ through the web UI or the forge.
### Approval kinds (wire shapes) ### Approval kinds (wire shapes)
`ApprovalKind` carries four variants; each maps to a different `ApprovalKind` carries three variants; each maps to a different
`commit_ref` encoding because `ApprovalKind` overloads that field as `commit_ref` encoding because `ApprovalKind` overloads that field as
the kind-specific payload carrier. the kind-specific payload carrier.
@ -165,17 +157,6 @@ the kind-specific payload carrier.
eval-verifies it; `DeployApply` fast-forward-merges the forge config eval-verifies it; `DeployApply` fast-forward-merges the forge config
repo's `main` to it (the merge) and runs `deploy_applied_target`; repo's `main` to it (the merge) and runs `deploy_applied_target`;
`DeployTail` compensates on failure. Never a first spawn. `DeployTail` compensates on failure. Never a first spawn.
- `Spawn` — direct container creation from the agent's config repo.
`commit_ref` is empty. Submitted via `HostRequest::RequestSpawn`
(operator-gated, the `◆ R3QU3ST SP4WN` dashboard button +
`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**: 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 - `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.
@ -412,8 +393,8 @@ submitter pushes again (or closes it) to retry.
### Dispatch via the job queue ### Dispatch via the job queue
Long-running approval work — `MergeConfigPr`, `UpdateMetaInputs`, Long-running approval work — `MergeConfigPr` and `UpdateMetaInputs`
`Spawn` — runs as a DAG on the global job queue — runs as a DAG on the global job queue
(`docs/scheduler/coordinator.md::Job queue`), submitted by the approval handler (`docs/scheduler/coordinator.md::Job queue`), submitted by the approval handler
rather than run inline: rather than run inline:
@ -421,7 +402,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` |
| `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
@ -433,7 +413,7 @@ state is the authoritative outcome. That hook fires the matching
`HelperEvent::*` via `finish_approval`, derives the `Rebuilt` event's `HelperEvent::*` via `finish_approval`, derives the `Rebuilt` event's
terminal tag (verifying the tag actually resolves in the applied repo — terminal tag (verifying the tag actually resolves in the applied repo —
a pre-merge rejection plants none), posts the failing build log back to a pre-merge rejection plants none), posts the failing build log back to
the config PR, and for a spawn runs the post-spawn forge bookkeeping. the config PR.
Two visible consequences: Two visible consequences:
@ -612,12 +592,12 @@ event to the agent that originally submitted the approval (looked up from
the `submitter` column on the `approvals` table). The harness delivers it the `submitter` column on the `approvals` table). The harness delivers it
as a regular `system` inbox message so it drives a normal claude turn. as a regular `system` inbox message so it drives a normal claude turn.
`finish_approval` fires an `ApprovalResolved` HelperEvent this way for `finish_approval` fires an `ApprovalResolved` HelperEvent this way for
**every** approval kind's terminal state, `Spawn` included. A **every** approval kind's terminal state. A
"FYI, check when convenient" event doesn't need a message — those go "FYI, check when convenient" event doesn't need a message — those go
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 `Spawn`/`MergeConfigPr`, *in addition to* one of these too for `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`):
@ -651,7 +631,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 / meta-update / deny, and the autoupdate commit (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

@ -10,8 +10,8 @@ keeps its state, purging it doesn't.**
- **`DESTR0Y`** (the default action) stops and removes the container - **`DESTR0Y`** (the default action) stops and removes the container
but keeps everything on disk — config history, claude login, `/state/` but keeps everything on disk — config history, claude login, `/state/`
notes, harness data. The agent shows up as a tombstone (K3PT ST4T3 on notes, harness data. The agent shows up as a tombstone (K3PT ST4T3 on
the C0R3 page) with a `⊕ R3V1V3` button that recreates it from the the C0R3 page); `swarmctl agent create` with the same name and hive
kept state, **no re-login needed**. recreates it from the kept state, **no re-login needed**.
- **`PURG3`** (opt-in, from the dashboard or `hivectl agent <name> - **`PURG3`** (opt-in, from the dashboard or `hivectl agent <name>
destroy --purge`) is `DESTR0Y` plus wiping all of it — config destroy --purge`) is `DESTR0Y` plus wiping all of it — config
history, claude credentials, `/state/` notes, everything. **No history, claude credentials, `/state/` notes, everything. **No
@ -432,8 +432,8 @@ See [For operators](#for-operators) above for what each action does to
an agent's state. The mechanics, for completeness: an agent's state. The mechanics, for completeness:
- `DESTR0Y` also drops the systemd drop-in and fails any pending - `DESTR0Y` also drops the systemd drop-in and fails any pending
approvals; the tombstone's `⊕ R3V1V3` button queues a Spawn approval approvals; reviving the agent is `swarmctl agent create` with the same
that reuses the kept state on approve. name and hive, which reuses the kept state.
- `PURG3` wipes `/var/lib/hyperhive/{agents,applied}/<name>/` — the - `PURG3` wipes `/var/lib/hyperhive/{agents,applied}/<name>/` — the
union of everything `DESTR0Y` left behind. union of everything `DESTR0Y` left behind.

View file

@ -303,17 +303,14 @@ a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`).
### 7 · Spawn sub-agents ### 7 · Spawn sub-agents
Sub-agent creation is an operator action — agents have no tool for it. Sub-agent creation is a swarm-level operator action — agents have no
Two steps: tool for it, and no hive can create one on its own:
``` ```
# Step 1: scaffold the new agent's config repo. The swarm controller's # Create iris on hive pr1ma. The swarm controller seeds its config repo
# InitAgentConfigRepo job does this (POST /api/agents), seeding # (agent-configs/iris) from the default template, then asks pr1ma to
# /agents/iris/config/agent.nix from the default template. # build + start the container from that config.
swarmctl agent create iris --hive pr1ma
# 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); # 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

@ -322,7 +322,7 @@ the build slot while its parent held the meta window could block waiting for
a resource its own parent already committed to, a lock-ordering hazard that a resource its own parent already committed to, a lock-ordering hazard that
one multi-resource root avoids by construction. one multi-resource root avoids by construction.
`Spawn` and `UpdateMetaInputs` approvals map onto the ordinary `spawn` / `UpdateMetaInputs` approvals map onto the ordinary
`meta-update` shapes. The scheduler fires `actions::resolve_approval_dag` `meta-update` shapes. The scheduler fires `actions::resolve_approval_dag`
exactly once when **any** approval-carrying DAG settles terminal — deploys exactly once when **any** approval-carrying DAG settles terminal — deploys
included, since their outcome is now the DAG's own state (including included, since their outcome is now the DAG's own state (including

View file

@ -21,8 +21,6 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl agent pause`↴](#hivectl-agent-pause) * [`hivectl agent pause`↴](#hivectl-agent-pause)
* [`hivectl agent resume`↴](#hivectl-agent-resume) * [`hivectl agent resume`↴](#hivectl-agent-resume)
* [`hivectl agent start`↴](#hivectl-agent-start) * [`hivectl agent start`↴](#hivectl-agent-start)
* [`hivectl agent create`↴](#hivectl-agent-create)
* [`hivectl agent request-create`↴](#hivectl-agent-request-create)
* [`hivectl agent stop`↴](#hivectl-agent-stop) * [`hivectl agent stop`↴](#hivectl-agent-stop)
* [`hivectl agent kill`↴](#hivectl-agent-kill) * [`hivectl agent kill`↴](#hivectl-agent-kill)
* [`hivectl agent destroy`↴](#hivectl-agent-destroy) * [`hivectl agent destroy`↴](#hivectl-agent-destroy)
@ -271,9 +269,7 @@ Everything here targets a single named agent (`hivectl agent foo restart`, `hive
* `restart` — Stop and start this agent container without rebuilding config * `restart` — Stop and start this agent container without rebuilding config
* `pause` — Park this agent's turn loop, leaving the container running * `pause` — Park this agent's turn loop, leaving the container running
* `resume` — Resume this paused agent — it drains whatever queued up while parked * `resume` — Resume this paused agent — it drains whatever queued up while parked
* `start` — Start this EXISTING agent container. Fails immediately if `name` has no config/topology entry at all — it never attempts first-time creation. Use `create` for that * `start` — Start this EXISTING agent container. Fails immediately if `name` has no config/topology entry at all — it never attempts first-time creation, which is swarm-level (`swarmctl agent create`)
* `create` — Create this agent container from scratch (full first-time provisioning), bypassing the approval queue
* `request-create` — Queue a first-creation request for operator approval
* `stop` — Gracefully stop this agent container: signal → drain → reconcile. Never escalates to a hard kill — use `kill` for that * `stop` — Gracefully stop this agent container: signal → drain → reconcile. Never escalates to a hard kill — use `kill` for that
* `kill` — Hard-stop this managed container * `kill` — Hard-stop this managed container
* `destroy` — Tear down this sub-agent container, keeping its state by default. No undo * `destroy` — Tear down this sub-agent container, keeping its state by default. No undo
@ -326,7 +322,7 @@ Resume this paused agent — it drains whatever queued up while parked
## `hivectl agent start` ## `hivectl agent start`
Start this EXISTING agent container. Fails immediately if `name` has no config/topology entry at all — it never attempts first-time creation. Use `create` for that Start this EXISTING agent container. Fails immediately if `name` has no config/topology entry at all — it never attempts first-time creation, which is swarm-level (`swarmctl agent create`)
**Usage:** `hivectl agent start [OPTIONS]` **Usage:** `hivectl agent start [OPTIONS]`
@ -336,24 +332,6 @@ Start this EXISTING agent container. Fails immediately if `name` has no config/t
## `hivectl agent create`
Create this agent container from scratch (full first-time provisioning), bypassing the approval queue.
Operator-on-the-host only; use `request-create` for an approval-gated creation.
**Usage:** `hivectl agent create`
## `hivectl agent request-create`
Queue a first-creation request for operator approval
**Usage:** `hivectl agent request-create`
## `hivectl agent stop` ## `hivectl agent stop`
Gracefully stop this agent container: signal → drain → reconcile. Never escalates to a hard kill — use `kill` for that Gracefully stop this agent container: signal → drain → reconcile. Never escalates to a hard kill — use `kill` for that

View file

@ -70,7 +70,7 @@ No approval gate guards this: running this binary already means being root on th
* `<NAME>` — Name for the new agent: 1–63 characters of `[a-z0-9-]`. * `<NAME>` — Name for the new agent: 1–63 characters of `[a-z0-9-]`.
Becomes an SSO subject, a forge user and a repository name, so it's validated here before queuing. Becomes an SSO subject, a forge user and a repository name, so it's validated here before queuing. The controller refuses a name the swarm has already placed on a different hive; the same name on the same hive re-creates that agent.
###### **Options:** ###### **Options:**

View file

@ -971,7 +971,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` | — |
| `spawn` | `⊕` | `spawn` | — |
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
The chip ticks live every second via a `data-requested-at` The chip ticks live every second via a `data-requested-at`
@ -985,7 +984,6 @@ 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` is true and `pr_number` has a value). The config diff only when `forge_present` is true and `pr_number` has a value). 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.
- `spawn`: a one-line "container will be created" 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`.
@ -1043,7 +1041,6 @@ below — some endpoints aren't in it yet.
- `POST /api/{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. - `POST /api/{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
`destroy` accepts `purge=on` to also wipe state dirs. `destroy` accepts `purge=on` to also wipe state dirs.
- `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs. - `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /api/request-spawn` — queue a Spawn approval.
- `POST /api/update-all` — rebuild every stale container. - `POST /api/update-all` — rebuild every stale container.
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry. - `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
Refuses `Running` / terminal-state entries (in-flight Refuses `Running` / terminal-state entries (in-flight
@ -1243,10 +1240,9 @@ payload):
- `container_state_changed` (container: ContainerView) / - `container_state_changed` (container: ContainerView) /
`container_removed` (name) — per-row container mutations, `container_removed` (name) — per-row container mutations,
emitted by `Coordinator::rescan_containers_and_emit` from emitted by `Coordinator::rescan_containers_and_emit` from
many mutation sites — post-spawn approval bookkeeping the job queue's own node execution (`job_queue::exec`, for example
(`actions::approve`), the job queue's own node execution after a rebuild's stop/swap/start steps or a destroy's teardown
(`job_queue::exec`, for example after a rebuild's stop/swap/start step) — and from the 10s
steps or a destroy's teardown step) — and from the 10s
`crash_watch` poll. Client upserts/removes by name and `crash_watch` poll. Client upserts/removes by name and
reads the pending overlay from `transientsState` since the reads the pending overlay from `transientsState` since the
payload doesn't carry it. payload doesn't carry it.

View file

@ -114,7 +114,7 @@ export function operatorInboxAppendFromEvent(ev) {
onCountsChanged(); onCountsChanged();
} }
// ─── approvals — the operator config-change / spawn approval queue ──────── // ─── approvals — the operator config-change approval queue ────────
const APPROVAL_TAB_KEY = "hyperhive:approvals:tab"; const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
// Derived approval state — cold-loaded from /api/state, then mutated // Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_added` / `approval_resolved` dashboard events. // live by `approval_added` / `approval_resolved` dashboard events.
@ -244,24 +244,14 @@ export function renderApprovals() {
el( el(
"span", "span",
{ class: "glyph" }, { class: "glyph" },
isMergePr ? "⇒" : isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⊕", isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⇒",
), ),
el("span", { class: "id" }, "#" + a.id), el("span", { class: "id" }, "#" + a.id),
el("span", { class: "agent" }, a.agent), el("span", { class: "agent" }, a.agent),
el( el(
"span", "span",
{ { class: "kind" },
class: isUpdateMeta ? "meta-update" : isSchedule ? "schedule" : "merge-pr",
"kind" +
(isMergePr || isUpdateMeta || isSchedule ? "" : " kind-spawn"),
},
isMergePr
? "merge-pr"
: isUpdateMeta
? "meta-update"
: isSchedule
? "schedule"
: "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));
@ -431,13 +421,11 @@ function renderApprovalHistory(root, history) {
el( el(
"span", "span",
{ class: "kind" }, { class: "kind" },
a.kind === "merge_config_pr" a.kind === "update_meta_inputs"
? "merge-pr" ? "meta-update"
: a.kind === "update_meta_inputs" : a.kind === "schedule_prompt"
? "meta-update" ? "schedule"
: a.kind === "schedule_prompt" : "merge-pr",
? "schedule"
: "spawn",
), ),
" ", " ",
); );

View file

@ -671,10 +671,6 @@ ul form.inline {
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
} }
.kind-spawn {
color: var(--amber);
border-color: var(--amber);
}
details { details {
margin-top: 0.5em; margin-top: 0.5em;
} }

View file

@ -81,10 +81,9 @@ 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 = a.kind === "spawn" ? "spawn approval" : "config commit";
NOTIF.show( NOTIF.show(
"◆ approval #" + a.id, "◆ approval #" + a.id,
`${verb} for ${a.agent}`, `config commit for ${a.agent}`,
"hyperhive:approval:" + a.id, "hyperhive:approval:" + a.id,
); );
} }

View file

@ -22,7 +22,6 @@ use crate::lifecycle;
/// FinalizeDeploy`, plus an `AfterAny` `DeployTail`, under a /// FinalizeDeploy`, plus an `AfterAny` `DeployTail`, under a
/// 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`)
/// ///
/// 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.
@ -55,25 +54,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
coord.emit_rebuild_queue_snapshot(); coord.emit_rebuild_queue_snapshot();
Ok(()) Ok(())
} }
ApprovalKind::Spawn => {
// The spawn's tail `Reconcile` starts the container, so the
// new agent's power intent is `Up` from the outset.
if let Err(e) = coord
.power
.set(approval.agent.as_str(), crate::power::Wanted::Up)
{
tracing::warn!(agent = %approval.agent, error = ?e, "agent_power: seed on spawn failed");
}
let inserted = coord.job_queue.insert_job(|b| {
crate::job_queue::templates::spawn(b, approval.agent.as_str(), id);
Vec::new()
});
if let Err(e) = inserted {
return Err(e.context("insert spawn dag"));
}
coord.emit_rebuild_queue_snapshot();
Ok(())
}
ApprovalKind::SchedulePrompt => { ApprovalKind::SchedulePrompt => {
// No queue card for SchedulePrompt — the work is a single // No queue card for SchedulePrompt — the work is a single
// sqlite insert, the actual "running" lifetime lives on // sqlite insert, the actual "running" lifetime lives on
@ -525,30 +505,16 @@ pub(crate) async fn resolve_approval_dag(
TerminalState::Failed => Err(anyhow::anyhow!("{}", error.unwrap_or("job dag failed"))), TerminalState::Failed => Err(anyhow::anyhow!("{}", error.unwrap_or("job dag failed"))),
}; };
let mut terminal_tag = None; let mut terminal_tag = None;
match approval.kind { if approval.kind == ApprovalKind::MergeConfigPr {
ApprovalKind::Spawn => { terminal_tag = deploy_terminal_tag(approval.agent.as_str(), approval_id, outcome).await;
// Post-spawn forge bookkeeping (config repo mirror, meta // On a failed deploy, surface the failing build log back onto the
// access) — warn-only, then the resolution events + a rescan so // PR so the manager sees why it was rejected without leaving the
// the dashboard reflects the post-spawn state either way. // forge. Posted here rather than inside a node because this is the
if result.is_ok() { // one place that holds the DAG's definitive error — a `MergeVerify`
forge_after_first_spawn(coord, approval.agent.as_str()).await; // rejection and a `DeployApply` build failure both land here.
} else { if let Err(e) = &result {
coord.rescan_containers_and_emit().await; post_merge_failure_to_pr(coord, &approval, e).await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
}
} }
ApprovalKind::MergeConfigPr => {
terminal_tag = deploy_terminal_tag(approval.agent.as_str(), approval_id, outcome).await;
// On a failed deploy, surface the failing build log back onto the
// PR so the manager sees why it was rejected without leaving the
// forge. Posted here rather than inside a node because this is the
// one place that holds the DAG's definitive error — a `MergeVerify`
// rejection and a `DeployApply` build failure both land here.
if let Err(e) = &result {
post_merge_failure_to_pr(coord, &approval, e).await;
}
}
_ => {}
} }
if let Err(e) = finish_approval(coord, &approval, result, terminal_tag).await { if let Err(e) = finish_approval(coord, &approval, result, terminal_tag).await {
tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure"); tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure");
@ -604,26 +570,6 @@ fn fetch_approval_for_worker(
Ok(approval) Ok(approval)
} }
/// Forge bookkeeping run once after the very first container spawn:
/// mirror the applied repo and grant read access to core/meta. The
/// agent's forge user and token are swarm-controller's, not this
/// hive's. Also rescans containers so the dashboard reflects the post-spawn state.
async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
if let Err(e) = crate::forge::ensure_config_repo(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed");
}
if let Some(core_token) = crate::forge::core_token()
&& let Err(e) = crate::forge::meta_read_access(agent, &core_token).await
{
tracing::warn!(%agent, error = ?e, "forge: meta_read_access after first spawn failed");
}
if let Err(e) = crate::forge::ensure_meta_remote(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_meta_remote after first spawn failed");
}
coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
}
async fn finish_approval( async fn finish_approval(
coord: &Coordinator, coord: &Coordinator,
approval: &hive_sh4re::approvals::Approval, approval: &hive_sh4re::approvals::Approval,
@ -670,35 +616,14 @@ async fn finish_approval(
note: note.clone(), note: note.clone(),
description: approval.description.clone(), description: approval.description.clone(),
}); });
// For spawn/rebuild approvals, also surface the underlying action so the // For rebuild approvals, also surface the underlying action so the
// manager knows whether the lifecycle step succeeded. The // manager knows whether the lifecycle step succeeded. 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::Spawn => {
let summary = if ok {
format!("agent '{}' spawned", approval.agent)
} else {
format!(
"agent '{}' spawn FAILED: {}",
approval.agent,
note.as_deref().unwrap_or("unknown error")
)
};
let _ = coord
.push_todo_submitter(
approval.id,
"core",
Some(format!("spawned:{}", approval.agent)),
summary,
None,
)
.await;
}
// MergeConfigPr ends in a container rebuild — surface a Rebuilt // MergeConfigPr ends in a container rebuild — surface a Rebuilt
// lifecycle event. (It is never a first spawn — the agent already // lifecycle event.
// exists — so it never needs the Spawned arm above.)
ApprovalKind::MergeConfigPr => { ApprovalKind::MergeConfigPr => {
let summary = crate::coordinator::rebuilt_todo_summary( let summary = crate::coordinator::rebuilt_todo_summary(
approval.agent.as_str(), approval.agent.as_str(),
@ -738,8 +663,8 @@ async fn finish_approval(
/// ///
/// Caller-specific bits stay OUT of here: fetching the PR head, the /// Caller-specific bits stay OUT of here: fetching the PR head, the
/// `verify_commit` gate, and the ff-merge. The agent always already exists here /// `verify_commit` gate, and the ff-merge. The agent always already exists here
/// (a merge is never a first spawn), so there's no `sync_agents` step — the /// (a merge is never a first deploy), so there's no `sync_agents` step — the
/// operator `Spawn` flow owns first-time meta registration. /// first-deploy DAG's `Provision` node owns first-time meta registration.
async fn prepare_applied_target( async fn prepare_applied_target(
agent: &str, agent: &str,
applied_dir: &std::path::Path, applied_dir: &std::path::Path,

View file

@ -841,7 +841,7 @@ impl Coordinator {
/// Call after any mutation that could affect what /// Call after any mutation that could affect what
/// `nixos-container list` returns or what a row's /// `nixos-container list` returns or what a row's
/// `running` / `needs_update` / `needs_login` / `deployed_sha` /// `running` / `needs_update` / `needs_login` / `deployed_sha`
/// resolves to — lifecycle ops, destroy, approve (post-spawn), /// resolves to — lifecycle ops, destroy,
/// rebuild, meta-update, and the crash-watcher's periodic poll. /// rebuild, meta-update, and the crash-watcher's periodic poll.
/// Cheap when nothing changed (one `nixos-container list` + a /// Cheap when nothing changed (one `nixos-container list` + a
/// `HashMap` diff + zero emits). /// `HashMap` diff + zero emits).

View file

@ -83,11 +83,6 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
approvals approvals
.into_iter() .into_iter()
.filter(|a| { .filter(|a| {
// 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() { if Coordinator::agent_proposed_dir(&a.agent).exists() {
true true
} else { } else {

View file

@ -232,56 +232,3 @@ pub(super) async fn post_op_send(
// no full-state refresh in between. // no full-state refresh in between.
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }
#[derive(Deserialize, ToSchema)]
pub(super) struct RequestSpawnForm {
name: String,
}
/// Queue a spawn approval for `name`.
#[utoipa::path(
post,
path = "/api/request-spawn",
request_body(content = RequestSpawnForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "spawn approval queued", body = String),
(status = 500, description = "missing name, or the approval submit failed"),
),
tag = "misc_api"
)]
pub(super) async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,
) -> Response {
let name = form.name.trim().to_owned();
if name.is_empty() {
return error_response("spawn: `name` required");
}
match state.coord.approvals.submit_kind(
&name,
hive_sh4re::approvals::ApprovalKind::Spawn,
"",
None,
"operator",
None,
) {
Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
// Phase 5b: notify the dashboard event channel so live
// subscribers can append the row without a snapshot
// refetch. Spawn approvals carry no sha.
state
.coord
.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: &name,
approval_kind: "spawn",
sha_short: None,
description: None,
pr_number: None,
});
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
}
}

View file

@ -149,7 +149,6 @@ pub async fn serve(
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send)) .routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node)) .routes(routes!(build_logs::get_build_log_for_node))
@ -386,7 +385,6 @@ mod router_build_probe {
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send)) .routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node)) .routes(routes!(build_logs::get_build_log_for_node))

View file

@ -140,7 +140,7 @@ struct ApprovalHistoryView {
agent: String, agent: String,
kind: &'static str, kind: &'static str,
/// First 12 chars of the canonical sha (preferred) or /// First 12 chars of the canonical sha (preferred) or
/// manager-supplied ref. None for resolved spawn approvals. /// manager-supplied ref. None when the approval carries neither.
sha_short: Option<String>, sha_short: Option<String>,
/// `approved` / `denied` / `failed`. /// `approved` / `denied` / `failed`.
status: &'static str, status: &'static str,
@ -404,7 +404,7 @@ fn build_transient_views(
} }
/// Render each pending approval into its dashboard view (short sha for /// Render each pending approval into its dashboard view (short sha for
/// `MergeConfigPr`, just the name for `Spawn`). /// `MergeConfigPr`).
/// Project a resolved sqlite row into the lean shape the dashboard /// Project a resolved sqlite row into the lean shape the dashboard
/// history tab consumes — no `diff_html` (rendering 30 of them /// history tab consumes — no `diff_html` (rendering 30 of them
/// per /api/state poll would mean 30 git diffs per refresh). /// per /api/state poll would mean 30 git diffs per refresh).
@ -439,16 +439,6 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len()); let mut out = Vec::with_capacity(approvals.len());
for a in approvals { for a in approvals {
out.push(match a.kind { out.push(match a.kind {
hive_sh4re::approvals::ApprovalKind::Spawn => ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: "spawn",
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

@ -31,7 +31,7 @@ pub struct TombstoneView {
} }
/// State-dir names that don't appear in the live container list. Each /// State-dir names that don't appear in the live container list. Each
/// one surfaces in the dashboard as a row with R3V1V3 + PURG3 actions. /// one surfaces in the dashboard as a row with a PURG3 action.
/// ///
/// ⚠️ **This lists every agent whose container is absent, not only destroyed /// ⚠️ **This lists every agent whose container is absent, not only destroyed
/// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not /// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not

View file

@ -52,7 +52,7 @@ pub enum DashboardEvent {
/// enough to render the dashboard row without a `/api/state` /// enough to render the dashboard row without a `/api/state`
/// refetch. /// refetch.
/// ///
/// The approval's own kind (`"merge_config_pr"` / `"spawn"`) lives /// The approval's own kind (`"merge_config_pr"` / `"schedule_prompt"`) lives
/// on `approval_kind` rather than `kind` because the latter is taken /// on `approval_kind` rather than `kind` because the latter is taken
/// by the serde tag identifying which `DashboardEvent` variant /// by the serde tag identifying which `DashboardEvent` variant
/// this is. /// this is.
@ -119,8 +119,8 @@ pub enum DashboardEvent {
name: String, name: String,
transient_kind: String, transient_kind: String,
}, },
/// One container row changed — new container appeared (post-spawn /// One container row changed — a new container appeared, an existing
/// finalise), an existing one flipped `running` / `needs_update` / /// one flipped `running` / `needs_update` /
/// `sha`, etc. Clients upsert by `container.name`. Payload carries /// `sha`, etc. Clients upsert by `container.name`. Payload carries
/// the full row so cold-loaded clients and event-driven clients /// the full row so cold-loaded clients and event-driven clients
/// converge on the same render. /// converge on the same render.
@ -136,10 +136,8 @@ pub enum DashboardEvent {
/// `nixos-container destroy` (operator-driven or otherwise) on the /// `nixos-container destroy` (operator-driven or otherwise) on the
/// next rescan. /// next rescan.
ContainerRemoved { seq: u64, name: String }, ContainerRemoved { seq: u64, name: String },
/// Full snapshot of the tombstones list. Emitted on every /// Full snapshot of the tombstones list. Emitted by destroy (with
/// mutation that could add / remove a tombstone: destroy /// or without purge) and purge-tombstone. Snapshot
/// (with or without purge), purge-tombstone, spawn approval
/// (which can consume a tombstone of the same name). Snapshot
/// shape (not diff) because the list is tiny (single-digit /// shape (not diff) because the list is tiny (single-digit
/// typical) and recomputing avoids the add/remove races a /// typical) and recomputing avoids the add/remove races a
/// per-row event would have. /// per-row event would have.

View file

@ -122,7 +122,7 @@ pub enum NodeKind {
/// node-inventory row for its three responsibilities and why it isn't /// node-inventory row for its three responsibilities and why it isn't
/// named `AbortDeploy`. /// named `AbortDeploy`.
DeployTail { agent: String, approval_id: i64 }, DeployTail { agent: String, approval_id: i64 },
/// Tail node of an approval-carrying DAG (spawn / opaque deploy / /// Tail node of an approval-carrying DAG (opaque deploy /
/// config-PR merge): resolve the approval row from how the work ended. /// config-PR merge): resolve the approval row from how the work ended.
ResolveApproval { ResolveApproval {
approval_id: i64, approval_id: i64,

Some files were not shown because too many files have changed in this diff Show more