diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 17749b20..d554b9a8 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -98,9 +98,31 @@ pub(super) async fn run_node(coord: &Arc, claim: &Claim) -> Result< // A pure grouping anchor (boot root): no work, completes immediately so // its child DAGs settle it and the boot tree resolves. NodeKind::Noop => Ok(NodeOutput::default()), + NodeKind::SetWanted { up } => run_set_wanted(coord, claim, *up), } } +/// Write the agent's durable power intent — the DAG-node form of the old +/// pre-submit `set_wanted` side effect. Store-only (no container touch), so +/// build-slot-exempt; but it takes the agent's lifecycle lease (see +/// `NodeKind::needs_lease`) so the whole power-op DAG is atomic per-agent. +/// The downstream `Reconcile` reads the intent this writes. Unlike the old +/// warn-and-continue write, a failed write fails the node (cancel-downstream +/// cancels the `Reconcile`) rather than letting it converge to a stale +/// intent — that atomicity is the point of moving it into the DAG. +fn run_set_wanted(coord: &Arc, claim: &Claim, up: bool) -> Result { + let wanted = if up { + crate::power::Wanted::Up + } else { + crate::power::Wanted::Offline + }; + coord + .power + .set(&claim.agent, wanted) + .with_context(|| format!("set wanted={} for agent {}", wanted.as_str(), claim.agent))?; + Ok(NodeOutput::default()) +} + /// Out-of-band toplevel build while the container keeps serving: meta /// sync + optional per-agent relock, then warm /// `system.build.toplevel` so the later `Swap` hits cache and skips diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index bf0e4c95..02ba0b2c 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -121,6 +121,20 @@ pub enum NodeKind { /// dashboard. Holds no lease and no build slot; the child DAGs it anchors /// still run concurrently — the grouping is a display link, not a dep edge. Noop, + /// Write the agent's durable power intent (`wanted = Up` when `up`, else + /// `Offline`) as a first-class DAG node, at the head of a power-op + /// template so the downstream `Reconcile` reads it. Replaces the old + /// pre-submit `set_wanted` side effect: the intent write is now part of + /// the atomic DAG (crash-safe, per-agent — a multi-agent DAG carries one + /// `SetWanted` per agent). Build-slot-exempt (a store write), but + /// **lease-needing**: it takes the agent's lifecycle lease so the whole + /// power-op DAG (intent write → reconcile) is atomic per-agent — two + /// racing ops (e.g. restart vs stop) can't clobber each other's intent + /// before either reconciles, which is the point of moving the write into + /// the DAG. (In `stale_start` the lease is thus held across the head + /// `Prebuild`, but that's a no-op there — the agent is down, so prebuild + /// is skipped.) + SetWanted { up: bool }, } impl NodeKind { @@ -142,6 +156,7 @@ impl NodeKind { NodeKind::WritePermFile => "write_perm_file", NodeKind::ApprovalDeploy => "approval_deploy", NodeKind::Noop => "noop", + NodeKind::SetWanted { .. } => "set_wanted", } } @@ -177,6 +192,7 @@ impl NodeKind { | NodeKind::Drain | NodeKind::WriteDropin | NodeKind::ApprovalDeploy + | NodeKind::SetWanted { .. } ) } } diff --git a/hive-c0re/src/job_queue/submit.rs b/hive-c0re/src/job_queue/submit.rs index 0e5bc4b4..17c83ba6 100644 --- a/hive-c0re/src/job_queue/submit.rs +++ b/hive-c0re/src/job_queue/submit.rs @@ -1,16 +1,15 @@ //! Request-level submit API — the surface the dashboard POST handlers, -//! the MCP socket handlers, and `hivectl` paths call. Owns the -//! submit-time side effects the DAG templates deliberately don't: -//! writing the durable `wanted` power intent (synchronously, -//! last-writer-wins) before the DAG whose `Reconcile` reads it, and -//! upgrading a stale start to a full rebuild. Every helper emits a -//! fresh queue snapshot so the dashboard shows the new DAG immediately. +//! the MCP socket handlers, and `hivectl` paths call. The durable +//! `wanted` power intent is now written by a `SetWanted` DAG node at the +//! head of each power-op template (not a pre-submit side effect); the +//! only submit-time logic left is the stale-start *shape* decision +//! (`start` vs `stale_start`). Every helper emits a fresh queue snapshot +//! so the dashboard shows the new DAG immediately. use std::sync::Arc; -use super::{Source, Template, templates}; +use super::{Source, templates}; use crate::coordinator::Coordinator; -use crate::power::Wanted; fn submit_and_emit(coord: &Arc, spec: super::DagSpec) -> u64 { let id = coord @@ -21,12 +20,6 @@ fn submit_and_emit(coord: &Arc, spec: super::DagSpec) -> u64 { id } -fn set_wanted(coord: &Arc, agent: &str, wanted: Wanted) { - if let Err(e) = coord.power.set(agent, wanted) { - tracing::warn!(%agent, wanted = wanted.as_str(), error = ?e, "agent_power: set failed"); - } -} - /// Manual/approval-independent rebuild (always relocks the agent's /// meta input — cascade children are built by the scheduler's fan-out /// instead of this surface). @@ -35,20 +28,20 @@ pub fn rebuild(coord: &Arc, agent: &str, source: Source, reason: St } /// Restart: mechanical stop + converge to `wanted = Up`. The intent -/// write matters when `wanted` drifted `Offline` under a running -/// agent — the old `kill + start` always ended up, and an operator -/// asking for a restart plainly wants it running, not a stop. +/// write is the template's head `SetWanted(Up)` node — it matters when +/// `wanted` drifted `Offline` under a running agent (an operator asking +/// for a restart plainly wants it running, not a stop). pub fn restart(coord: &Arc, agent: &str, source: Source, reason: String) -> u64 { - set_wanted(coord, agent, Wanted::Up); submit_and_emit(coord, templates::restart(agent, source, reason)) } -/// Start: persist `wanted = Up`, then reconcile. A stale rev marker -/// upgrades the start to a full rebuild (whose tail `Reconcile` does -/// the start) so the container always comes up on current derivations -/// — the old fast-lane `run_start` upgrade, moved to submit time. +/// Start: `SetWanted(Up)` (a DAG node now) then reconcile. A stale rev +/// marker upgrades the start to a rebuild-then-start (`stale_start`, whose +/// tail `Reconcile` does the start) so the container always comes up on +/// current derivations — the old fast-lane `run_start` upgrade, still a +/// submit-time *shape* decision (which template), while the intent write +/// itself is now the template's head node. pub fn start(coord: &Arc, agent: &str, source: Source, reason: String) -> u64 { - set_wanted(coord, agent, Wanted::Up); let stored = std::fs::read_to_string(crate::paths::applied_rev_marker(agent)).ok(); let stale = crate::auto_update::current_flake_rev(&coord.hyperhive_flake) .is_some_and(|rev| stored.as_deref() != Some(rev.as_str())); @@ -56,60 +49,34 @@ pub fn start(coord: &Arc, agent: &str, source: Source, reason: Stri tracing::info!(%agent, "start: rev stale — upgrading to rebuild+start"); return submit_and_emit( coord, - templates::rebuild( - agent, - source, - format!("{reason} (stale — rebuild+start)"), - None, - true, - ), + templates::stale_start(agent, source, format!("{reason} (stale — rebuild+start)")), ); } - submit_and_emit( - coord, - templates::reconcile_only( - Template::Start, - agent, - source, - reason, - Some(crate::coordinator::TransientKind::Starting), - ), - ) + submit_and_emit(coord, templates::start(agent, source, reason)) } -/// Hard stop: persist `wanted = Offline`, then reconcile (kill + +/// Hard stop: `SetWanted(Offline)` (a DAG node now) then reconcile (kill + /// unregister + `Killed` event). pub fn stop(coord: &Arc, agent: &str, source: Source, reason: String) -> u64 { - set_wanted(coord, agent, Wanted::Offline); - submit_and_emit( - coord, - templates::reconcile_only( - Template::Stop, - agent, - source, - reason, - Some(crate::coordinator::TransientKind::Stopping), - ), - ) + submit_and_emit(coord, templates::stop(agent, source, reason)) } -/// Graceful stop: persist `wanted = Offline`, then signal → drain → -/// reconcile (the actual stop). +/// Graceful stop: signal → drain → reconcile (the actual stop). The head +/// `SetWanted(Offline)` node writes the intent as part of the DAG. pub fn graceful_stop(coord: &Arc, agent: &str, source: Source, reason: String) -> u64 { - set_wanted(coord, agent, Wanted::Offline); submit_and_emit(coord, templates::graceful_stop(agent, source, reason)) } -/// Graceful restart: persist `wanted = Up`, then signal → drain → -/// mechanical stop → reconcile (starts it back up) — one atomic DAG, -/// no client-side "await the stop DAG then submit a start DAG" split. +/// Graceful restart: signal → drain → mechanical stop → reconcile (starts +/// it back up) — one atomic DAG, no client-side "await the stop DAG then +/// submit a start DAG" split. The head `SetWanted(Up)` node writes the +/// intent as part of the DAG. pub fn graceful_restart( coord: &Arc, agent: &str, source: Source, reason: String, ) -> u64 { - set_wanted(coord, agent, Wanted::Up); submit_and_emit(coord, templates::graceful_restart(agent, source, reason)) } diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 48c3086c..37d029c5 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -8,14 +8,19 @@ //! are single-agent (every node shares one agent); a future multi-agent //! template would stamp different agents per subgraph. //! +//! The power ops write the durable `wanted` intent via a head +//! `SetWanted(w)` node (not a pre-submit side effect); it holds the agent +//! lease so intent+reconcile is atomic per-agent. +//! //! ```text //! rebuild(a): Prebuild(a) → StopForUpdate(a) → Swap(a) →(any) Reconcile(a) -//! graceful-stop(a): [wanted=Offline] Signal(a) → Drain(a) → Reconcile(a) -//! restart(a): [wanted=Up] StopForUpdate(a) → Reconcile(a) -//! graceful-restart(a): [wanted=Up] Signal(a) → Drain(a) → StopForUpdate(a) → Reconcile(a) -//! start(a): [wanted=Up] Reconcile(a) -//! stop(a): [wanted=Offline] Reconcile(a) -//! spawn(a): [wanted=Up] Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a) +//! graceful-stop(a): SetWanted(a,Off) → Signal(a) → Drain(a) → Reconcile(a) +//! restart(a): SetWanted(a,Up) → StopForUpdate(a) → Reconcile(a) +//! graceful-restart(a): SetWanted(a,Up) → Signal(a) → Drain(a) → StopForUpdate(a) → Reconcile(a) +//! start(a): SetWanted(a,Up) → Reconcile(a) +//! stop(a): SetWanted(a,Off) → Reconcile(a) +//! stale-start(a): SetWanted(a,Up) → «rebuild subgraph» (rev stale; prebuild noops, agent down) +//! spawn(a): Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a) [wanted=Up at approve] //! 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)» @@ -117,10 +122,11 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec /// Graceful stop: cheap `Signal` fires immediately (no build slot), the /// `Drain` awaits the harness checkpoint (bounded), and the tail -/// `Reconcile` performs the actual container stop — the caller sets -/// `wanted = Offline` at submit time. A whole-hive graceful stop -/// therefore signals every agent up front and overlaps every drain, -/// replacing the old detached-watcher thread structurally. +/// `Reconcile` performs the actual container stop. The head `SetWanted` +/// node writes `wanted = Offline` as part of the DAG (was a pre-submit +/// side effect). A whole-hive graceful stop therefore signals every agent +/// up front and overlaps every drain, replacing the old detached-watcher +/// thread structurally. pub fn graceful_stop(agent: &str, source: Source, reason: String) -> DagSpec { DagSpec { template: Template::GracefulStop, @@ -132,16 +138,17 @@ pub fn graceful_stop(agent: &str, source: Source, reason: String) -> DagSpec { perm_payload: None, transient: Some(TransientKind::Stopping), nodes: vec![ - node(agent, NodeKind::Signal, Vec::new()), - node(agent, NodeKind::Drain, after_ok(0)), - node(agent, NodeKind::Reconcile, after_ok(1)), + node(agent, NodeKind::SetWanted { up: false }, Vec::new()), + node(agent, NodeKind::Signal, after_ok(0)), + node(agent, NodeKind::Drain, after_ok(1)), + node(agent, NodeKind::Reconcile, after_ok(2)), ], } } -/// Restart: mechanical stop, then converge to `wanted` — the submit -/// layer writes `wanted = Up` first, so this is a stop + start like -/// the old `lifecycle::restart` regardless of prior intent drift. +/// Restart: write `wanted = Up` (head `SetWanted` node), mechanical stop, +/// then converge — a stop + start like the old `lifecycle::restart` +/// regardless of prior intent drift. pub fn restart(agent: &str, source: Source, reason: String) -> DagSpec { DagSpec { template: Template::Restart, @@ -153,20 +160,20 @@ pub fn restart(agent: &str, source: Source, reason: String) -> DagSpec { perm_payload: None, transient: Some(TransientKind::Restarting), nodes: vec![ - node(agent, NodeKind::StopForUpdate, Vec::new()), - node(agent, NodeKind::Reconcile, after_ok(0)), + node(agent, NodeKind::SetWanted { up: true }, Vec::new()), + node(agent, NodeKind::StopForUpdate, after_ok(0)), + node(agent, NodeKind::Reconcile, after_ok(1)), ], } } -/// Graceful restart: signal → drain → mechanical stop → converge to -/// `wanted` — the caller writes `wanted = Up` first, same as `restart`. -/// One atomic DAG start to finish (no client- or server-side "submit -/// one DAG, await it, submit the next" composition): the `Drain` node -/// is the same bounded harness-checkpoint wait `graceful_stop` uses, -/// then `StopForUpdate` (mechanical, ignores `wanted`) and the tail -/// `Reconcile` (converges to `wanted = Up`, i.e. starts it back up) -/// chain exactly like `restart`'s tail. +/// Graceful restart: write `wanted = Up` (head `SetWanted`), signal → +/// drain → mechanical stop → converge. One atomic DAG start to finish +/// (no client- or server-side "submit one DAG, await it, submit the next" +/// composition): the `Drain` node is the same bounded harness-checkpoint +/// wait `graceful_stop` uses, then `StopForUpdate` (mechanical, ignores +/// `wanted`) and the tail `Reconcile` (converges to `wanted = Up`, i.e. +/// starts it back up) chain exactly like `restart`'s tail. pub fn graceful_restart(agent: &str, source: Source, reason: String) -> DagSpec { DagSpec { template: Template::GracefulRestart, @@ -178,16 +185,19 @@ pub fn graceful_restart(agent: &str, source: Source, reason: String) -> DagSpec perm_payload: None, transient: Some(TransientKind::Restarting), nodes: vec![ - node(agent, NodeKind::Signal, Vec::new()), - node(agent, NodeKind::Drain, after_ok(0)), - node(agent, NodeKind::StopForUpdate, after_ok(1)), - node(agent, NodeKind::Reconcile, after_ok(2)), + node(agent, NodeKind::SetWanted { up: true }, Vec::new()), + node(agent, NodeKind::Signal, after_ok(0)), + node(agent, NodeKind::Drain, after_ok(1)), + node(agent, NodeKind::StopForUpdate, after_ok(2)), + node(agent, NodeKind::Reconcile, after_ok(3)), ], } } -/// Single-`Reconcile` DAG: `Start` / `Stop` (caller writes `wanted` -/// first) and the boot-time `Reconcile` converge (wanted untouched). +/// Boot-time reconcile: a single `Reconcile` node that converges observed +/// power state to the persisted intent — `wanted` is untouched (no +/// `SetWanted`), unlike the operator `start`/`stop` templates. Used only +/// by the boot sweep now. pub fn reconcile_only( template: Template, agent: &str, @@ -208,6 +218,68 @@ pub fn reconcile_only( } } +/// Start: write `wanted = Up` (head `SetWanted`), then reconcile (which +/// starts the container). The intent write is a DAG node now, not a +/// pre-submit side effect. +pub fn start(agent: &str, source: Source, reason: String) -> DagSpec { + DagSpec { + template: Template::Start, + source, + reason, + parent_id: None, + approval_id: None, + inputs: Vec::new(), + perm_payload: None, + transient: Some(TransientKind::Starting), + nodes: vec![ + node(agent, NodeKind::SetWanted { up: true }, Vec::new()), + node(agent, NodeKind::Reconcile, after_ok(0)), + ], + } +} + +/// Stop: write `wanted = Offline` (head `SetWanted`), then reconcile +/// (kill + unregister + `Killed` event). +pub fn stop(agent: &str, source: Source, reason: String) -> DagSpec { + DagSpec { + template: Template::Stop, + source, + reason, + parent_id: None, + approval_id: None, + inputs: Vec::new(), + perm_payload: None, + transient: Some(TransientKind::Stopping), + nodes: vec![ + node(agent, NodeKind::SetWanted { up: false }, Vec::new()), + node(agent, NodeKind::Reconcile, after_ok(0)), + ], + } +} + +/// Stale start: a `start` whose rev marker is stale, so it rebuilds +/// before coming up — `SetWanted(Up)` → «rebuild subgraph» → `Reconcile` +/// (the rebuild's tail `Reconcile` starts it, since `wanted = Up`). The +/// rebuild nodes are the same `rebuild_nodes` chain a manual rebuild uses +/// (reused, not a variant); only the leading `SetWanted(Up)` intent +/// differs. Shows as a `Rebuild` on the dashboard like the old +/// submit-time upgrade did. +pub fn stale_start(agent: &str, source: Source, reason: String) -> DagSpec { + let mut nodes = vec![node(agent, NodeKind::SetWanted { up: true }, Vec::new())]; + nodes.extend(rebuild_nodes(agent, true, 1)); + DagSpec { + template: Template::Rebuild, + source, + reason, + parent_id: None, + approval_id: None, + inputs: Vec::new(), + perm_payload: None, + transient: Some(TransientKind::Rebuilding), + nodes, + } +} + /// First-deploy spawn (approval-driven): `Provision` (proposed/applied /// repos, state subvolume, meta registration) then `Create` /// (`nixos-container create`), drop-in write, then `Reconcile` starts diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index b969db92..df26da4e 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -212,21 +212,27 @@ fn lease_serializes_two_lifecycle_dags_for_same_agent() { None, ), ); - // Restart's StopForUpdate acquires the lease; stop's Reconcile - // must wait even though slots are free. + // Restart's head SetWanted takes the lease; stop's Reconcile must + // wait even though slots are free. let first = claim_one(&q); assert_eq!(first.dag_id, restart); + assert_eq!(first.kind.as_str(), "set_wanted"); assert!(first.lease_acquired); q.complete_node(restart, first.node_id, Ok(())); - // Same DAG keeps the lease for its Reconcile. + // Same DAG keeps the lease through StopForUpdate then Reconcile. let second = claim_one(&q); assert_eq!(second.dag_id, restart); + assert_eq!(second.kind.as_str(), "stop_for_update"); assert!(!second.lease_acquired, "lease already held by this DAG"); q.complete_node(restart, second.node_id, Ok(())); - // Restart terminal → lease released → stop's Reconcile runs. let third = claim_one(&q); - assert_eq!(third.dag_id, stop); - q.complete_node(stop, third.node_id, Ok(())); + assert_eq!(third.dag_id, restart); + assert_eq!(third.kind.as_str(), "reconcile"); + q.complete_node(restart, third.node_id, Ok(())); + // Restart terminal → lease released → stop's Reconcile runs. + let fourth = claim_one(&q); + assert_eq!(fourth.dag_id, stop); + q.complete_node(stop, fourth.node_id, Ok(())); assert_eq!(state_of(&q, restart), State::Done); assert_eq!(state_of(&q, stop), State::Done); } @@ -510,6 +516,11 @@ fn terminal_dag_reported_exactly_once_and_lease_released() { &q, templates::restart("agent-a", Source::Manual, "r".to_owned()), ); + // restart = SetWanted → StopForUpdate → Reconcile; not terminal until + // the last node completes. + let set_wanted = claim_one(&q); + q.complete_node(id, set_wanted.node_id, Ok(())); + assert!(q.drain_terminal().is_empty(), "dag not terminal yet"); let stop = claim_one(&q); q.complete_node(id, stop.node_id, Ok(())); assert!(q.drain_terminal().is_empty(), "dag not terminal yet"); @@ -727,7 +738,7 @@ fn graceful_stop_shape_signal_drain_reconcile() { &q, templates::graceful_stop("agent-a", Source::Manual, "graceful".to_owned()), ); - for expected in ["signal", "drain", "reconcile"] { + for expected in ["set_wanted", "signal", "drain", "reconcile"] { let c = claim_one(&q); assert_eq!(c.kind.as_str(), expected); q.complete_node(id, c.node_id, Ok(())); @@ -753,8 +764,9 @@ fn graceful_signal_and_drain_hold_no_build_slot() { let kinds: Vec<&str> = claims.iter().map(|c| c.kind.as_str()).collect(); assert_eq!( kinds, - vec!["prebuild", "signal", "signal"], - "both agents' signals fire while the slot is held" + vec!["prebuild", "set_wanted", "set_wanted"], + "both agents' graceful-stop heads (SetWanted, build-slot-exempt) run \ + while the slot is held; their signals follow" ); }