config PRs: remove the hive's config-PR webhook, poll and core merge
An operator's merge on the forge deploys a config PR through
swarm-controller's DeployRequest{rev}. The hive-side path that queued a
MergeConfigPr approval and merged the PR as `core` goes:
- the `/webhook/config-pr` receiver, its HMAC secret, the WebhookRegister
boot node and the org-hook registration; the hive vhost's `/webhook/`
location
- the 5-minute config-PR poll
- ApprovalKind::MergeConfigPr, its dashboard card, and the deploy DAG it
drove (DeployWindow, MergeVerify, DeployApply, FinalizeDeploy,
DeployTail), with verify_commit, the two-phase meta deploy, rollback
refs, the PR-failure comment and forge/pr_merge.rs
- `fetched_sha`, `sha_short`/`pr_number` on approval events, and
`sha`/`tag` on HelperEvent::ApprovalResolved: only the merge path set
them
`config_repo`, `merged_pr_for_commit` and `post_pr_comment` move to
forge/pr_comment.rs for the merged-rev deploy's refusal comment.
Approvals v5 drops stored `merge_config_pr` rows; a test reopens a v4
database holding them.
Closes #4850
This commit is contained in:
parent
a5ea015bc6
commit
efbfec6d01
39 changed files with 286 additions and 3199 deletions
|
|
@ -121,24 +121,13 @@ pub(super) async fn run_node(
|
|||
// takes it directly instead of re-matching the kind behind a `bail!`
|
||||
// that could never fire.
|
||||
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
|
||||
NodeKind::MergeVerify { approval_id, .. } => {
|
||||
run_merge_verify(coord, *approval_id, id).await
|
||||
}
|
||||
NodeKind::DeployApply { approval_id, .. } => {
|
||||
run_deploy_apply(coord, *approval_id, id).await.map(|()| {
|
||||
super::templates::deploy_rebuild_nodes(&builder, agent, *approval_id);
|
||||
})
|
||||
}
|
||||
NodeKind::FinalizeDeploy { approval_id, .. } => {
|
||||
run_finalize_deploy(coord, *approval_id).await
|
||||
}
|
||||
NodeKind::DeployTail { approval_id, .. } => {
|
||||
run_deploy_tail(coord, coord.job_queue.root_of(id), agent, *approval_id).await
|
||||
}
|
||||
NodeKind::ResolveApproval {
|
||||
approval_id,
|
||||
outcome,
|
||||
} => run_resolve_approval(coord, coord.job_queue.root_of(id), *approval_id, *outcome).await,
|
||||
} => {
|
||||
run_resolve_approval(coord, coord.job_queue.root_of(id), *approval_id, *outcome);
|
||||
Ok(())
|
||||
}
|
||||
NodeKind::EmitRebuilt { ok, .. } => {
|
||||
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
|
||||
Ok(())
|
||||
|
|
@ -147,10 +136,9 @@ pub(super) async fn run_node(
|
|||
// Braces carry no work of their own; completing one lets it reach
|
||||
// `Finishing` so the nodes under it start. What they declare stays held
|
||||
// until their whole subtree settles.
|
||||
NodeKind::DeployWindow { .. } | NodeKind::AgentWindow { .. } => Ok(()),
|
||||
NodeKind::AgentWindow { .. } => Ok(()),
|
||||
NodeKind::ForgeSweep => run_forge_sweep().await,
|
||||
NodeKind::MatrixSweep => run_matrix_sweep().await,
|
||||
NodeKind::WebhookRegister => run_webhook_register().await,
|
||||
NodeKind::KnowledgePull => run_knowledge_pull(coord).await,
|
||||
NodeKind::WantedPull => run_wanted_pull(coord).await,
|
||||
};
|
||||
|
|
@ -218,34 +206,6 @@ fn matrix_sweep_banner(ctx: sweep_health::SweepFailure) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// Boot-time Forgejo webhook management as a DAG node — see
|
||||
/// [`NodeKind::WebhookRegister`]. Mirrors the guard chain the
|
||||
/// `tokio::spawn` block it replaced used: no-op (not an error) when the
|
||||
/// HMAC secret, core token, or hive domain aren't available yet.
|
||||
///
|
||||
/// Registers the config-PR hook only. The knowledge hook is the swarm
|
||||
/// controller's, which addresses an event to each hive over the queue.
|
||||
async fn run_webhook_register() -> Result<()> {
|
||||
let Ok(webhook_secret) = crate::webhook_secret::load_or_generate() else {
|
||||
tracing::debug!("webhook secret unavailable; skipping hook registration");
|
||||
return Ok(());
|
||||
};
|
||||
let Some(token) = crate::forge::core_token() else {
|
||||
return Ok(());
|
||||
};
|
||||
let domain = std::env::var("HYPERHIVE_HIVE_DOMAIN")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty());
|
||||
let Some(domain) = domain else {
|
||||
tracing::debug!("HYPERHIVE_HIVE_DOMAIN unset; skipping webhook registration");
|
||||
return Ok(());
|
||||
};
|
||||
if let Err(e) = crate::forge::ensure_config_pr_webhook(&token, &domain, &webhook_secret).await {
|
||||
tracing::warn!(error = ?e, "forge: ensure_config_pr_webhook failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/knowledge` pull as a DAG node — see [`NodeKind::KnowledgePull`]. Every
|
||||
/// pass reports here, whoever submitted it: as the node's own error on the
|
||||
/// dashboard, and into the debounced banner.
|
||||
|
|
@ -313,17 +273,16 @@ async fn run_wanted_pull(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
/// answer. Best-effort — a resolution failure is logged inside
|
||||
/// [`crate::actions::resolve_approval_dag`], never surfaced as a node failure,
|
||||
/// since the work already happened and failing the tail would only misreport it.
|
||||
async fn run_resolve_approval(
|
||||
fn run_resolve_approval(
|
||||
coord: &Arc<Coordinator>,
|
||||
dag_id: Option<u64>,
|
||||
approval_id: i64,
|
||||
outcome: TerminalState,
|
||||
) -> Result<()> {
|
||||
) {
|
||||
let reason = (outcome == TerminalState::Failed)
|
||||
.then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag)))
|
||||
.flatten();
|
||||
crate::actions::resolve_approval_dag(coord, approval_id, outcome, reason.as_deref()).await;
|
||||
Ok(())
|
||||
crate::actions::resolve_approval_dag(coord, approval_id, outcome, reason.as_deref());
|
||||
}
|
||||
|
||||
/// Rerender the meta flake from whatever containers still exist on disk.
|
||||
|
|
@ -629,9 +588,8 @@ async fn run_meta_lock(
|
|||
// against whatever `applied/<name>` already happened to be locked to —
|
||||
// normally current, but silently stale forever if a past deploy failed
|
||||
// and nothing since retried it. Relocks against each agent's *declared*
|
||||
// input (the forge URL, not the local `applied/<name>` mirror
|
||||
// `prepare_deploy` uses for a reviewed deploy) — this path has no PR to
|
||||
// review, so there's nothing to gate. One combined call, not per-agent:
|
||||
// input (the forge URL, not the local `applied/<name>` mirror a rebuild
|
||||
// relocks against) — this path has nothing to gate. One combined call, not per-agent:
|
||||
// simpler, at the cost of one unreachable/broken agent repo failing the
|
||||
// whole cascade relock rather than just that agent.
|
||||
if !cascade.is_empty() {
|
||||
|
|
@ -890,50 +848,6 @@ async fn run_write_perm_file(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<Coordinator>, approval_id: i64, id: NodeId) -> Result<()> {
|
||||
crate::actions::run_deploy_merge_verify(coord, approval_id, Some(id.get())).await
|
||||
}
|
||||
|
||||
/// Deploy phase 2 — the irreversible half: ff-merge, then phase 1 of the
|
||||
/// two-phase meta deploy.
|
||||
///
|
||||
/// On success it grows the ordinary rebuild subgraph (plus its closing
|
||||
/// `FinalizeDeploy`) into this DAG rooted on *this* node — which is what puts
|
||||
/// the appended nodes inside the `DeployWindow`'s subtree, so the `MetaWindow`
|
||||
/// their `MetaSync` declares is re-entered rather than deadlocked against the
|
||||
/// ancestor already holding it. On failure nothing is appended and the tail
|
||||
/// compensates, exactly as before.
|
||||
async fn run_deploy_apply(coord: &Arc<Coordinator>, approval_id: i64, id: NodeId) -> Result<()> {
|
||||
crate::actions::run_deploy_apply(coord, approval_id, Some(id.get())).await
|
||||
}
|
||||
|
||||
/// Deploy phase 3 — close the staged-lock window once the appended rebuild has
|
||||
/// come up clean: drop the rollback ref, plant the `deployed/<id>` tag, commit
|
||||
/// the staged lock.
|
||||
async fn run_finalize_deploy(coord: &Arc<Coordinator>, approval_id: i64) -> Result<()> {
|
||||
crate::actions::run_finalize_deploy(coord, approval_id).await
|
||||
}
|
||||
|
||||
/// Deploy compensation + bookkeeping tail. `AfterAny` the apply node, so it
|
||||
/// runs on every outcome; it is deliberately infallible (see
|
||||
/// [`crate::actions::run_deploy_tail`]) — a failing tail must not flip an
|
||||
/// otherwise-successful deploy's DAG state.
|
||||
///
|
||||
/// 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<Coordinator>,
|
||||
dag_id: Option<u64>,
|
||||
agent: &str,
|
||||
approval_id: i64,
|
||||
) -> Result<()> {
|
||||
crate::actions::run_deploy_tail(coord, dag_id, agent, approval_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute which agents a `nix flake update <inputs>` on the meta
|
||||
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
|
||||
/// `inputs` or any input under `hyperhive` → every container;
|
||||
|
|
|
|||
|
|
@ -102,28 +102,11 @@ pub enum NodeKind {
|
|||
WriteDropin { agent: String },
|
||||
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`.
|
||||
WritePermFile { agent: String, payload: PermPayload },
|
||||
/// Group root of the approval-deploy (`MergeConfigPr`) subtree — the
|
||||
/// **brace** that owns the deploy window. See _Braces_ and _Approvals_.
|
||||
DeployWindow { agent: String, approval_id: i64 },
|
||||
/// Group root of a rebuild subtree — the **brace**, scoped to one
|
||||
/// agent. Why braces exist and what they cost: _Braces_.
|
||||
AgentWindow { agent: String },
|
||||
/// Deploy phase 1 — **verify only, mutates nothing.**
|
||||
MergeVerify { agent: String, approval_id: i64 },
|
||||
/// Deploy phase 2 — the irreversible fast-forward plus the *opening*
|
||||
/// half of the two-phase meta deploy. Does **not** build the container
|
||||
/// itself; grows the ordinary rebuild subgraph in as its own children
|
||||
/// (see the rebuild-path section's approval-deploy paragraph).
|
||||
DeployApply { agent: String, approval_id: i64 },
|
||||
/// Deploy phase 3 — closes the two-phase meta deploy once
|
||||
/// [`NodeKind::DeployApply`]'s rebuild subgraph comes up clean.
|
||||
FinalizeDeploy { agent: String, approval_id: i64 },
|
||||
/// Deploy compensation **and bookkeeping** tail. See the `DeployTail`
|
||||
/// node-inventory row for its three responsibilities and why it isn't
|
||||
/// named `AbortDeploy`.
|
||||
DeployTail { agent: String, approval_id: i64 },
|
||||
/// Tail node of an approval-carrying DAG (opaque deploy /
|
||||
/// config-PR merge): resolve the approval row from how the work ended.
|
||||
/// Tail node of an approval-carrying DAG (a meta-inputs update): resolve
|
||||
/// the approval row from how the work ended.
|
||||
ResolveApproval {
|
||||
approval_id: i64,
|
||||
/// Which outcome this node reports. A template emits **one per
|
||||
|
|
@ -147,8 +130,6 @@ pub enum NodeKind {
|
|||
/// Matrix user/space sweep (`matrix::ensure_all`): at boot and every 30
|
||||
/// minutes. Agentless.
|
||||
MatrixSweep,
|
||||
/// One-shot boot-time Forgejo webhook registration. Agentless.
|
||||
WebhookRegister,
|
||||
/// `/knowledge` pull (`knowledge::pull`): at boot, on the swarm
|
||||
/// knowledge-changed event, and hourly. Agentless.
|
||||
KnowledgePull,
|
||||
|
|
@ -174,9 +155,6 @@ impl hive_jobq_wire::WireNode for NodeKind {
|
|||
if !agent.is_empty() {
|
||||
data.insert("agent".to_owned(), agent.into());
|
||||
}
|
||||
if let NodeKind::DeployWindow { approval_id, .. } = self {
|
||||
data.insert("approval_id".to_owned(), (*approval_id).into());
|
||||
}
|
||||
if let NodeKind::MetaLock { inputs, .. } = self
|
||||
&& !inputs.is_empty()
|
||||
{
|
||||
|
|
@ -221,19 +199,13 @@ impl NodeKind {
|
|||
| NodeKind::PauseDrain { agent }
|
||||
| NodeKind::WriteDropin { agent }
|
||||
| NodeKind::WritePermFile { agent, .. }
|
||||
| NodeKind::DeployWindow { agent, .. }
|
||||
| NodeKind::AgentWindow { agent }
|
||||
| NodeKind::MergeVerify { agent, .. }
|
||||
| NodeKind::DeployApply { agent, .. }
|
||||
| NodeKind::FinalizeDeploy { agent, .. }
|
||||
| NodeKind::DeployTail { agent, .. }
|
||||
| NodeKind::EmitRebuilt { agent, .. }
|
||||
| NodeKind::SetWanted { agent, .. } => agent,
|
||||
NodeKind::MetaLock { .. }
|
||||
| NodeKind::ResolveApproval { .. }
|
||||
| NodeKind::ForgeSweep
|
||||
| NodeKind::MatrixSweep
|
||||
| NodeKind::WebhookRegister
|
||||
| NodeKind::KnowledgePull
|
||||
| NodeKind::WantedPull => "",
|
||||
}
|
||||
|
|
@ -280,8 +252,8 @@ impl NodeKind {
|
|||
// bug, and a `true` here would suppress the alert that says so.
|
||||
// - `Reconcile` is a planner; it fans out `Start` / `Stop`, which carry
|
||||
// their own answer.
|
||||
// - `DeployWindow` brackets a deploy without itself stopping anything.
|
||||
// - `AgentWindow` likewise, though it *parents* nodes that answer
|
||||
// - `AgentWindow` brackets a rebuild without itself stopping anything,
|
||||
// though it *parents* nodes that answer
|
||||
// `true`. This is per-node, not per-subtree, and each of those
|
||||
// children reports for itself — so `true` here would only widen
|
||||
// suppression over the build and tail, where a vanished container is
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//! that already held the resource could get away with declaring nothing.
|
||||
//!
|
||||
//! **The one sanctioned exception is a brace** — a pure-resource-holder root
|
||||
//! ([`NodeKind::AgentWindow`], [`NodeKind::DeployWindow`]) declaring for a
|
||||
//! ([`NodeKind::AgentWindow`]) declaring for a
|
||||
//! coordinated subtree whose members then declare nothing. See
|
||||
//! `docs/scheduler/coordinator.md`, _Braces_ — which also carries the per-operation DAG
|
||||
//! shapes, so they are not restated here.
|
||||
|
|
@ -314,42 +314,6 @@ pub(crate) fn graceful_rebuild_nodes<'a>(
|
|||
rebuild_subtree(builder, agent, relock, true, after)
|
||||
}
|
||||
|
||||
/// The rebuild subgraph a [`NodeKind::DeployApply`] grows into its own DAG once
|
||||
/// the merge has landed and `prepare_deploy` has staged the lock, plus the
|
||||
/// [`NodeKind::FinalizeDeploy`] that closes the window behind it.
|
||||
///
|
||||
/// `relock = false` is the whole reason this composes: `prepare_deploy` already
|
||||
/// relocked and staged `flake.lock`, so the appended `MetaSync` must do the dir
|
||||
/// prep + `sync_agents` *without* re-locking over it.
|
||||
///
|
||||
/// `FinalizeDeploy` waits on **two** roots, which together reproduce the gate
|
||||
/// the old fused node had around its inline `rebuild_no_meta` call:
|
||||
/// - `AfterOk` `Prebuild` — a parent's state is its roll-up, so this is `Done`
|
||||
/// only once `StopForUpdate` → `Swap` → `RebuildBookkeeping` all are (a failed *or*
|
||||
/// cancelled child rolls the parent up `Failed`). That's the old
|
||||
/// `build_result`.
|
||||
/// - `AfterOk` `Reconcile` — the old call passed `deferred_start = false` on
|
||||
/// purpose: the container had to come back up *before* the deploy was
|
||||
/// finalized. `Reconcile` alone would not do, being `AfterAny` — it reaches
|
||||
/// `Done` even after a failed `Swap`.
|
||||
///
|
||||
/// Declared into a **running** `DeployApply`'s own builder, not submitted: the
|
||||
/// roots below become children of that node, which puts them inside the
|
||||
/// `DeployWindow`'s subtree — so the `MetaWindow` this subgraph's `MetaSync`
|
||||
/// and `FinalizeDeploy` declare is re-entered from the ancestor already holding
|
||||
/// it rather than deadlocking against it.
|
||||
pub(crate) fn deploy_rebuild_nodes(builder: &JobBuilder, agent: &str, approval_id: i64) {
|
||||
let roots = rebuild_nodes(builder, agent, false, None);
|
||||
let _finalize = builder
|
||||
.node(NodeKind::FinalizeDeploy {
|
||||
agent: agent.to_owned(),
|
||||
approval_id,
|
||||
})
|
||||
.needs(Resource::MetaWindow)
|
||||
.after_ok(roots.agent_window)
|
||||
.after_ok(roots.reconcile);
|
||||
}
|
||||
|
||||
/// One uniform rebuild shape — no `was_running` branch. `StopForUpdate`
|
||||
/// noops when already down; the tail `Reconcile` auto-noops the start
|
||||
/// when `wanted = Offline` (a rebuild of a deliberately-stopped agent
|
||||
|
|
@ -375,71 +339,6 @@ pub fn rebuild(builder: &JobBuilder, agent: &str, relock: bool) -> Vec<hive_jobq
|
|||
]
|
||||
}
|
||||
|
||||
/// Approval-driven deploy (`MergeConfigPr`) as a phase subtree rather than the
|
||||
/// single opaque node it used to be. Structure:
|
||||
/// - `DeployWindow` (**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` (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` (child, `AfterOk` `MergeVerify`): the irreversible half —
|
||||
/// ff-merge + `prepare_deploy`. It doesn't rebuild inline; it grows
|
||||
/// [`deploy_rebuild_nodes`] into this DAG as its own children, so the build
|
||||
/// and the closing `FinalizeDeploy` are real nodes under the same window.
|
||||
/// - `DeployTail` (child, `AfterAny` `DeployApply`): the compensation +
|
||||
/// bookkeeping tail — rollback when a merge landed unfinalized, forge tag
|
||||
/// mirror, PR failure comment (see [`NodeKind::DeployTail`]).
|
||||
///
|
||||
/// - `ResolveApproval` (**root**, `AfterAny` `DeployWindow`): resolves the
|
||||
/// approval row. A root rather than another child, so it isn't inside the
|
||||
/// window's resource subtree — it runs once the window has released the meta
|
||||
/// window, lease and build slot. One edge suffices here: `DeployWindow` is the
|
||||
/// DAG's only other group-root, so its roll-up already *is* the whole
|
||||
/// pipeline's outcome.
|
||||
///
|
||||
/// 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(builder: &JobBuilder, agent: &str, approval_id: i64) {
|
||||
let a = || agent.to_owned();
|
||||
// The window is the widest holder in the tree: it brackets a nix
|
||||
// build (`BuildSlot`), takes the container down across the swap
|
||||
// (`Agent`), and serialises the meta mutation its subtree performs
|
||||
// (`MetaWindow`). All three are held for its whole subtree, which
|
||||
// is what lets the appended rebuild's `MetaSync` and the
|
||||
// `FinalizeDeploy` re-enter rather than contend.
|
||||
let window = builder
|
||||
.node(NodeKind::DeployWindow {
|
||||
agent: a(),
|
||||
approval_id,
|
||||
})
|
||||
.needs(Resource::BuildSlot)
|
||||
.needs(Resource::Agent(a()))
|
||||
.needs(Resource::MetaWindow);
|
||||
let verify = builder
|
||||
.node(NodeKind::MergeVerify {
|
||||
agent: a(),
|
||||
approval_id,
|
||||
})
|
||||
.part_of(window);
|
||||
let apply = builder
|
||||
.node(NodeKind::DeployApply {
|
||||
agent: a(),
|
||||
approval_id,
|
||||
})
|
||||
.part_of(window)
|
||||
.after_ok(verify);
|
||||
let _tail = builder
|
||||
.node(NodeKind::DeployTail {
|
||||
agent: a(),
|
||||
approval_id,
|
||||
})
|
||||
.part_of(window)
|
||||
.after_any(apply);
|
||||
|
||||
resolve_approval_tails(builder, approval_id, window);
|
||||
}
|
||||
|
||||
/// First deploy of an agent this hive has never seen, asked for by the swarm:
|
||||
/// `Provision` (proposed/applied repos, state subvolume, meta registration)
|
||||
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
|
||||
|
|
|
|||
|
|
@ -1203,19 +1203,15 @@ fn cancelled_power_op_runs_no_compensating_node() {
|
|||
fn cancelled_dag_still_runs_its_approval_tail() {
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::approval_deploy(builder, "agent-a", 7);
|
||||
templates::meta_update(builder, Vec::new(), Some(7));
|
||||
});
|
||||
// Found by kind, not returned: `approval_deploy` deliberately names
|
||||
// nothing, because nothing polls it — the approval row is how an operator
|
||||
// follows a deploy, so the template is fire-and-forget in production and a
|
||||
// test must not make it return an id it would otherwise have no use for.
|
||||
let window = node_of(&q, "deploy_window").get();
|
||||
assert!(q.cancel(window), "fully-queued dag cancels");
|
||||
// Found by kind, not returned: `meta_update` names nothing, because the
|
||||
// approval row is how an operator follows it.
|
||||
let lock = node_of(&q, "meta_lock").get();
|
||||
assert!(q.cancel(lock), "fully-queued dag cancels");
|
||||
// The `Cancelled` tail is the only node whose edge accepts a dropped
|
||||
// dependency, so it is the only one `cancel` spares — and *which* tail
|
||||
// survives is the whole assertion: the template emits one per outcome and
|
||||
// the spared one names how the approval row is about to be resolved.
|
||||
// Nothing computes it, so reading the survivor is reading the answer.
|
||||
// survives names how the approval row is about to be resolved.
|
||||
let spared = pending_payloads(&q);
|
||||
assert!(
|
||||
matches!(
|
||||
|
|
@ -1227,171 +1223,9 @@ fn cancelled_dag_still_runs_its_approval_tail() {
|
|||
),
|
||||
"only the cancelled-outcome tail is spared, got {spared:?}"
|
||||
);
|
||||
// ⚠️ `Cancelled`, and it reads terminal **while the spared tail is still
|
||||
// pending** — the one place this differs from the container era, where the
|
||||
// cancel landed on a node *above* the window and the window rolled up
|
||||
// `Finishing`. Here the operator cancels the window itself, so its own
|
||||
// state is `Cancelled` however its subtree is doing. Deliberately asserted
|
||||
// rather than routed around: the group's card goes terminal while a
|
||||
// bookkeeping node runs on. That is acceptable for the tail this test
|
||||
// protects (it resolves the approval row and nothing waits on it), and it
|
||||
// would not be for work an operator expects to still be watching.
|
||||
assert_eq!(state_of(&q, window), State::Cancelled);
|
||||
// An unrelated group landing in the same graph doesn't disturb this one's
|
||||
// state — a root rolls up its own subtree, not the graph.
|
||||
let _other = insert_named(&q, |builder| rebuild(builder, "agent-b"));
|
||||
assert_eq!(state_of(&q, window), State::Cancelled);
|
||||
assert_eq!(state_of(&q, lock), State::Cancelled);
|
||||
}
|
||||
|
||||
// ---- approval deploy subtree ----
|
||||
|
||||
/// The config-PR deploy is a subtree, not one opaque node. The
|
||||
/// resource-holding root completes immediately (its `Finishing` state is the
|
||||
/// parent gate that releases the children), then the phases run strictly in
|
||||
/// order — and the `AfterAny` tail still runs when the irreversible half fails,
|
||||
/// because it's the node that compensates for it.
|
||||
#[test]
|
||||
fn deploy_dag_runs_phases_in_order_and_tails_a_failed_apply() {
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::approval_deploy(builder, "agent-a", 7);
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![
|
||||
// The window is the group root and holds the meta window for the
|
||||
// whole subtree; the three phases are its sub-nodes.
|
||||
row("deploy_window", None, &[]),
|
||||
row("merge_verify", Some("deploy_window"), &[]),
|
||||
// Apply only on a clean verify — a failed verify cancel-cascades
|
||||
// it, which is what leaves the forge and the applied repo untouched.
|
||||
row(
|
||||
"deploy_apply",
|
||||
Some("deploy_window"),
|
||||
&[("merge_verify", "done")]
|
||||
),
|
||||
// The compensation tail accepts every terminal outcome of apply,
|
||||
// *including `skipped`* — which is the state apply lands in when
|
||||
// verify failed and it never ran. That one edge is the entire
|
||||
// "still tails a failed apply / a failed verify" behaviour, and it
|
||||
// is why two separate DAG-driving tests collapsed into this table.
|
||||
row(
|
||||
"deploy_tail",
|
||||
Some("deploy_window"),
|
||||
&[("deploy_apply", "done|failed|skipped")]
|
||||
),
|
||||
// One approval tail per outcome, gated on the window's roll-up.
|
||||
row("resolve_approval", None, &[("deploy_window", "done")]),
|
||||
row("resolve_approval", None, &[("deploy_window", "failed")]),
|
||||
row("resolve_approval", None, &[("deploy_window", "cancelled")]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The deploy's happy path: `DeployApply` does not build. It grows the ordinary
|
||||
/// rebuild chain into the live DAG under itself, and `FinalizeDeploy` — gated on
|
||||
/// that graft finishing — plants the deploy tag last.
|
||||
///
|
||||
/// The queue is built with **one** build slot on purpose. `DeployWindow` already
|
||||
/// holds that slot (and the meta window) for the whole subtree, so the grafted
|
||||
/// `Prebuild` can only ever claim by *re-entering* its ancestor's hold. If the
|
||||
/// graft were rooted anywhere outside `DeployWindow`'s subtree it would block on
|
||||
/// a resource its own DAG owns and deadlock — this test is what pins that down.
|
||||
#[test]
|
||||
fn deploy_apply_grows_rebuild_subgraph_and_finalizes_after_it() {
|
||||
// What `DeployApply` grows is `deploy_rebuild_nodes`' output, and that is a
|
||||
// pure declaration — so it is declared here directly rather than by running
|
||||
// a deploy far enough to graft it. **Reproducing the runtime path is not
|
||||
// needed to test what the runtime path declares.**
|
||||
//
|
||||
// The grafting mechanism itself is hive_jobq's and tested there: the work
|
||||
// lands under the emitter *before* it settles, and the emitter parks in
|
||||
// `Finishing` so a downstream `AfterAny` gate stays shut while the new
|
||||
// children run (`a_completing_node_grows_the_work_it_declared`,
|
||||
// `parent_parks_in_finishing_until_children_roll_up`).
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::deploy_rebuild_nodes(builder, "agent-a", 11);
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![
|
||||
row("meta_sync", None, &[]),
|
||||
row("agent_window", None, &[("meta_sync", "done")]),
|
||||
row("prebuild", Some("agent_window"), &[]),
|
||||
row(
|
||||
"stop_for_update",
|
||||
Some("agent_window"),
|
||||
&[("prebuild", "done")]
|
||||
),
|
||||
row("swap", Some("stop_for_update"), &[]),
|
||||
row(
|
||||
"rebuild_bookkeeping",
|
||||
Some("stop_for_update"),
|
||||
&[("swap", "done")]
|
||||
),
|
||||
row(
|
||||
"reconcile",
|
||||
None,
|
||||
&[("agent_window", "done|failed|skipped")]
|
||||
),
|
||||
// The deploy tag is planted only after the rebuild came up clean:
|
||||
// `AfterOk` on **both** roots, so either one failing skips it. That
|
||||
// pair of edges is the whole "skips finalize on a failed graft"
|
||||
// behaviour — no run needed to see it.
|
||||
row(
|
||||
"finalize_deploy",
|
||||
None,
|
||||
&[("agent_window", "done"), ("reconcile", "done")]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// `deploy_dag_skips_finalize_but_still_tails_a_failed_graft` lived here.
|
||||
//
|
||||
// A failure *inside* the grafted rebuild is the failure mode subgraph growth
|
||||
// introduces: the deploy is already merged and the container half-swapped, so
|
||||
// `FinalizeDeploy` must be cancel-cascaded (no `deployed/<id>` tag planted)
|
||||
// while the tail still runs to compensate, and `Reconcile` is deliberately
|
||||
// still reached — it boots the container back up.
|
||||
//
|
||||
// Every declared half of that is asserted by
|
||||
// `deploy_apply_grows_rebuild_subgraph_and_finalizes_after_it`, which reads
|
||||
// `deploy_rebuild_nodes`' shape directly:
|
||||
//
|
||||
// - "a failed swap still reaches Reconcile" is the `reconcile` row's
|
||||
// `AfterAny` edge on `prebuild` (`done|failed|skipped`);
|
||||
// - "finalize is cancel-cascaded" is `finalize_deploy`'s `AfterOk` pair —
|
||||
// either root failing skips it;
|
||||
// - "the tail still runs" is `deploy_tail`'s own `done|failed|skipped` edge
|
||||
// on apply, asserted in the `approval_deploy` table above.
|
||||
//
|
||||
// The runtime halves are hive_jobq's: cascade on failure, roll-up, and
|
||||
// `first_error` digging past a group root that rolled up `Failed` while
|
||||
// carrying no error of its own (`first_error_skips_a_rolled_up_failure_
|
||||
// carrying_no_error`). That last one is why the DAG reports "profile swap
|
||||
// failed" rather than nothing — the mechanism, not this shape.
|
||||
|
||||
// `deploy_dag_skips_apply_but_still_runs_tail_when_verify_fails` lived here.
|
||||
//
|
||||
// A pre-merge rejection (drift gate, eval failure) cancel-cascades the
|
||||
// irreversible half via its `AfterOk` edge, while the tail is still reached —
|
||||
// it owns the forge mirror, not just compensation. That test and
|
||||
// `deploy_dag_runs_phases_in_order_and_tails_a_failed_apply` differed only in
|
||||
// *where* they injected the failure, and each drove the whole DAG to watch the
|
||||
// tail run anyway.
|
||||
//
|
||||
// Both outcomes follow from one declared edge, which the surviving test now
|
||||
// asserts directly: the tail accepts `done|failed|skipped` on apply, and
|
||||
// `skipped` is exactly the state apply lands in when verify failed and it never
|
||||
// ran. The runtime halves are hive_jobq's and tested there —
|
||||
// `failed_after_ok_dep_cancels_dependents_but_after_any_still_runs` and
|
||||
// `failed_child_rolls_parent_up_to_failed` (an Ok tail cannot launder a failed
|
||||
// deploy into a success).
|
||||
|
||||
// ---- history ----
|
||||
//
|
||||
// The node → build-log link is no longer queue state: the log row carries
|
||||
|
|
|
|||
Loading…
Reference in a new issue