job_queue: split ApprovalDeploy into a four-node deploy subtree

A config-PR deploy was one opaque node that fetched, verified, merged,
built and compensated. That shape made three things impossible: the
nix-heavy phases could not take the meta window without the cheap ones
holding it too, a crash mid-build left no node to run the rollback, and
the dashboard could only ever show "deploying" for the whole thing.

Replace it with a `DeployWindow` group root over `MergeVerify ->
DeployApply` (AfterOk) plus a `DeployTail` hanging off the apply with
AfterAny, so the tail runs whether the apply succeeded, failed, or was
cancel-cascaded by a failing verify.
This commit is contained in:
atlas 2026-07-25 21:34:22 +02:00 committed by mara
commit bdf15168db
2 changed files with 99 additions and 19 deletions

View file

@ -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/<id>` 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/<id>`;
/// 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/<approval-id>` 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 { .. }
)
}
}

View file

@ -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,
}],
),
],
}
}