refactor(#2449): write power intent via a SetWanted DAG node, not a pre-submit side effect

The durable 'wanted' power intent was written by submit::{start,stop,
restart,graceful_restart,graceful_stop} as a synchronous pre-submit side
effect, then read by the DAG's tail Reconcile. That's not crash-safe
(a crash between the write and the enqueue loses it) and, with agent now
per-node, can't be per-agent in a DAG that spans agents.

Move it into the DAG as a head SetWanted node:
- NodeKind::SetWanted { up } + run_set_wanted executor (fails the node on
  a write error, unlike the old warn-and-continue, so a stale intent
  never reaches Reconcile).
- LEASE-NEEDING, not lease-exempt: it takes the agent lease so a power-op
  DAG's intent-write + reconcile is atomic per-agent. If it were exempt,
  two racing ops (restart vs stop) would run both intent-writes up front
  and clobber each other before either reconciled — defeating the point
  of moving the write into the DAG. (In stale_start the lease is thus held
  across the head Prebuild, but that's a no-op there: the agent is down so
  prebuild is skipped.)
- templates: explicit SetWanted node 0 on restart/graceful_restart/
  graceful_stop, plus dedicated start/stop templates (SetWanted -> Reconcile)
  and stale_start (SetWanted(Up) -> rebuild subgraph, reusing rebuild_nodes).
  No compose helper / rebuild variant. reconcile_only is now boot-only.
- submit.rs: drop the set_wanted side effect; the stale-rev shape decision
  (start vs stale_start) stays submit-side.

All 33 job_queue tests pass (shape/lease tests updated for the head node).
This commit is contained in:
atlas 2026-07-14 22:28:59 +02:00 committed by mara
commit 5fe8008cce
5 changed files with 189 additions and 100 deletions

View file

@ -212,21 +212,27 @@ fn lease_serializes_two_lifecycle_dags_for_same_agent() {
None,
),
);
// Restart's StopForUpdate acquires the lease; stop's Reconcile
// must wait even though slots are free.
// Restart's head SetWanted takes the lease; stop's Reconcile must
// wait even though slots are free.
let first = claim_one(&q);
assert_eq!(first.dag_id, restart);
assert_eq!(first.kind.as_str(), "set_wanted");
assert!(first.lease_acquired);
q.complete_node(restart, first.node_id, Ok(()));
// Same DAG keeps the lease for its Reconcile.
// Same DAG keeps the lease through StopForUpdate then Reconcile.
let second = claim_one(&q);
assert_eq!(second.dag_id, restart);
assert_eq!(second.kind.as_str(), "stop_for_update");
assert!(!second.lease_acquired, "lease already held by this DAG");
q.complete_node(restart, second.node_id, Ok(()));
// Restart terminal → lease released → stop's Reconcile runs.
let third = claim_one(&q);
assert_eq!(third.dag_id, stop);
q.complete_node(stop, third.node_id, Ok(()));
assert_eq!(third.dag_id, restart);
assert_eq!(third.kind.as_str(), "reconcile");
q.complete_node(restart, third.node_id, Ok(()));
// Restart terminal → lease released → stop's Reconcile runs.
let fourth = claim_one(&q);
assert_eq!(fourth.dag_id, stop);
q.complete_node(stop, fourth.node_id, Ok(()));
assert_eq!(state_of(&q, restart), State::Done);
assert_eq!(state_of(&q, stop), State::Done);
}
@ -510,6 +516,11 @@ fn terminal_dag_reported_exactly_once_and_lease_released() {
&q,
templates::restart("agent-a", Source::Manual, "r".to_owned()),
);
// restart = SetWanted → StopForUpdate → Reconcile; not terminal until
// the last node completes.
let set_wanted = claim_one(&q);
q.complete_node(id, set_wanted.node_id, Ok(()));
assert!(q.drain_terminal().is_empty(), "dag not terminal yet");
let stop = claim_one(&q);
q.complete_node(id, stop.node_id, Ok(()));
assert!(q.drain_terminal().is_empty(), "dag not terminal yet");
@ -727,7 +738,7 @@ fn graceful_stop_shape_signal_drain_reconcile() {
&q,
templates::graceful_stop("agent-a", Source::Manual, "graceful".to_owned()),
);
for expected in ["signal", "drain", "reconcile"] {
for expected in ["set_wanted", "signal", "drain", "reconcile"] {
let c = claim_one(&q);
assert_eq!(c.kind.as_str(), expected);
q.complete_node(id, c.node_id, Ok(()));
@ -753,8 +764,9 @@ fn graceful_signal_and_drain_hold_no_build_slot() {
let kinds: Vec<&str> = claims.iter().map(|c| c.kind.as_str()).collect();
assert_eq!(
kinds,
vec!["prebuild", "signal", "signal"],
"both agents' signals fire while the slot is held"
vec!["prebuild", "set_wanted", "set_wanted"],
"both agents' graceful-stop heads (SetWanted, build-slot-exempt) run \
while the slot is held; their signals follow"
);
}