split Swap's Ok-tail into a first-class PostSwap DAG node (#2390)

This commit is contained in:
damocles 2026-07-16 12:09:15 +02:00 committed by mara
commit f2ff0deb6b
4 changed files with 126 additions and 43 deletions

View file

@ -150,7 +150,13 @@ fn unknown_dep_is_rejected_at_submit() {
fn rebuild_chain_claims_in_dep_order() {
let q = JobQueue::new(1);
let id = submit(&q, rebuild("agent-a", "r"));
for expected in ["prebuild", "stop_for_update", "swap", "reconcile"] {
for expected in [
"prebuild",
"stop_for_update",
"swap",
"post_swap",
"reconcile",
] {
let c = claim_one(&q);
assert_eq!(c.dag_id, id);
assert_eq!(c.kind.as_str(), expected);
@ -624,6 +630,7 @@ fn failed_node_cancels_downstream_but_afterany_reconcile_runs() {
assert_eq!(by_kind("prebuild"), State::Failed);
assert_eq!(by_kind("stop_for_update"), State::Cancelled);
assert_eq!(by_kind("swap"), State::Cancelled);
assert_eq!(by_kind("post_swap"), State::Cancelled);
assert_eq!(by_kind("reconcile"), State::Done);
assert_eq!(
dag.nodes
@ -634,9 +641,10 @@ fn failed_node_cancels_downstream_but_afterany_reconcile_runs() {
);
}
/// The swap-failure recovery: `Swap` fails → the `AfterAny` edge still
/// runs `Reconcile`, which brings a wanted-up agent back on its old
/// config.
/// The swap-failure recovery: `Swap` fails → the `AfterOk` `PostSwap` is
/// cancel-cascaded → its terminal state still satisfies `Reconcile`'s
/// `AfterAny(PostSwap)` edge, so recovery-start runs and brings a wanted-up
/// agent back on its old config.
#[test]
fn swap_failure_still_runs_reconcile() {
let q = JobQueue::new(1);
@ -648,12 +656,55 @@ fn swap_failure_still_runs_reconcile() {
let swap = claim_one(&q);
assert_eq!(swap.kind.as_str(), "swap");
q.complete_node(id, swap.node_id, Err("update failed".to_owned()));
// PostSwap (AfterOk on the failed Swap) is cancel-cascaded; Reconcile is
// next-claimable via its AfterAny(PostSwap) edge.
let reconcile = claim_one(&q);
assert_eq!(reconcile.kind.as_str(), "reconcile");
q.complete_node(id, reconcile.node_id, Ok(()));
let all_dags = q.snapshot();
let dag = all_dags.iter().find(|d| d.id == id).expect("dag");
assert_eq!(
dag.nodes
.iter()
.find(|n| n.kind == "post_swap")
.expect("post_swap node")
.state,
State::Cancelled,
"PostSwap must cancel-cascade when Swap fails"
);
assert_eq!(state_of(&q, id), State::Failed);
}
/// The swap-success path: `Swap` ok → the `AfterOk` `PostSwap` (bookkeeping
/// tail) runs, and only then does `Reconcile` fire — serialized behind
/// `PostSwap` (not racing it) because `Reconcile` deps `AfterAny(PostSwap)`.
#[test]
fn swap_ok_runs_post_swap_before_reconcile() {
let q = JobQueue::new(1);
let id = submit(&q, rebuild("agent-a", "r"));
// prebuild + stop_for_update
for _ in 0..2 {
let c = claim_one(&q);
q.complete_node(id, c.node_id, Ok(()));
}
let swap = claim_one(&q);
assert_eq!(swap.kind.as_str(), "swap");
q.complete_node(id, swap.node_id, Ok(()));
// PostSwap runs next, and nothing else is claimable while it does — the
// tail serializes ahead of Reconcile.
let post_swap = claim_one(&q);
assert_eq!(post_swap.kind.as_str(), "post_swap");
assert!(
q.claim_ready().is_empty(),
"Reconcile must wait for PostSwap, not race it"
);
q.complete_node(id, post_swap.node_id, Ok(()));
let reconcile = claim_one(&q);
assert_eq!(reconcile.kind.as_str(), "reconcile");
q.complete_node(id, reconcile.node_id, Ok(()));
assert_eq!(state_of(&q, id), State::Done);
}
#[test]
fn failed_reconcile_marks_dag_failed() {
let q = JobQueue::new(1);
@ -903,6 +954,7 @@ fn perm_change_shape_prefixes_rebuild_chain() {
"prebuild",
"stop_for_update",
"swap",
"post_swap",
"reconcile",
] {
let c = claim_one(&q);