job_queue: assert declared shape instead of driving the DAG

Three template tests ran a whole DAG -- claim, complete, repeat -- to
observe an order that is fully determined the moment submit returns.
They now read the graph directly: kinds, parent nesting, and dep edges
with the outcome set each accepts.

rebuild_chain_claims_in_dep_order is renamed, because the old name was
wrong about the mechanism and reading it rather than the graph is how
you stay wrong: only half that chain is dep edges. stop_for_update and
swap declare no deps at all and are ordered by parent nesting -- a
node's sub-nodes run after its own logic. Both axes are asserted now,
since a template can break either independently.

declared_shape spells out each edge's accepted outcomes rather than
bucketing them into ok/any. Bucketing made spawn's three
ResolveApproval tails -- which differ only in accepted outcome -- render
as identical rows, which would have made the assertion a tautology.

Checked by mutation against the code under test rather than the
assertion: dropping .after_ok(signal) from the graceful-stop Drain fails
graceful_stop_shape with a diff naming the one missing edge.
This commit is contained in:
atlas 2026-08-02 19:45:48 +02:00 committed by mara
commit d879d3e67a

View file

@ -167,6 +167,89 @@ impl CompleteNode for JobQueue {
}
}
/// One node's **declared** shape: what it is, what it hangs under, and what it
/// waits for — all by kind, since ids are not stable across runs.
#[derive(Debug, PartialEq, Eq)]
struct Declared {
kind: &'static str,
/// Parent kind, or `None` when the node hangs directly under the DAG
/// container (i.e. it is a group root).
parent: Option<&'static str>,
/// Kinds this node declared a node-dep on, in declaration order, each with
/// the outcome set that satisfies it.
///
/// The outcome set is **not** decoration: a template emits its tails as a
/// pair edged on the same upstream nodes, and the *only* thing telling the
/// ok-tail from the fail-tail is which outcomes each accepts. Without it
/// two structurally different nodes read as identical.
after: Vec<(&'static str, String)>,
}
/// Render a dep's outcome set as the outcomes it actually accepts.
///
/// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other. The first
/// version of this did bucket, and a template's three `ResolveApproval` tails —
/// which differ ONLY in their accepted outcomes — all rendered as `"other"`.
/// A helper that prints two structurally different nodes identically turns an
/// assertion into a tautology.
fn when_tag(when: hive_jobq::DepWhen) -> String {
[
(TerminalState::Done, "done"),
(TerminalState::Failed, "failed"),
(TerminalState::Cancelled, "cancelled"),
(TerminalState::Skipped, "skipped"),
]
.into_iter()
.filter(|(outcome, _)| when.accepts(*outcome))
.map(|(_, name)| name)
.collect::<Vec<_>>()
.join("|")
}
/// Every work node under `dag`, in insertion order, as its declared shape.
///
/// **This is what the template tests are actually about.** A template's output
/// is fully determined the moment `submit` returns: the kinds, the parent
/// nesting and the dep edges are all sitting in the graph. Reading them here
/// keeps the assertion on c0re's own product. Whether the scheduler then
/// *honours* those edges — runs a chain serially, holds a grant across a
/// subtree — is `hive_jobq`'s property and is tested in `hive_jobq`.
fn declared_shape(q: &JobQueue, dag: u64) -> Vec<Declared> {
let sched = q.sched().lock().expect("job_queue mutex poisoned");
let graph = sched.graph();
let root = graph.resolve_id(dag).expect("dag id is a real node id");
let kind_of = |id: NodeId| graph.node(id).map(|n| n.payload.as_str());
graph
.nodes()
.filter(|n| n.id != root && graph.root_of(n.id) == Some(root))
.map(|n| Declared {
kind: n.payload.as_str(),
parent: n.parent.filter(|p| *p != root).and_then(kind_of),
after: n
.deps
.iter()
.filter_map(|dep| match dep {
hive_jobq::Dep::Node { id, when } => kind_of(*id).map(|k| (k, when_tag(*when))),
hive_jobq::Dep::Resource { .. } => None,
})
.collect(),
})
.collect()
}
/// Shorthand for one expected row, so the tables below read as a shape.
fn row(
kind: &'static str,
parent: Option<&'static str>,
after: &[(&'static str, &str)],
) -> Declared {
Declared {
kind,
parent,
after: after.iter().map(|(k, w)| (*k, (*w).to_owned())).collect(),
}
}
/// Claim helper asserting exactly one node comes back.
fn claim_one(q: &JobQueue) -> Claimed {
let mut claims = q.claim_ready();
@ -310,28 +393,52 @@ fn resubmit_while_running_is_new_dag() {
// ---- dependency order within a DAG ----
#[test]
fn rebuild_chain_claims_in_dep_order() {
fn rebuild_chain_is_declared_serial() {
// Was `rebuild_chain_claims_in_dep_order`, which drove the whole DAG to
// observe an order that is fully declared the moment `submit` returns.
//
// ⚠️ The old name was also wrong about the mechanism, and reading it rather
// than the graph is how you'd stay wrong: **only half this chain is dep
// edges.** `stop_for_update` and `swap` declare no deps at all — they are
// ordered by *parent nesting* ("a node's sub-nodes run after its own
// logic"). Both axes are asserted below because a template can break either
// one independently.
let q = JobQueue::new(1);
let id = submit(&q, rebuild("agent-a", "r"));
for expected in [
"meta_sync",
"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);
assert!(
q.claim_ready().is_empty(),
"chain must serialize: nothing ready while {expected} runs"
);
q.complete_node(c.node_id, Ok(()));
}
settle_rebuild_tail(&q, "agent-a", true);
assert_eq!(state_of(&q, id), State::Done);
assert_eq!(
declared_shape(&q, id),
vec![
row("meta_sync", None, &[]),
row("prebuild", None, &[("meta_sync", "done")]),
// No dep: ordered by hanging under `prebuild`.
row("stop_for_update", Some("prebuild"), &[]),
row("swap", Some("stop_for_update"), &[]),
row("post_swap", Some("stop_for_update"), &[("swap", "done")]),
row("reconcile", None, &[("prebuild", "done|failed|skipped")]),
// The tail pair. The ok tail needs every root to succeed; the !ok
// tail hangs off the ok tail's *elimination* (`skipped`), which is
// what makes exactly one of them run.
row(
"emit_rebuilt",
None,
&[
("meta_sync", "done"),
("prebuild", "done"),
("reconcile", "done"),
],
),
row(
"emit_rebuilt",
None,
&[
("emit_rebuilt", "skipped"),
("meta_sync", "done|failed|skipped"),
("prebuild", "done|failed|skipped"),
("reconcile", "done|failed|skipped"),
],
),
]
);
}
/// The boot sweep's graceful shape: the agent gets `Signal` → `Drain` to
@ -1722,12 +1829,17 @@ fn error_truncation_cuts_on_a_char_boundary() {
fn graceful_stop_shape_signal_drain_reconcile() {
let q = JobQueue::new(1);
let id = submit(&q, stop_online(&["agent-a"], true, "graceful"));
for expected in ["set_wanted", "signal", "drain", "reconcile"] {
let c = claim_one(&q);
assert_eq!(c.kind.as_str(), expected);
q.complete_node(c.node_id, Ok(()));
}
assert_eq!(state_of(&q, id), State::Done);
assert_eq!(
declared_shape(&q, id),
vec![
// The whole stop hangs under `set_wanted`: the durable intent is
// written first, and the mechanical steps are its sub-nodes.
row("set_wanted", None, &[]),
row("signal", Some("set_wanted"), &[]),
row("drain", Some("set_wanted"), &[("signal", "done")]),
row("reconcile", Some("set_wanted"), &[("drain", "done")]),
]
);
}
#[test]
@ -1767,13 +1879,23 @@ fn spawn_shape_provision_create_dropin_reconcile() {
&q,
templates::spawn("newbie", 7, "approval #7 spawn".to_owned()),
);
for expected in ["provision", "create", "write_dropin", "reconcile"] {
let c = claim_one(&q);
assert_eq!(c.kind.as_str(), expected);
q.complete_node(c.node_id, Ok(()));
}
settle_approval_tail(&q, 7, TerminalState::Done);
assert_eq!(state_of(&q, id), State::Done);
assert_eq!(
declared_shape(&q, id),
vec![
row("provision", None, &[]),
row("create", Some("provision"), &[]),
row("write_dropin", Some("create"), &[]),
row("reconcile", Some("create"), &[("write_dropin", "done")]),
// One tail per outcome, each edged to accept only that one — so
// *which* tail the graph lets run already is the answer, and
// nothing branches at runtime. The three differ **only** in their
// accepted outcome, which is why `declared_shape` spells the
// outcome set out instead of bucketing it.
row("resolve_approval", None, &[("provision", "done")]),
row("resolve_approval", None, &[("provision", "failed")]),
row("resolve_approval", None, &[("provision", "cancelled")]),
]
);
}
#[test]