refactor(job-queue): build DAGs by naming nodes, not counting them

Every template built a `Vec<NodeSpec>` whose edges and parents were
positional indices into that vector, so a shape was expressed as
arithmetic: `base + 1`, `stop_root + 2`, `sfu + 1`, and a
`reconcile_index()` helper that read the emitted vector's length to find
out where its own last node had landed. `concat_subgraphs` existed
solely to rebase one per-agent subgraph's indices onto another's.

Templates now declare into a `hive_jobq::JobBuilder` and hold the
handles they get back, so an edge names the node it waits on. The
arithmetic is gone, and with it:

- `NodeSpec` and the job-queue's own index-based `Dep`.
- `insert_group`'s index resolution — it wraps `Scheduler::insert_job`.
- `concat_subgraphs` — per-agent chains share one builder and each keeps
  its own root, so independence is structural rather than computed.
- `reconcile_index` and `dep_index`.
- `templates::validate` and its petgraph toposort. It rejected dangling
  deps and cycles; both are now unrepresentable, since a handle only
  exists for an already-declared node and every edge therefore points
  backwards. (petgraph stays in the tree for `agent_config::topology`.)

`NodeOutput.append_subgraph` becomes `Vec<Job>`: an executor cannot
reach the queue, so it hands back declarations and the scheduler inserts
them under its own lock. That is what the in-DAG growth path always
wanted — a transferable declaration, not a vector of specs.

Resource declaration is unchanged in behaviour: the `templates::node`
helper applies `NodeKind::resource_deps()` at the construction site, so
every node still declares what its kind needs. Moving that declaration
to the call sites is #2818's job; this leaves it one place to delete.

Three tests went with the guard they covered — they hand-built malformed
specs out of indices, which is the representation that made those shapes
possible. Two more now read a DAG's shape off the queue rather than out
of a spec vector, which is where it is observable. The remaining 45
job-queue tests are unchanged and still pass: lease serialization,
roll-up, cancel-cascade, in-DAG growth and per-agent concurrency all
behave as before.
This commit is contained in:
atlas 2026-08-02 13:05:17 +02:00 committed by mara
commit e7c3cf5a3d
9 changed files with 528 additions and 696 deletions

View file

@ -112,6 +112,13 @@ impl<N, R> JobBuilder<N, R> {
Self::default()
}
/// Whether nothing has been declared yet — for a caller deciding whether an
/// insertion is worth taking a lock for.
#[must_use]
pub fn is_empty(&self) -> bool {
self.nodes.borrow().is_empty()
}
/// Add a node carrying `payload`, with no edges, resources, or parent yet.
///
/// The returned handle is where those are declared; it is [`Copy`], so it
@ -275,6 +282,14 @@ impl<N, R> NodeRef<'_, N, R> {
self.edge(on.into(), DepWhen::AFTER_ANY)
}
/// Run only on the listed outcomes of `on` — the general form of
/// [`NodeRef::after_ok`] / [`NodeRef::after_any`], for the
/// one-node-per-outcome shape a job with a row to resolve uses.
#[must_use]
pub fn on_outcome(self, on: impl Into<NodeGuid>, outcomes: &[TerminalState]) -> Self {
self.edge(on.into(), DepWhen::of(outcomes))
}
/// Run only if `on` was **ruled out** — i.e. its own `after_ok` edges did
/// not hold.
///