refactor(#2756): declare the terminal hook instead of inferring it

`Template` was a DAG-level enum that three different things read back
out: `terminal_hook()` mapped it to a side effect, the retention pass
bucketed history by it, and a tracing field printed it. None of those
needed a *label* — they needed the two facts the label happened to
encode. So the enum was a lossy stand-in for intent, and every new DAG
shape had to pick the variant whose inferred behaviour matched, whether
or not the name fit (`reparent` rode `MetaUpdate` for exactly this
reason, with a 10-line comment apologising for it).

Replace the inference with a declaration: `DagSpec.hook:
Option<HookKind>`. Only the builder assembling a DAG knows why it did
so, so only the builder can say what should happen when it settles.
`run_terminal_hook` becomes a field read, and `reparent`'s apology
becomes `hook: None`.

Hook assignment is byte-identical to the old precedence rule
(`approval_id.is_some()` wins, then `Rebuild | PermChange`), checked
site by site; `meta_update` is the only builder with a variable
approval id and so the only remaining conditional.

Retention loses the per-template bucket with the enum that keyed it.
The dashboard renders one recent-builds list, so one flat newest-first
cap (`MAX_HISTORY_DAGS`) bounds it. `HISTORY_GRACE_SECS` goes too — it
existed to stop a burst of same-template DAGs evicting each other
inside one poll interval, which is not a failure mode a flat cap has.
That takes `snapshot_capped()` and the `snapshot_no_grace()` test hook
with it.

The queue is runtime-only (empty graph on boot), so the serde changes
carry no migration risk.
This commit is contained in:
atlas 2026-07-27 12:37:03 +02:00 committed by mara
commit ca7146e4f0
8 changed files with 113 additions and 241 deletions

View file

@ -17,55 +17,25 @@ use serde::Serialize;
use crate::coordinator::TransientKind;
/// What a DAG *means* — the request-level shape. Internal to the queue now:
/// it drives the terminal-hook dispatch ([`crate::job_queue`]'s `dag_hook`)
/// and the meta-update dedup key, and is **no longer sent on the wire** — the
/// dashboard derives a DAG's label from its node kinds (see `DagView`). The
/// `NodeKind::Dag` container carries it in its payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
/// The inline side effect a settled DAG fires when its container node rolls
/// up terminal (there is no hook *node*). Stated explicitly by the builder in
/// `templates.rs` / `submit.rs` rather than inferred from a DAG-level enum:
/// only the builder knows why it assembled the DAG, so only the builder can
/// say what should happen at the end of it.
///
/// A cancelled DAG deliberately gets **no** compensating hook. [`super::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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Template {
/// Rebuild one agent's container (prebuild → stop → profile-swap →
/// reconcile).
Rebuild,
/// Bump meta flake locks; grows a rebuild subgraph per affected
/// agent into the same DAG on completion.
MetaUpdate,
/// First-deploy spawn (approval-driven).
Spawn,
/// Mechanical stop + converge to `wanted = Up` (a restart).
Restart,
/// Signal → drain → mechanical stop → converge to `wanted = Up` — a
/// graceful restart as one atomic DAG.
GracefulRestart,
/// Perm-file commit followed by the rebuild subgraph.
PermChange,
/// Quiesce the harness, drain, then stop (`wanted = Offline`).
GracefulStop,
/// Converge to `wanted = Up`.
Start,
/// Converge to `wanted = Offline`.
Stop,
/// Boot-time config sweep as one DAG.
Boot,
}
impl Template {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Template::Rebuild => "rebuild",
Template::MetaUpdate => "meta_update",
Template::Spawn => "spawn",
Template::Restart => "restart",
Template::GracefulRestart => "graceful_restart",
Template::PermChange => "perm_change",
Template::GracefulStop => "graceful_stop",
Template::Start => "start",
Template::Stop => "stop",
Template::Boot => "boot",
}
}
pub enum HookKind {
/// Approval-driven DAG (spawn / opaque deploy): resolve the approval row.
ResolveApproval,
/// Rebuild / perm-change: emit one `Rebuilt` manager event per agent.
EmitRebuilt,
}
/// When a dependency edge is considered satisfied.
@ -293,15 +263,16 @@ pub enum NodeKind {
/// is skipped.)
SetWanted { agent: String, up: bool },
/// The **DAG container** node: one per submitted DAG, carrying the group's
/// domain metadata. Every template node hangs *under* it (its subtree), so
/// domain metadata. Every node hangs *under* it (its subtree), so
/// the container's `NodeId` **is** the DAG id, its rolled-up state **is** the
/// DAG state, and it reaching terminal **is** the completion signal that
/// fires the DAG's inline hook (approval-resolve / rebuilt-emit /
/// intent-revert, dispatched off `template`). Pure grouping — lease- and
/// fires the DAG's inline `hook`. Pure grouping — lease- and
/// build-slot-exempt; the executor instant-completes it (`Done`) so it
/// reaches `Finishing` and its children start.
Dag {
template: Template,
/// The side effect to run when this DAG settles, or `None` for a DAG
/// with none (power op, meta-update, boot).
hook: Option<HookKind>,
source: Source,
reason: String,
transient: Option<TransientKind>,
@ -475,14 +446,16 @@ pub struct NodeSpec {
/// ([`NodeKind::WritePermFile`]), not this generic spec.
#[derive(Debug, Clone)]
pub struct DagSpec {
pub template: Template,
/// The inline side effect to fire when this DAG settles. Explicit — the
/// builder assembling the DAG is the only thing that knows its intent.
pub hook: Option<HookKind>,
pub source: Source,
/// Free-form "why".
pub reason: String,
/// Fires the approval-resolution hook on DAG terminal.
/// The approval row [`HookKind::ResolveApproval`] resolves. Set together
/// with that hook; carried separately because the hook needs the id.
pub approval_id: Option<i64>,
/// `MetaUpdate`-only: the inputs to bump (also part of the dedup
/// key for that template). Display copy lives on the DAG.
/// Meta-update only: the inputs to bump. Display copy lives on the DAG.
pub inputs: Vec<String>,
/// Dashboard transient pill (and crash-watch suppression) held for
/// the lease window — from lease acquisition to DAG terminal.