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:
parent
91607f3896
commit
2a59f2f5fc
7 changed files with 223 additions and 356 deletions
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}],
|
)],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ----
|
||||||
|
|
|
||||||
|
|
@ -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` /
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue