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

@ -30,7 +30,7 @@
use anyhow::{Result, bail};
use super::model::{DagSpec, Dep, DepWhen, NodeKind, NodeSpec, PermPayload, Source, Template};
use super::model::{DagSpec, Dep, DepWhen, HookKind, NodeKind, NodeSpec, PermPayload, Source};
use crate::coordinator::TransientKind;
/// After-ok edge on the previous node — the common chain link. Shared with
@ -175,7 +175,7 @@ pub(crate) fn deploy_rebuild_nodes(agent: &str) -> Vec<NodeSpec> {
/// children.
pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> DagSpec {
DagSpec {
template: Template::Rebuild,
hook: Some(HookKind::EmitRebuilt),
source,
reason,
approval_id: None,
@ -206,7 +206,7 @@ pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> Dag
pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec {
let a = || agent.to_owned();
DagSpec {
template: Template::Rebuild,
hook: Some(HookKind::ResolveApproval),
source: Source::Approval,
reason,
approval_id: Some(approval_id),
@ -235,14 +235,13 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec
/// in the queue tests); production paths no longer emit a bare reconcile.
#[cfg(test)]
pub fn reconcile_only(
template: Template,
agent: &str,
source: Source,
reason: String,
transient: Option<TransientKind>,
) -> DagSpec {
DagSpec {
template,
hook: None,
source,
reason,
approval_id: None,
@ -268,7 +267,7 @@ pub fn reconcile_only(
/// container was never created).
pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
DagSpec {
template: Template::Spawn,
hook: Some(HookKind::ResolveApproval),
source: Source::Approval,
reason,
approval_id: Some(approval_id),
@ -299,7 +298,7 @@ pub fn perm_change(agent: &str, source: Source, reason: String, payload: PermPay
)];
nodes.extend(rebuild_nodes(agent, true, 1));
DagSpec {
template: Template::PermChange,
hook: Some(HookKind::EmitRebuilt),
source,
reason,
approval_id: None,
@ -326,7 +325,9 @@ pub fn meta_update(
approval_id: Option<i64>,
) -> DagSpec {
DagSpec {
template: Template::MetaUpdate,
// The bump itself has no side effect; an approval-driven one still has
// its row to resolve.
hook: approval_id.map(|_| HookKind::ResolveApproval),
source,
reason,
approval_id,
@ -350,24 +351,14 @@ pub fn meta_update(
/// (dashboard tree, `<parent>`/`<children>` sentinel routing, permission
/// checks), so a parent move needs no container rebuild to take effect.
/// No transient pill either — the node is agentless (no lease to hang one
/// off of) and near-instant.
///
/// Rides `Template::MetaUpdate` rather than a dedicated variant because
/// `Template` is being removed and nothing should dispatch on it — a fresh
/// variant would just be more surface to delete later. `Template` is already
/// internal-only (not on `DagView`'s wire shape — the dashboard derives its
/// label from `nodes`), so the choice of stand-in variant only affects
/// `terminal_hook` dispatch (`MetaUpdate` resolves to `None`, same as a
/// dedicated variant would) and the per-template history-retention bucket —
/// both cosmetic. Swap this to whatever the eventual node-kind-derived
/// dispatch lands with, whenever it lands.
/// off of) and near-instant. No terminal hook: the write is the whole effect.
pub fn reparent(
moves: Vec<(hive_types::Ident, Option<hive_types::Ident>)>,
source: Source,
reason: String,
) -> DagSpec {
DagSpec {
template: Template::MetaUpdate,
hook: None,
source,
reason,
approval_id: None,
@ -388,7 +379,7 @@ pub fn reparent(
/// old queue's documented "circular dep silently deadlocks forever" caveat.
pub fn validate(spec: &DagSpec) -> Result<()> {
if spec.nodes.is_empty() {
bail!("dag spec {:?} has no nodes", spec.template);
bail!("dag spec {:?} has no nodes", spec.reason);
}
let n = spec.nodes.len();
let mut graph = petgraph::graph::DiGraph::<u32, ()>::new();
@ -404,14 +395,14 @@ pub fn validate(spec: &DagSpec) -> Result<()> {
{
bail!(
"dag spec {:?} node {i} has invalid parent {p} (must be an earlier node)",
spec.template
spec.reason
);
}
for dep in &node.deps {
let Some(&dep_idx) = usize::try_from(dep.on).ok().and_then(|i| idx.get(i)) else {
bail!(
"dag spec {:?} node {i} depends on unknown node {}",
spec.template,
spec.reason,
dep.on
);
};
@ -419,7 +410,7 @@ pub fn validate(spec: &DagSpec) -> Result<()> {
}
}
if petgraph::algo::toposort(&graph, None).is_err() {
bail!("dag spec {:?} contains a dependency cycle", spec.template);
bail!("dag spec {:?} contains a dependency cycle", spec.reason);
}
Ok(())
}