refactor(#2441): move agent field from DAG onto Node; drop dedup

Agent was a single field on Dag/DagSpec, making a DAG structurally
one-agent — a multi-agent op could only ever be N separate DAGs. Move it
onto Node/NodeSpec (and the NodeView wire type), drop it from Dag/DagSpec
(and DagView): a DAG can now span agents.

- lifecycle lease keys on the node's agent, still globally exclusive per
  agent across all DAGs (Inner.leases unchanged in shape). A DAG holds one
  lease per distinct agent it touches; settle() frees each at DAG-terminal
  (per-agent-subgraph early release is a follow-up, only observable with
  multi-agent DAGs).
- transient guard keyed (dag_id, agent); cancel-revert + Rebuilt events
  walk TerminalDag.agents.
- submit-time dedup removed (a multi-agent DAG has no single agent to key
  on); every submit enqueues a fresh DAG. Whether dedup needs reintroducing
  is tracked in a follow-up sub-issue.
- templates gain a node(agent, kind, deps) helper stamping the agent onto
  every node; meta templates stamp "hyperhive".

Templates stay single-agent in this PR — behaviour is unchanged, only the
representation + wire shape. Multi-agent DAG emission (restart/restart-all/
broad stop+start as one DAG) and the SetWanted-as-a-node change are
follow-ups off #2439.
This commit is contained in:
atlas 2026-07-14 21:42:26 +02:00 committed by mara
commit 2a59f2f5fc
7 changed files with 223 additions and 356 deletions

View file

@ -514,12 +514,17 @@ pub(super) async fn on_dag_terminal(coord: &Arc<Coordinator>, terminal: &Termina
| Template::GracefulRestart | Template::GracefulRestart
) )
{ {
let running = crate::lifecycle::is_running(&terminal.agent).await; // Revert each targeted agent's power intent to its observed state —
if let Err(e) = coord // the operator's cancel means "don't do it". Single-agent power-op
.power // DAGs have one agent here.
.set(&terminal.agent, crate::power::Wanted::from_running(running)) for agent in &terminal.agents {
{ let running = crate::lifecycle::is_running(agent).await;
tracing::warn!(agent = %terminal.agent, error = ?e, "agent_power: cancel revert failed"); if let Err(e) = coord
.power
.set(agent, crate::power::Wanted::from_running(running))
{
tracing::warn!(%agent, error = ?e, "agent_power: cancel revert failed");
}
} }
} }
if terminal.approval_id.is_some() { if terminal.approval_id.is_some() {
@ -527,22 +532,26 @@ pub(super) async fn on_dag_terminal(coord: &Arc<Coordinator>, terminal: &Termina
return; return;
} }
if matches!(terminal.template, Template::Rebuild | Template::PermChange) { if matches!(terminal.template, Template::Rebuild | Template::PermChange) {
match terminal.state { // Rebuild / PermChange are single-agent; emit one `Rebuilt` per
State::Done => coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt { // targeted agent (exactly one today).
agent: terminal.agent.clone(), for agent in &terminal.agents {
ok: true, match terminal.state {
note: None, State::Done => coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
sha: None, agent: agent.clone(),
tag: None, ok: true,
}), note: None,
State::Failed => coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt { sha: None,
agent: terminal.agent.clone(), tag: None,
ok: false, }),
note: terminal.error.clone(), State::Failed => coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
sha: None, agent: agent.clone(),
tag: None, ok: false,
}), note: terminal.error.clone(),
_ => {} sha: None,
tag: None,
}),
_ => {}
}
} }
} }
} }

View file

