diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 3d8ef461..a73fa321 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -92,21 +92,29 @@ pub(super) async fn run_node(coord: &Arc, claim: &Claim) -> Result< NodeKind::WriteDropin { .. } => run_write_dropin(coord, claim).await, NodeKind::WritePermFile { .. } => run_write_perm_file(coord, claim).await, NodeKind::Reparent { .. } => run_reparent(coord, claim).await, - NodeKind::DeployWindow { .. } => run_deploy_window(claim), - NodeKind::MergeVerify { .. } => run_merge_verify(coord, claim).await, - NodeKind::DeployApply { .. } => run_deploy_apply(coord, claim).await, - NodeKind::FinalizeDeploy { .. } => run_finalize_deploy(coord, claim).await, - NodeKind::DeployTail { .. } => run_deploy_tail(coord, claim).await, + NodeKind::MergeVerify { approval_id, .. } => run_merge_verify(coord, *approval_id).await, + NodeKind::DeployApply { approval_id, .. } => { + run_deploy_apply(coord, claim, *approval_id).await + } + NodeKind::FinalizeDeploy { approval_id, .. } => { + run_finalize_deploy(coord, *approval_id).await + } + NodeKind::DeployTail { approval_id, .. } => { + run_deploy_tail(coord, claim, *approval_id).await + } NodeKind::ResolveApproval { approval_id, outcome, } => run_resolve_approval(coord, claim, *approval_id, *outcome).await, NodeKind::EmitRebuilt { ok, .. } => Ok(run_emit_rebuilt(coord, claim, *ok)), NodeKind::SetWanted { up, .. } => run_set_wanted(coord, claim, *up), - // Pure grouping container — no work; completing it lets it reach - // `Finishing` so its child work nodes start. The DAG's terminal side - // effect, if any, is its own tail node in the graph. - NodeKind::Dag { .. } => Ok(NodeOutput::default()), + // The two nodes that carry no work of their own; completing either + // lets it reach `Finishing` so the nodes under it start. + // - `Dag`: pure grouping container. The DAG's terminal side effect, if + // any, is its own tail node in the graph. + // - `DeployWindow`: pure resource holder — the meta window, agent lease + // and build slot it declares stay held until its subtree settles. + NodeKind::Dag { .. } | NodeKind::DeployWindow { .. } => Ok(NodeOutput::default()), } } @@ -587,36 +595,11 @@ async fn run_reparent(coord: &Arc, claim: &Claim) -> Result Result { - claim - .approval_id - .with_context(|| format!("approval deploy dag {} has no approval_id", claim.dag_id)) -} - -/// The deploy subtree's root: pure resource holder, no work of its own. -/// -/// It exists so the global meta window (plus the agent lease and a build slot) -/// is held continuously across every phase below it. `prepare_deploy` leaves -/// `flake.lock` staged-uncommitted for the whole container build, and any other -/// meta mutation landing inside that span would sweep the staged lock into its -/// own commit and neuter `abort_deploy` — so the window has to outlive any one -/// node, which the `MutexGuard` this replaced could not do. -/// -/// Completing immediately moves it to `Finishing`, which is what starts the -/// children; the resources stay held until the whole subtree settles. -fn run_deploy_window(claim: &Claim) -> Result { - deploy_approval_id(claim)?; - Ok(NodeOutput::default()) -} - /// Deploy phase 1 — drift gate, fetch, eval-verify. Mutates nothing, so a /// failure here cancel-cascades the rest of the subtree with the forge and the /// applied repo exactly as they were. -async fn run_merge_verify(coord: &Arc, claim: &Claim) -> Result { - crate::actions::run_deploy_merge_verify(coord, deploy_approval_id(claim)?) +async fn run_merge_verify(coord: &Arc, approval_id: i64) -> Result { + crate::actions::run_deploy_merge_verify(coord, approval_id) .await .map(|()| NodeOutput::default()) } @@ -630,18 +613,25 @@ async fn run_merge_verify(coord: &Arc, claim: &Claim) -> Result, claim: &Claim) -> Result { - crate::actions::run_deploy_apply(coord, deploy_approval_id(claim)?).await?; +async fn run_deploy_apply( + coord: &Arc, + claim: &Claim, + approval_id: i64, +) -> Result { + crate::actions::run_deploy_apply(coord, approval_id).await?; Ok(NodeOutput { - append_subgraph: vec![super::templates::deploy_rebuild_nodes(claim.kind.agent())], + append_subgraph: vec![super::templates::deploy_rebuild_nodes( + claim.kind.agent(), + approval_id, + )], }) } /// Deploy phase 3 — close the staged-lock window once the appended rebuild has /// come up clean: drop the rollback ref, plant the `deployed/` tag, commit /// the staged lock. -async fn run_finalize_deploy(coord: &Arc, claim: &Claim) -> Result { - crate::actions::run_finalize_deploy(coord, deploy_approval_id(claim)?) +async fn run_finalize_deploy(coord: &Arc, approval_id: i64) -> Result { + crate::actions::run_finalize_deploy(coord, approval_id) .await .map(|()| NodeOutput::default()) } @@ -653,14 +643,13 @@ async fn run_finalize_deploy(coord: &Arc, claim: &Claim) -> Result< /// /// Takes the agent from the node payload so the tail can still compensate when /// the approval row is gone (deny race, purge). -async fn run_deploy_tail(coord: &Arc, claim: &Claim) -> Result { - crate::actions::run_deploy_tail( - coord, - Some(claim.dag_id), - claim.kind.agent(), - deploy_approval_id(claim)?, - ) - .await; +async fn run_deploy_tail( + coord: &Arc, + claim: &Claim, + approval_id: i64, +) -> Result { + crate::actions::run_deploy_tail(coord, Some(claim.dag_id), claim.kind.agent(), approval_id) + .await; Ok(NodeOutput::default()) } diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 2af5c84e..7842a905 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -70,7 +70,6 @@ pub struct Claim { /// The agent this node targets (its own, not a DAG-level field). Empty for /// the agentless [`NodeKind::MetaLock`] + [`NodeKind::Dag`] container nodes. pub agent: String, - pub approval_id: Option, pub inputs: Vec, /// Transient pill kind for the lease window (from the spec). Whether the /// pill is currently shown is derived from live lease ownership @@ -94,7 +93,6 @@ struct DagMeta { source: Source, reason: String, transient: Option, - approval_id: Option, inputs: Vec, created_at: i64, } @@ -220,7 +218,6 @@ impl JobQueue { source: spec.source, reason: spec.reason, transient: spec.transient, - approval_id: spec.approval_id, inputs: spec.inputs, created_at: now_unix(), }, @@ -307,7 +304,6 @@ impl JobQueue { node_id: id, kind, agent, - approval_id: meta.approval_id, inputs: meta.inputs, transient: meta.transient, }); @@ -485,7 +481,6 @@ impl QueueInner { source, reason, transient, - approval_id, inputs, created_at, } = &self.sched.graph().node(container)?.payload @@ -496,7 +491,6 @@ impl QueueInner { source: *source, reason: reason.clone(), transient: *transient, - approval_id: *approval_id, inputs: inputs.clone(), created_at: *created_at, }) @@ -547,13 +541,14 @@ impl QueueInner { Dep::Resource { .. } => None, }) .collect(); - // Non-derivable per-node payload rides the node that owns it. For a - // deploy that's the subtree root: the phases below it are ordinary - // nodes, and hanging the approval link off all four would render the - // same card four times. - let approval_id = matches!(node.payload, NodeKind::DeployWindow { .. }) - .then_some(meta.approval_id) - .flatten(); + // Non-derivable per-node payload rides the node that owns it. Every + // deploy phase carries the approval id, but only the subtree root + // projects it onto the wire — hanging the approval link off all of + // them would render the same card once per phase. + let approval_id = match &node.payload { + NodeKind::DeployWindow { approval_id, .. } => Some(*approval_id), + _ => None, + }; let inputs = if matches!(node.payload, NodeKind::MetaLock { .. }) { meta.inputs.clone() } else { diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index dcddd2f4..73b14a85 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -154,13 +154,17 @@ pub enum NodeKind { /// Cheap, too: the window has to span the container build regardless (see /// [`NodeKind::DeployApply`]), so nothing is over-serialised by hoisting /// the slot and the lease up alongside it. - DeployWindow { agent: String }, + /// + /// Carries the approval row every phase below it re-reads, like each of + /// those phases does — the id is the node's own payload, not something a + /// DAG-level catch-all hands down. + DeployWindow { agent: String, approval_id: i64 }, /// Deploy phase 1 — **verify only, mutates nothing.** Drift-gate the /// approval's PR head, fetch it into the applied repo, and eval-verify the /// merge head. Any failure here aborts the deploy with the forge state /// untouched, so it is safely retryable and cancel-safe: nothing downstream /// has happened yet. - MergeVerify { agent: String }, + MergeVerify { agent: String, approval_id: i64 }, /// Deploy phase 2 — the irreversible fast-forward plus the *opening* half of /// the two-phase meta deploy: park the rollback ref, ff-merge the reviewed /// head to `main` via the forge API, ff `applied/main`, and @@ -174,7 +178,7 @@ pub enum NodeKind { /// staged-lock window is likewise its own node /// ([`NodeKind::FinalizeDeploy`]), and the compensation path is /// [`NodeKind::DeployTail`]. - DeployApply { agent: String }, + DeployApply { agent: String, approval_id: i64 }, /// Deploy phase 3 — close the two-phase meta deploy once the rebuild /// subgraph under [`NodeKind::DeployApply`] has come up clean: drop the /// rollback ref, plant the `deployed/` tag, commit the staged @@ -190,7 +194,7 @@ pub enum NodeKind { /// The trailing `meta::finalize_deploy` stays warn-only: by then the /// container already runs the new config, and an uncommitted staged lock is /// something the operator can commit by hand. - FinalizeDeploy { agent: String }, + FinalizeDeploy { agent: String, approval_id: i64 }, /// Deploy compensation **and bookkeeping** tail — `AfterAny` /// [`NodeKind::DeployApply`], so it runs on success, failure, and cancel /// alike, in the same spirit as the rebuild template's tail `Reconcile` @@ -215,7 +219,7 @@ pub enum NodeKind { /// this node a no-op. Parking it in git rather than in a node payload also /// means it survives a `hive-c0re` restart mid-deploy, which an in-memory /// queue does not. - DeployTail { agent: String }, + DeployTail { agent: String, approval_id: i64 }, /// Tail node of an approval-carrying DAG (spawn / opaque deploy / config-PR /// merge): resolve the approval row from how the work actually ended. /// @@ -277,7 +281,6 @@ pub enum NodeKind { source: Source, reason: String, transient: Option, - approval_id: Option, inputs: Vec, created_at: i64, }, @@ -336,11 +339,11 @@ impl NodeKind { | NodeKind::Drain { agent } | NodeKind::WriteDropin { agent } | NodeKind::WritePermFile { agent, .. } - | NodeKind::DeployWindow { agent } - | NodeKind::MergeVerify { agent } - | NodeKind::DeployApply { agent } - | NodeKind::FinalizeDeploy { agent } - | NodeKind::DeployTail { agent } + | NodeKind::DeployWindow { agent, .. } + | NodeKind::MergeVerify { agent, .. } + | NodeKind::DeployApply { agent, .. } + | NodeKind::FinalizeDeploy { agent, .. } + | NodeKind::DeployTail { agent, .. } | NodeKind::EmitRebuilt { agent, .. } | NodeKind::SetWanted { agent, .. } => agent, NodeKind::MetaLock { .. } @@ -456,13 +459,6 @@ pub struct DagSpec { pub source: Source, /// Free-form "why". pub reason: String, - /// The approval row this DAG belongs to, for display + the `approval_id` on - /// every [`Claim`]. The *resolving* of it rides the - /// [`NodeKind::ResolveApproval`] tail node instead — this field does not - /// drive it. - /// - /// [`Claim`]: super::Claim - pub approval_id: Option, /// Meta-update only: the inputs to bump. Display copy lives on the DAG. pub inputs: Vec, /// Dashboard transient pill (and crash-watch suppression) held for diff --git a/hive-c0re/src/job_queue/submit.rs b/hive-c0re/src/job_queue/submit.rs index 528f3bf6..8f0316ec 100644 --- a/hive-c0re/src/job_queue/submit.rs +++ b/hive-c0re/src/job_queue/submit.rs @@ -207,7 +207,6 @@ fn power_dag( DagSpec { source, reason, - approval_id: None, inputs: Vec::new(), transient: Some(transient), nodes, diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 2245bd23..e48e3d34 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -305,7 +305,7 @@ pub(crate) fn rebuild_nodes(agent: &str, opts: RebuildOpts, base: u64) -> Vec Vec { +pub(crate) fn deploy_rebuild_nodes(agent: &str, approval_id: i64) -> Vec { let mut nodes = rebuild_nodes( agent, RebuildOpts { @@ -318,6 +318,7 @@ pub(crate) fn deploy_rebuild_nodes(agent: &str) -> Vec { nodes.push(node( NodeKind::FinalizeDeploy { agent: agent.to_owned(), + approval_id, }, vec![ Dep { @@ -366,7 +367,6 @@ pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> Dag DagSpec { source, reason, - approval_id: None, inputs: Vec::new(), transient: Some(TransientKind::Rebuilding), nodes, @@ -403,16 +403,38 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec DagSpec { source: Source::Approval, reason, - approval_id: Some(approval_id), inputs: Vec::new(), transient: Some(TransientKind::Rebuilding), nodes: vec![ - node(NodeKind::DeployWindow { agent: a() }, Vec::new()), - child(0, NodeKind::MergeVerify { agent: a() }, Vec::new()), - child(0, NodeKind::DeployApply { agent: a() }, after_ok(1)), + node( + NodeKind::DeployWindow { + agent: a(), + approval_id, + }, + Vec::new(), + ), child( 0, - NodeKind::DeployTail { agent: a() }, + NodeKind::MergeVerify { + agent: a(), + approval_id, + }, + Vec::new(), + ), + child( + 0, + NodeKind::DeployApply { + agent: a(), + approval_id, + }, + after_ok(1), + ), + child( + 0, + NodeKind::DeployTail { + agent: a(), + approval_id, + }, vec![Dep { on: 2, when: DepWhen::AFTER_ANY, @@ -440,7 +462,6 @@ pub fn reconcile_only( DagSpec { source, reason, - approval_id: None, inputs: Vec::new(), transient, nodes: vec![node( @@ -467,7 +488,6 @@ pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec { DagSpec { source: Source::Approval, reason, - approval_id: Some(approval_id), inputs: Vec::new(), transient: Some(TransientKind::Spawning), nodes: { @@ -513,7 +533,6 @@ pub fn perm_change(agent: &str, source: Source, reason: String, payload: PermPay DagSpec { source, reason, - approval_id: None, inputs: Vec::new(), transient: Some(TransientKind::Rebuilding), nodes, @@ -553,7 +572,6 @@ pub fn meta_update( DagSpec { source, reason, - approval_id, inputs, transient: Some(TransientKind::Rebuilding), nodes, @@ -577,7 +595,6 @@ pub fn reparent( DagSpec { source, reason, - approval_id: None, inputs: Vec::new(), transient: None, nodes: vec![node(NodeKind::Reparent { moves }, Vec::new())], diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 5832a583..a8a33dad 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -246,7 +246,6 @@ fn graceful_rebuild_chain_drains_before_stopping() { let spec = DagSpec { source: Source::AutoUpdate, reason: "sweep".to_owned(), - approval_id: None, inputs: Vec::new(), transient: None, nodes: templates::rebuild_nodes( @@ -725,7 +724,6 @@ fn append_subgraph_roots_on_emitter_and_rebases_local_deps() { let spec = DagSpec { source: Source::AutoUpdate, reason: "sweep".to_owned(), - approval_id: None, inputs: Vec::new(), transient: None, nodes: vec![NodeSpec { @@ -1214,7 +1212,7 @@ fn deploy_apply_grows_rebuild_subgraph_and_finalizes_after_it() { // gate immediately and letting the deploy "finish" before it had built. let grown = q.append_subgraph( id, - &templates::deploy_rebuild_nodes("agent-a"), + &templates::deploy_rebuild_nodes("agent-a", 11), apply.node_id, ); assert!(!grown.is_empty(), "subgraph grafted onto the apply node"); @@ -1272,7 +1270,7 @@ fn deploy_dag_skips_finalize_but_still_tails_a_failed_graft() { let apply = claim_one(&q); q.append_subgraph( id, - &templates::deploy_rebuild_nodes("agent-a"), + &templates::deploy_rebuild_nodes("agent-a", 13), apply.node_id, ); q.complete_node(apply.node_id, Ok(())); @@ -1470,7 +1468,6 @@ fn spawn_shape_provision_create_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)); q.complete_node(c.node_id, Ok(())); } settle_approval_tail(&q, 7, TerminalState::Done); diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 90d3e555..cbf223c0 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -347,7 +347,6 @@ fn submit_boot_tree( // land; the boot DAG as a whole has no terminal side effect, so no tail. source: Source::AutoUpdate, reason, - approval_id: None, inputs: Vec::new(), // Rebuilding when the sweep will grow rebuild subgraphs (per-agent // crash-watch suppression during their Swap, applied at claim time);