diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 7a39d809..e56d5060 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -145,6 +145,7 @@ const NODE_KIND_LABEL = { prebuild: 'prebuild', stop_for_update: 'stop', swap: 'swap', + provision: 'provision', create: 'create', meta_lock: 'meta lock', reconcile: 'reconcile', diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 3d7efa9a..7d5dd6d4 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -81,7 +81,8 @@ pub(super) async fn run_node(coord: &Arc, claim: &Claim) -> Result< match &claim.kind { NodeKind::Prebuild { relock } => run_prebuild(coord, claim, &ctx, *relock).await, NodeKind::Swap => run_swap(coord, claim, &ctx).await, - NodeKind::Create => run_create(coord, claim, &ctx).await, + NodeKind::Provision => run_provision(coord, claim, &ctx).await, + NodeKind::Create => run_create(claim, &ctx).await, NodeKind::MetaLock { sweep, fanout } => { run_meta_lock(coord, claim, &ctx, *sweep, fanout.clone()).await } @@ -191,23 +192,35 @@ async fn run_swap(coord: &Arc, claim: &Claim, ctx: &Ctx<'_>) -> Res result.map(|()| NodeOutput::default()) } -/// First-spawn provisioning + `nixos-container create` (atomic -/// build+create — no prebuild needed). -async fn run_create(coord: &Arc, claim: &Claim, ctx: &Ctx<'_>) -> Result { +/// First-spawn pre-create provisioning: proposed/applied repos, state +/// subvolume, and the meta `sync_agents` registration. Holds the +/// deploy-window gate for the commit so it can't land inside another +/// node's staged deploy window — same discipline as `Prebuild`, which +/// commits under the gate then drops it before the (store-only) build. +async fn run_provision( + coord: &Arc, + claim: &Claim, + ctx: &Ctx<'_>, +) -> Result { let name = &claim.agent; let agent_dir = crate::paths::agent_runtime_dir(name); let hive = coord.hive_env(); let paths = Coordinator::agent_paths(name, agent_dir); - ctx.step("nixos-container create"); - // create_container registers the new agent in the meta flake - // (sync_agents commit) before `nixos-container create` — hold the - // deploy-window gate so that commit can't land inside another - // node's staged deploy window. - // Runtime dir creation and MCP listener registration are deferred to - // the tail Reconcile (converge_start_preamble + register_agent) so this - // node stays purely "provision + create", not "create + start". + ctx.step("provisioning"); let _window = crate::meta::exclusive().await; - crate::lifecycle::create_container(name, &hive, &paths).await?; + crate::lifecycle::provision_container(name, &hive, &paths).await?; + Ok(NodeOutput::default()) +} + +/// `nixos-container create` proper — the upstream `Provision` node +/// already registered the agent in meta, so this only reads the store +/// (no deploy-window gate needed, mirroring `Prebuild`'s build). Runtime +/// dir creation and MCP listener registration are deferred to the tail +/// `Reconcile` (`converge_start_preamble` + `register_agent`) so this +/// node stays purely "create", not "create + start". +async fn run_create(claim: &Claim, ctx: &Ctx<'_>) -> Result { + ctx.step("nixos-container create"); + crate::lifecycle::create_only(&claim.agent).await?; Ok(NodeOutput::default()) } diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index 2bdfa8c3..684da72e 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -75,9 +75,14 @@ pub enum NodeKind { /// rebuild is the reconcile verb — and carries the post-rebuild /// bookkeeping tail (rev marker, forge/matrix sync, kick, rescan). Swap, - /// First-spawn `nixos-container create` plus the pre-create - /// provisioning (proposed/applied repos, state subvolume, meta - /// registration). + /// First-spawn pre-create provisioning: proposed/applied repos, + /// state subvolume, and meta registration (`sync_agents`). Runs + /// ahead of `Create` so the `nixos-container create --flake + /// meta#` ref resolves. Store/meta-only — no container yet — + /// so it's lease- and build-slot-exempt like `Prebuild`. + Provision, + /// First-spawn `nixos-container create` proper. Assumes the + /// upstream `Provision` node already registered the agent in meta. Create, /// Meta flake lock bump. `sweep = false`: `meta::lock_update` /// (commit fused, under `META_LOCK`) with the DAG's `inputs`; @@ -139,6 +144,7 @@ impl NodeKind { match self { NodeKind::Prebuild { .. } => "prebuild", NodeKind::Swap => "swap", + NodeKind::Provision => "provision", NodeKind::Create => "create", NodeKind::MetaLock { .. } => "meta_lock", NodeKind::Reconcile => "reconcile", @@ -169,10 +175,12 @@ impl NodeKind { /// Container-affecting kinds require the DAG to hold the agent's /// lifecycle lease (acquired at the first such node, held until the - /// DAG is terminal). Lease-exempt kinds (`Prebuild`, `MetaLock`, - /// `WritePermFile`) touch the store / meta repo, not the running - /// container — which is exactly why a `Prebuild` can overlap - /// another DAG's work on the same agent. + /// DAG is terminal). Lease-exempt kinds (`Prebuild`, `Provision`, + /// `MetaLock`, `WritePermFile`) touch the store / meta repo, not the + /// running container — which is exactly why a `Prebuild` can overlap + /// another DAG's work on the same agent. `Provision` precedes the + /// container's existence entirely, so the lease is first taken at the + /// `Create` node it feeds. pub fn needs_lease(&self) -> bool { matches!( self, diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 112ab256..586e1d01 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -9,7 +9,7 @@ //! restart(a): [wanted=Up] StopForUpdate(a) → Reconcile(a) //! start(a): [wanted=Up] Reconcile(a) //! stop(a): [wanted=Offline] Reconcile(a) -//! spawn(a): [wanted=Up] Create(a) → WriteDropin(a) → Reconcile(a) +//! spawn(a): [wanted=Up] Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a) //! perm-change(a): WritePermFile(a) → «rebuild subgraph» //! meta-update(inp): MetaLock(inp) → «fan-out rebuild(a) per affected a» //! startup sweep: MetaLock(hyperhive, non-fatal) → «fan-out rebuild(stale a)» @@ -194,9 +194,10 @@ pub fn reconcile_only( } } -/// First-deploy spawn (approval-driven): pre-start provisioning + -/// `nixos-container create`, drop-in write, then `Reconcile` starts the -/// container (`wanted = Up` written at approve time). +/// First-deploy spawn (approval-driven): `Provision` (proposed/applied +/// repos, state subvolume, meta registration) then `Create` +/// (`nixos-container create`), drop-in write, then `Reconcile` starts +/// the container (`wanted = Up` written at approve time). pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec { DagSpec { template: Template::Spawn, @@ -210,17 +211,21 @@ pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec { transient: Some(TransientKind::Spawning), nodes: vec![ NodeSpec { - kind: NodeKind::Create, + kind: NodeKind::Provision, deps: Vec::new(), }, NodeSpec { - kind: NodeKind::WriteDropin, + kind: NodeKind::Create, deps: after_ok(0), }, NodeSpec { - kind: NodeKind::Reconcile, + kind: NodeKind::WriteDropin, deps: after_ok(1), }, + NodeSpec { + kind: NodeKind::Reconcile, + deps: after_ok(2), + }, ], } } diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 321a6419..958040da 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -874,13 +874,13 @@ fn graceful_signal_and_drain_hold_no_build_slot() { } #[test] -fn spawn_shape_create_dropin_reconcile() { +fn spawn_shape_provision_create_dropin_reconcile() { let q = JobQueue::new(1); let id = submit( &q, templates::spawn("newbie", 7, "approval #7 spawn".to_owned()), ); - for expected in ["create", "write_dropin", "reconcile"] { + for expected in ["provision", "create", "write_dropin", "reconcile"] { let c = claim_one(&q); assert_eq!(c.kind.as_str(), expected); assert_eq!(c.approval_id, Some(7)); diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index f9c59695..c8840634 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -226,11 +226,13 @@ pub async fn spawn(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> priv_run("start", name).await } -/// First-spawn provisioning + `nixos-container create`, without the -/// drop-in write or the start — the job queue's `Create` node. -/// `spawn` composes this with `write_dropins` + start for direct -/// callers (root-agent bootstrap). -pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { +/// First-spawn pre-create provisioning — the job queue's `Provision` +/// node. Fail fast on a port collision, set up the proposed/applied +/// repos + state subvolume + claude/notes dirs, then register the new +/// agent in the meta flake (`sync_agents`) so the later +/// `nixos-container create --flake meta#` ref resolves. Does NOT +/// create the container — that's `create_only` / the `Create` node. +pub async fn provision_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { validate(name)?; if let Some(other) = port_collision(name).await { bail!( @@ -247,10 +249,25 @@ pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> // before `nixos-container create` so the `--flake meta#` // ref resolves. let agents = agents_after_spawn(name).await?; - crate::meta::sync_agents(hive, &agents).await?; + crate::meta::sync_agents(hive, &agents).await +} + +/// The `nixos-container create` proper — the job queue's `Create` node. +/// Assumes `provision_container` already registered the agent in meta. +pub async fn create_only(name: &str) -> Result<()> { priv_run("create", name).await } +/// First-spawn provisioning + `nixos-container create`, without the +/// drop-in write or the start. `spawn` composes this with +/// `write_dropins` + start for direct callers (root-agent bootstrap); +/// the job queue instead runs `provision_container` (`Provision` node) +/// and `create_only` (`Create` node) as separate DAG steps. +pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { + provision_container(name, hive, paths).await?; + create_only(name).await +} + /// Rebuild-path preamble shared by the job queue's `Prebuild` node and /// `rebuild_no_meta`: fail fast on a port collision, then make sure /// the applied repo + state dirs exist. Container untouched.