refactor(#2756): replace the DAG terminal hook with real tail nodes

The queue carried a per-DAG `HookKind` that fired an inline side effect
from outside the graph when a container rolled up terminal. mara asked
three times why this could not be an ordinary node; the answer in the
code was a doc-comment claiming a node could not work, and it was wrong.

`DepWhen::AfterAny` already existed with two live users, and a weak edge
is satisfied by a `Cancelled` dep, so a tail node runs on success,
failure and cancel alike. What was genuinely missing was smaller than a
hook: a node had no way to learn how the work it followed ended.

So: `Claim` now carries `deps: Vec<DepOutcome>`, snapshotted at claim
time from the graph the scheduler already holds (no `hive-jobq` change).
`Claim::deps_state()` / `deps_error()` roll that up, and two new kinds
consume it — `ResolveApproval { approval_id }` and `EmitRebuilt { agent }`.
Templates append one as a group-root with `AfterAny` edges onto the DAG's
other group roots; a root's state is its subtree's roll-up, so that
covers every node without fanning out to each of them.

Deleted: `HookKind`, `DagSpec.hook`, `NodeKind::Dag.hook`, `DagMeta.hook`,
`TerminalDag`, `terminal_dag()`, `terminal_summary()`, `dag_agents()`,
`dag_rollup()`, `fire_terminal_hook()`, `run_terminal_hook()`,
`emit_rebuilt()`. `complete_node` returns `()`.

Load-bearing details:

- `JobQueue::cancel` spares tail nodes instead of cancelling the whole
  subtree, and returns `bool`. Without this a cancelled approval DAG
  would dangle its approval forever — the hazard `tests.rs` already
  named. The spared tail's deps are `Cancelled`, which satisfies its weak
  edge, so the scheduler claims it and it resolves the row as cancelled.
  `hive-jobq` anticipated exactly this: `cancel_node`'s doc already says
  to settle afterwards so "a weak-edge terminal node observing the
  cancellation" can advance.
- The existing `complete(container)` call after cancelling is kept and is
  deliberately a no-op when a tail was spared (a non-terminal child parks
  the container back in `Finishing`), so power ops still settle
  synchronously with no branch.
- `DeployTail` is NOT `is_tail()`: it does real compensating work, and a
  cancelled DAG has nothing to compensate.
- `exec::failure_reason` falls back to `first_error(dag_id)` because a
  group root that rolled up `Failed` from a child carries no error of its
  own — without it every tail-reported failure would lose its reason.
- `EmitRebuilt` is per agent, so a multi-agent DAG reports each agent's
  own outcome rather than painting all of them with the DAG roll-up.
- `ResolveApproval` is agentless: the approval row already names its
  agent, and that is also what lets one tail close a multi-agent DAG.

Transients-derived-from-running-nodes and the frontend's node-kind
strings stay out of this change; they touch iris's slice and review
better next to their own diff.
This commit is contained in:
atlas 2026-07-27 14:25:03 +02:00 committed by mara
commit e8e6998ac5
11 changed files with 521 additions and 324 deletions

View file

@ -30,7 +30,7 @@
use anyhow::{Result, bail};
use super::model::{DagSpec, Dep, DepWhen, HookKind, NodeKind, NodeSpec, PermPayload, Source};
use super::model::{DagSpec, Dep, DepWhen, NodeKind, NodeSpec, PermPayload, Source};
use crate::coordinator::TransientKind;
/// After-ok edge on the previous node — the common chain link. Shared with
@ -43,6 +43,23 @@ pub(crate) fn after_ok(on: u64) -> Vec<Dep> {
}]
}
/// Weak edges onto every one of a DAG's other **group-roots** — how a tail node
/// (`ResolveApproval` / `EmitRebuilt`) sees the whole DAG's outcome.
///
/// Group-roots are the right granularity, not "every node": a root's state *is*
/// its subtree's roll-up, so edging the roots covers every descendant while
/// keeping the tail's dep list small and stable as subtrees grow. `AfterAny`
/// throughout, so the tail runs on success, failure and cancel alike and decides
/// from [`super::Claim::deps_state`].
pub(crate) fn after_any_all(ons: &[u64]) -> Vec<Dep> {
ons.iter()
.map(|&on| Dep {
on,
when: DepWhen::AfterAny,
})
.collect()
}
/// Build one **top-level (group-root)** node — `parent = None`. `kind` carries
/// the agent it targets ([`NodeKind`] is the payload directly). Shared with
/// `submit.rs`'s dynamic power-op builders. A root owns whatever resource it
@ -173,15 +190,27 @@ pub(crate) fn deploy_rebuild_nodes(agent: &str) -> Vec<NodeSpec> {
/// when `wanted = Offline` (a rebuild of a deliberately-stopped agent
/// leaves it stopped). `relock = false` only for meta-update cascade
/// children.
///
/// Closed by an [`NodeKind::EmitRebuilt`] tail edged onto all three group-roots
/// (`MetaSync`, `Prebuild`, `Reconcile`) — `Prebuild`'s roll-up carries the
/// whole `StopForUpdate`→`Swap`→`PostSwap` subtree, so those three cover every
/// node. Edging `Reconcile` alone would not do: it is `AfterAny` `Prebuild`, so
/// it reaches `Done` even after a failed swap and the tail would report success.
pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> DagSpec {
let mut nodes = rebuild_nodes(agent, relock, 0);
nodes.push(node(
NodeKind::EmitRebuilt {
agent: agent.to_owned(),
},
after_any_all(&[0, 1, 5]),
));
DagSpec {
hook: Some(HookKind::EmitRebuilt),
source,
reason,
approval_id: None,
inputs: Vec::new(),
transient: Some(TransientKind::Rebuilding),
nodes: rebuild_nodes(agent, relock, 0),
nodes,
}
}
@ -201,12 +230,18 @@ pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> Dag
/// bookkeeping tail — rollback when a merge landed unfinalized, forge tag
/// mirror, PR failure comment (see [`NodeKind::DeployTail`]).
///
/// - `ResolveApproval` (4, **root**, `AfterAny` `DeployWindow`): resolves the
/// approval row. A root rather than another child, so it isn't inside the
/// window's resource subtree — it runs once the window has released the meta
/// window, lease and build slot. One edge suffices here: `DeployWindow` is the
/// DAG's only other group-root, so its roll-up already *is* the whole
/// pipeline's outcome.
///
/// The window still spans the container build, as it must: `prepare_deploy`
/// leaves `flake.lock` staged-uncommitted for the build's whole duration.
pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec {
let a = || agent.to_owned();
DagSpec {
hook: Some(HookKind::ResolveApproval),
source: Source::Approval,
reason,
approval_id: Some(approval_id),
@ -224,6 +259,10 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec
when: DepWhen::AfterAny,
}],
),
node(
NodeKind::ResolveApproval { approval_id },
after_any_all(&[0]),
),
],
}
}
@ -241,7 +280,6 @@ pub fn reconcile_only(
transient: Option<TransientKind>,
) -> DagSpec {
DagSpec {
hook: None,
source,
reason,
approval_id: None,
@ -264,10 +302,11 @@ pub fn reconcile_only(
/// `Create` (child) owns the agent lease; `WriteDropin` + `Reconcile`
/// (children of `Create`) borrow it. A failure cancel-cascades the rest —
/// unlike rebuild there's no recovery-reconcile (nothing to converge if the
/// container was never created).
/// container was never created). Closed by a `ResolveApproval` tail root edged
/// `AfterAny` onto `Provision` — the DAG's only other group-root, so its roll-up
/// already carries the whole cascade.
pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
DagSpec {
hook: Some(HookKind::ResolveApproval),
source: Source::Approval,
reason,
approval_id: Some(approval_id),
@ -280,6 +319,10 @@ pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
child(0, NodeKind::Create { agent: a() }, Vec::new()),
child(1, NodeKind::WriteDropin { agent: a() }, Vec::new()),
child(1, NodeKind::Reconcile { agent: a() }, after_ok(2)),
node(
NodeKind::ResolveApproval { approval_id },
after_any_all(&[0]),
),
]
},
}
@ -287,7 +330,9 @@ pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
/// Perm change: commit the JSON file(s), then the rebuild subgraph so
/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes
/// effect in the container.
/// effect in the container. Group-roots are `WritePermFile`(0) plus the rebuild
/// subgraph's `MetaSync`(1) / `Prebuild`(2) / `Reconcile`(6), so the
/// `EmitRebuilt` tail edges all four.
pub fn perm_change(agent: &str, source: Source, reason: String, payload: PermPayload) -> DagSpec {
let mut nodes = vec![node(
NodeKind::WritePermFile {
@ -297,8 +342,13 @@ pub fn perm_change(agent: &str, source: Source, reason: String, payload: PermPay
Vec::new(),
)];
nodes.extend(rebuild_nodes(agent, true, 1));
nodes.push(node(
NodeKind::EmitRebuilt {
agent: agent.to_owned(),
},
after_any_all(&[0, 1, 2, 6]),
));
DagSpec {
hook: Some(HookKind::EmitRebuilt),
source,
reason,
approval_id: None,
@ -324,22 +374,30 @@ pub fn meta_update(
reason: String,
approval_id: Option<i64>,
) -> DagSpec {
let mut nodes = vec![node(
NodeKind::MetaLock {
sweep: false,
fanout: None,
},
Vec::new(),
)];
// The bump itself has no side effect, so an operator-driven one ends at the
// `MetaLock`; an approval-driven one still has its row to resolve and gets a
// tail edged onto that single group-root — whose roll-up covers the rebuild
// subgraphs `MetaLock` grows into itself.
if let Some(approval_id) = approval_id {
nodes.push(node(
NodeKind::ResolveApproval { approval_id },
after_any_all(&[0]),
));
}
DagSpec {
// 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,
inputs,
transient: Some(TransientKind::Rebuilding),
nodes: vec![node(
NodeKind::MetaLock {
sweep: false,
fanout: None,
},
Vec::new(),
)],
nodes,
}
}
@ -351,14 +409,13 @@ 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. No terminal hook: the write is the whole effect.
/// off of) and near-instant. No tail node: the write is the whole effect.
pub fn reparent(
moves: Vec<(hive_types::Ident, Option<hive_types::Ident>)>,
source: Source,
reason: String,
) -> DagSpec {
DagSpec {
hook: None,
source,
reason,
approval_id: None,