diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 507c9dea..2b8f55e3 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -112,6 +112,7 @@ pub(crate) async fn run_terminal_hook(coord: &Arc, terminal: &super crate::actions::resolve_approval_dag(coord, terminal).await; } Some(super::HookKind::EmitRebuilt) => emit_rebuilt(coord, terminal), + Some(super::HookKind::RevertIntent) => revert_intent(coord, terminal).await, None => {} } } @@ -140,6 +141,25 @@ fn emit_rebuilt(coord: &Arc, terminal: &super::TerminalDag) { } } +/// Power-op hook: on a *cancelled* DAG, revert each targeted agent's `wanted` +/// intent to its observed state — the operator's cancel means "don't do it", so +/// the intent snaps back instead of the flip executing as a surprise side effect +/// of some later reconcile. Noop on any non-cancelled outcome. +async fn revert_intent(coord: &Arc, terminal: &super::TerminalDag) { + if terminal.state != State::Cancelled { + return; + } + for agent in &terminal.agents { + let running = crate::lifecycle::is_running(agent).await; + if let Err(e) = coord + .power + .set(agent, crate::power::Wanted::from_running(running)) + { + tracing::warn!(%agent, error = ?e, "agent_power: cancel revert failed"); + } + } +} + /// Write the agent's durable power intent — the DAG-node form of the old /// pre-submit `set_wanted` side effect. Store-only (no container touch), so /// build-slot-exempt; but it takes the agent's lifecycle lease (see diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 6ebcc9f1..877ca89a 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -169,18 +169,12 @@ pub enum HookKind { ResolveApproval, /// Rebuild / perm-change: emit one `Rebuilt` manager event per agent. EmitRebuilt, + /// Power-op: on a *cancelled* DAG, revert each agent's `wanted` intent. + RevertIntent, } /// The terminal hook a DAG needs, from its template + approval id — or `None` -/// for a DAG with no terminal side effect (power-op, meta-update, boot, bare -/// reconcile). -/// -/// A cancelled DAG deliberately gets **no** compensating hook. [`JobQueue::cancel`] -/// refuses unless every work node is still `Pending`, and a cancel *cascade* -/// rolls up `Failed` (see `dag_rollup`), never `Cancelled` — so on a -/// `Cancelled` DAG no node ever executed and there is nothing to undo. A power -/// op's `SetWanted` head provably never ran, so its intent is still whatever -/// the operator last set it to. +/// for a DAG with no terminal side effect (meta-update, boot, bare reconcile). #[must_use] pub fn terminal_hook(template: Template, approval_id: Option) -> Option { if approval_id.is_some() { @@ -188,6 +182,11 @@ pub fn terminal_hook(template: Template, approval_id: Option) -> Option Some(HookKind::EmitRebuilt), + Template::Start + | Template::Stop + | Template::GracefulStop + | Template::Restart + | Template::GracefulRestart => Some(HookKind::RevertIntent), _ => None, } } diff --git a/hive-c0re/src/job_queue/scheduler.rs b/hive-c0re/src/job_queue/scheduler.rs index 850d6bb2..1c2022f9 100644 --- a/hive-c0re/src/job_queue/scheduler.rs +++ b/hive-c0re/src/job_queue/scheduler.rs @@ -10,10 +10,10 @@ //! held, so it appears when the agent's owner node starts and disappears when //! its subgraph settles — one pill per agent a DAG touches. //! -//! Per-DAG terminal work (approval resolution, `Rebuilt`) is not drained here: -//! it runs as the DAG's focused terminal node (`ResolveApproval` / -//! `EmitRebuilt`), dispatched through `exec::run_node` like any other node once -//! the DAG settles. +//! Per-DAG terminal work (approval resolution, `Rebuilt`, cancelled-power-op +//! intent revert) is not drained here: it runs as the DAG's focused terminal +//! node (`ResolveApproval` / `EmitRebuilt` / `RevertIntent`), dispatched through +//! `exec::run_node` like any other node once the DAG settles. //! //! In-DAG growth (a `MetaLock` growing rebuild subgraphs, a `Reconcile` fanning //! its `Start`/`Stop`) flows through `NodeOutput.append_subgraph`, applied diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 094af83a..1327515a 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -863,66 +863,6 @@ fn cancel_refuses_running_dag() { assert_eq!(state_of(&q, id), State::Running); } -/// A cancelled power op must fire **no** compensating hook — not even one that -/// carries a `SetWanted` head. -/// -/// `cancel` refuses unless every work node is still `Pending` -/// (`cancel_refuses_running_dag`) and a cancel *cascade* rolls up `Failed` -/// rather than `Cancelled`, so a `Cancelled` DAG provably never executed a -/// node: its `SetWanted` never ran and the agent's intent still reads whatever -/// the operator last set. A "revert" instead writes the agent's *observed* -/// state, which for a down-but-`wanted = Up` agent (crashed, or caught -/// mid-bounce) flips the intent to `Offline` and leaves it -/// deliberately-stopped as far as reconcile and crash-watch are concerned. -#[test] -fn cancelled_power_op_fires_no_hook() { - for graceful in [false, true] { - for running in [false, true] { - let targets = vec![("agent-a".to_owned(), running)]; - let cases = [ - ( - "restart", - false, - submit::restart_spec(&targets, graceful, Source::Manual, "bounce".to_owned()), - ), - ( - "stop", - true, - submit::stop_spec(&targets, graceful, Source::Manual, "stop".to_owned()), - ), - ( - "start", - true, - submit::start_spec( - &[("agent-a".to_owned(), running, false)], - Source::Manual, - "start".to_owned(), - ), - ), - ]; - for (name, writes_intent, spec) in cases { - assert_eq!( - spec.nodes - .iter() - .any(|n| matches!(n.kind, NodeKind::SetWanted { .. })), - writes_intent, - "{name} intent head (graceful={graceful}, running={running})" - ); - let q = JobQueue::new(1); - let id = submit(&q, spec); - let summary = q.cancel(id).expect("cancelled while queued"); - assert_eq!(summary.state, State::Cancelled); - assert_eq!( - terminal_hook(summary.template, summary.approval_id), - None, - "cancelled {name} (graceful={graceful}, running={running}) must \ - fire no hook — no node of it ever ran" - ); - } - } - } -} - // ---- terminal reporting + lease release ---- #[test]