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

@ -24,7 +24,7 @@
use std::sync::Arc;
use super::model::{DagSpec, Dep, NodeKind, NodeSpec, Template};
use super::model::{DagSpec, Dep, NodeKind, NodeSpec};
use super::templates::{after_ok, child, node, rebuild_nodes};
use super::{Source, templates};
use crate::coordinator::{Coordinator, TransientKind};
@ -188,16 +188,17 @@ fn concat_subgraphs(chains: Vec<Vec<NodeSpec>>) -> Vec<NodeSpec> {
out
}
/// Wrap assembled power-op `nodes` in a `DagSpec`.
/// Wrap assembled power-op `nodes` in a `DagSpec`. No terminal hook: a power
/// op's effect is its nodes (`SetWanted` + `Reconcile`), with nothing left to
/// do once they settle.
fn power_dag(
template: Template,
transient: TransientKind,
source: Source,
reason: String,
nodes: Vec<NodeSpec>,
) -> DagSpec {
DagSpec {
template,
hook: None,
source,
reason,
approval_id: None,
@ -223,13 +224,7 @@ pub(crate) fn stop_spec(
.iter()
.map(|(agent, running)| stop_chain(agent, graceful, *running))
.collect();
let template = if graceful {
Template::GracefulStop
} else {
Template::Stop
};
power_dag(
template,
TransientKind::Stopping,
source,
reason,
@ -256,13 +251,7 @@ pub(crate) fn start_spec(
} else {
TransientKind::Starting
};
power_dag(
Template::Start,
transient,
source,
reason,
concat_subgraphs(chains),
)
power_dag(transient, source, reason, concat_subgraphs(chains))
}
/// Assemble the restart DAG from explicit `(agent, running)` targets.
@ -276,13 +265,7 @@ pub(crate) fn restart_spec(
.iter()
.map(|(agent, running)| restart_chain(agent, graceful, *running))
.collect();
let template = if graceful {
Template::GracefulRestart
} else {
Template::Restart
};
power_dag(
template,
TransientKind::Restarting,
source,
reason,