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

@ -102,6 +102,28 @@ fn settle_rebuild_tail(q: &JobQueue, agent: &str, expect_ok: bool) {
q.complete_node(tail.node_id, Ok(()));
}
/// The resources a node **declared**, read off its graph edges.
///
/// The declaration is the thing under test now that construction sites state
/// their own holdings: asking the `NodeKind` what it "should" need would just
/// re-run the derivation this module removed, and would pass even if the
/// construction site declared nothing.
fn declared_resources(q: &JobQueue, node_id: hive_jobq::NodeId) -> Vec<Resource> {
let inner = q.lock();
inner
.sched
.graph()
.node(node_id)
.expect("node exists")
.deps
.iter()
.filter_map(|dep| match dep {
hive_jobq::Dep::Resource { name, .. } => Some(name.clone()),
hive_jobq::Dep::Node { .. } => None,
})
.collect()
}
fn state_of(q: &JobQueue, dag_id: u64) -> State {
// A DAG whose nodes have all settled `Done` or `Skipped` drops out of the
// snapshot — absence is the completion signal, so map it to `Done`.
@ -708,6 +730,74 @@ fn offline_agents_skip_mechanical_nodes_but_keep_reconcile() {
);
}
#[test]
fn a_fanned_out_start_declares_the_lease_and_re_enters_its_reconciles_grant() {
// `Start` / `Stop` / `PostSwap` were lease-exempt *as kinds*, which was only
// safe because every construction site fans them out from inside a
// lease-holding ancestor. Now they declare the lease themselves.
//
// The contract says that costs nothing — a descendant re-enters the
// ancestor's grant instead of taking a fresh unit. That is exactly the sort
// of claim that is true until a node is used from a second site, so it is
// pinned here rather than argued: the fanned-out `Start` must (a) actually
// carry the declaration, (b) still run under its parent's grant, and
// (c) not have consumed a second unit of a cap-1 lease.
let q = JobQueue::new(4);
let id = submit(
&q,
templates::reconcile_only("agent-a", Source::Manual, "converge".to_owned()),
);
// A competing DAG on the same agent, to prove the lease is genuinely held
// (and held *once*) across the fan-out.
let rival = submit(
&q,
templates::reconcile_only("agent-a", Source::Manual, "rival".to_owned()),
);
let reconcile = claim_one(&q);
assert_eq!(reconcile.dag_id, id);
assert_eq!(reconcile.kind.as_str(), "reconcile");
// What `run_reconcile` does on observing a down container with wanted=Up.
q.append_subgraph(
id,
Box::new(|b: &Job| {
let kind = NodeKind::Start {
agent: "agent-a".to_owned(),
};
let lease = Resource::Agent(kind.agent().to_owned());
let _ = templates::node(b, kind).needs(lease);
}),
reconcile.node_id,
);
q.complete_node(reconcile.node_id, Ok(()));
// (a) + (b): the child runs, under the parent that parked in `Finishing`.
let start = claim_one(&q);
assert_eq!(start.kind.as_str(), "start");
assert_eq!(
declared_resources(&q, start.node_id),
vec![Resource::Agent("agent-a".to_owned())],
"a fanned-out Start declares the lease it runs under"
);
// (c): one unit, not two. `claim_one` above already asserted the rival did
// not come back in the same pass; make the reason explicit.
assert!(
q.claim_ready().is_empty(),
"the rival DAG's Reconcile must still be blocked — the appended Start \
borrowed the grant rather than acquiring a second unit"
);
q.complete_node(start.node_id, Ok(()));
// Subtree terminal → the grant releases and the rival finally runs.
let rival_reconcile = claim_one(&q);
assert_eq!(rival_reconcile.dag_id, rival);
q.complete_node(rival_reconcile.node_id, Ok(()));
assert_eq!(state_of(&q, id), State::Done);
assert_eq!(state_of(&q, rival), State::Done);
}
#[test]
fn append_subgraph_roots_on_emitter_and_rebases_local_deps() {
// The startup-sweep mechanism: a `MetaLock` emitter grows one rebuild
@ -1594,12 +1684,12 @@ fn reparent_shape_is_a_lone_agentless_meta_window_node() {
let c = claim_one(&q);
assert_eq!(c.kind.as_str(), "reparent");
assert_eq!(c.agent, "", "Reparent is agentless — no per-agent lease");
assert!(
c.kind.needs_meta_window(),
"a topology commit must hold the same MetaWindow as WritePermFile"
assert_eq!(
declared_resources(&q, c.node_id),
vec![Resource::MetaWindow],
"a topology commit must declare the same MetaWindow as WritePermFile, \
and nothing else no lease (agentless), no build slot (no nix work)"
);
assert!(!c.kind.needs_lease());
assert!(!c.kind.needs_build_slot());
q.complete_node(c.node_id, Ok(()));
assert_eq!(state_of(&q, id), State::Done);
}