refactor(job-queue): a job is a recipe, not a value you carry

Follows the jobq change: a builder can no longer be constructed or
inserted outside `hive_jobq`, so `DagSpec` cannot hold one. It carries a
`Declare` — `Box<dyn FnOnce(&Job) + Send>` — and the queue runs it
against a builder jobq owns, at the moment it inserts.

`NodeOutput.append_subgraph` becomes `Vec<Declare>` for the same reason,
and this is where the shape was always heading: that field's doc already
said an executor "cannot reach the queue, so it hands the declaration
back", while its type was a `Vec<Job>` the executor had built itself.
The rejected `build_nodes -> Vec<NodeSpec>` was the first version of that
escape hatch; a recipe is the last one, because there is no job-shaped
value to hand over at all.

Templates and the power-op assemblers move their owned data into the
closure and are otherwise unchanged — `rebuild_nodes`, `node` and the
tail helpers already took `&Job` and returned handles, so only each
template's outermost frame moved.

Two `Debug` impls are hand-written: a closure has nothing to show, and
its nodes do not exist until the queue runs it. `NodeOutput` reports how
many subgraphs were emitted, `DagSpec` its source and reason.

`append_subgraph`'s `is_empty()` early-return is gone — you cannot ask a
recipe whether it will declare anything without running it. It now
inserts and returns an empty id list if nothing was declared, which
takes the queue lock in a case that previously skipped it.

The two in-DAG-growth tests build `Declare`s now, so they exercise the
shape an executor actually produces rather than one only a test could
construct. 45 job-queue tests unchanged and passing.
This commit is contained in:
atlas 2026-08-02 13:40:03 +02:00 committed by mara
commit 9c97365f8f
8 changed files with 336 additions and 284 deletions

View file

@ -192,22 +192,22 @@ fn rebuild_chain_claims_in_dep_order() {
#[test]
fn graceful_rebuild_chain_drains_before_stopping() {
let q = JobQueue::new(1);
let job = Job::new();
templates::rebuild_nodes(
&job,
"agent-a",
templates::RebuildOpts {
relock: true,
graceful: true,
},
None,
);
let id = submit(
&q,
DagSpec {
source: Source::AutoUpdate,
reason: "sweep".to_owned(),
job,
declare: Box::new(|b| {
templates::rebuild_nodes(
b,
"agent-a",
templates::RebuildOpts {
relock: true,
graceful: true,
},
None,
);
}),
},
);
for expected in [
@ -241,22 +241,22 @@ fn non_graceful_rebuild_has_no_signal_or_drain() {
// job keeps its nodes to itself and inserts them, so what it built is
// observable where it matters — in what the scheduler runs.
let q = JobQueue::new(1);
let job = Job::new();
templates::rebuild_nodes(
&job,
"agent-a",
templates::RebuildOpts {
relock: true,
graceful: false,
},
None,
);
let id = submit(
&q,
DagSpec {
source: Source::Manual,
reason: "manual".to_owned(),
job,
declare: Box::new(|b| {
templates::rebuild_nodes(
b,
"agent-a",
templates::RebuildOpts {
relock: true,
graceful: false,
},
None,
);
}),
},
);
let mut kinds = Vec::new();
@ -692,19 +692,19 @@ fn append_subgraph_roots_on_emitter_and_rebases_local_deps() {
// subgraph per stale agent into its OWN DAG. Each subgraph is rooted on
// the emitter and its LOCAL 0-based deps are rebased onto the DAG.
let q = JobQueue::new(4);
let job = Job::new();
let _lock = templates::node(
&job,
NodeKind::MetaLock {
sweep: true,
fanout: None,
inputs: Vec::new(),
},
);
let spec = DagSpec {
source: Source::AutoUpdate,
reason: "sweep".to_owned(),
job,
declare: Box::new(|b| {
let _lock = templates::node(
b,
NodeKind::MetaLock {
sweep: true,
fanout: None,
inputs: Vec::new(),
},
);
}),
};
let id = submit(&q, spec);
let emitter = claim_one(&q);
@ -713,18 +713,19 @@ fn append_subgraph_roots_on_emitter_and_rebases_local_deps() {
// sweep MetaLock grows: root MetaSync → root Prebuild → Signal → Drain →
// StopForUpdate → Swap → Reconcile, local 0-based deps. `graceful` must
// match the sweep arm of `run_meta_lock` or this stops tracking production.
let subgraph = |agent: &str| {
let job = Job::new();
templates::rebuild_nodes(
&job,
agent,
templates::RebuildOpts {
relock: true,
graceful: true,
},
None,
);
job
let subgraph = |agent: &str| -> Declare {
let agent = agent.to_owned();
Box::new(move |b| {
templates::rebuild_nodes(
b,
&agent,
templates::RebuildOpts {
relock: true,
graceful: true,
},
None,
);
})
};
// Must append BEFORE completing the emitter (the documented contract).
q.append_subgraph(id, subgraph("a"), emitter.node_id);
@ -820,17 +821,18 @@ fn meta_update_grows_cascade_in_dag() {
// Simulate the executor growing the cascade in-DAG (`relock = false` — a
// cascade child must not re-lock and revert the parent's bump).
for agent in ["alice", "bob"] {
let job = Job::new();
templates::rebuild_nodes(
&job,
agent,
templates::RebuildOpts {
relock: false,
graceful: false,
},
None,
);
q.append_subgraph(id, job, meta_lock.node_id);
let declare: Declare = Box::new(move |b| {
templates::rebuild_nodes(
b,
agent,
templates::RebuildOpts {
relock: false,
graceful: false,
},
None,
);
});
q.append_subgraph(id, declare, meta_lock.node_id);
}
q.complete_node(meta_lock.node_id, Ok(()));
// Still ONE DAG — no child DAGs — and both cascade rebuild subgraphs root