a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
683 lines
26 KiB
Rust
683 lines
26 KiB
Rust
//! Task-DAG engine model.
|
|
//!
|
|
//! This is the DAG-first reframe of swarm described in `docs/SWARM_TASK_GRAPH.md`.
|
|
//! The graph is the primary object: nodes are tasks, edges are dependencies, and
|
|
//! agents are fungible workers that execute, decompose (composite nodes), and
|
|
//! verify (gate nodes) those tasks.
|
|
//!
|
|
//! The model here is deliberately decoupled from the server/runtime wiring so it
|
|
//! can be exercised end-to-end by the deterministic simulator in [`crate::dag::sim`]
|
|
//! before being attached to live swarm sessions.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
mod ops;
|
|
mod schedule;
|
|
pub mod sim;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
pub use ops::{
|
|
ExpandOutcome, GATE_COVERAGE_ENUMERATION_CAP, complete_node, expand_node, fail_node,
|
|
inject_from_gate, requeue_failed, seed,
|
|
};
|
|
pub use schedule::{
|
|
LIGHT_MODE_SUGGESTED_WORKERS, assemble_input, dispatch, is_terminal, ready_nodes,
|
|
};
|
|
|
|
/// A node identifier. Stable string ids keep the model serializable and let the
|
|
/// auto-generated gate ids derive deterministically from their parent.
|
|
pub type NodeId = String;
|
|
|
|
/// Engine mode. One engine, two presets (see doc section 1a). The data model,
|
|
/// scheduler, and dataflow are identical; the mode only controls whether the
|
|
/// rigor machinery (mandatory gates + strict artifact validation) is engaged.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Mode {
|
|
/// Comprehensive: composite nodes get an auto-inserted critique/verify gate
|
|
/// before they can close, and completion artifacts are strictly validated.
|
|
Deep,
|
|
/// Fan-out: cheap parallelism. No mandatory gates, lightweight artifacts.
|
|
Light,
|
|
}
|
|
|
|
impl Mode {
|
|
pub fn requires_gates(self) -> bool {
|
|
matches!(self, Mode::Deep)
|
|
}
|
|
}
|
|
|
|
/// Where a node came from. Deep mode's growth pressure is measured against
|
|
/// this: `Seed` nodes are the first agent's draft, everything else is growth
|
|
/// the machinery generated (decomposition, gate-injected gaps, or the gates
|
|
/// themselves). Status surfaces report seeded-vs-grown so a plan that never
|
|
/// outgrew its seed is visibly under-explored.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum NodeOrigin {
|
|
/// Part of the initial `seed` batch (or a later re-seed).
|
|
Seed,
|
|
/// Born from `expand_node` decomposition.
|
|
Expand,
|
|
/// Injected by a gate that found a gap or failure.
|
|
Gap,
|
|
/// An auto-inserted critique/verify gate (including the root gate).
|
|
Gate,
|
|
}
|
|
|
|
/// The terminal action a node represents. The DAG is task-type agnostic; only the
|
|
/// artifact contract and which gate kind is inserted vary by node kind.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum NodeKind {
|
|
/// Research/analysis. Artifact = findings. Gated by `Critique`.
|
|
Explore,
|
|
/// Code change. Artifact = diff/commit ref. Gated by `Verify`.
|
|
Implement,
|
|
/// Acceptance check (build/tests). A gate kind.
|
|
Verify,
|
|
/// Repair after a failed verify. Gated by `Verify`.
|
|
Fix,
|
|
/// Map-reduce rollup of a composite node's children. Gated by `Critique`.
|
|
Synthesize,
|
|
/// Adversarial gap-finder for exploration. A gate kind.
|
|
Critique,
|
|
}
|
|
|
|
impl NodeKind {
|
|
/// Whether this kind is itself a gate (auto-inserted, not user-seeded work).
|
|
pub fn is_gate_kind(self) -> bool {
|
|
matches!(self, NodeKind::Critique | NodeKind::Verify)
|
|
}
|
|
|
|
/// The gate kind that guards a composite node of `self` before it may close.
|
|
/// Exploration-style work is guarded by a critique (gap-finding); code-style
|
|
/// work is guarded by a verify (does it actually work).
|
|
pub fn gate_kind(self) -> NodeKind {
|
|
match self {
|
|
NodeKind::Implement | NodeKind::Fix => NodeKind::Verify,
|
|
_ => NodeKind::Critique,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Node lifecycle status. "Blocked" is intentionally not stored: it is computed
|
|
/// from dependency state by the scheduler, so there is a single source of truth.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum NodeStatus {
|
|
/// Not yet dispatched. Becomes runnable once all dependencies are `Done`.
|
|
Queued,
|
|
/// Dispatched to a worker and actively executing.
|
|
Running,
|
|
/// Finished successfully; `output` artifact is attached.
|
|
Done,
|
|
/// Unrecoverable failure. A `Fix`/re-verify path may supersede it.
|
|
Failed,
|
|
}
|
|
|
|
/// Machine-readable confidence rung parsed from an artifact's free-text
|
|
/// `confidence` field.
|
|
///
|
|
/// Confidence is the breadth signal of the task graph: a node completed at
|
|
/// [`ConfidenceLevel::Low`] is an admission that its scope was not adequately
|
|
/// covered, so the machinery treats it like `what_i_did_not_check` — gates are
|
|
/// pointed at low-confidence siblings and (in deep mode) cannot pass while such
|
|
/// a sibling is unaddressed. The artifact field stays a free string on the wire
|
|
/// for compatibility; this enum is the single lenient interpretation of it so
|
|
/// the engine, prompts, and status surfaces never disagree.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum ConfidenceLevel {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
}
|
|
|
|
impl ConfidenceLevel {
|
|
/// Lenient parse. Accepts the common shapes agents actually emit: rung
|
|
/// words with qualifiers ("very low", "medium-high", "High."), negations
|
|
/// ("not confident", "uncertain"), and bare percentages, fractions
|
|
/// ("1/10", "7 out of 10"), or 0-1/0-10/0-100 scores. Returns `None` when
|
|
/// nothing recognizable is present.
|
|
pub fn parse(raw: &str) -> Option<Self> {
|
|
let normalized = raw.trim().to_ascii_lowercase();
|
|
if normalized.is_empty() {
|
|
return None;
|
|
}
|
|
// Negated/uncertain phrasing reads as low. This must run before the
|
|
// word rungs, or "not confident" would match "confident" -> High and
|
|
// silently erase a confidence debt the gate machinery should enforce.
|
|
const NEGATIONS: [&str; 7] = [
|
|
"not high",
|
|
"not confident",
|
|
"not certain",
|
|
"not sure",
|
|
"no confidence",
|
|
"unsure",
|
|
"uncertain",
|
|
];
|
|
if NEGATIONS.iter().any(|neg| normalized.contains(neg)) {
|
|
return Some(Self::Low);
|
|
}
|
|
// Word rungs; check "low" before "high" so "low-to-high" style
|
|
// hedges resolve pessimistically.
|
|
if normalized.contains("low") {
|
|
return Some(Self::Low);
|
|
}
|
|
if normalized.contains("med") || normalized.contains("moderate") {
|
|
return Some(Self::Medium);
|
|
}
|
|
if normalized.contains("high")
|
|
|| normalized.contains("certain")
|
|
|| normalized.contains("confident")
|
|
{
|
|
return Some(Self::High);
|
|
}
|
|
// Numeric: take the first number, honoring an explicit denominator
|
|
// ("1/10", "7 out of 10", "3 of 5") before inferring the scale, so a
|
|
// fractional low score is not misread as a 0-1 probability.
|
|
let (value, raw_token, after) = extract_leading_number(&normalized)?;
|
|
let after = after.trim_start();
|
|
let denominator = after
|
|
.strip_prefix('/')
|
|
.or_else(|| after.strip_prefix("out of "))
|
|
.or_else(|| after.strip_prefix("of "))
|
|
.and_then(|rest| extract_leading_number(rest.trim_start()).map(|(d, _, _)| d))
|
|
.filter(|d| *d > 0.0);
|
|
let percent = if let Some(denominator) = denominator {
|
|
value / denominator * 100.0
|
|
} else if normalized.contains('%') || value > 10.0 {
|
|
value
|
|
} else if value <= 1.0 && raw_token.contains('.') {
|
|
// Only a decimal like "0.9" reads as a 0-1 probability; a bare
|
|
// integer "1" is a 1-of-10 score, not full confidence.
|
|
value * 100.0
|
|
} else {
|
|
value * 10.0
|
|
};
|
|
Some(if percent < 50.0 {
|
|
Self::Low
|
|
} else if percent < 80.0 {
|
|
Self::Medium
|
|
} else {
|
|
Self::High
|
|
})
|
|
}
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Low => "low",
|
|
Self::Medium => "medium",
|
|
Self::High => "high",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deserialize `confidence` from either a JSON string or a bare number.
|
|
/// Agents frequently emit `"confidence": 0.8` instead of `"0.8"`; rejecting
|
|
/// that with a serde type error is pointless friction, so numbers are
|
|
/// stringified and handed to the same lenient [`ConfidenceLevel::parse`].
|
|
fn de_confidence_scalar<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum Scalar {
|
|
Text(String),
|
|
Number(f64),
|
|
Bool(bool),
|
|
}
|
|
Ok(
|
|
Option::<Scalar>::deserialize(deserializer)?.map(|scalar| match scalar {
|
|
Scalar::Text(text) => text,
|
|
Scalar::Number(number) => number.to_string(),
|
|
Scalar::Bool(flag) => flag.to_string(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Extract the first number in `s`, returning its value, raw token, and the
|
|
/// remainder of the string after it. Used by [`ConfidenceLevel::parse`] for
|
|
/// score inference (the raw token distinguishes "0.9" from a bare "1").
|
|
fn extract_leading_number(s: &str) -> Option<(f64, &str, &str)> {
|
|
let start = s.find(|c: char| c.is_ascii_digit() || c == '.')?;
|
|
let rest = &s[start..];
|
|
let end = rest
|
|
.find(|c: char| !c.is_ascii_digit() && c != '.')
|
|
.unwrap_or(rest.len());
|
|
let token = &rest[..end];
|
|
let value: f64 = token.parse().ok()?;
|
|
Some((value, token, &rest[end..]))
|
|
}
|
|
|
|
/// The typed handoff artifact attached to a node on completion. This is the
|
|
/// dataflow payload that travels forward along edges to dependents.
|
|
///
|
|
/// In deep mode, `findings` and `what_i_did_not_check` are required: forcing an
|
|
/// agent to enumerate what it did *not* check is what makes thin work structurally
|
|
/// visible (doc section 6.3). In light mode any artifact is accepted.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct HandoffArtifact {
|
|
/// The deliverable summary (findings for explore, what shipped for implement).
|
|
#[serde(default)]
|
|
pub findings: String,
|
|
/// References, not claims: file:line, commit refs, paths.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub evidence: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub edge_cases_considered: Vec<String>,
|
|
/// Verify results for code-style nodes.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub validation: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub open_questions: Vec<String>,
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Option::is_none",
|
|
deserialize_with = "de_confidence_scalar"
|
|
)]
|
|
pub confidence: Option<String>,
|
|
/// The cheat code: explicit unexplored surface. Gates convert these into new
|
|
/// nodes.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub what_i_did_not_check: Vec<String>,
|
|
}
|
|
|
|
impl HandoffArtifact {
|
|
/// A minimal artifact for light mode or tests.
|
|
pub fn brief(findings: impl Into<String>) -> Self {
|
|
Self {
|
|
findings: findings.into(),
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
/// The machine-readable confidence rung of this artifact, if the free-text
|
|
/// `confidence` field parses to one. See [`ConfidenceLevel`].
|
|
pub fn confidence_level(&self) -> Option<ConfidenceLevel> {
|
|
self.confidence.as_deref().and_then(ConfidenceLevel::parse)
|
|
}
|
|
|
|
/// Render this artifact as a forward-dataflow section for a downstream worker
|
|
/// (or a gate). This is the single source of truth for how an artifact is
|
|
/// surfaced on a dependency edge, so the engine scheduler and the live bridge
|
|
/// stay in lockstep.
|
|
///
|
|
/// Critically this includes `edge_cases_considered` and `what_i_did_not_check`:
|
|
/// a critique gate is explicitly instructed to read what each child did *not*
|
|
/// check, so dropping those fields here would make the gate structurally unable
|
|
/// to do its job (doc sections 5, 6.3).
|
|
pub fn render_section(&self, id: &str, kind: &str) -> String {
|
|
let mut body = format!("## {id} ({kind})\n");
|
|
if !self.findings.trim().is_empty() {
|
|
body.push_str(&self.findings);
|
|
body.push('\n');
|
|
}
|
|
if !self.evidence.is_empty() {
|
|
body.push_str(&format!("Evidence: {}\n", self.evidence.join("; ")));
|
|
}
|
|
if !self.edge_cases_considered.is_empty() {
|
|
body.push_str(&format!(
|
|
"Edge cases considered: {}\n",
|
|
self.edge_cases_considered.join("; ")
|
|
));
|
|
}
|
|
if let Some(validation) = &self.validation {
|
|
body.push_str(&format!("Validation: {validation}\n"));
|
|
}
|
|
if !self.open_questions.is_empty() {
|
|
body.push_str(&format!(
|
|
"Open questions: {}\n",
|
|
self.open_questions.join("; ")
|
|
));
|
|
}
|
|
if let Some(confidence) = &self.confidence {
|
|
body.push_str(&format!("Confidence: {confidence}\n"));
|
|
}
|
|
if !self.what_i_did_not_check.is_empty() {
|
|
body.push_str(&format!(
|
|
"What was not checked: {}\n",
|
|
self.what_i_did_not_check.join("; ")
|
|
));
|
|
}
|
|
body
|
|
}
|
|
}
|
|
|
|
/// A single task node in the DAG.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TaskNode {
|
|
pub id: NodeId,
|
|
/// The task prompt/instructions for the worker.
|
|
pub content: String,
|
|
pub kind: NodeKind,
|
|
pub status: NodeStatus,
|
|
/// The worker that owns this node (assigned on dispatch). Only the owner may
|
|
/// expand or complete it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub owner: Option<String>,
|
|
/// The composite node this was decomposed from, if any.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub parent: Option<NodeId>,
|
|
/// Upstream node ids that must be `Done` before this node is runnable. This is
|
|
/// both the dependency relation and the dataflow channel.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub depends_on: Vec<NodeId>,
|
|
/// True once this node has been decomposed into children (composite). A
|
|
/// composite node re-runs as a synthesis/join once its children + gate close.
|
|
#[serde(default)]
|
|
pub expanded: bool,
|
|
/// True if this node is an auto-inserted gate (critique/verify).
|
|
#[serde(default)]
|
|
pub is_gate: bool,
|
|
/// The agent that planned this node's decomposition. Set when a node is
|
|
/// expanded into a composite; used to prefer the same planner for the
|
|
/// synthesis re-wake while leaving `owner` free for normal scheduling.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub planner: Option<String>,
|
|
/// Priority used to order the ready set. Lower rank runs first.
|
|
#[serde(default)]
|
|
pub priority: u8,
|
|
/// The typed handoff artifact, present once `Done`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub output: Option<HandoffArtifact>,
|
|
/// Where this node came from (seed vs machinery-generated growth). `None`
|
|
/// on legacy nodes, which are treated as seeded.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub origin: Option<NodeOrigin>,
|
|
}
|
|
|
|
impl TaskNode {
|
|
pub fn is_composite(&self) -> bool {
|
|
self.expanded
|
|
}
|
|
|
|
pub fn is_done(&self) -> bool {
|
|
matches!(self.status, NodeStatus::Done)
|
|
}
|
|
|
|
pub fn is_terminal(&self) -> bool {
|
|
matches!(self.status, NodeStatus::Done | NodeStatus::Failed)
|
|
}
|
|
}
|
|
|
|
/// A declarative spec for a node to add (seed or expand). Ids may be omitted to be
|
|
/// auto-assigned by the engine.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct NodeSpec {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<NodeId>,
|
|
pub content: String,
|
|
pub kind: NodeKind,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub depends_on: Vec<NodeId>,
|
|
#[serde(default)]
|
|
pub priority: u8,
|
|
}
|
|
|
|
impl NodeSpec {
|
|
pub fn new(id: impl Into<String>, content: impl Into<String>, kind: NodeKind) -> Self {
|
|
Self {
|
|
id: Some(id.into()),
|
|
content: content.into(),
|
|
kind,
|
|
depends_on: Vec::new(),
|
|
priority: 0,
|
|
}
|
|
}
|
|
|
|
pub fn depends_on(mut self, deps: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
|
self.depends_on = deps.into_iter().map(Into::into).collect();
|
|
self
|
|
}
|
|
|
|
pub fn priority(mut self, priority: u8) -> Self {
|
|
self.priority = priority;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Errors produced by validated graph mutations.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum DagError {
|
|
/// A referenced node id does not exist.
|
|
UnknownNode(NodeId),
|
|
/// A node id collides with an existing one.
|
|
DuplicateNode(NodeId),
|
|
/// An edge references a node id that exists nowhere in the operation.
|
|
UnknownDependency { node: NodeId, dependency: NodeId },
|
|
/// The mutation would introduce a cycle.
|
|
WouldCreateCycle(Vec<NodeId>),
|
|
/// The actor is not the owner of the node it tried to mutate.
|
|
NotOwner { node: NodeId, actor: String },
|
|
/// The node is not in a state where the operation is valid.
|
|
InvalidState { node: NodeId, status: NodeStatus },
|
|
/// The completion artifact failed deep-mode validation.
|
|
ThinArtifact { node: NodeId, reason: String },
|
|
/// A deep gate tried to pass while low-confidence sibling work was
|
|
/// unaddressed. The gate must either `inject_from_gate` to convert the doubt
|
|
/// into new nodes, or explicitly address each listed node id in its artifact.
|
|
UnaddressedLowConfidence { gate: NodeId, nodes: Vec<NodeId> },
|
|
/// A deep gate tried to pass without accounting for every completed node in
|
|
/// its audit scope. A passing gate artifact must name each id it reviewed;
|
|
/// enumeration is what makes the audit real instead of a rubber stamp.
|
|
UncoveredSiblings { gate: NodeId, nodes: Vec<NodeId> },
|
|
/// A deep gate tried to pass while its audit scope has non-terminal nodes
|
|
/// (new work arrived after the gate was dispatched, e.g. a re-seed widened
|
|
/// the root set). The gate's view is stale; it must re-run after they drain.
|
|
StaleGateScope { gate: NodeId, pending: Vec<NodeId> },
|
|
/// A gate kind was supplied as user work, or vice versa.
|
|
GateMisuse(String),
|
|
}
|
|
|
|
impl std::fmt::Display for DagError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
DagError::UnknownNode(id) => write!(f, "unknown node '{id}'"),
|
|
DagError::DuplicateNode(id) => write!(f, "duplicate node id '{id}'"),
|
|
DagError::UnknownDependency { node, dependency } => {
|
|
write!(f, "node '{node}' depends on unknown node '{dependency}'")
|
|
}
|
|
DagError::WouldCreateCycle(ids) => {
|
|
write!(
|
|
f,
|
|
"operation would create a cycle among: {}",
|
|
ids.join(", ")
|
|
)
|
|
}
|
|
DagError::NotOwner { node, actor } => {
|
|
write!(f, "actor '{actor}' does not own node '{node}'")
|
|
}
|
|
DagError::InvalidState { node, status } => {
|
|
write!(
|
|
f,
|
|
"node '{node}' is in invalid state {status:?} for this operation"
|
|
)
|
|
}
|
|
DagError::ThinArtifact { node, reason } => {
|
|
write!(f, "node '{node}' artifact rejected: {reason}")
|
|
}
|
|
DagError::UnaddressedLowConfidence { gate, nodes } => {
|
|
write!(
|
|
f,
|
|
"gate '{gate}' cannot pass: sibling node(s) [{}] completed with LOW \
|
|
confidence and the gate artifact does not address them. Either \
|
|
inject_gap with follow-up nodes that shore up that work, or name each \
|
|
id in your findings with why its low confidence is acceptable",
|
|
nodes.join(", ")
|
|
)
|
|
}
|
|
DagError::UncoveredSiblings { gate, nodes } => {
|
|
write!(
|
|
f,
|
|
"gate '{gate}' cannot pass: completed node(s) [{}] in its audit scope are \
|
|
not addressed in the gate artifact. A passing deep gate must account for \
|
|
every node it audits: name each id in findings/open_questions with what \
|
|
you checked, or inject_gap with follow-up nodes for anything shaky",
|
|
nodes.join(", ")
|
|
)
|
|
}
|
|
DagError::StaleGateScope { gate, pending } => {
|
|
write!(
|
|
f,
|
|
"gate '{gate}' cannot pass: node(s) [{}] entered its audit scope after it \
|
|
was dispatched and are not finished. The gate's view is stale; it re-runs \
|
|
after they drain",
|
|
pending.join(", ")
|
|
)
|
|
}
|
|
DagError::GateMisuse(msg) => write!(f, "gate misuse: {msg}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DagError {}
|
|
|
|
/// The task DAG: a mode plus a set of nodes. Insertion order is preserved for
|
|
/// deterministic iteration; lookups are by id.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TaskGraph {
|
|
pub mode: Mode,
|
|
nodes: Vec<TaskNode>,
|
|
}
|
|
|
|
impl TaskGraph {
|
|
pub fn new(mode: Mode) -> Self {
|
|
Self {
|
|
mode,
|
|
nodes: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn nodes(&self) -> &[TaskNode] {
|
|
&self.nodes
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.nodes.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.nodes.is_empty()
|
|
}
|
|
|
|
pub fn get(&self, id: &str) -> Option<&TaskNode> {
|
|
self.nodes.iter().find(|node| node.id == id)
|
|
}
|
|
|
|
pub(crate) fn get_mut(&mut self, id: &str) -> Option<&mut TaskNode> {
|
|
self.nodes.iter_mut().find(|node| node.id == id)
|
|
}
|
|
|
|
pub fn contains(&self, id: &str) -> bool {
|
|
self.nodes.iter().any(|node| node.id == id)
|
|
}
|
|
|
|
pub(crate) fn push(&mut self, node: TaskNode) {
|
|
self.nodes.push(node);
|
|
}
|
|
|
|
/// Push a fully-formed node. Used by the bridge to lift a `VersionedPlan` into
|
|
/// a `TaskGraph`. Callers are responsible for keeping ids unique; the
|
|
/// validated ops (`seed`/`expand_node`) enforce uniqueness on the write path.
|
|
pub fn push_node(&mut self, node: TaskNode) {
|
|
self.nodes.push(node);
|
|
}
|
|
|
|
/// Children of a composite node (excluding its gate).
|
|
pub fn children_of(&self, id: &str) -> Vec<&TaskNode> {
|
|
self.nodes
|
|
.iter()
|
|
.filter(|node| node.parent.as_deref() == Some(id) && !node.is_gate)
|
|
.collect()
|
|
}
|
|
|
|
/// The gate node guarding a composite node, if any.
|
|
pub fn gate_of(&self, id: &str) -> Option<&TaskNode> {
|
|
self.nodes
|
|
.iter()
|
|
.find(|node| node.parent.as_deref() == Some(id) && node.is_gate)
|
|
}
|
|
|
|
/// Ids of `Done` nodes whose artifact self-reported low confidence. This is
|
|
/// the graph's "shaky coverage" set: work that finished but whose author did
|
|
/// not trust it. Gates treat these as priority probe targets and (in deep
|
|
/// mode) cannot pass over an unaddressed one; status surfaces report them so
|
|
/// a coordinator can widen the graph.
|
|
pub fn low_confidence_done_ids(&self) -> Vec<NodeId> {
|
|
self.nodes
|
|
.iter()
|
|
.filter(|node| node.is_done() && !node.is_gate)
|
|
.filter(|node| {
|
|
node.output
|
|
.as_ref()
|
|
.and_then(HandoffArtifact::confidence_level)
|
|
== Some(ConfidenceLevel::Low)
|
|
})
|
|
.map(|node| node.id.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Whether every node has reached a terminal status.
|
|
pub fn all_terminal(&self) -> bool {
|
|
self.nodes.iter().all(TaskNode::is_terminal)
|
|
}
|
|
|
|
/// Detect a cycle over the current `depends_on` edges, returning the node ids
|
|
/// that participate in (or are downstream of) a cycle. Empty when acyclic.
|
|
pub fn cycle_nodes(&self) -> Vec<NodeId> {
|
|
// Kahn's algorithm: repeatedly remove zero-indegree nodes. Anything left
|
|
// is part of, or fed by, a cycle.
|
|
use std::collections::HashMap;
|
|
let known: std::collections::HashSet<&str> =
|
|
self.nodes.iter().map(|n| n.id.as_str()).collect();
|
|
let mut indegree: HashMap<&str, usize> = HashMap::new();
|
|
for node in &self.nodes {
|
|
indegree.entry(node.id.as_str()).or_insert(0);
|
|
}
|
|
for node in &self.nodes {
|
|
// Count each unique in-graph dependency once. `depends_on` can carry
|
|
// duplicates (agent-supplied specs are not deduped), and the
|
|
// relaxation below decrements once per unique (dep, dependent) pair,
|
|
// so counting occurrences here would strand acyclic nodes at
|
|
// indegree > 0 and falsely report a cycle.
|
|
let unique_deps: std::collections::HashSet<&str> = node
|
|
.depends_on
|
|
.iter()
|
|
.map(String::as_str)
|
|
.filter(|dep| known.contains(dep))
|
|
.collect();
|
|
*indegree.entry(node.id.as_str()).or_insert(0) += unique_deps.len();
|
|
}
|
|
let mut queue: Vec<&str> = indegree
|
|
.iter()
|
|
.filter_map(|(id, deg)| (*deg == 0).then_some(*id))
|
|
.collect();
|
|
queue.sort_unstable();
|
|
let mut visited = std::collections::HashSet::new();
|
|
while let Some(id) = queue.pop() {
|
|
if !visited.insert(id) {
|
|
continue;
|
|
}
|
|
for node in &self.nodes {
|
|
if node.depends_on.iter().any(|dep| dep == id)
|
|
&& let Some(deg) = indegree.get_mut(node.id.as_str())
|
|
{
|
|
*deg = deg.saturating_sub(1);
|
|
if *deg == 0 {
|
|
queue.push(node.id.as_str());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut leftover: Vec<NodeId> = self
|
|
.nodes
|
|
.iter()
|
|
.map(|n| n.id.clone())
|
|
.filter(|id| !visited.contains(id.as_str()))
|
|
.collect();
|
|
leftover.sort();
|
|
leftover
|
|
}
|
|
}
|