feat(#2788): carry Skipped to the wire as its own state

A node ruled out by its own dependency edges settles `Skipped` host-side,
but the wire folded it into `Cancelled` and `dag_view` filtered it out
entirely, so a client never saw which branch a run didn't take. Post-#2785
that is not a rare shape: every approval DAG has two not-taken tails and
every rebuild has one, on the happy path as much as on failure.

`State` gains `Skipped`, and it counts as terminal — the wait loops in
hivectl's progress display and the daemon's dag-settled check decide
"finished" with `all(is_terminal)`, so omitting it would hang them on
essentially every DAG.

`dag_view` now emits skipped nodes but no longer lets them keep a DAG
alive. Serialization and completion were the same expression: a DAG left
the snapshot because its nodes had all been filtered away. Keeping skipped
nodes on the wire under that rule would pin every finished deploy in the
queue view forever, so the completion test is now its own flag.

`rollupState` in the dashboard gains the matching arm. It has no `done`
case — `done` is inferred by falling off the wire — so its trailing
`return 'queued'` catches anything it doesn't recognise, and a green
deploy would have read as permanently queued the moment the backend
started emitting the new state. The Rust and JS roll-ups have silently
disagreed before; they are edited together here and say so.
This commit is contained in:
atlas 2026-07-27 19:24:26 +02:00
commit 657d1b5061
5 changed files with 88 additions and 36 deletions

View file

@ -49,12 +49,25 @@ pub enum State {
Done,
Failed,
Cancelled,
/// The node's own dependency edges ruled it out: an outcome branch that
/// wasn't taken. Distinct from `Cancelled`, which is work actively
/// dropped. A skipped node is an expected part of a healthy run — every
/// approval DAG has two not-taken tails and every rebuild has one — so
/// consumers must not read it as a failure or cancellation signal.
Skipped,
}
impl State {
/// Whether the node will never change state again. `Skipped` counts:
/// a branch that was ruled out is as final as one that ran, and the
/// wait loops (`hivectl`'s progress display, the daemon's
/// dag-settled check) hang forever if it doesn't.
#[must_use]
pub fn is_terminal(self) -> bool {
matches!(self, State::Done | State::Failed | State::Cancelled)
matches!(
self,
State::Done | State::Failed | State::Cancelled | State::Skipped
)
}
}
@ -160,9 +173,12 @@ pub struct DagView {
/// `started_at`. `None` while the DAG is still live.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<DateTime<Utc>>,
/// Nodes of this DAG with `Done` ones excluded. A DAG whose nodes are
/// all `Done` is omitted from the snapshot entirely; a `Failed` DAG
/// lingers until aged out by the history cap.
/// Nodes of this DAG with `Done` ones excluded. `Skipped` nodes are
/// carried so the dashboard can show which branches weren't taken, but
/// they don't keep a DAG alive: one whose nodes are all `Done` or
/// `Skipped` is omitted from the snapshot entirely, and its absence is
/// what signals completion. A `Failed` DAG lingers until aged out by the
/// history cap.
pub nodes: Vec<NodeView>,
}
@ -182,9 +198,13 @@ impl DagView {
/// the states barely co-occur: a cancel *cascade* originates at a `Failed`
/// node, which returns early above.
///
/// `Skipped` contributes nothing: a not-taken branch is an expected part of
/// a healthy run, so counting it would make every successful DAG roll up
/// non-`Done`.
///
/// This ordering matches `frontend/packages/dashboard/src/builds.js`'s
/// `rollupState`, which has always ranked cancelled second — the two had
/// silently disagreed, and this is the side that was wrong.
/// `rollupState`. The two implementations must be edited together — they
/// have silently disagreed before.
#[must_use]
pub fn rollup_state(&self) -> State {
let mut any_running = false;
@ -196,7 +216,7 @@ impl DagView {
State::Running => any_running = true,
State::Queued => any_queued = true,
State::Cancelled => any_cancelled = true,
State::Done => {}
State::Done | State::Skipped => {}
}
}
if any_cancelled {