diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index d0ba1157..e69c2bd5 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -186,11 +186,64 @@ pub enum NodeKind { /// (commit fused under `META_LOCK`). The payload rides this node — the only /// consumer — rather than the generic DAG container. WritePermFile { agent: String, payload: PermPayload }, - /// Opaque approval deploy pipeline (`MergeConfigPr`): the two-phase - /// prepare/finalize/abort meta deploy stays inside `actions.rs` in v1 — - /// deliberately not - /// modeled as scheduler nodes (see the design doc §9). - ApprovalDeploy { agent: String }, + /// Group root of the approval-deploy (`MergeConfigPr`) subtree, and the + /// node that **owns the deploy window**. It performs no work of its own — + /// it exists so the resources it declares (the global + /// [`Resource::MetaWindow`](super::resource::Resource::MetaWindow), the + /// agent lease, a build slot) are held continuously across every child + /// phase, which a per-node acquisition could not guarantee. + /// + /// All three resources are declared *here*, on one node, on purpose. The + /// queue acquires a node's resources atomically (all-or-nothing), so a + /// single multi-resource root can never hold one and block on another — + /// whereas letting a child take the build slot while its parent held the + /// meta window would introduce exactly that pattern, and with it a + /// lock-ordering argument that has to be re-verified on every future edit. + /// 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 }, + /// 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 }, + /// Deploy phase 2 — everything from the irreversible fast-forward onward: + /// ff-merge the reviewed head to `main` via the forge API, two-phase meta + /// `prepare_deploy`, the container rebuild, then on success the + /// `deployed/` tag + `finalize_deploy`. + /// + /// Still one node in this increment: splitting the tail into + /// `FfMain`/`PrepareDeploy`/rebuild/`FinalizeDeploy` children is the next + /// one. What *is* already split out is the compensation path — see + /// [`NodeKind::DeployTail`]. + DeployApply { agent: String }, + /// 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` + /// ("always runs, decides internally"). It: + /// 1. compensates a merge that landed but was never finalized — roll `main` + /// back, reset the tree, `meta::abort_deploy`, plant `failed/`; + /// 2. mirrors whichever deploy tag got planted to the forge config repo + /// (`forge::push_config`), always, best-effort; + /// 3. posts the failing build log back onto the config PR when the deploy + /// failed, so the manager sees the rejection without leaving the forge. + /// + /// Steps 2 and 3 are why this is `DeployTail` and not `AbortDeploy`: it has + /// work to do on the success path too, and a node name that claims + /// otherwise would be a lie on the dashboard. + /// + /// For (1) it needs no knowledge of how far the deploy got, because that state is + /// parked in the applied repo rather than passed between nodes: + /// `DeployApply` writes the pre-merge `main` sha to + /// `refs/hyperhive/rollback/` before the fast-forward and + /// deletes it once the deploy has been finalized. So the ref existing *is* + /// the "a merge landed and was not finalized" signal, and its absence makes + /// 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 }, /// 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 @@ -243,7 +296,10 @@ impl NodeKind { NodeKind::Drain { .. } => "drain", NodeKind::WriteDropin { .. } => "write_dropin", NodeKind::WritePermFile { .. } => "write_perm_file", - NodeKind::ApprovalDeploy { .. } => "approval_deploy", + NodeKind::DeployWindow { .. } => "deploy_window", + NodeKind::MergeVerify { .. } => "merge_verify", + NodeKind::DeployApply { .. } => "deploy_apply", + NodeKind::DeployTail { .. } => "deploy_tail", NodeKind::SetWanted { .. } => "set_wanted", NodeKind::Dag { .. } => "dag", } @@ -269,7 +325,10 @@ impl NodeKind { | NodeKind::Drain { agent } | NodeKind::WriteDropin { agent } | NodeKind::WritePermFile { agent, .. } - | NodeKind::ApprovalDeploy { agent } + | NodeKind::DeployWindow { agent } + | NodeKind::MergeVerify { agent } + | NodeKind::DeployApply { agent } + | NodeKind::DeployTail { agent } | NodeKind::SetWanted { agent, .. } => agent, NodeKind::MetaLock { .. } | NodeKind::Dag { .. } => "", } @@ -284,7 +343,7 @@ impl NodeKind { | NodeKind::Swap { .. } | NodeKind::Create { .. } | NodeKind::MetaLock { .. } - | NodeKind::ApprovalDeploy { .. } + | NodeKind::DeployWindow { .. } ) } @@ -306,7 +365,7 @@ impl NodeKind { | NodeKind::Signal { .. } | NodeKind::Drain { .. } | NodeKind::WriteDropin { .. } - | NodeKind::ApprovalDeploy { .. } + | NodeKind::DeployWindow { .. } | NodeKind::SetWanted { .. } ) } @@ -337,7 +396,7 @@ impl NodeKind { | NodeKind::Provision { .. } | NodeKind::MetaLock { .. } | NodeKind::WritePermFile { .. } - | NodeKind::ApprovalDeploy { .. } + | NodeKind::DeployWindow { .. } ) } } diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index f2b3747c..0fd03da8 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -140,10 +140,24 @@ pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> Dag } } -/// Approval-driven deploy (`MergeConfigPr`): the whole two-phase pipeline -/// stays one opaque node in v1 (design doc §9) — wire-visible as a `rebuild` -/// card like today. +/// Approval-driven deploy (`MergeConfigPr`) as a phase subtree rather than the +/// single opaque node it used to be. Structure: +/// - `DeployWindow` (0, **root**): the resource holder — global meta window, +/// agent lease, build slot — held across every child below. No work of its +/// own; it reaches `Finishing` immediately and the children run inside it. +/// - `MergeVerify` (1, child): drift-gate + fetch + eval-verify. Mutates +/// nothing, so a failure here cancel-cascades its siblings with the forge and +/// the applied repo exactly as they were. +/// - `DeployApply` (2, child, `AfterOk` `MergeVerify`): the irreversible half — +/// ff-merge, `prepare_deploy`, rebuild, `finalize_deploy`. +/// - `DeployTail` (3, child, `AfterAny` `DeployApply`): the compensation + +/// bookkeeping tail — rollback when a merge landed unfinalized, forge tag +/// mirror, PR failure comment (see [`NodeKind::DeployTail`]). +/// +/// The window still spans the container build, as it must: `prepare_deploy` +/// leaves `flake.lock` staged-uncommitted for the build's whole duration. pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec { + let a = || agent.to_owned(); DagSpec { template: Template::Rebuild, source: Source::Approval, @@ -151,12 +165,19 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec approval_id: Some(approval_id), inputs: Vec::new(), transient: Some(TransientKind::Rebuilding), - nodes: vec![node( - NodeKind::ApprovalDeploy { - agent: agent.to_owned(), - }, - Vec::new(), - )], + 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)), + child( + 0, + NodeKind::DeployTail { agent: a() }, + vec![Dep { + on: 2, + when: DepWhen::AfterAny, + }], + ), + ], } }