@ -7,10 +7,12 @@
//! Concurrency is gated by two resource classes: //! Concurrency is gated by two resource classes:
//! 1. **Build slots** — N permits (`services.hyperhive.c0re.buildSlots`, //! 1. **Build slots** — N permits (`services.hyperhive.c0re.buildSlots`,
//! default 1) held by nix-heavy nodes for the node's duration. //! default 1) held by nix-heavy nodes for the node's duration.
//! 2. **Per-agent lifecycle lease** — DAG-scoped: acquired before the //! 2. **Per-agent lifecycle lease** — keyed on the *node's* agent and
//! DAG's first container-affecting node runs, held until the DAG is //! globally exclusive per agent across all DAGs: acquired before a
//! terminal, so two lifecycle DAGs for one agent never interleave //! container-affecting node runs, held (by the owning DAG) until no
//! their container ops. //! live node of that DAG still targets the agent, so two DAGs never
//! interleave container ops on the same agent. A DAG spanning multiple
//! agents holds one lease per agent it touches.
//! //!
//! The meta *repo* is serialized by `meta::META_LOCK` inside the //! The meta *repo* is serialized by `meta::META_LOCK` inside the
//! executors themselves. Per-agent power *intent* (`wanted`) lives in //! executors themselves. Per-agent power *intent* (`wanted`) lives in
@ -57,15 +59,18 @@ pub struct Claim {
pub dag_id: u64, pub dag_id: u64,
pub node_id: NodeId, pub node_id: NodeId,
pub kind: NodeKind, pub kind: NodeKind,
/// The agent this node targets (its own, not a DAG-level field). The
/// executor operates on this agent's container; the lease is keyed on
/// it.
pub agent: String, pub agent: String,
pub template: Template, pub template: Template,
pub source: Source, pub source: Source,
pub approval_id: Option<i64>, pub approval_id: Option<i64>,
pub inputs: Vec<String>, pub inputs: Vec<String>,
pub perm_payload: Option<PermPayload>, pub perm_payload: Option<PermPayload>,
/// True when claiming this node acquired the DAG's agent lease — /// True when claiming this node newly acquired its agent's lease —
/// the scheduler creates the DAG-scoped transient guard on this /// the scheduler creates the per-`(dag, agent)` transient guard on
/// edge. /// this edge.
pub lease_acquired: bool, pub lease_acquired: bool,
/// Transient pill kind for the lease window (from the spec). /// Transient pill kind for the lease window (from the spec).
pub transient: Option<crate::coordinator::TransientKind>, pub transient: Option<crate::coordinator::TransientKind>,
@ -78,7 +83,10 @@ pub struct Claim {
pub struct TerminalDag { pub struct TerminalDag {
pub dag_id: u64, pub dag_id: u64,
pub template: Template, pub template: Template,
pub agent: String, /// Distinct agents this DAG's nodes targeted (one for a single-agent
/// DAG). The cancel-revert hook walks these to snap each agent's
/// power intent back on a cancelled power-op DAG.
pub agents: Vec<String>,
pub approval_id: Option<i64>, pub approval_id: Option<i64>,
pub state: State, pub state: State,
/// First failed node's error when `state == Failed`. /// First failed node's error when `state == Failed`.
@ -128,27 +136,17 @@ impl JobQueue {
} }
} }
/// Submit a DAG. Validates the spec (cycle rejection) and dedups /// Submit a DAG. Validates the spec (cycle rejection) and returns the
/// against non-started DAGs; returns the DAG id (newly-allocated, /// newly-allocated DAG id.
/// or the existing DAG's id with the new reason appended).
/// ///
/// Dedup: a DAG whose roll-up is still `Queued` (no node started) /// Submit-time dedup was removed with the agent-per-node refactor
/// with the same `(template, agent, parent_id, approval_id)` — plus /// (a multi-agent DAG has no single agent to key a dedup on) — every
/// `inputs` for `MetaUpdate` and the perm-type discriminant for /// submit now enqueues a fresh DAG. Whether any dedup needs
/// `PermChange` — swallows the repeat. `parent_id` is part of the /// reintroducing (and in what form) is tracked as a follow-up; see the
/// key so a meta-update cascade rebuild never collapses into a /// dedup re-evaluation issue.
/// standalone or sweep rebuild. Running / terminal DAGs never
/// dedup — operators are free to re-queue.
pub fn submit(&self, spec: DagSpec) -> anyhow::Result<u64> { pub fn submit(&self, spec: DagSpec) -> anyhow::Result<u64> {
templates::validate(&spec)?; templates::validate(&spec)?;
let mut inner = self.inner.lock().expect("job_queue mutex poisoned"); let mut inner = self.inner.lock().expect("job_queue mutex poisoned");
if let Some(existing) = Self::dedup_target(&mut inner, &spec) {
if !existing.reason.contains(&spec.reason) {
use std::fmt::Write as _;
let _ = write!(existing.reason, "\nalso requested by: {}", spec.reason);
}
return Ok(existing.id);
}
let id = Self::push_dag(&mut inner, spec); let id = Self::push_dag(&mut inner, spec);
drop(inner); drop(inner);
self.notify.notify_one(); self.notify.notify_one();
@ -156,8 +154,8 @@ impl JobQueue {
} }
/// Append fan-out children under a parent DAG (meta-update / sweep /// Append fan-out children under a parent DAG (meta-update / sweep
/// cascade). Applies the same dedup as [`Self::submit`]; returns /// cascade); returns the child ids created. No dedup (see
/// the child ids actually created or coalesced into. /// [`Self::submit`]).
pub fn append_children(&self, specs: Vec<DagSpec>) -> Vec<u64> { pub fn append_children(&self, specs: Vec<DagSpec>) -> Vec<u64> {
let mut ids = Vec::with_capacity(specs.len()); let mut ids = Vec::with_capacity(specs.len());
for spec in specs { for spec in specs {
@ -183,9 +181,14 @@ impl JobQueue {
pub fn append_node(&self, dag_id: u64, kind: NodeKind, dep_on: NodeId) -> Option<NodeId> { pub fn append_node(&self, dag_id: u64, kind: NodeKind, dep_on: NodeId) -> Option<NodeId> {
let mut inner = self.inner.lock().expect("job_queue mutex poisoned"); let mut inner = self.inner.lock().expect("job_queue mutex poisoned");
let dag = inner.dags.iter_mut().find(|d| d.id == dag_id)?; let dag = inner.dags.iter_mut().find(|d| d.id == dag_id)?;
// The appended sub-step targets the same agent as the node that
// emitted it (a `Reconcile` fanning out its `Start`/`Stop` acts on
// the same container), so inherit `dep_on`'s agent.
let agent = dag.node(dep_on)?.agent.clone();
let new_id: NodeId = u32::try_from(dag.nodes.len()).unwrap_or(u32::MAX); let new_id: NodeId = u32::try_from(dag.nodes.len()).unwrap_or(u32::MAX);
dag.nodes.push(Node { dag.nodes.push(Node {
id: new_id, id: new_id,
agent,
kind, kind,
deps: vec![model::Dep { deps: vec![model::Dep {
on: dep_on, on: dep_on,
@ -203,21 +206,6 @@ impl JobQueue {
Some(new_id) Some(new_id)
} }
fn dedup_target<'a>(inner: &'a mut Inner, spec: &DagSpec) -> Option<&'a mut Dag> {
inner.dags.iter_mut().find(|d| {
d.rollup() == State::Queued
&& d.template == spec.template
&& d.agent == spec.agent
&& d.parent_id == spec.parent_id
&& d.approval_id == spec.approval_id
&& (d.template != Template::MetaUpdate || d.inputs == spec.inputs)
&& model::perm_payload_same_type(
d.perm_payload.as_ref(),
spec.perm_payload.as_ref(),
)
})
}
fn push_dag(inner: &mut Inner, spec: DagSpec) -> u64 { fn push_dag(inner: &mut Inner, spec: DagSpec) -> u64 {
inner.next_id += 1; inner.next_id += 1;
let id = inner.next_id; let id = inner.next_id;
@ -227,6 +215,7 @@ impl JobQueue {
.enumerate() .enumerate()
.map(|(i, n)| Node { .map(|(i, n)| Node {
id: u32::try_from(i).unwrap_or(u32::MAX), id: u32::try_from(i).unwrap_or(u32::MAX),
agent: n.agent,
kind: n.kind, kind: n.kind,
deps: n.deps, deps: n.deps,
state: State::Queued, state: State::Queued,
@ -240,7 +229,6 @@ impl JobQueue {
inner.dags.push_back(Dag { inner.dags.push_back(Dag {
id, id,
template: spec.template, template: spec.template,
agent: spec.agent,
source: spec.source, source: spec.source,
reason: spec.reason, reason: spec.reason,
parent_id: spec.parent_id, parent_id: spec.parent_id,
@ -282,16 +270,23 @@ impl JobQueue {
let dag = &inner.dags[di]; let dag = &inner.dags[di];
let node = dag.node(node_id).expect("node id from same dag"); let node = dag.node(node_id).expect("node id from same dag");
let needs_slot = node.kind.needs_build_slot(); let needs_slot = node.kind.needs_build_slot();
let needs_lease = node.kind.needs_lease();
// The lifecycle lease is keyed on the *node's* agent, held
// by this DAG (dag_id) — still globally exclusive per agent
// across all DAGs. A multi-agent DAG acquires one lease per
// agent it touches; each is released in `settle` once no
// live node of this DAG still targets that agent.
let node_agent = node.agent.clone();
if needs_slot && inner.slots_used >= inner.build_slots { if needs_slot && inner.slots_used >= inner.build_slots {
continue; continue;
} }
let mut lease_acquired = false; let mut lease_acquired = false;
if node.kind.needs_lease() { if needs_lease {
match inner.leases.get(dag.agent.as_str()) { match inner.leases.get(node_agent.as_str()) {
Some(&holder) if holder != dag_id => continue, Some(&holder) if holder != dag_id => continue,
Some(_) => {} Some(_) => {}
None => { None => {
inner.leases.insert(dag.agent.clone(), dag_id); inner.leases.insert(node_agent.clone(), dag_id);
lease_acquired = true; lease_acquired = true;
} }
} }
@ -304,7 +299,7 @@ impl JobQueue {
dag_id, dag_id,
node_id, node_id,
kind: dag.node(node_id).expect("node").kind.clone(), kind: dag.node(node_id).expect("node").kind.clone(),
agent: dag.agent.clone(), agent: node_agent,
template: dag.template, template: dag.template,
source: dag.source, source: dag.source,
approval_id: dag.approval_id, approval_id: dag.approval_id,
@ -425,13 +420,23 @@ impl JobQueue {
continue; continue;
} }
dag.terminal_reported = true; dag.terminal_reported = true;
if inner.leases.get(dag.agent.as_str()) == Some(&dag.id) { // Free every agent-lease this DAG holds (one per distinct
freed.push(dag.agent.clone()); // agent it touched). Single-agent DAGs release their one lease;
// a future multi-agent DAG releases all of them at terminal.
// (Per-agent early release — freeing an agent's lease the moment
// that agent's subgraph is terminal rather than at whole-DAG
// terminal — is a refinement for the multi-agent-emission
// follow-up, where it actually matters.)
let agents = dag.agents();
for agent in &agents {
if inner.leases.get(agent.as_str()) == Some(&dag.id) {
freed.push(agent.clone());
}
} }
reports.push(TerminalDag { reports.push(TerminalDag {
dag_id: dag.id, dag_id: dag.id,
template: dag.template, template: dag.template,
agent: dag.agent.clone(), agents,
approval_id: dag.approval_id, approval_id: dag.approval_id,
state: dag.rollup(), state: dag.rollup(),
error: dag.first_error().map(str::to_owned), error: dag.first_error().map(str::to_owned),

View file

@ -5,33 +5,15 @@
//! `hive_sh4re::jobs` (wire types belong to the shared crate) and are //! `hive_sh4re::jobs` (wire types belong to the shared crate) and are
//! re-exported here for the queue's internal use. //! re-exported here for the queue's internal use.
//! //!
//! Two levels: the **DAG** is the unit of dedup / cancel / //! Two levels: the **DAG** is the unit of cancel / approval-resolution
//! approval-resolution and the dashboard group; the **node** is the //! and the dashboard group; the **node** is the unit of scheduling /
//! unit of scheduling / execution / build-log / step label. See //! execution / build-log / step label, and carries its own `agent` (a
//! `docs/coordinator.md::Job queue` for the full design. //! DAG can span agents). See `docs/coordinator.md::Job queue` for the
//! full design.
pub use hive_sh4re::jobs::{DagView, NodeId, NodeView, PermPayload, Source, State, Template}; pub use hive_sh4re::jobs::{DagView, NodeId, NodeView, PermPayload, Source, State, Template};
use serde::Serialize; use serde::Serialize;
/// Dedup compares the perm *type*, not the value — a tool-groups
/// change and a capabilities change for the same agent are distinct
/// operations that must not collapse.
pub(super) fn perm_payload_same_type(a: Option<&PermPayload>, b: Option<&PermPayload>) -> bool {
matches!(
(a, b),
(
Some(PermPayload::ToolGroups { .. }),
Some(PermPayload::ToolGroups { .. })
) | (
Some(PermPayload::Capabilities { .. }),
Some(PermPayload::Capabilities { .. })
) | (
Some(PermPayload::Combined { .. }),
Some(PermPayload::Combined { .. })
) | (None, None)
)
}
/// When a dependency edge is considered satisfied. /// When a dependency edge is considered satisfied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@ -203,6 +185,11 @@ impl NodeKind {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Node { pub struct Node {
pub id: NodeId, pub id: NodeId,
/// The agent this node's work targets. Per-node so a single DAG can
/// span agents (e.g. a hive-wide restart); the lifecycle lease is
/// acquired against *this* agent (still globally exclusive per agent
/// across all DAGs). `"hyperhive"` for meta-level nodes.
pub agent: String,
pub kind: NodeKind, pub kind: NodeKind,
pub deps: Vec<Dep>, pub deps: Vec<Dep>,
pub state: State, pub state: State,
@ -221,19 +208,23 @@ pub struct Node {
/// Submit-time spec for one node. /// Submit-time spec for one node.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NodeSpec { pub struct NodeSpec {
/// The agent this node targets (see [`Node::agent`]). Built by the
/// `templates.rs` `node` helper, which stamps the template's agent
/// onto every node.
pub agent: String,
pub kind: NodeKind, pub kind: NodeKind,
pub deps: Vec<Dep>, pub deps: Vec<Dep>,
} }
/// Submit-time spec for a whole DAG. Built by `templates.rs`; validated /// Submit-time spec for a whole DAG. Built by `templates.rs`; validated
/// (cycle rejection) and dedup'd by `JobQueue::submit`. /// (cycle rejection) by `JobQueue::submit`. No DAG-level `agent` — every
/// node carries its own (a DAG can span agents), and the queue derives
/// per-agent leasing from [`NodeSpec::agent`].
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DagSpec { pub struct DagSpec {
pub template: Template, pub template: Template,
/// Primary target agent, or `"hyperhive"` for meta-level DAGs.
pub agent: String,
pub source: Source, pub source: Source,
/// Free-form "why"; dedup appends "also requested by …" lines. /// Free-form "why".
pub reason: String, pub reason: String,
/// Cascade grouping (meta-update / sweep children). /// Cascade grouping (meta-update / sweep children).
pub parent_id: Option<u64>, pub parent_id: Option<u64>,
@ -250,12 +241,13 @@ pub struct DagSpec {
pub nodes: Vec<NodeSpec>, pub nodes: Vec<NodeSpec>,
} }
/// A live DAG in the queue. /// A live DAG in the queue. No DAG-level `agent`: agent is per-[`Node`],
/// so a DAG can span agents. Per-agent leasing is derived from the
/// nodes' agents.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Dag { pub struct Dag {
pub id: u64, pub id: u64,
pub template: Template, pub template: Template,
pub agent: String,
pub source: Source, pub source: Source,
pub reason: String, pub reason: String,
pub parent_id: Option<u64>, pub parent_id: Option<u64>,
@ -319,6 +311,19 @@ impl Dag {
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> { pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
self.nodes.iter_mut().find(|n| n.id == id) self.nodes.iter_mut().find(|n| n.id == id)
} }
/// Distinct agents this DAG's nodes target, in first-seen order.
/// Used for per-agent lease release and the terminal cancel-revert —
/// a single-agent DAG yields one, a multi-agent DAG yields several.
pub fn agents(&self) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for n in &self.nodes {
if !seen.iter().any(|a| a == &n.agent) {
seen.push(n.agent.clone());
}
}
seen
}
} }
impl Dag { impl Dag {
@ -331,7 +336,6 @@ impl Dag {
}; };
DagView { DagView {
id: self.id, id: self.id,
agent: self.agent.clone(),
kind: self.template, kind: self.template,
state: self.rollup(), state: self.rollup(),
source: self.source, source: self.source,
@ -348,6 +352,7 @@ impl Dag {
.iter() .iter()
.map(|n| NodeView { .map(|n| NodeView {
id: n.id, id: n.id,
agent: n.agent.clone(),
kind: n.kind.as_str().to_owned(), kind: n.kind.as_str().to_owned(),
deps: n.deps.iter().map(|d| d.on).collect(), deps: n.deps.iter().map(|d| d.on).collect(),
state: n.state, state: n.state,

View file

@ -35,8 +35,10 @@ struct NodeDone {
pub async fn run_worker(coord: Arc<Coordinator>) { pub async fn run_worker(coord: Arc<Coordinator>) {
let mut shutdown = coord.shutdown_rx(); let mut shutdown = coord.shutdown_rx();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<NodeDone>(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<NodeDone>();
// DAG id → transient guard held for the lease window. // (DAG id, agent) → transient guard held for that agent's lease
let mut transients: HashMap<u64, crate::coordinator::TransientGuard> = HashMap::new(); // window. Keyed per-agent so a multi-agent DAG shows one transient
// pill per agent it touches.
let mut transients: HashMap<(u64, String), crate::coordinator::TransientGuard> = HashMap::new();
loop { loop {
// Terminal roll-ups can appear without a node completion — // Terminal roll-ups can appear without a node completion —
// the cancel surfaces settle DAGs directly and wake this loop // the cancel surfaces settle DAGs directly and wake this loop
@ -49,7 +51,10 @@ pub async fn run_worker(coord: Arc<Coordinator>) {
if claim.lease_acquired if claim.lease_acquired
&& let Some(kind) = claim.transient && let Some(kind) = claim.transient
{ {
transients.insert(claim.dag_id, coord.transient_guard(&claim.agent, kind)); transients.insert(
(claim.dag_id, claim.agent.clone()),
coord.transient_guard(&claim.agent, kind),
);
} }
tracing::info!( tracing::info!(
dag = claim.dag_id, dag = claim.dag_id,
@ -88,7 +93,7 @@ pub async fn run_worker(coord: Arc<Coordinator>) {
async fn handle_completion( async fn handle_completion(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
transients: &mut HashMap<u64, crate::coordinator::TransientGuard>, transients: &mut HashMap<(u64, String), crate::coordinator::TransientGuard>,
done: NodeDone, done: NodeDone,
) { ) {
let NodeDone { claim, result } = done; let NodeDone { claim, result } = done;
@ -143,10 +148,11 @@ async fn handle_completion(
/// `Rebuilt` events, cancelled-power-op intent revert). /// `Rebuilt` events, cancelled-power-op intent revert).
async fn process_terminals( async fn process_terminals(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
transients: &mut HashMap<u64, crate::coordinator::TransientGuard>, transients: &mut HashMap<(u64, String), crate::coordinator::TransientGuard>,
) { ) {
for terminal in coord.job_queue.drain_terminal() { for terminal in coord.job_queue.drain_terminal() {
transients.remove(&terminal.dag_id); // Drop every per-agent transient guard this DAG held.
transients.retain(|(dag_id, _), _| *dag_id != terminal.dag_id);
exec::on_dag_terminal(coord, &terminal).await; exec::on_dag_terminal(coord, &terminal).await;
} }
} }

View file

@ -3,6 +3,11 @@
//! confined to this validation; the runtime store stays the plain //! confined to this validation; the runtime store stays the plain
//! `Vec<Node>` + `deps`). //! `Vec<Node>` + `deps`).
//! //!
//! Every node carries its own `agent` (there is no DAG-level agent) — the
//! `node` helper stamps the template's agent onto each. Today's templates
//! are single-agent (every node shares one agent); a future multi-agent
//! template would stamp different agents per subgraph.
//!
//! ```text //! ```text
//! rebuild(a): Prebuild(a) → StopForUpdate(a) → Swap(a) →(any) Reconcile(a) //! rebuild(a): Prebuild(a) → StopForUpdate(a) → Swap(a) →(any) Reconcile(a)
//! graceful-stop(a): [wanted=Offline] Signal(a) → Drain(a) → Reconcile(a) //! graceful-stop(a): [wanted=Offline] Signal(a) → Drain(a) → Reconcile(a)
@ -29,35 +34,42 @@ fn after_ok(on: u32) -> Vec<Dep> {
}] }]
} }
/// Build one node targeting `agent`. The single place templates stamp a
/// node's agent, so a whole template is single-agent by passing the same
/// `agent` to every call.
fn node(agent: &str, kind: NodeKind, deps: Vec<Dep>) -> NodeSpec {
NodeSpec {
agent: agent.to_owned(),
kind,
deps,
}
}
/// The rebuild node chain. `Reconcile` deps on `Swap` with `AfterAny`: /// The rebuild node chain. `Reconcile` deps on `Swap` with `AfterAny`:
/// it must run even when the profile swap failed, so a previously-up /// it must run even when the profile swap failed, so a previously-up
/// agent comes back on its old config (today's recovery-start). This /// agent comes back on its old config (today's recovery-start). This
/// is the only `AfterAny` edge in v1. /// is the only `AfterAny` edge in v1.
fn rebuild_nodes(relock: bool, base: u32) -> Vec<NodeSpec> { fn rebuild_nodes(agent: &str, relock: bool, base: u32) -> Vec<NodeSpec> {
vec![ vec![
NodeSpec { node(
kind: NodeKind::Prebuild { relock }, agent,
deps: if base == 0 { NodeKind::Prebuild { relock },
if base == 0 {
Vec::new() Vec::new()
} else { } else {
after_ok(base - 1) after_ok(base - 1)
}, },
}, ),
NodeSpec { node(agent, NodeKind::StopForUpdate, after_ok(base)),
kind: NodeKind::StopForUpdate, node(agent, NodeKind::Swap, after_ok(base + 1)),
deps: after_ok(base), node(
}, agent,
NodeSpec { NodeKind::Reconcile,
kind: NodeKind::Swap, vec![Dep {
deps: after_ok(base + 1),
},
NodeSpec {
kind: NodeKind::Reconcile,
deps: vec![Dep {
on: base + 2, on: base + 2,
when: DepWhen::AfterAny, when: DepWhen::AfterAny,
}], }],
}, ),
] ]
} }
@ -75,7 +87,6 @@ pub fn rebuild(
) -> DagSpec { ) -> DagSpec {
DagSpec { DagSpec {
template: Template::Rebuild, template: Template::Rebuild,
agent: agent.to_owned(),
source, source,
reason, reason,
parent_id, parent_id,
@ -83,7 +94,7 @@ pub fn rebuild(
inputs: Vec::new(), inputs: Vec::new(),
perm_payload: None, perm_payload: None,
transient: Some(TransientKind::Rebuilding), transient: Some(TransientKind::Rebuilding),
nodes: rebuild_nodes(relock, 0), nodes: rebuild_nodes(agent, relock, 0),
} }
} }
@ -93,7 +104,6 @@ pub fn rebuild(
pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec { pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec {
DagSpec { DagSpec {
template: Template::Rebuild, template: Template::Rebuild,
agent: agent.to_owned(),
source: Source::Approval, source: Source::Approval,
reason, reason,
parent_id: None, parent_id: None,
@ -101,10 +111,7 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec
inputs: Vec::new(), inputs: Vec::new(),
perm_payload: None, perm_payload: None,
transient: Some(TransientKind::Rebuilding), transient: Some(TransientKind::Rebuilding),
nodes: vec![NodeSpec { nodes: vec![node(agent, NodeKind::ApprovalDeploy, Vec::new())],
kind: NodeKind::ApprovalDeploy,
deps: Vec::new(),
}],
} }
} }
@ -117,7 +124,6 @@ pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec
pub fn graceful_stop(agent: &str, source: Source, reason: String) -> DagSpec { pub fn graceful_stop(agent: &str, source: Source, reason: String) -> DagSpec {
DagSpec { DagSpec {
template: Template::GracefulStop, template: Template::GracefulStop,
agent: agent.to_owned(),
source, source,
reason, reason,
parent_id: None, parent_id: None,
@ -126,18 +132,9 @@ pub fn graceful_stop(agent: &str, source: Source, reason: String) -> DagSpec {
perm_payload: None, perm_payload: None,
transient: Some(TransientKind::Stopping), transient: Some(TransientKind::Stopping),
nodes: vec![ nodes: vec![
NodeSpec { node(agent, NodeKind::Signal, Vec::new()),
kind: NodeKind::Signal, node(agent, NodeKind::Drain, after_ok(0)),
deps: Vec::new(), node(agent, NodeKind::Reconcile, after_ok(1)),
},
NodeSpec {
kind: NodeKind::Drain,
deps: after_ok(0),
},
NodeSpec {
kind: NodeKind::Reconcile,
deps: after_ok(1),
},
], ],
} }
} }
@ -148,7 +145,6 @@ pub fn graceful_stop(agent: &str, source: Source, reason: String) -> DagSpec {
pub fn restart(agent: &str, source: Source, reason: String) -> DagSpec { pub fn restart(agent: &str, source: Source, reason: String) -> DagSpec {
DagSpec { DagSpec {
template: Template::Restart, template: Template::Restart,
agent: agent.to_owned(),
source, source,
reason, reason,
parent_id: None, parent_id: None,
@ -157,14 +153,8 @@ pub fn restart(agent: &str, source: Source, reason: String) -> DagSpec {
perm_payload: None, perm_payload: None,
transient: Some(TransientKind::Restarting), transient: Some(TransientKind::Restarting),
nodes: vec![ nodes: vec![
NodeSpec { node(agent, NodeKind::StopForUpdate, Vec::new()),
kind: NodeKind::StopForUpdate, node(agent, NodeKind::Reconcile, after_ok(0)),
deps: Vec::new(),
},
NodeSpec {
kind: NodeKind::Reconcile,
deps: after_ok(0),
},
], ],
} }
} }
@ -180,7 +170,6 @@ pub fn restart(agent: &str, source: Source, reason: String) -> DagSpec {
pub fn graceful_restart(agent: &str, source: Source, reason: String) -> DagSpec { pub fn graceful_restart(agent: &str, source: Source, reason: String) -> DagSpec {
DagSpec { DagSpec {
template: Template::GracefulRestart, template: Template::GracefulRestart,
agent: agent.to_owned(),
source, source,
reason, reason,
parent_id: None, parent_id: None,
@ -189,22 +178,10 @@ pub fn graceful_restart(agent: &str, source: Source, reason: String) -> DagSpec
perm_payload: None, perm_payload: None,
transient: Some(TransientKind::Restarting), transient: Some(TransientKind::Restarting),
nodes: vec![ nodes: vec![
NodeSpec { node(agent, NodeKind::Signal, Vec::new()),
kind: NodeKind::Signal, node(agent, NodeKind::Drain, after_ok(0)),
deps: Vec::new(), node(agent, NodeKind::StopForUpdate, after_ok(1)),
}, node(agent, NodeKind::Reconcile, after_ok(2)),
NodeSpec {
kind: NodeKind::Drain,
deps: after_ok(0),
},
NodeSpec {
kind: NodeKind::StopForUpdate,
deps: after_ok(1),
},
NodeSpec {
kind: NodeKind::Reconcile,
deps: after_ok(2),
},
], ],
} }
} }
@ -220,7 +197,6 @@ pub fn reconcile_only(
) -> DagSpec { ) -> DagSpec {
DagSpec { DagSpec {
template, template,
agent: agent.to_owned(),
source, source,
reason, reason,
parent_id: None, parent_id: None,
@ -228,10 +204,7 @@ pub fn reconcile_only(
inputs: Vec::new(), inputs: Vec::new(),
perm_payload: None, perm_payload: None,
transient, transient,
nodes: vec![NodeSpec { nodes: vec![node(agent, NodeKind::Reconcile, Vec::new())],
kind: NodeKind::Reconcile,
deps: Vec::new(),
}],
} }
} }
@ -242,7 +215,6 @@ pub fn reconcile_only(
pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec { pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
DagSpec { DagSpec {
template: Template::Spawn, template: Template::Spawn,
agent: agent.to_owned(),
source: Source::Approval, source: Source::Approval,
reason, reason,
parent_id: None, parent_id: None,
@ -251,22 +223,10 @@ pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
perm_payload: None, perm_payload: None,
transient: Some(TransientKind::Spawning), transient: Some(TransientKind::Spawning),
nodes: vec![ nodes: vec![
NodeSpec { node(agent, NodeKind::Provision, Vec::new()),
kind: NodeKind::Provision, node(agent, NodeKind::Create, after_ok(0)),
deps: Vec::new(), node(agent, NodeKind::WriteDropin, after_ok(1)),
}, node(agent, NodeKind::Reconcile, after_ok(2)),
NodeSpec {
kind: NodeKind::Create,
deps: after_ok(0),
},
NodeSpec {
kind: NodeKind::WriteDropin,
deps: after_ok(1),
},
NodeSpec {
kind: NodeKind::Reconcile,
deps: after_ok(2),
},
], ],
} }
} }
@ -275,14 +235,10 @@ pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes /// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes
/// effect in the container. /// effect in the container.
pub fn perm_change(agent: &str, source: Source, reason: String, payload: PermPayload) -> DagSpec { pub fn perm_change(agent: &str, source: Source, reason: String, payload: PermPayload) -> DagSpec {
let mut nodes = vec![NodeSpec { let mut nodes = vec![node(agent, NodeKind::WritePermFile, Vec::new())];
kind: NodeKind::WritePermFile, nodes.extend(rebuild_nodes(agent, true, 1));
deps: Vec::new(),
}];
nodes.extend(rebuild_nodes(true, 1));
DagSpec { DagSpec {
template: Template::PermChange, template: Template::PermChange,
agent: agent.to_owned(),
source, source,
reason, reason,
parent_id: None, parent_id: None,
@ -306,7 +262,6 @@ pub fn meta_update(
) -> DagSpec { ) -> DagSpec {
DagSpec { DagSpec {
template: Template::MetaUpdate, template: Template::MetaUpdate,
agent: "hyperhive".to_owned(),
source, source,
reason, reason,
parent_id: None, parent_id: None,
@ -314,19 +269,17 @@ pub fn meta_update(
inputs, inputs,
perm_payload: None, perm_payload: None,
transient: None, transient: None,
nodes: vec![NodeSpec { nodes: vec![node(
kind: NodeKind::MetaLock { "hyperhive",
NodeKind::MetaLock {
sweep: false, sweep: false,
fanout: None, fanout: None,
}, },
deps: Vec::new(), Vec::new(),
}], )],
} }
} }
/// Boot-time sweep parent: bump meta's hyperhive input (non-fatal),
/// then fan out `Rebuild` children for the precomputed stale agent
/// list (topology-sorted by the caller).
/// Boot-time root anchor DAG: a single [`NodeKind::Noop`] node that groups /// Boot-time root anchor DAG: a single [`NodeKind::Noop`] node that groups
/// this boot's `StartupSweep` + per-agent `Reconcile` child DAGs (linked via /// this boot's `StartupSweep` + per-agent `Reconcile` child DAGs (linked via
/// `parent_id`) into one tree so the dashboard renders the boot as one entry. /// `parent_id`) into one tree so the dashboard renders the boot as one entry.
@ -336,7 +289,6 @@ pub fn meta_update(
pub fn boot_root(reason: String) -> DagSpec { pub fn boot_root(reason: String) -> DagSpec {
DagSpec { DagSpec {
template: Template::Boot, template: Template::Boot,
agent: "hyperhive".to_owned(),
source: Source::AutoUpdate, source: Source::AutoUpdate,
reason, reason,
parent_id: None, parent_id: None,
@ -344,17 +296,16 @@ pub fn boot_root(reason: String) -> DagSpec {
inputs: Vec::new(), inputs: Vec::new(),
perm_payload: None, perm_payload: None,
transient: None, transient: None,
nodes: vec![NodeSpec { nodes: vec![node("hyperhive", NodeKind::Noop, Vec::new())],
kind: NodeKind::Noop,
deps: Vec::new(),
}],
} }
} }
/// Boot-time sweep parent: bump meta's hyperhive input (non-fatal),
/// then fan out `Rebuild` children for the precomputed stale agent
/// list (topology-sorted by the caller).
pub fn startup_sweep(reason: String, stale_agents: Vec<String>) -> DagSpec { pub fn startup_sweep(reason: String, stale_agents: Vec<String>) -> DagSpec {
DagSpec { DagSpec {
template: Template::StartupSweep, template: Template::StartupSweep,
agent: "hyperhive".to_owned(),
source: Source::AutoUpdate, source: Source::AutoUpdate,
reason, reason,
parent_id: None, parent_id: None,
@ -362,13 +313,14 @@ pub fn startup_sweep(reason: String, stale_agents: Vec<String>) -> DagSpec {
inputs: Vec::new(), inputs: Vec::new(),
perm_payload: None, perm_payload: None,
transient: None, transient: None,
nodes: vec![NodeSpec { nodes: vec![node(
kind: NodeKind::MetaLock { "hyperhive",
NodeKind::MetaLock {
sweep: true, sweep: true,
fanout: Some(stale_agents), fanout: Some(stale_agents),
}, },
deps: Vec::new(), Vec::new(),
}], )],
} }
} }

View file

@ -1,4 +1,4 @@
//! Queue-core unit tests: dedup, cycle rejection, resource //! Queue-core unit tests: submit / no-dedup, cycle rejection, resource
//! serialization (build slots / per-agent leases), lease-exempt //! serialization (build slots / per-agent leases), lease-exempt
//! overlap, FIFO fairness, cancel semantics, `AfterAny` failure //! overlap, FIFO fairness, cancel semantics, `AfterAny` failure
//! routing, fan-out, and history retention. All synchronous — the //! routing, fan-out, and history retention. All synchronous — the
@ -35,7 +35,7 @@ fn state_of(q: &JobQueue, dag_id: u64) -> State {
.state .state
} }
// ---- submit / dedup ---- // ---- submit (dedup removed — every submit is a fresh DAG) ----
#[test] #[test]
fn submit_assigns_distinct_ids() { fn submit_assigns_distinct_ids() {
@ -46,20 +46,22 @@ fn submit_assigns_distinct_ids() {
assert_eq!(q.snapshot().len(), 2); assert_eq!(q.snapshot().len(), 2);
} }
/// Submit-time dedup was removed with the agent-per-node refactor (a
/// multi-agent DAG has no single agent to key a dedup on), so an identical
/// resubmit — same template + agent, still queued — now enqueues a distinct
/// DAG instead of collapsing into the pending one. Whether any dedup needs
/// reintroducing is tracked as a follow-up.
#[test] #[test]
fn dedup_pending_same_template_and_agent() { fn identical_resubmit_is_a_distinct_dag() {
let q = JobQueue::new(1); let q = JobQueue::new(1);
let a = submit(&q, rebuild("agent-a", "first")); let a = submit(&q, rebuild("agent-a", "first"));
let b = submit(&q, rebuild("agent-a", "auto sweep")); let b = submit(&q, rebuild("agent-a", "again"));
assert_eq!(a, b, "dedup should return existing id"); assert_ne!(a, b, "no dedup: identical resubmit is a new DAG");
let snap = q.snapshot(); assert_eq!(q.snapshot().len(), 2);
assert_eq!(snap.len(), 1);
assert!(snap[0].reason.contains("first"));
assert!(snap[0].reason.contains("auto sweep"));
} }
#[test] #[test]
fn dedup_does_not_apply_across_templates_or_agents() { fn distinct_submits_never_collapse() {
let q = JobQueue::new(1); let q = JobQueue::new(1);
let a = submit(&q, rebuild("agent-a", "r")); let a = submit(&q, rebuild("agent-a", "r"));
let b = submit(&q, rebuild("agent-b", "r")); let b = submit(&q, rebuild("agent-b", "r"));
@ -73,7 +75,7 @@ fn dedup_does_not_apply_across_templates_or_agents() {
} }
#[test] #[test]
fn dedup_skips_running_dags() { fn resubmit_while_running_is_new_dag() {
let q = JobQueue::new(1); let q = JobQueue::new(1);
let a = submit(&q, rebuild("agent-a", "first")); let a = submit(&q, rebuild("agent-a", "first"));
let claim = claim_one(&q); // Prebuild running let claim = claim_one(&q); // Prebuild running
@ -84,126 +86,6 @@ fn dedup_skips_running_dags() {
assert_eq!(q.snapshot().len(), 2); assert_eq!(q.snapshot().len(), 2);
} }
#[test]
fn meta_update_dedup_matches_inputs() {
let q = JobQueue::new(1);
let a = submit(
&q,
templates::meta_update(
vec!["nixpkgs".to_owned()],
Source::Manual,
"first".to_owned(),
None,
),
);
let b = submit(
&q,
templates::meta_update(
vec!["nixpkgs".to_owned()],
Source::Manual,
"duplicate click".to_owned(),
None,
),
);
assert_eq!(a, b, "identical-inputs meta-updates should dedup");
let c = submit(
&q,
templates::meta_update(
vec!["agent-bitburner/bitburner-agent".to_owned()],
Source::Manual,
"bump agent".to_owned(),
None,
),
);
assert_ne!(a, c, "different-inputs meta-updates must NOT dedup");
assert_eq!(q.snapshot().len(), 2);
}
#[test]
fn approval_dags_dedup_only_on_matching_id() {
let q = JobQueue::new(1);
let a = submit(
&q,
templates::approval_deploy("agent-a", 1, "approval #1".to_owned()),
);
let b = submit(
&q,
templates::approval_deploy("agent-a", 2, "approval #2".to_owned()),
);
assert_ne!(a, b, "distinct approvals must not collapse");
// Rapid double-click on the same approval IS a single op.
let c = submit(
&q,
templates::approval_deploy("agent-a", 1, "approval #1 (dup)".to_owned()),
);
assert_eq!(a, c);
assert_eq!(q.snapshot().len(), 2);
}
#[test]
fn perm_change_dedup_respects_perm_type() {
let q = JobQueue::new(1);
let groups = templates::perm_change(
"agent-a",
Source::Manual,
"groups".to_owned(),
PermPayload::ToolGroups { groups: vec![] },
);
let caps = templates::perm_change(
"agent-a",
Source::Manual,
"caps".to_owned(),
PermPayload::Capabilities { caps: vec![] },
);
let a = submit(&q, groups.clone());
let b = submit(&q, caps);
assert_ne!(a, b, "tool-groups vs capabilities must not collapse");
let c = submit(&q, groups);
assert_eq!(a, c, "same perm type dedups");
}
/// A `MetaUpdate` cascade `Rebuild` (with `parent_id = Some(meta_id)`)
/// must NOT dedup into a queued `Rebuild` with a different
/// `parent_id` (e.g. from a startup sweep) — without the guard the
/// cascade child would be swallowed and the agent never rebuilt
/// against the post-bump meta.
#[test]
fn dedup_respects_parent_id() {
let q = JobQueue::new(1);
let sweep = submit(&q, templates::startup_sweep("boot".to_owned(), vec![]));
let sweep_child = submit(
&q,
templates::rebuild(
"alice",
Source::StartupSweep,
"startup sweep".to_owned(),
Some(sweep),
true,
),
);
let meta = submit(
&q,
templates::meta_update(vec![], Source::Manual, "bump".to_owned(), None),
);
let cascade_child = submit(
&q,
templates::rebuild(
"alice",
Source::MetaUpdate,
"meta-update cascade".to_owned(),
Some(meta),
false,
),
);
assert_ne!(sweep_child, cascade_child);
let rebuilds = q
.snapshot()
.iter()
.filter(|d| d.kind == Template::Rebuild && d.agent == "alice")
.count();
assert_eq!(rebuilds, 2, "both rebuilds must be present");
}
// ---- cycle rejection ---- // ---- cycle rejection ----
#[test] #[test]
@ -213,6 +95,7 @@ fn cyclic_dag_is_rejected_at_submit() {
// 0 → 1 → 0 cycle. // 0 → 1 → 0 cycle.
spec.nodes = vec![ spec.nodes = vec![
NodeSpec { NodeSpec {
agent: "agent-a".to_owned(),
kind: NodeKind::StopForUpdate, kind: NodeKind::StopForUpdate,
deps: vec![Dep { deps: vec![Dep {
on: 1, on: 1,
@ -220,6 +103,7 @@ fn cyclic_dag_is_rejected_at_submit() {
}], }],
}, },
NodeSpec { NodeSpec {
agent: "agent-a".to_owned(),
kind: NodeKind::Reconcile, kind: NodeKind::Reconcile,
deps: vec![Dep { deps: vec![Dep {
on: 0, on: 0,
@ -236,6 +120,7 @@ fn unknown_dep_is_rejected_at_submit() {
let q = JobQueue::new(1); let q = JobQueue::new(1);
let mut spec = rebuild("agent-a", "bad dep"); let mut spec = rebuild("agent-a", "bad dep");
spec.nodes = vec![NodeSpec { spec.nodes = vec![NodeSpec {
agent: "agent-a".to_owned(),
kind: NodeKind::Reconcile, kind: NodeKind::Reconcile,
deps: vec![Dep { deps: vec![Dep {
on: 9, on: 9,
@ -578,7 +463,7 @@ fn cancel_children_skips_running_child() {
// ---- fan-out ---- // ---- fan-out ----
#[test] #[test]
fn append_children_sets_parent_and_dedups() { fn append_children_sets_parent() {
let q = JobQueue::new(1); let q = JobQueue::new(1);
let meta = submit( let meta = submit(
&q, &q,
@ -599,7 +484,7 @@ fn append_children_sets_parent_and_dedups() {
Some(meta), Some(meta),
false, false,
), ),
// Duplicate — must coalesce into the first alice child. // No dedup: a second alice child is its own DAG now.
templates::rebuild( templates::rebuild(
"alice", "alice",
Source::MetaUpdate, Source::MetaUpdate,
@ -610,10 +495,10 @@ fn append_children_sets_parent_and_dedups() {
]; ];
let ids = q.append_children(specs); let ids = q.append_children(specs);
assert_eq!(ids.len(), 3); assert_eq!(ids.len(), 3);
assert_eq!(ids[0], ids[2], "duplicate child dedups"); assert_ne!(ids[0], ids[2], "no dedup: duplicate child is distinct");
let snap = q.snapshot(); let snap = q.snapshot();
let children: Vec<_> = snap.iter().filter(|d| d.parent_id == Some(meta)).collect(); let children: Vec<_> = snap.iter().filter(|d| d.parent_id == Some(meta)).collect();
assert_eq!(children.len(), 2); assert_eq!(children.len(), 3);
} }
// ---- terminal reporting + lease release ---- // ---- terminal reporting + lease release ----

View file

@ -150,6 +150,11 @@ pub type NodeId = u32;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeView { pub struct NodeView {
pub id: NodeId, pub id: NodeId,
/// The agent whose container (or meta repo, for `hyperhive` meta-level
/// nodes) this node operates on. Agent is per-node — a single DAG can
/// span multiple agents (e.g. a hive-wide restart), so there is no
/// DAG-level agent field; consumers group by this.
pub agent: String,
/// Node primitive tag: `"prebuild"`, `"stop_for_update"`, /// Node primitive tag: `"prebuild"`, `"stop_for_update"`,
/// `"swap"`, `"create"`, `"meta_lock"`, `"reconcile"`, `"signal"`, /// `"swap"`, `"create"`, `"meta_lock"`, `"reconcile"`, `"signal"`,
/// `"drain"`, `"write_dropin"`, `"write_perm_file"`, /// `"drain"`, `"write_dropin"`, `"write_perm_file"`,
@ -171,13 +176,13 @@ pub struct NodeView {
pub error: Option<String>, pub error: Option<String>,
} }
/// A queued/running/recent DAG. DAG-level fields mirror the pre-DAG /// A queued/running/recent DAG. `kind` = template string, roll-up
/// `QueueEntry` names (`kind` = template string, roll-up `state`); /// `state`; everything per-node appears exactly once, inside `nodes`.
/// everything per-node appears exactly once, inside `nodes`. /// There is no DAG-level `agent` — a DAG can span agents, so agent lives
/// on each [`NodeView`]; consumers group nodes by `NodeView::agent`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DagView { pub struct DagView {
pub id: u64, pub id: u64,
pub agent: String,
/// Template wire string — same values the old `kind` field used. /// Template wire string — same values the old `kind` field used.
pub kind: Template, pub kind: Template,
/// Roll-up: `failed` if any node failed, else `running` / /// Roll-up: `failed` if any node failed, else `running` /