job_queue: declare a node's resources where the node is constructed

Resources were derived from the node's kind: `templates::node` called
`NodeKind::resource_deps()`, which fanned out to `needs_build_slot` /
`needs_lease` / `needs_meta_window`. That made the requirement a property
of the *kind*, so a kind that happened to run under an ancestor already
holding the resource could get away with declaring nothing.

Three did. `Start`, `Stop` and `PostSwap` appear in none of the three
predicates, and that was only safe because one construction site fans
them out from inside a lease-holding `Reconcile` — a fact about today's
DAG shape, not about the nodes.

Each of the 41 construction sites now says what it holds. `Start` /
`Stop` / `PostSwap` declare the agent lease; per the contract that is a
re-entrant borrow, which a new test pins rather than argues.

`running_transients` reads the node's declared deps instead of
re-deriving from the kind. That closes the blank-pill gap: the pill went
blank during container start, stop and the post-swap tail because the
declaration was missing, not because the filter was wrong.

The deleted predicates carried the only written record of three design
decisions; each moved to the `Resource` variant it constrains rather than
dying with its function.
This commit is contained in:
atlas 2026-08-02 15:57:51 +02:00 committed by mara
commit 10dbdb444d
9 changed files with 256 additions and 177 deletions

View file

@ -9,7 +9,7 @@
//! - [`model::NodeKind`] **is** the crate payload `N` directly — each variant
//! carries the agent it targets ([`NodeKind::agent`]); the two resource
//! classes are [`resource::Resource`] (`BuildSlot` node-held, `Agent` lease
//! subtree-held), derived per node by [`NodeKind::resource_deps`];
//! subtree-held), declared per node at its construction site;
//! - a **DAG is a single container node** ([`NodeKind::Dag`], `parent = None`)
//! carrying the group's metadata, with the work nodes hung under it as
//! its subtree (the **parent axis** groups; `deps` order). So the container's
@ -436,10 +436,13 @@ impl JobQueue {
/// `Stop` pill are both pills; only one means a vanished container is
/// expected.
///
/// By design, `Start` / `Stop` / `PostSwap` run inside a lease-holding
/// ancestor and re-declare nothing, so they light no pill; closing that is
/// the resources-where-constructed work, not this function. An agent's lease
/// is cap-1, so at most one entry per agent.
/// Read off the node's **declared** resource edges, not off its kind. Those
/// are the same thing now that every construction site states what it holds,
/// and the distinction is the whole point: `Start` / `Stop` / `PostSwap` run
/// inside a lease-holding ancestor, and while the declaration was derived
/// from the kind they re-declared nothing and lit no pill. Asking the node
/// what it holds cannot go stale that way. An agent's lease is cap-1, so at
/// most one entry per agent.
#[must_use]
pub fn running_transients(&self) -> Vec<RunningTransient> {
let inner = self.lock();
@ -449,14 +452,13 @@ impl JobQueue {
.nodes()
.filter(|n| matches!(n.state, State::Running))
.filter_map(|n| {
let agent =
n.payload
.resource_deps()
.into_iter()
.find_map(|(name, _)| match name {
Resource::Agent(a) => Some(a),
_ => None,
})?;
let agent = n.deps.iter().find_map(|dep| match dep {
hive_jobq::Dep::Resource {
name: Resource::Agent(a),
..
} => Some(a.clone()),
_ => None,
})?;
Some(RunningTransient {
agent,
label: n.payload.as_str().to_owned(),