diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 019651f5..4aeb5f22 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -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::>() + .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 { + 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]