fix(#2802): a group whose children were all dropped rolls up Cancelled

The roll-up treated a cancelled child the same as a failed one, so a DAG
the operator cancelled before it started reported `Failed` — it claimed to
have failed at something when nothing under it ever ran.

`Failed` still outranks `Cancelled`: a group where one step broke and the
rest were dropped in response is a failure, and that is the fact worth
surfacing. Only a group with no failed child at all reports the cancel.

This also settles a disagreement. `DagView::rollup_state` on the wire has
always ranked failed over cancelled over the rest; the graph's own roll-up
had no `Cancelled` outcome to rank, so the two described the same DAG
differently depending on which one you asked.
This commit is contained in:
atlas 2026-07-27 21:22:53 +02:00
commit 6fd91ccf6a

View file

@ -233,15 +233,31 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
.all(|n| n.parent != Some(id) || n.state.is_terminal()) .all(|n| n.parent != Some(id) || n.state.is_terminal())
} }
/// Whether any direct child of `id` ended `Failed`/`Cancelled` — the roll-up /// What `id` rolls up to once all its children are terminal: `Failed` if any
/// failure condition for the parent. /// child failed, else `Cancelled` if any was cancelled, else `Done`.
/// `Skipped` children are **not** counted: being ruled out by an edge is the ///
/// expected fate of every branch not taken, so counting it would make any /// `Failed` outranks `Cancelled` because the failure is the actionable fact —
/// group that branches on outcome roll up failed no matter how the run went. /// a group where one step broke and the rest were dropped in response is a
fn any_child_failed(&self, id: NodeId) -> bool { /// failure, not a cancellation. A group whose children were *all* dropped
self.graph /// never failed at anything, and says so.
.nodes() ///
.any(|n| n.parent == Some(id) && matches!(n.state, State::Failed | State::Cancelled)) /// `Skipped` children are ignored: being ruled out by an edge is the expected
/// fate of every branch not taken, so counting it would make any group that
/// branches on outcome roll up non-`Done` however the run went.
fn rolled_up_state(&self, id: NodeId) -> State {
let mut any_cancelled = false;
for child in self.graph.nodes().filter(|n| n.parent == Some(id)) {
match child.state {
State::Failed => return State::Failed,
State::Cancelled => any_cancelled = true,
_ => {}
}
}
if any_cancelled {
State::Cancelled
} else {
State::Done
}
} }
/// Transition a node whose own logic just *succeeded* to its resulting state: /// Transition a node whose own logic just *succeeded* to its resulting state:
@ -254,12 +270,10 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
/// its dependency succeeds, and leaving it `Pending` would wedge the subtree /// its dependency succeeds, and leaving it `Pending` would wedge the subtree
/// non-terminal forever. /// non-terminal forever.
fn settle_terminal(&mut self, id: NodeId) { fn settle_terminal(&mut self, id: NodeId) {
let state = if !self.all_children_terminal(id) { let state = if self.all_children_terminal(id) {
State::Finishing self.rolled_up_state(id)
} else if self.any_child_failed(id) {
State::Failed
} else { } else {
State::Done State::Finishing
}; };
self.graph.set_state(id, state); self.graph.set_state(id, state);
if state.is_terminal() { if state.is_terminal() {
@ -279,11 +293,7 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
{ {
break; break;
} }
let state = if self.any_child_failed(a) { let state = self.rolled_up_state(a);
State::Failed
} else {
State::Done
};
self.graph.set_state(a, state); self.graph.set_state(a, state);
// Any terminal outcome can rule a dependent out — see `settle_terminal`. // Any terminal outcome can rule a dependent out — see `settle_terminal`.
self.cascade_cancel(a); self.cascade_cancel(a);
@ -982,13 +992,10 @@ mod tests {
assert!(s.cancel_node(root)); assert!(s.cancel_node(root));
assert_eq!(s.graph().node(a).unwrap().state, State::Cancelled); assert_eq!(s.graph().node(a).unwrap().state, State::Cancelled);
assert_eq!(s.graph().node(b).unwrap().state, State::Cancelled); assert_eq!(s.graph().node(b).unwrap().state, State::Cancelled);
// With every child terminal the root leaves `Finishing` and rolls up. // With every child terminal the root leaves `Finishing` and rolls up —
// It rolls up `Failed`, not `Cancelled`: the roll-up counts a cancelled // as `Cancelled`, not `Failed`: nothing under it failed at anything, the
// child as a non-success, and it has no separate "the whole group was // work was dropped.
// dropped" outcome. Pre-existing and unchanged here — a caller that assert_eq!(s.graph().node(root).unwrap().state, State::Cancelled);
// wants to show a cancel as a cancel derives that from the node states,
// not from the root's.
assert_eq!(s.graph().node(root).unwrap().state, State::Failed);
} }
/// All-or-nothing: an in-flight node's work is not interruptible, and /// All-or-nothing: an in-flight node's work is not interruptible, and