feat(#2772): make a dependency edge a set of accepted outcomes

`DepWhen` was two named cases, so every new combination wanted a new
variant. It is now a set over the terminal outcomes: a `u8` bitset
newtype, no dependency, with `AFTER_OK` / `AFTER_ANY` kept as the two
constants the templates actually use. "Run regardless" is all outcomes,
"anything that isn't a failure" is `{Done, Cancelled}`, a compensation
branch is `{Failed}` — closed under combination, so it never needs
another variant.

`TerminalState` is its own type rather than a subset of `State`, so an
edge cannot name `Pending` / `Running` / `Finishing`. Those are
meaningless in a dependency and are better unrepresentable than
validated against. The empty set is the one thing that can't be typed
away — nothing satisfies it, so `validate` rejects it next to the cycle
check.

Two consequences worth calling out:

- `cascade_cancel` collapses to one rule: a pending node is doomed once
  any edge it names can no longer be satisfied. The hardcoded `AfterOk`
  special case is gone, and a weak-edged node survives its dependency's
  cancellation because of its own edge rather than by exemption.
- The cascade now runs on **any** terminal outcome, `Done` included.
  With sets, success rules dependents out just as failure does — a
  `{Failed}` branch is unsatisfiable the moment its dependency succeeds,
  and leaving it `Pending` would wedge the subtree non-terminal forever.
  That is a hang, not a wrong answer, so it is the load-bearing half of
  this commit.

Edges are conjunctive, so "any of these N failed" is not directly
sayable. The composition that works is in the tests: the success branch
depends `AFTER_OK` on every root, so it is itself cancelled the moment
one of them doesn't succeed, and the failure branch hangs off *that*
with `{Cancelled}`. Exactly one of the two runs.

Also deletes hive-c0re's duplicate `DepWhen` enum and the
`to_crate_when` translation beside it. The copy bought nothing and had
to be widened in lockstep with the crate's edge model — it is the
in-between layer #2772 exists to remove, and it is what broke the build
when the crate's spelling changed.

All 34 jobq tests pass, including the four new ones covering both
directions of a failure-only branch, weak-edge survival of a cancelled
dependency, and the aggregator composition.
This commit is contained in:
atlas 2026-07-27 16:00:18 +02:00 committed by mara
commit affedecaa5
6 changed files with 303 additions and 82 deletions

View file

@ -44,12 +44,12 @@ use chrono::{DateTime, Utc};
use hive_host_sock::jobs::NodeView;
use hive_jobq::resources::ResourceTable;
use hive_jobq::scheduler::{Outcome, Scheduler};
use hive_jobq::{Dep, DepWhen as JobDepWhen, Graph, NodeId, State as JobState};
use hive_jobq::{Dep, Graph, NodeId, State as JobState};
use hive_sh4re::wire_time::now_unix;
use tokio::sync::Notify;
use crate::coordinator::TransientKind;
pub use model::{DagSpec, DagView, DepWhen, NodeKind, NodeSpec, PermPayload, Source, State};
pub use model::{DagSpec, DagView, NodeKind, NodeSpec, PermPayload, Source, State};
use resource::Resource;
/// How many terminal DAGs (`Done` / `Failed` / `Cancelled`) the snapshot
@ -190,14 +190,6 @@ impl Default for JobQueue {
}
}
/// Map a spec dependency edge kind onto the crate's.
fn to_crate_when(when: DepWhen) -> JobDepWhen {
match when {
DepWhen::AfterOk => JobDepWhen::AfterOk,
DepWhen::AfterAny => JobDepWhen::AfterAny,
}
}
/// Map a crate node state onto the wire state (`Pending` ↔ `Queued`;
/// `Finishing` — own logic done, sub-nodes still running — reads as `Running`).
fn to_wire_state(state: JobState) -> State {
@ -241,7 +233,7 @@ fn insert_group(
for d in &ns.deps {
deps.push(Dep::Node {
id: ids[dep_index(d.on)],
when: to_crate_when(d.when),
when: d.when,
});
}
let parent = match ns.parent {

View file

@ -17,18 +17,11 @@ use serde::Serialize;
use crate::coordinator::TransientKind;
/// When a dependency edge is considered satisfied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DepWhen {
/// Dep must reach `Done`. A `Failed` / `Cancelled` dep cancels this
/// node (cancel-downstream).
AfterOk,
/// Dep must merely reach a terminal state (ok *or* fail). Used only
/// by `rebuild`'s tail `Reconcile` so the recovery-start runs even
/// when `Swap` failed.
AfterAny,
}
/// When a dependency edge is satisfied — re-exported from [`hive_jobq`] rather
/// than mirrored here. It used to be a duplicate enum with a `to_crate_when`
/// translation beside it; the copy bought nothing and had to be widened in
/// lockstep every time the crate's edge model grew (#2772).
pub use hive_jobq::DepWhen;
/// A dependency edge (intra-DAG only — cross-DAG ordering comes from
/// the per-agent lease + dedup, never from edges between DAGs).
@ -230,7 +223,7 @@ pub enum NodeKind {
/// Tail node of an approval-carrying DAG (spawn / opaque deploy / config-PR
/// merge): resolve the approval row from how the work actually ended.
///
/// Weak-edged (`DepWhen::AfterAny`) like [`NodeKind::DeployTail`], so it runs on
/// Weak-edged (`DepWhen::AFTER_ANY`) like [`NodeKind::DeployTail`], so it runs on
/// success, failure **and cancel** alike and decides internally. It reads its
/// dependencies' terminal states off its own [`Claim::deps`] rather than
/// re-deriving them from the world the way `DeployTail` reads git: a node is

View file

@ -39,7 +39,7 @@ use crate::coordinator::TransientKind;
pub(crate) fn after_ok(on: u64) -> Vec<Dep> {
vec![Dep {
on,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}]
}
@ -55,7 +55,7 @@ pub(crate) fn after_any_all(ons: &[u64]) -> Vec<Dep> {
ons.iter()
.map(|&on| Dep {
on,
when: DepWhen::AfterAny,
when: DepWhen::AFTER_ANY,
})
.collect()
}
@ -135,7 +135,7 @@ pub(crate) fn rebuild_nodes(agent: &str, relock: bool, base: u64) -> Vec<NodeSpe
NodeKind::Reconcile { agent: a() },
vec![Dep {
on: base + 1,
when: DepWhen::AfterAny,
when: DepWhen::AFTER_ANY,
}],
),
]
@ -174,11 +174,11 @@ pub(crate) fn deploy_rebuild_nodes(agent: &str) -> Vec<NodeSpec> {
vec![
Dep {
on: 1,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
},
Dep {
on: 5,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
},
],
));
@ -256,7 +256,7 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec
NodeKind::DeployTail { agent: a() },
vec![Dep {
on: 2,
when: DepWhen::AfterAny,
when: DepWhen::AFTER_ANY,
}],
),
node(

View file

@ -154,7 +154,7 @@ fn cyclic_dag_is_rejected_at_submit() {
},
deps: vec![Dep {
on: 1,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}],
parent: None,
},
@ -164,7 +164,7 @@ fn cyclic_dag_is_rejected_at_submit() {
},
deps: vec![Dep {
on: 0,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}],
parent: None,
},
@ -183,7 +183,7 @@ fn unknown_dep_is_rejected_at_submit() {
},
deps: vec![Dep {
on: 9,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}],
parent: None,
}];