chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "jcode-plan"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -0,0 +1,330 @@
//! Measure how many agents a swarm task graph would spawn.
//!
//! This drives the *real* task-DAG engine (`jcode_plan::dag`) with scripted mock
//! workers and counts the things that map to live runtime behaviour:
//!
//! * **nodes**: total nodes in the final graph, including auto-inserted gates.
//! * **dispatches**: worker turns. Under `run_plan`'s default (`prefer_spawn=true`),
//! each dispatch is a *fresh* spawned agent, so this is the agent-spawn count.
//! * **gates**: critique/verify gates auto-inserted in deep mode.
//! * **peak concurrency**: max nodes runnable at once (the natural parallelism),
//! which is what `run_plan`'s `concurrency_limit` (default 3) would clamp.
//!
//! Run with: `cargo run -p jcode-plan --example swarm_agent_count`
//!
//! The point is to replace hand-waving ("it spawns a lot") with reproducible
//! numbers for several representative task shapes, and to show how the deep-mode
//! gate machinery and gap injection inflate the agent count versus light mode.
use std::collections::{HashMap, HashSet};
use jcode_plan::dag::sim::deep_artifact;
use jcode_plan::dag::{
HandoffArtifact, Mode, NodeKind, NodeSpec, TaskGraph, complete_node, dispatch, expand_node,
fail_node, inject_from_gate, ready_nodes, seed,
};
/// What a scripted worker decides to do with a dispatched node.
#[derive(Clone)]
enum Act {
Complete,
Expand(Vec<NodeSpec>),
InjectGap(Vec<NodeSpec>),
#[allow(dead_code)]
Fail,
}
/// A scenario is a mode, a seed, and a per-node behaviour script (by node id).
struct Scenario {
name: &'static str,
mode: Mode,
seed: Vec<NodeSpec>,
/// Returns the action for a node the *first* time it is dispatched. Composite
/// nodes are dispatched twice (expand, then synthesis re-wake); the synthesis
/// re-wake always just completes.
script: HashMap<String, Act>,
}
/// Result of running a scenario through the engine.
#[derive(Default)]
struct Measured {
nodes_final: usize,
gates: usize,
dispatches: usize,
expansions: usize,
gaps_injected: usize,
peak_concurrency_unbounded: usize,
steps: usize,
stalled: bool,
}
fn spec(id: &str, kind: NodeKind) -> NodeSpec {
NodeSpec::new(id, format!("task {id}"), kind)
}
/// Drive a scenario to completion with an *unbounded* worker pool so we can read
/// the natural peak concurrency, while still counting every dispatch (= agent).
fn measure(scn: &Scenario) -> Measured {
let mut g = TaskGraph::new(scn.mode);
if let Err(err) = seed(&mut g, scn.seed.clone()) {
eprintln!("scenario seed failed to validate: {err}");
return Measured {
stalled: true,
..Measured::default()
};
}
let mut script = scn.script.clone();
let mut done_once: HashSet<String> = HashSet::new();
let mut m = Measured::default();
let max_steps = 10_000;
loop {
if g.all_terminal() {
break;
}
if m.steps >= max_steps {
m.stalled = true;
break;
}
let ready: Vec<(String, NodeKind)> = ready_nodes(&g)
.into_iter()
.map(|n| (n.id.clone(), n.kind))
.collect();
if ready.is_empty() {
m.stalled = true;
break;
}
// Natural parallelism this step (unbounded pool dispatches all ready).
m.peak_concurrency_unbounded = m.peak_concurrency_unbounded.max(ready.len());
for (idx, (id, _kind)) in ready.into_iter().enumerate() {
let worker = format!("w{idx}");
if !dispatch(&mut g, &id, &worker) {
continue;
}
m.dispatches += 1;
// A node already expanded that re-wakes for synthesis just completes.
let already = done_once.contains(&id);
let act = if already {
Act::Complete
} else {
script.remove(&id).unwrap_or(Act::Complete)
};
done_once.insert(id.clone());
let step = match act {
Act::Complete => {
let art = if scn.mode.requires_gates() {
deep_artifact(&format!("did {id}"))
} else {
HandoffArtifact::brief(format!("did {id}"))
};
complete_node(&mut g, &id, &worker, art)
}
Act::Expand(children) => {
m.expansions += 1;
expand_node(&mut g, &id, &worker, children).map(|_| ())
}
Act::InjectGap(nodes) => {
m.gaps_injected += nodes.len();
inject_from_gate(&mut g, &id, &worker, nodes).map(|_| ())
}
Act::Fail => fail_node(&mut g, &id, &worker),
};
if let Err(err) = step {
eprintln!("scenario step on '{id}' failed: {err}");
m.stalled = true;
break;
}
m.steps += 1;
}
}
m.nodes_final = g.nodes().len();
m.gates = g.nodes().iter().filter(|n| n.is_gate).count();
m
}
/// Scenario 1: light flat fan-out. N independent implement tasks + 1 merge.
fn light_fanout(n: usize) -> Scenario {
let mut seed_nodes = Vec::new();
let mut deps = Vec::new();
for i in 0..n {
let id = format!("t{i}");
seed_nodes.push(spec(&id, NodeKind::Implement));
deps.push(id);
}
seed_nodes.push(spec("merge", NodeKind::Synthesize).depends_on(deps));
Scenario {
name: "light: flat fan-out (N impl + merge)",
mode: Mode::Light,
seed: seed_nodes,
script: HashMap::new(),
}
}
/// Scenario 2: deep shallow. One root explore decomposed into K facets. Deep mode
/// adds a critique gate + a synthesis re-wake. No gaps found.
fn deep_shallow(k: usize) -> Scenario {
let mut script = HashMap::new();
let children: Vec<NodeSpec> = (0..k)
.map(|i| spec(&format!("root.{i}"), NodeKind::Explore))
.collect();
script.insert("root".to_string(), Act::Expand(children));
Scenario {
name: "deep: 1 root -> K facets (gate, no gaps)",
mode: Mode::Deep,
seed: vec![spec("root", NodeKind::Explore)],
script,
}
}
/// Scenario 3: deep shallow but the critique gate finds one gap, spawning an extra
/// node and re-running the gate (the comprehensiveness loop).
fn deep_with_gap(k: usize) -> Scenario {
let mut script = HashMap::new();
let children: Vec<NodeSpec> = (0..k)
.map(|i| spec(&format!("root.{i}"), NodeKind::Explore))
.collect();
script.insert("root".to_string(), Act::Expand(children));
// The auto gate id is "root::gate"; first dispatch injects a gap.
script.insert(
"root::gate".to_string(),
Act::InjectGap(vec![spec("root.gap", NodeKind::Explore)]),
);
Scenario {
name: "deep: 1 root -> K facets, gate finds 1 gap",
mode: Mode::Deep,
seed: vec![spec("root", NodeKind::Explore)],
script,
}
}
/// Scenario 4: deep nested. Root -> K facets; one facet itself decomposes into M
/// sub-facets (a second composite + gate). Models real recursive decomposition.
fn deep_nested(k: usize, m: usize) -> Scenario {
let mut script = HashMap::new();
let children: Vec<NodeSpec> = (0..k)
.map(|i| spec(&format!("root.{i}"), NodeKind::Explore))
.collect();
script.insert("root".to_string(), Act::Expand(children));
let sub: Vec<NodeSpec> = (0..m)
.map(|i| spec(&format!("root.0.{i}"), NodeKind::Explore))
.collect();
script.insert("root.0".to_string(), Act::Expand(sub));
Scenario {
name: "deep: nested (root->K, facet0->M), 2 gates",
mode: Mode::Deep,
seed: vec![spec("root", NodeKind::Explore)],
script,
}
}
/// Scenario 5: a realistic "explore then implement then verify" deep graph.
fn deep_explore_implement_verify() -> Scenario {
let mut script = HashMap::new();
// explore decomposes into 3 facets of investigation.
script.insert(
"explore".to_string(),
Act::Expand(vec![
spec("explore.api", NodeKind::Explore),
spec("explore.data", NodeKind::Explore),
spec("explore.ui", NodeKind::Explore),
]),
);
// implement decomposes into 2 code changes.
script.insert(
"implement".to_string(),
Act::Expand(vec![
spec("impl.core", NodeKind::Implement),
spec("impl.glue", NodeKind::Implement),
]),
);
Scenario {
name: "deep: explore(3) -> implement(2) -> verify",
mode: Mode::Deep,
seed: vec![
spec("explore", NodeKind::Explore),
spec("implement", NodeKind::Implement).depends_on(["explore"]),
spec("verify", NodeKind::Verify).depends_on(["implement"]),
],
script,
}
}
fn print_row(scn: &Scenario, m: &Measured) {
// Deep mode now fans out to the full ready set (bounded only by the member
// cap / the configurable swarm_max_concurrent_agents, default 32). Light mode
// keeps a small default (4). So the effective peak parallelism is the natural
// ready-set width clamped by the mode's default ceiling.
let mode_ceiling = match scn.mode {
Mode::Deep => 32,
Mode::Light => 4,
};
let effective_peak = m.peak_concurrency_unbounded.min(mode_ceiling);
println!("{}", scn.name);
println!(
" mode={:<5} nodes(final)={:<3} gates={:<2} gaps_injected={:<2} expansions={}",
match scn.mode {
Mode::Deep => "deep",
Mode::Light => "light",
},
m.nodes_final,
m.gates,
m.gaps_injected,
m.expansions,
);
println!(
" dispatches(=agents spawned, fresh-per-node)={:<3} peak_parallel(natural)={:<2} peak_parallel(mode default cap)={}",
m.dispatches, m.peak_concurrency_unbounded, effective_peak,
);
if m.stalled {
println!(" !! STALLED (engine could not drive to terminal)");
}
println!();
}
fn main() {
println!("=== Swarm agent-count measurement (real dag engine) ===\n");
println!(
"dispatches == worker turns. run_plan defaults to a fresh spawned agent per node\n\
(prefer_spawn=true), so dispatches is the number of agents spawned. Composite\n\
nodes are dispatched twice (decompose, then synthesis re-wake) so they cost 2.\n\
peak_parallel(natural) is how many nodes are unblocked at once. run_plan clamps\n\
that to a mode default: deep => agents.swarm_max_concurrent_agents (default 32,\n\
0 = unbounded up to the 1000 member cap); light => 4. The total agents spawned\n\
over the run is unaffected by the cap; only how many run simultaneously is.\n"
);
let scenarios = vec![
light_fanout(4),
light_fanout(16),
deep_shallow(3),
deep_shallow(6),
deep_with_gap(3),
deep_nested(3, 3),
deep_explore_implement_verify(),
];
for scn in &scenarios {
let m = measure(scn);
print_row(scn, &m);
}
// A compact growth table for deep shallow decomposition.
println!("--- deep shallow: agents spawned as facet count K grows ---");
println!(" K facets | final nodes | gates | dispatches(agents)");
for k in [1usize, 2, 3, 4, 6, 8, 12, 16] {
let m = measure(&deep_shallow(k));
println!(
" {k:>8} | {:>11} | {:>5} | {:>17}",
m.nodes_final, m.gates, m.dispatches
);
}
println!(
"\nFormula (deep, 1 level, no gaps): nodes = K + 2 (root + gate), \
agents = K + 3\n (K facet dispatches + 1 root-expand + 1 gate + 1 root-synthesis re-wake)."
);
}
+503
View File
@@ -0,0 +1,503 @@
//! Bridge between the validated [`crate::dag`] engine and the live
//! [`VersionedPlan`] storage used by the swarm runtime.
//!
//! The `dag` engine is the brain: it owns validation (acyclicity, ownership,
//! gate insertion, artifact checks) and the reference simulator. `VersionedPlan`
//! is the live, persisted, broadcast storage. Rather than run two parallel
//! runtimes, server handlers lift the current plan into a `TaskGraph`, apply an
//! engine op, then lower the result back. This keeps a single source of truth and
//! reuses the existing persistence/broadcast/scheduler machinery.
use crate::dag::{HandoffArtifact, Mode, NodeKind, NodeOrigin, NodeStatus, TaskGraph, TaskNode};
use crate::{NodeMeta, PlanItem, VersionedPlan};
/// Parse a mode string ("deep"/"light"); unknown values fall back to light.
pub fn parse_mode(mode: &str) -> Mode {
match mode.trim().to_ascii_lowercase().as_str() {
"deep" => Mode::Deep,
_ => Mode::Light,
}
}
pub fn mode_str(mode: Mode) -> &'static str {
match mode {
Mode::Deep => "deep",
Mode::Light => "light",
}
}
/// Parse a node-kind string; unknown/absent values default to `Explore`.
pub fn parse_kind(kind: Option<&str>) -> NodeKind {
match kind.map(|k| k.trim().to_ascii_lowercase()).as_deref() {
Some("implement") => NodeKind::Implement,
Some("verify") => NodeKind::Verify,
Some("fix") => NodeKind::Fix,
Some("synthesize") => NodeKind::Synthesize,
Some("critique") => NodeKind::Critique,
_ => NodeKind::Explore,
}
}
pub fn kind_str(kind: NodeKind) -> &'static str {
match kind {
NodeKind::Explore => "explore",
NodeKind::Implement => "implement",
NodeKind::Verify => "verify",
NodeKind::Fix => "fix",
NodeKind::Synthesize => "synthesize",
NodeKind::Critique => "critique",
}
}
/// Parse a node-origin string; unknown/absent values yield `None` (legacy
/// nodes, treated as seeded by the growth accounting).
pub fn parse_origin(origin: Option<&str>) -> Option<NodeOrigin> {
match origin.map(|o| o.trim().to_ascii_lowercase()).as_deref() {
Some("seed") => Some(NodeOrigin::Seed),
Some("expand") => Some(NodeOrigin::Expand),
Some("gap") => Some(NodeOrigin::Gap),
Some("gate") => Some(NodeOrigin::Gate),
_ => None,
}
}
pub fn origin_str(origin: NodeOrigin) -> &'static str {
match origin {
NodeOrigin::Seed => "seed",
NodeOrigin::Expand => "expand",
NodeOrigin::Gap => "gap",
NodeOrigin::Gate => "gate",
}
}
/// Map a plan status string to an engine [`NodeStatus`].
fn status_from_plan(status: &str) -> NodeStatus {
match status {
"running" | "running_stale" => NodeStatus::Running,
"completed" | "done" => NodeStatus::Done,
"failed" | "stopped" | "crashed" => NodeStatus::Failed,
_ => NodeStatus::Queued,
}
}
/// Map an engine [`NodeStatus`] back to the canonical plan status string.
fn status_to_plan(status: NodeStatus) -> &'static str {
match status {
NodeStatus::Queued => "queued",
NodeStatus::Running => "running",
NodeStatus::Done => "completed",
NodeStatus::Failed => "failed",
}
}
/// Lift a [`VersionedPlan`] into a validated [`TaskGraph`] for engine ops.
pub fn to_task_graph(plan: &VersionedPlan) -> TaskGraph {
let mut graph = TaskGraph::new(parse_mode(&plan.mode));
for item in &plan.items {
let meta = plan.node_meta.get(&item.id).cloned().unwrap_or_default();
let artifact = meta
.artifact_json
.as_deref()
.and_then(|json| serde_json::from_str::<HandoffArtifact>(json).ok());
graph.push_node(TaskNode {
id: item.id.clone(),
content: item.content.clone(),
kind: parse_kind(meta.kind.as_deref()),
status: status_from_plan(&item.status),
owner: item.assigned_to.clone(),
parent: meta.parent.clone(),
depends_on: item.blocked_by.clone(),
expanded: meta.expanded,
is_gate: meta.is_gate,
planner: meta.planner.clone(),
priority: crate::priority_rank(&item.priority),
output: artifact,
origin: parse_origin(meta.origin.as_deref()),
});
}
graph
}
/// Lower a [`TaskGraph`] back into the plan's items + node_meta, preserving the
/// fields the engine does not own (subsystem, file_scope, original priority
/// string) from the prior plan where ids still match.
pub fn apply_task_graph(plan: &mut VersionedPlan, graph: &TaskGraph) {
plan.mode = mode_str(graph.mode).to_string();
// Index prior items to retain non-engine fields.
let prior: std::collections::HashMap<String, PlanItem> = plan
.items
.iter()
.map(|item| (item.id.clone(), item.clone()))
.collect();
let mut items = Vec::with_capacity(graph.nodes().len());
let mut node_meta = std::collections::HashMap::new();
for node in graph.nodes() {
let prev = prior.get(&node.id);
items.push(PlanItem {
content: node.content.clone(),
status: status_to_plan(node.status).to_string(),
priority: prev
.map(|p| p.priority.clone())
.unwrap_or_else(|| priority_string(node.priority)),
id: node.id.clone(),
subsystem: prev.and_then(|p| p.subsystem.clone()),
file_scope: prev.map(|p| p.file_scope.clone()).unwrap_or_default(),
blocked_by: node.depends_on.clone(),
assigned_to: node.owner.clone(),
});
node_meta.insert(
node.id.clone(),
NodeMeta {
kind: Some(kind_str(node.kind).to_string()),
parent: node.parent.clone(),
expanded: node.expanded,
is_gate: node.is_gate,
planner: node.planner.clone(),
artifact_json: node
.output
.as_ref()
.and_then(|a| serde_json::to_string(a).ok()),
origin: node.origin.map(|o| origin_str(o).to_string()),
},
);
}
plan.items = items;
plan.node_meta = node_meta;
}
fn priority_string(rank: u8) -> String {
match rank {
0 => "high".to_string(),
2 => "low".to_string(),
_ => "medium".to_string(),
}
}
/// Build the forward-dataflow context for a task: the merged handoff artifacts of
/// all its completed upstream dependencies, formatted for injection into the
/// assigned worker's prompt. Returns `None` when the task has no completed
/// dependencies with artifacts, so callers can skip appending anything.
///
/// This is the live counterpart of `dag::assemble_input`, but it reads artifacts
/// from the plan's `node_meta` side-map instead of a `TaskGraph`, so it can run
/// directly on the assignment path without lifting the whole graph.
pub fn upstream_context(plan: &VersionedPlan, task_id: &str) -> Option<String> {
let item = plan.items.iter().find(|item| item.id == task_id)?;
if item.blocked_by.is_empty() {
return None;
}
let mut sections = Vec::new();
for dep_id in &item.blocked_by {
let Some(dep) = plan.items.iter().find(|i| &i.id == dep_id) else {
continue;
};
if !crate::is_completed_status(&dep.status) {
continue;
}
let Some(meta) = plan.node_meta.get(dep_id) else {
continue;
};
let Some(json) = meta.artifact_json.as_deref() else {
continue;
};
let Ok(artifact) = serde_json::from_str::<HandoffArtifact>(json) else {
continue;
};
let kind = meta.kind.as_deref().unwrap_or("task");
sections.push(artifact.render_section(dep_id, kind));
}
if sections.is_empty() {
None
} else {
Some(format!(
"# Inputs from completed dependencies\n\n{}",
sections.join("\n")
))
}
}
/// Prepend upstream dependency context (if any) to a task's assignment content.
pub fn hydrate_assignment(plan: &VersionedPlan, task_id: &str, content: &str) -> String {
match upstream_context(plan, task_id) {
Some(context) => format!("{content}\n\n{context}"),
None => content.to_string(),
}
}
/// Growth accounting for a plan: how far the graph outgrew its seed. This is
/// deep mode's visibility signal — a deep plan whose node count equals its
/// seed count never decomposed or gated anything, which almost always means
/// under-exploration rather than a genuinely atomic plan.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GrowthStats {
/// Nodes from the initial seed batch (plus legacy nodes with no origin).
pub seeded: usize,
/// Nodes born from `expand_node` decomposition.
pub from_expansion: usize,
/// Nodes injected by gates that found gaps/failures.
pub from_gaps: usize,
/// Auto-inserted critique/verify gates (including the root gate).
pub gates: usize,
}
impl GrowthStats {
pub fn total(&self) -> usize {
self.seeded + self.from_expansion + self.from_gaps + self.gates
}
/// Machinery-generated nodes (everything that is not seed).
pub fn grown(&self) -> usize {
self.from_expansion + self.from_gaps + self.gates
}
/// One-line human summary, e.g.
/// `12 seeded -> 87 nodes (+55 expansion, +14 gap, +6 gates)`.
pub fn summary_line(&self) -> String {
format!(
"{} seeded -> {} nodes (+{} expansion, +{} gap, +{} gates)",
self.seeded,
self.total(),
self.from_expansion,
self.from_gaps,
self.gates
)
}
}
/// Compute growth stats from the plan's `node_meta` origin records. Nodes with
/// no recorded origin (legacy plans, hand-written items) count as seeded.
pub fn growth_stats(plan: &VersionedPlan) -> GrowthStats {
let mut stats = GrowthStats::default();
for item in &plan.items {
let origin = plan
.node_meta
.get(&item.id)
.and_then(|meta| parse_origin(meta.origin.as_deref()));
match origin {
Some(NodeOrigin::Expand) => stats.from_expansion += 1,
Some(NodeOrigin::Gap) => stats.from_gaps += 1,
Some(NodeOrigin::Gate) => stats.gates += 1,
Some(NodeOrigin::Seed) | None => stats.seeded += 1,
}
}
stats
}
/// Ids of completed plan items whose stored artifact self-reported LOW
/// confidence. Live counterpart of `TaskGraph::low_confidence_done_ids`,
/// reading artifacts from the plan's `node_meta` side-map so status surfaces
/// (plan_status, run_plan reports) can flag shaky coverage without lifting the
/// whole graph. Gate nodes are excluded: their confidence describes the gate's
/// judgement, not the underlying work.
pub fn low_confidence_completed_ids(plan: &VersionedPlan) -> Vec<String> {
plan.items
.iter()
.filter(|item| crate::is_completed_status(&item.status))
.filter(|item| {
let Some(meta) = plan.node_meta.get(&item.id) else {
return false;
};
if meta.is_gate {
return false;
}
meta.artifact_json
.as_deref()
.and_then(|json| serde_json::from_str::<HandoffArtifact>(json).ok())
.and_then(|artifact| artifact.confidence_level())
== Some(crate::dag::ConfidenceLevel::Low)
})
.map(|item| item.id.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dag::{NodeSpec, complete_node, dispatch, expand_node, seed};
fn plan_item(id: &str, status: &str) -> PlanItem {
PlanItem {
content: format!("task {id}"),
status: status.to_string(),
priority: "medium".to_string(),
id: id.to_string(),
subsystem: None,
file_scope: Vec::new(),
blocked_by: Vec::new(),
assigned_to: None,
}
}
#[test]
fn round_trip_preserves_items_and_edges() {
let mut plan = VersionedPlan::new();
plan.mode = "deep".to_string();
plan.items = vec![
plan_item("a", "completed"),
PlanItem {
blocked_by: vec!["a".to_string()],
..plan_item("b", "queued")
},
];
let graph = to_task_graph(&plan);
assert_eq!(graph.mode, Mode::Deep);
assert_eq!(graph.len(), 2);
assert!(graph.get("a").unwrap().is_done());
assert_eq!(graph.get("b").unwrap().depends_on, vec!["a".to_string()]);
let mut plan2 = plan.clone();
apply_task_graph(&mut plan2, &graph);
assert_eq!(plan2.items.len(), 2);
let b = plan2.items.iter().find(|i| i.id == "b").unwrap();
assert_eq!(b.blocked_by, vec!["a".to_string()]);
assert_eq!(b.status, "queued");
}
#[test]
fn engine_op_through_bridge_updates_plan() {
let mut plan = VersionedPlan::new();
plan.mode = "deep".to_string();
// Seed via engine, lower back into the plan. Deep mode auto-inserts a
// plan-wide root gate alongside the seeded node.
let mut graph = to_task_graph(&plan);
seed(
&mut graph,
vec![NodeSpec::new("root", "explore X", NodeKind::Explore)],
)
.unwrap();
apply_task_graph(&mut plan, &graph);
assert_eq!(plan.items.len(), 2);
assert_eq!(plan.node_meta["root"].kind.as_deref(), Some("explore"));
assert_eq!(plan.node_meta["root"].origin.as_deref(), Some("seed"));
let root_gate_id = plan
.items
.iter()
.map(|i| i.id.clone())
.find(|id| {
plan.node_meta
.get(id)
.map(|m| m.is_gate && m.parent.is_none())
.unwrap_or(false)
})
.expect("deep seed must lower a root gate into the plan");
assert_eq!(
plan.node_meta[&root_gate_id].origin.as_deref(),
Some("gate")
);
// Dispatch + expand via engine, lower back; the composite's own gate must
// appear in the plan with the composite parent marked expanded.
let mut graph = to_task_graph(&plan);
dispatch(&mut graph, "root", "w0");
expand_node(
&mut graph,
"root",
"w0",
vec![NodeSpec::new("root.1", "facet", NodeKind::Explore)],
)
.unwrap();
apply_task_graph(&mut plan, &graph);
assert!(plan.node_meta["root"].expanded);
assert_eq!(plan.node_meta["root.1"].origin.as_deref(), Some("expand"));
let gate = plan
.items
.iter()
.find(|i| {
plan.node_meta
.get(&i.id)
.map(|m| m.is_gate && m.parent.as_deref() == Some("root"))
.unwrap_or(false)
})
.expect("gate should exist in lowered plan");
assert_eq!(plan.node_meta[&gate.id].kind.as_deref(), Some("critique"));
// Complete the child + gate + synthesis end to end through the bridge.
let mut graph = to_task_graph(&plan);
dispatch(&mut graph, "root.1", "w0");
complete_node(
&mut graph,
"root.1",
"w0",
HandoffArtifact {
findings: "found".into(),
what_i_did_not_check: vec!["nothing".into()],
confidence: Some("high".into()),
..HandoffArtifact::default()
},
)
.unwrap();
apply_task_graph(&mut plan, &graph);
// The child's artifact round-trips through node_meta JSON.
let stored = &plan.node_meta["root.1"].artifact_json;
assert!(stored.as_ref().unwrap().contains("found"));
}
#[test]
fn upstream_context_merges_completed_dependency_artifacts() {
let mut plan = VersionedPlan::new();
plan.items = vec![
plan_item("dep", "completed"),
PlanItem {
blocked_by: vec!["dep".to_string()],
..plan_item("task", "queued")
},
];
plan.node_meta.insert(
"dep".to_string(),
NodeMeta {
kind: Some("explore".to_string()),
artifact_json: Some(
serde_json::to_string(&HandoffArtifact {
findings: "API in foo.rs".into(),
evidence: vec!["crates/foo/api.rs:12".into()],
..HandoffArtifact::default()
})
.unwrap(),
),
..NodeMeta::default()
},
);
let hydrated = hydrate_assignment(&plan, "task", "do the work");
assert!(hydrated.contains("do the work"));
assert!(hydrated.contains("Inputs from completed dependencies"));
assert!(hydrated.contains("API in foo.rs"));
assert!(hydrated.contains("crates/foo/api.rs:12"));
// A task with no deps is returned unchanged.
assert_eq!(hydrate_assignment(&plan, "dep", "x"), "x");
}
#[test]
fn upstream_context_skips_incomplete_dependencies() {
let mut plan = VersionedPlan::new();
plan.items = vec![
plan_item("dep", "running"),
PlanItem {
blocked_by: vec!["dep".to_string()],
..plan_item("task", "queued")
},
];
plan.node_meta.insert(
"dep".to_string(),
NodeMeta {
artifact_json: Some(
serde_json::to_string(&HandoffArtifact::brief("partial")).unwrap(),
),
..NodeMeta::default()
},
);
// dep is not completed, so no context is injected.
assert_eq!(upstream_context(&plan, "task"), None);
}
}
+682
View File
@@ -0,0 +1,682 @@
//! 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
}
}
+878
View File
@@ -0,0 +1,878 @@
//! Validated graph mutations.
//!
//! Every mutation is append-style and server-validated. Writes are partitioned by
//! owner (you may only expand/complete a node you own), edges may only reference
//! existing nodes, and the result must stay acyclic. In deep mode, expanding a
//! node auto-inserts a critique/verify gate so a composite node cannot close
//! without surviving its gate (doc sections 2, 3, 6).
use super::{
DagError, HandoffArtifact, Mode, NodeKind, NodeOrigin, NodeSpec, NodeStatus, TaskGraph,
TaskNode,
};
/// Seed the initial DAG from a batch of specs (the first agent's draft). All
/// referenced dependencies must resolve within the supplied set and the result
/// must be acyclic. Replaying an identical seed definition is a no-op, which makes
/// transport/tool retries safe. Reusing an id for a different definition remains
/// an error. The seed has no owner yet; ownership is assigned on dispatch.
pub fn seed(graph: &mut TaskGraph, specs: Vec<NodeSpec>) -> Result<(), DagError> {
// Validate ids and collapse exact replays within the same request. A repeated
// id with different declarative fields is still ambiguous and rejected.
let mut unique_specs: Vec<NodeSpec> = Vec::with_capacity(specs.len());
let mut indexes = std::collections::HashMap::<String, usize>::new();
for spec in specs {
let id = validated_spec_id(&spec, "seed")?;
if let Some(existing_index) = indexes.get(&id).copied() {
if !seed_specs_equivalent(&unique_specs[existing_index], &spec) {
return Err(DagError::DuplicateNode(id));
}
continue;
}
indexes.insert(id, unique_specs.len());
unique_specs.push(spec);
}
let known: std::collections::HashSet<&str> = indexes.keys().map(String::as_str).collect();
for spec in &unique_specs {
for dep in &spec.depends_on {
if !known.contains(dep.as_str()) && !graph.contains(dep) {
return Err(DagError::UnknownDependency {
node: spec.id.clone().unwrap_or_default(),
dependency: dep.clone(),
});
}
}
}
// Apply onto a clone, verify acyclicity, then commit.
let mut staged = graph.clone();
for spec in unique_specs {
let id = spec.id.as_deref().expect("seed ids were validated above");
if let Some(existing) = graph.get(id) {
if !seed_spec_matches_existing(graph, existing, &spec) {
return Err(DagError::DuplicateNode(id.to_string()));
}
continue;
}
staged.push(spec_to_node(spec, None, NodeOrigin::Seed));
}
// Deep mode: the whole plan ends in a mandatory adversarial audit. Without
// this, a flat seed whose nodes all execute atomically would close with
// zero gates ever firing, silently downgrading deep mode to light. The root
// gate depends on every root-level node, so the plan cannot reach a
// terminal state until a final critique/verify pass over everything
// succeeds — and that gate can `inject_from_gate` new root-level work,
// which is the top-of-tree growth lever (doc sections 6.2, 7).
if staged.mode.requires_gates() {
ensure_root_gate(&mut staged);
}
let cycle = staged.cycle_nodes();
if !cycle.is_empty() {
return Err(DagError::WouldCreateCycle(cycle));
}
*graph = staged;
Ok(())
}
fn seed_specs_equivalent(left: &NodeSpec, right: &NodeSpec) -> bool {
left.id == right.id
&& left.content == right.content
&& left.kind == right.kind
&& left.priority == right.priority
&& dependency_sets_equal(&left.depends_on, &right.depends_on)
}
fn seed_spec_matches_existing(graph: &TaskGraph, node: &TaskNode, spec: &NodeSpec) -> bool {
// Only top-level seeded (or legacy, origin-less) work can be replayed. A
// collision with an expanded child or an auto-generated gate must never be
// silently treated as the same declaration.
if node.parent.is_some()
|| node.is_gate
|| !matches!(node.origin, None | Some(NodeOrigin::Seed))
|| node.content != spec.content
|| node.kind != spec.kind
|| node.priority != spec.priority
{
return false;
}
// Expanding a seeded node appends its children and gate to the parent's
// dependency list. Filter those machinery-owned join edges so a later replay
// can still be recognized from the original upstream dependencies.
let declared_dependencies: Vec<&str> = node
.depends_on
.iter()
.filter(|dependency| {
graph
.get(dependency)
.is_none_or(|candidate| candidate.parent.as_deref() != Some(node.id.as_str()))
})
.map(String::as_str)
.collect();
dependency_sets_equal_iter(
declared_dependencies,
spec.depends_on.iter().map(String::as_str),
)
}
fn dependency_sets_equal(left: &[String], right: &[String]) -> bool {
dependency_sets_equal_iter(
left.iter().map(String::as_str),
right.iter().map(String::as_str),
)
}
fn dependency_sets_equal_iter<'a>(
left: impl IntoIterator<Item = &'a str>,
right: impl IntoIterator<Item = &'a str>,
) -> bool {
let left: std::collections::HashSet<&str> = left.into_iter().collect();
let right: std::collections::HashSet<&str> = right.into_iter().collect();
left == right
}
/// Insert or refresh the deep-mode root gate so it audits the current
/// root-level node set.
///
/// - No root gate yet and root work exists: create one depending on every
/// non-gate root node.
/// - Root gate exists: extend its dependencies to any new root nodes, and if it
/// already reached a terminal state, re-queue it — new work re-opens the
/// audit, so a re-seeded plan can never stay "finished" unaudited.
fn ensure_root_gate(graph: &mut TaskGraph) {
let root_ids: Vec<String> = graph
.nodes()
.iter()
.filter(|node| node.parent.is_none() && !node.is_gate)
.map(|node| node.id.clone())
.collect();
if root_ids.is_empty() {
return;
}
let existing_gate = graph
.nodes()
.iter()
.find(|node| node.is_gate && node.parent.is_none())
.map(|node| node.id.clone());
match existing_gate {
Some(gate_id) => {
// Id was resolved from `graph` two lines above; skip silently if a
// racecondition-free graph somehow lost it rather than panic.
let Some(gate) = graph.get_mut(&gate_id) else {
return;
};
let mut widened = false;
for id in root_ids {
if !gate.depends_on.contains(&id) {
gate.depends_on.push(id);
widened = true;
}
}
if widened && gate.is_terminal() {
gate.status = NodeStatus::Queued;
gate.owner = None;
}
}
None => {
// Verify-style root gate only when the whole root set is code work;
// any exploration in the mix gets the critique (gap-finding) form.
let all_code = graph
.nodes()
.iter()
.filter(|node| node.parent.is_none() && !node.is_gate)
.all(|node| matches!(node.kind, NodeKind::Implement | NodeKind::Fix));
let gate_kind = if all_code {
NodeKind::Verify
} else {
NodeKind::Critique
};
let gate_id = unique_gate_id(graph, "plan");
graph.push(TaskNode {
id: gate_id,
content: root_gate_content(gate_kind),
kind: gate_kind,
status: NodeStatus::Queued,
owner: None,
parent: None,
depends_on: root_ids,
expanded: false,
is_gate: true,
planner: None,
priority: 0,
output: None,
origin: Some(NodeOrigin::Gate),
});
}
}
}
/// The result of expanding a node into children.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpandOutcome {
/// Ids of the child nodes created.
pub child_ids: Vec<String>,
/// The id of the auto-inserted gate, if deep mode inserted one.
pub gate_id: Option<String>,
}
/// Decompose a node the actor owns into a child sub-DAG (the composite path). The
/// node flips to composite and becomes a join/synthesis point that depends on its
/// children. In deep mode a critique/verify gate is auto-inserted between the
/// children and the synthesis, so the composite cannot close without surviving it.
///
/// Children may depend on each other and on the parent's own upstream
/// dependencies (already-existing nodes), preserving acyclicity by construction.
pub fn expand_node(
graph: &mut TaskGraph,
node_id: &str,
actor: &str,
children: Vec<NodeSpec>,
) -> Result<ExpandOutcome, DagError> {
{
let node = graph
.get(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?;
if node.owner.as_deref() != Some(actor) {
return Err(DagError::NotOwner {
node: node_id.to_string(),
actor: actor.to_string(),
});
}
// Only a running, not-yet-expanded, non-gate node may be decomposed.
if node.is_gate {
return Err(DagError::GateMisuse(format!(
"gate node '{node_id}' cannot be decomposed"
)));
}
if node.expanded || node.status != NodeStatus::Running {
return Err(DagError::InvalidState {
node: node_id.to_string(),
status: node.status,
});
}
if children.is_empty() {
return Err(DagError::GateMisuse(
"expand requires at least one child".into(),
));
}
}
// Validate child ids and dependency references. Collect the validated ids
// once so later steps never re-unwrap `spec.id`.
let mut seen = std::collections::HashSet::new();
let mut child_ids: Vec<String> = Vec::with_capacity(children.len());
for spec in &children {
let id = validated_spec_id(spec, "expand")?;
if graph.contains(&id) || !seen.insert(id.clone()) {
return Err(DagError::DuplicateNode(id));
}
child_ids.push(id);
}
let child_set: std::collections::HashSet<&str> = child_ids.iter().map(String::as_str).collect();
for (spec, child_id) in children.iter().zip(child_ids.iter()) {
for dep in &spec.depends_on {
// A child may depend on a sibling or any already-existing node.
if !child_set.contains(dep.as_str()) && !graph.contains(dep) {
return Err(DagError::UnknownDependency {
node: child_id.clone(),
dependency: dep.clone(),
});
}
}
}
// Stage onto a clone so a cycle rejects the whole expansion.
let mut staged = graph.clone();
// Insert children, parented to this node.
for spec in children {
staged.push(spec_to_node(
spec,
Some(node_id.to_string()),
NodeOrigin::Expand,
));
}
// The synthesis (parent) must wait for every child. In deep mode it must also
// wait for the gate. We keep the child edges even in deep mode: the gate
// already depends on every child, so "gate done" implies "children done" for
// *scheduling*, but the forward-dataflow hydration only reads a node's *direct*
// dependencies. Dropping the child edges would mean the map-reduce synthesis
// re-wake never receives its children's artifacts (doc section 5).
let mut synth_deps = child_ids.clone();
// Deep mode: insert a gate that depends on all children; the synthesis then
// additionally depends on the gate so it cannot close until the gate passes.
let gate_id = if staged.mode.requires_gates() {
let parent_kind = staged
.get(node_id)
.map(|n| n.kind)
.unwrap_or(NodeKind::Explore);
let gate_kind = parent_kind.gate_kind();
let gate_id = unique_gate_id(&staged, node_id);
let gate = TaskNode {
id: gate_id.clone(),
content: gate_content(gate_kind, node_id),
kind: gate_kind,
status: NodeStatus::Queued,
owner: None,
parent: Some(node_id.to_string()),
depends_on: child_ids.clone(),
expanded: false,
is_gate: true,
planner: None,
priority: 0,
output: None,
origin: Some(NodeOrigin::Gate),
};
staged.push(gate);
synth_deps.push(gate_id.clone());
Some(gate_id)
} else {
None
};
// Flip the parent into a composite join: it re-queues, depends on the
// gate/children, and is marked expanded. Its prior upstream deps are retained
// so the synthesis still waits on the original dependencies too.
{
let node = staged
.get_mut(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?;
node.expanded = true;
node.status = NodeStatus::Queued;
// Record the planner (current owner) for synthesis re-wake affinity, then
// free `owner` so the re-queued composite is eligible for normal
// scheduling once its children + gate complete.
if node.planner.is_none() {
node.planner = node.owner.clone();
}
node.owner = None;
// Keep its original upstream deps and add the join deps.
for dep in synth_deps {
if !node.depends_on.contains(&dep) {
node.depends_on.push(dep);
}
}
}
let cycle = staged.cycle_nodes();
if !cycle.is_empty() {
return Err(DagError::WouldCreateCycle(cycle));
}
*graph = staged;
Ok(ExpandOutcome { child_ids, gate_id })
}
/// Complete a node the actor owns with a typed handoff artifact. In deep mode the
/// artifact is validated for thinness (findings + an honest "what I did not check"
/// on substantive work) and must carry a parseable confidence rung. A gate
/// additionally may not pass while a sibling under the same composite completed
/// with low confidence, unless the gate's artifact explicitly addresses that node
/// by id — the intended escape hatch is `inject_from_gate`, which converts the
/// doubt into new breadth. The artifact becomes the dataflow payload for
/// dependents.
pub fn complete_node(
graph: &mut TaskGraph,
node_id: &str,
actor: &str,
artifact: HandoffArtifact,
) -> Result<(), DagError> {
let mode = graph.mode;
let node = graph
.get(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?;
if node.owner.as_deref() != Some(actor) {
return Err(DagError::NotOwner {
node: node_id.to_string(),
actor: actor.to_string(),
});
}
if node.status != NodeStatus::Running {
return Err(DagError::InvalidState {
node: node_id.to_string(),
status: node.status,
});
}
let is_gate = node.is_gate;
validate_artifact(mode, node_id, is_gate, &artifact)?;
if is_gate && mode.requires_gates() {
validate_gate_pass(graph, node_id, &artifact)?;
}
let node = graph
.get_mut(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?;
node.status = NodeStatus::Done;
node.output = Some(artifact);
Ok(())
}
/// Mark a node the actor owns as failed. A downstream verify/fix path may then
/// supersede it.
pub fn fail_node(graph: &mut TaskGraph, node_id: &str, actor: &str) -> Result<(), DagError> {
let node = graph
.get(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?;
if node.owner.as_deref() != Some(actor) {
return Err(DagError::NotOwner {
node: node_id.to_string(),
actor: actor.to_string(),
});
}
if node.status != NodeStatus::Running {
return Err(DagError::InvalidState {
node: node_id.to_string(),
status: node.status,
});
}
graph
.get_mut(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?
.status = NodeStatus::Failed;
Ok(())
}
/// Inject new gap/fix nodes from a gate that found a problem (the adversarial
/// path). The gate does not decompose itself; instead it adds new sibling nodes
/// under the same composite parent and re-queues itself to depend on them. This is
/// the "re-critique"/"re-verify" loop: the gate cannot pass, and the composite
/// parent (which depends on the gate) cannot close, until the new nodes drain and
/// the gate re-runs cleanly (doc section 6.2).
pub fn inject_from_gate(
graph: &mut TaskGraph,
gate_id: &str,
actor: &str,
new_nodes: Vec<NodeSpec>,
) -> Result<Vec<String>, DagError> {
let parent = {
let gate = graph
.get(gate_id)
.ok_or_else(|| DagError::UnknownNode(gate_id.to_string()))?;
if gate.owner.as_deref() != Some(actor) {
return Err(DagError::NotOwner {
node: gate_id.to_string(),
actor: actor.to_string(),
});
}
if !gate.is_gate {
return Err(DagError::GateMisuse(format!(
"node '{gate_id}' is not a gate; use expand_node to decompose work"
)));
}
if gate.status != NodeStatus::Running {
return Err(DagError::InvalidState {
node: gate_id.to_string(),
status: gate.status,
});
}
if new_nodes.is_empty() {
return Err(DagError::GateMisuse(
"inject_from_gate requires at least one new node".into(),
));
}
gate.parent.clone()
};
// Validate new node ids/deps.
let mut seen = std::collections::HashSet::new();
for spec in &new_nodes {
let id = validated_spec_id(spec, "inject_from_gate")?;
if graph.contains(&id) || !seen.insert(id.clone()) {
return Err(DagError::DuplicateNode(id));
}
}
let mut new_ids: Vec<String> = Vec::with_capacity(new_nodes.len());
for spec in &new_nodes {
new_ids.push(validated_spec_id(spec, "inject_from_gate")?);
}
let new_set: std::collections::HashSet<&str> = new_ids.iter().map(String::as_str).collect();
for (spec, new_id) in new_nodes.iter().zip(new_ids.iter()) {
for dep in &spec.depends_on {
if !new_set.contains(dep.as_str()) && !graph.contains(dep) {
return Err(DagError::UnknownDependency {
node: new_id.clone(),
dependency: dep.clone(),
});
}
}
}
let mut staged = graph.clone();
for spec in new_nodes {
staged.push(spec_to_node(spec, parent.clone(), NodeOrigin::Gap));
}
// Re-queue the gate, now depending on the new nodes (re-critique/re-verify).
{
let gate = staged
.get_mut(gate_id)
.ok_or_else(|| DagError::UnknownNode(gate_id.to_string()))?;
gate.status = NodeStatus::Queued;
gate.owner = None;
for id in &new_ids {
if !gate.depends_on.contains(id) {
gate.depends_on.push(id.clone());
}
}
}
// The composite parent must also depend on the gap nodes directly. Scheduling
// alone would not need this (the gate already gates the parent), but forward
// dataflow hydration reads only a node's *direct* dependencies, so without
// these edges the synthesis re-wake would never receive the gap nodes'
// artifacts — the same reason expand_node keeps child edges (doc section 5).
if let Some(parent_id) = &parent
&& let Some(parent_node) = staged.get_mut(parent_id)
{
for id in &new_ids {
if !parent_node.depends_on.contains(id) {
parent_node.depends_on.push(id.clone());
}
}
}
let cycle = staged.cycle_nodes();
if !cycle.is_empty() {
return Err(DagError::WouldCreateCycle(cycle));
}
*graph = staged;
Ok(new_ids)
}
/// Re-queue a failed node so it can be dispatched again (the retry path). The
/// owner is cleared: the retry may go to any worker. This is the engine-level
/// counterpart of the live `task_control retry` action; without it a failed
/// deep-mode gate would wedge its composite forever, because `deps_satisfied`
/// requires `Done` and every other mutation requires `Running`.
pub fn requeue_failed(graph: &mut TaskGraph, node_id: &str) -> Result<(), DagError> {
let node = graph
.get(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?;
if node.status != NodeStatus::Failed {
return Err(DagError::InvalidState {
node: node_id.to_string(),
status: node.status,
});
}
let node = graph
.get_mut(node_id)
.ok_or_else(|| DagError::UnknownNode(node_id.to_string()))?;
node.status = NodeStatus::Queued;
node.owner = None;
Ok(())
}
/// Derive a gate id for a composite node that does not collide with an existing
/// node id. The natural choice is `{node}::gate`; if a user happened to seed a
/// node by that exact id we suffix a counter so the engine never silently creates
/// a duplicate id (which would corrupt id-based lookups).
fn unique_gate_id(graph: &TaskGraph, node_id: &str) -> String {
let base = format!("{node_id}::gate");
if !graph.contains(&base) {
return base;
}
let mut n = 2u32;
loop {
let candidate = format!("{base}{n}");
if !graph.contains(&candidate) {
return candidate;
}
n += 1;
}
}
/// Whether free text mentions a node id as a standalone token (not merely as a
/// substring of a longer word). The confidence-debt rule turns on this: with
/// bare `contains`, a short child id like "a" or "fix" would match nearly any
/// English sentence and let a gate rubber-stamp an unaddressed low-confidence
/// sibling. Boundaries are any non-id characters; ids themselves may contain
/// alphanumerics plus `-_.:`/`::` (matching the gate-id convention). A `.` or
/// `:` directly after the id only extends it when followed by another id
/// character, so sentence punctuation ("checked explore.hot.udev.") does not
/// reject an otherwise exact mention.
fn mentions_node_id(text: &str, id: &str) -> bool {
if id.is_empty() {
return false;
}
let is_id_char = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':');
let mut start = 0;
while let Some(pos) = text[start..].find(id) {
let begin = start + pos;
let end = begin + id.len();
let before_ok = begin == 0 || !text[..begin].chars().next_back().is_some_and(is_id_char);
let after_ok = match text[end..].chars().next() {
None => true,
Some(c @ ('.' | ':')) => {
// Ambiguous: '.'/':' are legal id characters AND common prose
// punctuation. They only continue the id if an id character
// follows; "node.a." at the end of a sentence is a mention,
// "node.a.b" is a different id.
!text[end + c.len_utf8()..]
.chars()
.next()
.is_some_and(is_id_char)
}
Some(c) => !is_id_char(c),
};
if before_ok && after_ok {
return true;
}
// Advance past the first char of this match (char-boundary safe).
let step = text[begin..].chars().next().map_or(1, char::len_utf8);
start = begin + step;
if start >= text.len() {
break;
}
}
false
}
/// Validate that a spec carries an explicit, non-blank id and return it. A
/// missing id is a misuse; an empty/whitespace id would corrupt id-based
/// lookups and edge references just like a duplicate would.
fn validated_spec_id(spec: &NodeSpec, op: &str) -> Result<String, DagError> {
let id = spec
.id
.clone()
.ok_or_else(|| DagError::GateMisuse(format!("{op} specs must carry explicit ids")))?;
if id.trim().is_empty() {
return Err(DagError::GateMisuse(format!(
"{op} specs must carry non-empty ids"
)));
}
Ok(id)
}
fn spec_to_node(spec: NodeSpec, parent: Option<String>, origin: NodeOrigin) -> TaskNode {
// Dedup dependencies (order-preserving). Agent-supplied specs sometimes
// repeat a dep; duplicates carry no meaning and used to trip the cycle
// detector's indegree accounting.
let mut seen = std::collections::HashSet::new();
let depends_on: Vec<String> = spec
.depends_on
.into_iter()
.filter(|dep| seen.insert(dep.clone()))
.collect();
TaskNode {
id: spec.id.unwrap_or_default(),
content: spec.content,
kind: spec.kind,
status: NodeStatus::Queued,
owner: None,
parent,
depends_on,
expanded: false,
is_gate: false,
planner: None,
priority: spec.priority,
output: None,
origin: Some(origin),
}
}
fn gate_content(kind: NodeKind, parent: &str) -> String {
match kind {
NodeKind::Verify => format!(
"Verify the work of '{parent}': run the declared acceptance checks (build, tests, lint). \
If anything fails, emit fix nodes back into the graph; do not pass until they drain."
),
_ => format!(
"Critique the work of '{parent}' adversarially. Read every child's 'what_i_did_not_check' \
and find unexplored gaps given this task's stated scope. For each gap, emit a new child node; \
do not pass until no gaps remain."
),
}
}
/// Content for the auto-inserted root gate: the plan-wide final audit.
fn root_gate_content(kind: NodeKind) -> String {
match kind {
NodeKind::Verify => "Final plan-wide verify: run the acceptance checks for the whole \
plan's declared scope (build, tests, lint) across everything the plan changed. If \
anything fails, inject fix nodes; the plan cannot finish until they drain."
.to_string(),
_ => "Final plan-wide critique: audit the ENTIRE plan adversarially before it may \
finish. Read every completed node's artifact, especially each \
'what_i_did_not_check' and every open question, and hunt for whole facets the \
plan never covered. For each gap, inject a new node; the plan cannot finish \
until they drain."
.to_string(),
}
}
fn validate_artifact(
mode: Mode,
node_id: &str,
is_gate: bool,
artifact: &HandoffArtifact,
) -> Result<(), DagError> {
if !mode.requires_gates() {
// Light mode accepts any artifact.
return Ok(());
}
if is_gate {
// Gate artifacts are pass/fail records; thinness rules don't apply, and
// their confidence is about the *gate's* judgement, not the work.
return Ok(());
}
if artifact.findings.trim().is_empty() {
return Err(DagError::ThinArtifact {
node: node_id.to_string(),
reason: "deep-mode artifact requires non-empty findings".into(),
});
}
if artifact.what_i_did_not_check.is_empty() {
return Err(DagError::ThinArtifact {
node: node_id.to_string(),
reason: "deep-mode artifact must list 'what_i_did_not_check' (use an explicit \
'nothing, fully covered' entry only when truly exhaustive)"
.into(),
});
}
// Confidence is the breadth signal: gates prioritize probing low-confidence
// siblings and cannot pass over unaddressed ones, and status surfaces report
// them. That machinery only works if every substantive artifact carries a
// parseable rung, so an absent/unparseable confidence is rejected the same
// way thin findings are.
if artifact.confidence_level().is_none() {
return Err(DagError::ThinArtifact {
node: node_id.to_string(),
reason: "deep-mode artifact must state a confidence of low, medium, or high \
(honest 'low' is welcome: it routes follow-up work instead of \
penalizing you)"
.into(),
});
}
Ok(())
}
/// Above this many audited nodes, a passing gate artifact no longer has to
/// enumerate every id (the artifact would degenerate into a list); instead only
/// non-HIGH-confidence nodes must be addressed by id (see `validate_gate_pass`).
pub const GATE_COVERAGE_ENUMERATION_CAP: usize = 20;
/// A gate's audit scope: the non-gate nodes it depends on. For a composite
/// gate this is the parent's children plus any gap nodes injected so far; for
/// the root gate it is the plan's root-level node set. Using `depends_on`
/// (rather than parent-based sibling lookup) makes both cases one rule: a gate
/// audits exactly what it waits for.
fn gate_audit_scope<'a>(graph: &'a TaskGraph, gate: &TaskNode) -> Vec<&'a TaskNode> {
gate.depends_on
.iter()
.filter_map(|id| graph.get(id))
.filter(|node| !node.is_gate)
.collect()
}
/// Deep-mode rules for a gate trying to PASS (complete rather than inject).
///
/// Three checks, most-specific error first:
///
/// 1. **Stale scope**: every node in the audit scope must be done. Normally
/// guaranteed by dispatch, but the live bridge allows out-of-band mutations
/// (re-seeds widening the root gate, task_control restarts), so a running
/// gate can go stale. Its pass is rejected; it re-runs after the scope
/// drains.
/// 2. **Confidence debt**: a done scope node whose artifact self-reported LOW
/// confidence must be addressed by id in the gate's findings or
/// open_questions (or shored up via `inject_from_gate` first). Applies at
/// any scope width. The gate's own `what_i_did_not_check` deliberately does
/// NOT count: declaring "I did not check X" is the opposite of addressing X.
/// 3. **Coverage debt**: up to [`GATE_COVERAGE_ENUMERATION_CAP`] audited
/// nodes, the passing artifact must address EVERY done node in scope, not
/// just the shaky ones. Enumerated accounting is what separates an audit
/// from a rubber stamp: "all good, no gaps" cannot pass over work it never
/// names. Above the cap, enumeration relaxes only for HIGH-confidence
/// nodes: every node that self-reported medium/low/unparseable confidence
/// must still be addressed by id, so rigor does not silently degrade on
/// exactly the widest scopes where the audit matters most.
fn validate_gate_pass(
graph: &TaskGraph,
gate_id: &str,
artifact: &HandoffArtifact,
) -> Result<(), DagError> {
let Some(gate) = graph.get(gate_id) else {
return Ok(());
};
let scope = gate_audit_scope(graph, gate);
if scope.is_empty() {
return Ok(());
}
let pending: Vec<String> = scope
.iter()
.filter(|node| !node.is_done())
.map(|node| node.id.clone())
.collect();
if !pending.is_empty() {
return Err(DagError::StaleGateScope {
gate: gate_id.to_string(),
pending,
});
}
let addressed = |id: &str| {
mentions_node_id(&artifact.findings, id)
|| artifact
.open_questions
.iter()
.any(|q| mentions_node_id(q, id))
};
let confidence_debts: Vec<String> = scope
.iter()
.filter(|node| {
node.output
.as_ref()
.and_then(HandoffArtifact::confidence_level)
== Some(super::ConfidenceLevel::Low)
})
.filter(|node| !addressed(&node.id))
.map(|node| node.id.clone())
.collect();
if !confidence_debts.is_empty() {
return Err(DagError::UnaddressedLowConfidence {
gate: gate_id.to_string(),
nodes: confidence_debts,
});
}
if scope.len() <= GATE_COVERAGE_ENUMERATION_CAP {
let uncovered: Vec<String> = scope
.iter()
.filter(|node| !addressed(&node.id))
.map(|node| node.id.clone())
.collect();
if !uncovered.is_empty() {
return Err(DagError::UncoveredSiblings {
gate: gate_id.to_string(),
nodes: uncovered,
});
}
} else {
// Wide scope: naming every id would degenerate into a list, so full
// enumeration relaxes. But the audit must still drain every doubt by
// id: any node that did not self-report HIGH confidence (medium, low,
// or unparseable) stays on the hook and must be addressed. Without
// this, gate rigor would silently degrade exactly when the scope is
// largest and the audit matters most.
let uncovered: Vec<String> = scope
.iter()
.filter(|node| {
node.output
.as_ref()
.and_then(HandoffArtifact::confidence_level)
!= Some(super::ConfidenceLevel::High)
})
.filter(|node| !addressed(&node.id))
.map(|node| node.id.clone())
.collect();
if !uncovered.is_empty() {
return Err(DagError::UncoveredSiblings {
gate: gate_id.to_string(),
nodes: uncovered,
});
}
}
Ok(())
}
+106
View File
@@ -0,0 +1,106 @@
//! Scheduler: ready-set computation, dispatch, and dataflow hydration.
//!
//! The scheduler walks the DAG. A node becomes runnable when all its dependencies
//! are `Done`. On dispatch it is assigned to a worker (ownership) and its input is
//! hydrated from the merged artifacts of its upstream dependencies, which is the
//! forward dataflow along edges (doc section 5).
use super::{NodeStatus, TaskGraph, TaskNode};
/// Suggested default worker ceiling for light mode (doc section 1a). Deep mode is
/// bounded by the swarm-level `MAX_SWARM_MEMBERS` cap instead.
pub const LIGHT_MODE_SUGGESTED_WORKERS: usize = 16;
/// Whether a node has reached a terminal status.
pub fn is_terminal(node: &TaskNode) -> bool {
node.is_terminal()
}
/// The set of nodes that are runnable right now: queued, with every dependency
/// `Done`. Returned in scheduling order (priority asc, then id) for determinism.
pub fn ready_nodes(graph: &TaskGraph) -> Vec<&TaskNode> {
let mut ready: Vec<&TaskNode> = graph
.nodes()
.iter()
.filter(|node| node.status == NodeStatus::Queued && deps_satisfied(graph, node))
.collect();
ready.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
ready
}
fn deps_satisfied(graph: &TaskGraph, node: &TaskNode) -> bool {
node.depends_on.iter().all(|dep| {
graph
.get(dep)
.map(TaskNode::is_done)
// A dependency that does not exist is treated as unsatisfiable; this
// should never happen because edges are validated on insertion.
.unwrap_or(false)
})
}
/// Dispatch a ready node to `worker`: assign ownership and flip it to `Running`.
/// Returns false if the node is not currently dispatchable.
pub fn dispatch(graph: &mut TaskGraph, node_id: &str, worker: &str) -> bool {
let dispatchable = graph
.get(node_id)
.map(|node| node.status == NodeStatus::Queued && deps_satisfied(graph, node))
.unwrap_or(false);
if !dispatchable {
return false;
}
// `dispatchable` proved the node exists under this same borrow of `graph`.
let Some(node) = graph.get_mut(node_id) else {
return false;
};
node.owner = Some(worker.to_string());
node.status = NodeStatus::Running;
true
}
/// Assemble the worker input for a node: its own prompt plus the merged handoff
/// artifacts of all its upstream dependencies. Artifacts are passed by reference
/// (findings + evidence), keeping context small (doc section 5).
pub fn assemble_input(graph: &TaskGraph, node_id: &str) -> String {
let Some(node) = graph.get(node_id) else {
return String::new();
};
let mut out = String::new();
out.push_str(&node.content);
let upstream: Vec<&TaskNode> = node
.depends_on
.iter()
.filter_map(|dep| graph.get(dep))
.filter(|dep| dep.is_done())
.collect();
if upstream.is_empty() {
return out;
}
out.push_str("\n\n# Inputs from completed dependencies\n");
for dep in upstream {
out.push('\n');
if let Some(artifact) = &dep.output {
out.push_str(&artifact.render_section(&dep.id, kind_label(dep.kind)));
} else {
out.push_str(&format!("## {} ({})\n", dep.id, kind_label(dep.kind)));
}
}
out
}
/// Lowercase label for a node kind, matching the bridge's `kind_str` so engine and
/// live formatting agree.
fn kind_label(kind: super::NodeKind) -> &'static str {
use super::NodeKind::*;
match kind {
Explore => "explore",
Implement => "implement",
Verify => "verify",
Fix => "fix",
Synthesize => "synthesize",
Critique => "critique",
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Deterministic task-DAG simulator.
//!
//! This drives the engine end-to-end with scripted mock workers so the scheduler,
//! ops, dataflow, and gate mechanics can be verified without any live agents. It
//! is the executable analogue of the worked example in `docs/SWARM_TASK_GRAPH.md`
//! section 9.
//!
//! A worker is a closure that, given the assembled input for a node, returns a
//! [`WorkerAction`]. The driver loops: dispatch all ready nodes round-robin to a
//! bounded worker pool, run each one step, apply the resulting mutation, and
//! repeat until the graph is fully terminal or it stalls.
use super::{
DagError, HandoffArtifact, Mode, NodeKind, NodeSpec, TaskGraph, complete_node, dispatch,
expand_node, fail_node, inject_from_gate, ready_nodes,
};
/// What a mock worker decides to do with the node it was handed.
#[derive(Debug, Clone)]
pub enum WorkerAction {
/// Execute the node directly and complete it with this artifact.
Complete(HandoffArtifact),
/// Decompose the node into these children (composite path).
Expand(Vec<NodeSpec>),
/// Gate found a problem: inject these gap/fix nodes and re-queue the gate.
/// Only valid when the dispatched node is a gate.
InjectGap(Vec<NodeSpec>),
/// Fail the node.
Fail,
}
/// A scripted worker. Receives the node id, kind, and assembled input; returns an
/// action. The closure may capture mutable state (e.g. to expand only once).
pub type Worker<'a> = dyn FnMut(&str, NodeKind, &str) -> WorkerAction + 'a;
/// Outcome of a simulation run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimReport {
pub steps: usize,
pub completed: usize,
pub failed: usize,
pub stalled: bool,
}
/// Run the simulation to completion (or stall). `max_workers` bounds how many
/// nodes run concurrently per step; `max_steps` guards against runaway loops.
pub fn run(
graph: &mut TaskGraph,
max_workers: usize,
max_steps: usize,
worker: &mut Worker<'_>,
) -> Result<SimReport, DagError> {
let mut steps = 0usize;
loop {
if graph.all_terminal() {
break;
}
if steps >= max_steps {
return Ok(report(graph, steps, true));
}
// Dispatch up to `max_workers` ready nodes this step. We collect ids first
// to avoid borrowing the graph while mutating it.
let ready: Vec<(String, NodeKind)> = ready_nodes(graph)
.into_iter()
.take(max_workers)
.map(|node| (node.id.clone(), node.kind))
.collect();
if ready.is_empty() {
// Nothing runnable and not all terminal => stall (e.g. a Failed node
// blocking its dependents with no fix path).
return Ok(report(graph, steps, true));
}
for (idx, (node_id, kind)) in ready.into_iter().enumerate() {
let worker_name = format!("w{}", idx % max_workers);
if !dispatch(graph, &node_id, &worker_name) {
continue;
}
let input = super::assemble_input(graph, &node_id);
let action = worker(&node_id, kind, &input);
match action {
WorkerAction::Complete(artifact) => {
complete_node(graph, &node_id, &worker_name, artifact)?;
}
WorkerAction::Expand(children) => {
expand_node(graph, &node_id, &worker_name, children)?;
}
WorkerAction::InjectGap(new_nodes) => {
inject_from_gate(graph, &node_id, &worker_name, new_nodes)?;
}
WorkerAction::Fail => {
fail_node(graph, &node_id, &worker_name)?;
}
}
steps += 1;
}
}
Ok(report(graph, steps, false))
}
fn report(graph: &TaskGraph, steps: usize, stalled: bool) -> SimReport {
let completed = graph.nodes().iter().filter(|node| node.is_done()).count();
let failed = graph
.nodes()
.iter()
.filter(|node| matches!(node.status, super::NodeStatus::Failed))
.count();
SimReport {
steps,
completed,
failed,
stalled,
}
}
/// Convenience: a deep-mode artifact that satisfies validation, for tests/sims.
pub fn deep_artifact(findings: &str) -> HandoffArtifact {
HandoffArtifact {
findings: findings.to_string(),
what_i_did_not_check: vec!["nothing material; covered the stated scope".to_string()],
confidence: Some("high".to_string()),
..HandoffArtifact::default()
}
}
/// Convenience: a passing gate artifact that satisfies the deep-mode coverage
/// rule by naming every audited node found in the gate's assembled input.
///
/// The scheduler hydrates a gate's input with one `## <id> (<kind>)` section per
/// done dependency, and a gate's dependencies are exactly its audit scope, so
/// scraping those headers enumerates the scope without needing the graph. This
/// is what a real gate is instructed to do: account for each id it audited.
pub fn gate_pass_artifact(input: &str) -> HandoffArtifact {
let audited: Vec<&str> = input
.lines()
.filter_map(|line| line.strip_prefix("## "))
.filter_map(|rest| rest.split(" (").next())
.collect();
let findings = if audited.is_empty() {
"gate passed: no audited nodes in scope".to_string()
} else {
format!(
"gate passed; audited each node: {}. No gaps remain.",
audited.join(", ")
)
};
HandoffArtifact::brief(findings)
}
/// Convenience: build a graph in a mode for sims/tests.
pub fn graph(mode: Mode) -> TaskGraph {
TaskGraph::new(mode)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+558
View File
@@ -0,0 +1,558 @@
//! Mermaid flowchart source generation for a swarm plan's task DAG.
//!
//! Lives in `jcode-plan` (rather than the TUI) so every consumer renders the
//! same graph from the same logic: the TUI's inline plan-graph message, and
//! the renderer stress probe in `jcode-tui-mermaid`
//! (`examples/swarm_plan_stress.rs`) which feeds this exact output through the
//! real mermaid pipeline. Status classification reuses
//! [`summarize_plan_graph`], so node colors always agree with the scheduler's
//! view of ready/blocked/active/done/failed.
//!
//! This module only builds mermaid source; callers decide when to render it.
use crate::{PlanItem, summarize_plan_graph};
use std::collections::{HashMap, HashSet};
/// Max tasks drawn before the graph is truncated with a summary node.
/// Beyond this the diagram stops being readable at terminal cell sizes.
const MAX_GRAPH_NODES: usize = 30;
/// Max characters of task content shown per node label.
const MAX_LABEL_CHARS: usize = 42;
/// Max characters of the assignee suffix shown per node label.
const MAX_ASSIGNEE_CHARS: usize = 12;
/// Switch from top-down to left-right layout above this many drawn nodes.
/// TD squeezes wide graphs into horizontal slivers at terminal widths, while
/// LR stacks labels vertically where the transcript can scroll.
const LR_NODE_THRESHOLD: usize = 10;
/// Switch to left-right layout when any node has more incoming dependency
/// edges than this: wide fan-ins (gate nodes) force huge TD layouts.
const LR_FAN_IN_THRESHOLD: usize = 4;
/// Never use left-right layout when the longest dependency path exceeds
/// this: a deep chain in LR becomes one long horizontal row that fits the
/// terminal width as an unreadable few-pixel strip. TD lets chains flow
/// downward instead.
const LR_MAX_DEPTH: usize = 8;
/// Build mermaid flowchart source for a swarm plan, or `None` when the plan
/// is empty. Node styling encodes scheduler status (done/active/failed/
/// blocked/pending), gate nodes (`*::gate`) render as hexagons, and edges
/// follow `blocked_by` dependencies.
pub fn swarm_plan_mermaid(items: &[PlanItem]) -> Option<String> {
if items.is_empty() {
return None;
}
// Classify against the FULL plan (not just drawn nodes) with the same
// logic the scheduler uses, so a queued task with unmet deps shows as
// blocked rather than pending.
let summary = summarize_plan_graph(items);
let done: HashSet<&str> = summary.completed_ids.iter().map(String::as_str).collect();
let failed: HashSet<&str> = summary.failed_ids.iter().map(String::as_str).collect();
let active: HashSet<&str> = summary.active_ids.iter().map(String::as_str).collect();
let blocked: HashSet<&str> = summary.blocked_ids.iter().map(String::as_str).collect();
let class_of = |id: &str, status: &str| -> &'static str {
if done.contains(id) {
"done"
} else if failed.contains(id) {
"failed"
} else if active.contains(id) {
"active"
} else if blocked.contains(id) {
"blocked"
} else {
// Statuses outside the scheduler vocabulary (external plans can
// inject arbitrary strings) keep their legacy visual mapping.
match status {
"in_progress" | "active" => "active",
"cancelled" => "failed",
_ => "pending",
}
}
};
// When the plan is over the cap, drop completed tasks first: they are the
// least interesting nodes, and a long-running plan otherwise fills the
// whole graph with stale green boxes while live work gets truncated.
let shown: Vec<&PlanItem> = if items.len() <= MAX_GRAPH_NODES {
items.iter().collect()
} else {
let mut keep: Vec<usize> = (0..items.len())
.filter(|&i| !done.contains(items[i].id.as_str()))
.take(MAX_GRAPH_NODES)
.collect();
if keep.len() < MAX_GRAPH_NODES {
for (i, item) in items.iter().enumerate() {
if done.contains(item.id.as_str()) {
keep.push(i);
if keep.len() == MAX_GRAPH_NODES {
break;
}
}
}
keep.sort_unstable();
}
keep.into_iter().map(|i| &items[i]).collect()
};
// Mermaid-safe node ids. Distinct item ids can sanitize to the same node
// id (`a-1` and `a_1` both become `t_a_1`), which mermaid silently merges
// last-wins; suffix collisions so every drawn task stays visible.
let mut node_ids: HashMap<&str, String> = HashMap::new();
let mut taken: HashSet<String> = HashSet::new();
for item in &shown {
if node_ids.contains_key(item.id.as_str()) {
// Duplicate raw item id (invalid plan, but defend anyway): the
// first occurrence wins the id; later duplicates merge.
continue;
}
let mut id = node_id(&item.id);
if !taken.insert(id.clone()) {
let mut n = 2usize;
loop {
let candidate = format!("{id}_{n}");
if taken.insert(candidate.clone()) {
id = candidate;
break;
}
n += 1;
}
}
node_ids.insert(item.id.as_str(), id);
}
// Dependency edges, deduped, only between drawn nodes, no self-loops.
let mut edges: Vec<(String, String)> = Vec::new();
let mut seen_edges: HashSet<(String, String)> = HashSet::new();
let mut fan_in: HashMap<&str, usize> = HashMap::new();
for item in &shown {
let Some(to) = node_ids.get(item.id.as_str()) else {
continue;
};
for dep in &item.blocked_by {
if dep == &item.id {
continue;
}
let Some(from) = node_ids.get(dep.as_str()) else {
continue;
};
let edge = (from.clone(), to.clone());
if seen_edges.insert(edge.clone()) {
*fan_in.entry(item.id.as_str()).or_default() += 1;
edges.push(edge);
}
}
}
// Direction: TD reads best for small plans; larger plans or wide fan-ins
// (deep-mode gates commonly collect 10+ deps) become unreadable slivers
// in TD at terminal widths, so lay those out LR. Exceptions where LR is
// strictly worse: deep chains (LR turns them into one long horizontal
// row) and structureless flat lists (LR packs disconnected nodes into a
// single row) both stay TD.
let max_fan_in = fan_in.values().copied().max().unwrap_or(0);
let depth = longest_path_len(&shown, &node_ids, &edges);
let wants_lr = shown.len() > LR_NODE_THRESHOLD || max_fan_in > LR_FAN_IN_THRESHOLD;
let chain_like = depth > LR_MAX_DEPTH;
let flat_list = edges.is_empty();
let direction = if wants_lr && !chain_like && !flat_list {
"LR"
} else {
"TD"
};
let mut out = format!("flowchart {direction}\n");
for item in &shown {
let Some(id) = node_ids.get(item.id.as_str()) else {
continue;
};
let class = class_of(&item.id, &item.status);
let label = node_label(item, class);
// Gate nodes (deep-mode critique/verify gates use `<parent>::gate`
// ids) render as hexagons so they stand out from normal tasks.
if item.id.ends_with("::gate") {
out.push_str(&format!(" {id}{{{{\"{label}\"}}}}:::{class}\n"));
} else {
out.push_str(&format!(" {id}[\"{label}\"]:::{class}\n"));
}
}
for (from, to) in &edges {
out.push_str(&format!(" {from} --> {to}\n"));
}
let hidden = items.len().saturating_sub(shown.len());
if hidden > 0 {
out.push_str(&format!(
" more[\"…and {hidden} more tasks\"]:::pending\n"
));
// Tie the summary node to the graph with a dashed edge so it does not
// float disconnected in a corner of the layout.
if let Some(last) = shown.last().and_then(|item| node_ids.get(item.id.as_str())) {
out.push_str(&format!(" {last} -.-> more\n"));
}
}
// Palette mirrors the swarm gallery status accents.
out.push_str(" classDef done fill:#1d3a1d,stroke:#64c864,color:#a8e0a8\n");
out.push_str(" classDef active fill:#3a321d,stroke:#ffc864,color:#ffe0a8\n");
out.push_str(" classDef failed fill:#3a1d1d,stroke:#ff6464,color:#ffa8a8\n");
out.push_str(" classDef blocked fill:#3a2a1d,stroke:#ffaa50,color:#ffd0a0\n");
out.push_str(" classDef pending fill:#26262e,stroke:#8c8c96,color:#b4b4be\n");
Some(out)
}
/// Longest path (in nodes) through the drawn dependency DAG, used by the
/// layout-direction heuristic. Iterative relaxation over the edge list keeps
/// it simple; drawn graphs are capped at [`MAX_GRAPH_NODES`], and cycles
/// terminate via the pass bound.
fn longest_path_len(
shown: &[&PlanItem],
node_ids: &HashMap<&str, String>,
edges: &[(String, String)],
) -> usize {
if shown.is_empty() {
return 0;
}
let mut depth: HashMap<&str, usize> = node_ids.values().map(|id| (id.as_str(), 1)).collect();
// At most N-1 relaxation passes are needed for a DAG of N nodes.
for _ in 0..shown.len() {
let mut changed = false;
for (from, to) in edges {
let from_depth = depth.get(from.as_str()).copied().unwrap_or(1);
let to_depth = depth.entry(to.as_str()).or_insert(1);
if from_depth + 1 > *to_depth {
*to_depth = from_depth + 1;
changed = true;
}
}
if !changed {
break;
}
}
depth.values().copied().max().unwrap_or(1)
}
/// A mermaid-safe node id derived from a plan item id.
fn node_id(raw: &str) -> String {
let mut id: String = raw
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if id.is_empty() {
id.push('x');
}
// Mermaid ids must not start with a digit for some directives; prefix
// uniformly so ids stay predictable.
format!("t_{id}")
}
/// Node label: status glyph + truncated content + optional short assignee.
fn node_label(item: &PlanItem, class: &str) -> String {
let glyph = match class {
"done" => "",
"active" => "",
"failed" => "",
"blocked" => "",
_ => "·",
};
let content = truncate_chars(&sanitize_label(&item.content), MAX_LABEL_CHARS);
// Keep labels single-line plain text: HTML-ish line breaks (<br/>) are
// not reliably supported by the Rust mermaid renderer's SVG output.
match &item.assigned_to {
Some(who) if !who.is_empty() => {
format!("{glyph} {content} · @{}", short_assignee(who))
}
_ => format!("{glyph} {content}"),
}
}
/// Compact an assignee for display: session ids like
/// `session_hamster_1783199147688_8fa34a84b95fe291` reduce to the friendly
/// animal name (`hamster`); anything else is truncated. Raw session ids are
/// half the label width and all look identical at a glance.
fn short_assignee(who: &str) -> String {
let sanitized = sanitize_label(who);
if let Some(rest) = sanitized.strip_prefix("session_") {
let name = rest.split('_').next().unwrap_or(rest);
if !name.is_empty() {
return truncate_chars(name, MAX_ASSIGNEE_CHARS);
}
}
truncate_chars(&sanitized, MAX_ASSIGNEE_CHARS)
}
fn truncate_chars(text: &str, max: usize) -> String {
if text.chars().count() > max {
let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
} else {
text.to_string()
}
}
/// Strip characters that would break out of a mermaid quoted label.
///
/// Both quote characters are replaced with a typographic apostrophe: the
/// mermaid tokenizer treats an unbalanced `'` or `"` inside a quoted label as
/// a string delimiter and shatters the line into phantom nodes, which was the
/// primary cause of illegible real-world plan graphs.
fn sanitize_label(text: &str) -> String {
text.chars()
.map(|c| match c {
'"' | '\'' => '',
'\n' | '\r' | '\t' => ' ',
'[' | ']' | '{' | '}' => '(',
_ => c,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn item(id: &str, content: &str, status: &str, blocked_by: &[&str]) -> PlanItem {
PlanItem {
content: content.to_string(),
status: status.to_string(),
priority: "normal".to_string(),
id: id.to_string(),
subsystem: None,
file_scope: Vec::new(),
blocked_by: blocked_by.iter().map(|s| s.to_string()).collect(),
assigned_to: None,
}
}
#[test]
fn empty_plan_yields_no_graph() {
assert!(swarm_plan_mermaid(&[]).is_none());
}
#[test]
fn graph_has_nodes_edges_and_status_classes() {
let mut assigned = item("b-2", "carve the gallery band", "running", &["a-1"]);
assigned.assigned_to = Some("worker-fox".to_string());
let items = vec![
item("a-1", "wire the bus tap", "completed", &[]),
assigned,
item("c-3", "run the ui tests", "queued", &["b-2"]),
];
let graph = swarm_plan_mermaid(&items).expect("graph");
assert!(graph.starts_with("flowchart TD"), "got: {graph}");
assert!(
graph.contains("t_a_1[\"✓ wire the bus tap\"]:::done"),
"got: {graph}"
);
assert!(graph.contains(":::active"), "got: {graph}");
assert!(graph.contains("@worker-fox"), "got: {graph}");
assert!(
!graph.contains("<br"),
"labels must stay single-line: {graph}"
);
assert!(graph.contains("t_a_1 --> t_b_2"), "got: {graph}");
assert!(graph.contains("t_b_2 --> t_c_3"), "got: {graph}");
assert!(graph.contains("classDef done"), "got: {graph}");
}
#[test]
fn labels_are_sanitized_and_truncated() {
let items = vec![item(
"x!y",
"a \"quoted\" [bracketed]\nmultiline label that is much longer than the cap allows here",
"weird-status",
&["missing-dep"],
)];
let graph = swarm_plan_mermaid(&items).expect("graph");
// Quotes/brackets/newlines neutralized; unresolvable dep -> blocked.
assert!(
graph.contains("t_x_y[\"⏸ a quoted (bracketed( multiline"),
"got: {graph}"
);
assert!(graph.contains(":::blocked"), "got: {graph}");
assert!(graph.contains('…'), "expected truncation: {graph}");
// Edge to an undrawn/missing dependency is dropped.
assert!(!graph.contains("-->"), "got: {graph}");
}
#[test]
fn quotes_never_survive_into_labels() {
// A lone apostrophe inside a quoted mermaid label shatters the graph
// into phantom nodes (renderer tokenizer bug), so both quote chars
// must be replaced, not passed through.
let items = vec![item(
"q",
"verify the work of 'fix-swarm-member-task'",
"queued",
&[],
)];
let graph = swarm_plan_mermaid(&items).expect("graph");
assert!(!graph.contains('\''), "raw apostrophe leaked: {graph}");
assert!(
graph.contains("fix-swarm-member-task"),
"expected typographic replacement: {graph}"
);
}
#[test]
fn session_id_assignees_shorten_to_friendly_name() {
let mut assigned = item("t1", "do the thing", "running", &[]);
assigned.assigned_to = Some("session_hamster_1783199147688_8fa34a84b95fe291".to_string());
let graph = swarm_plan_mermaid(&[assigned]).expect("graph");
assert!(graph.contains("· @hamster\""), "got: {graph}");
assert!(
!graph.contains("8fa34a84b95fe291"),
"raw session id leaked into label: {graph}"
);
}
#[test]
fn gate_nodes_render_as_hexagons() {
let items = vec![
item("work", "implement the feature", "completed", &[]),
item("work::gate", "verify the work", "queued", &["work"]),
];
let graph = swarm_plan_mermaid(&items).expect("graph");
assert!(
graph.contains("t_work__gate{{\"· verify the work\"}}:::pending"),
"gate should be a hexagon: {graph}"
);
assert!(
graph.contains("t_work[\"✓ implement the feature\"]:::done"),
"normal tasks stay rectangles: {graph}"
);
}
#[test]
fn queued_items_with_unmet_deps_render_blocked() {
let items = vec![
item("dep", "still running", "running", &[]),
item("waiting", "waits on dep", "queued", &["dep"]),
];
let graph = swarm_plan_mermaid(&items).expect("graph");
assert!(
graph.contains("t_waiting[\"⏸ waits on dep\"]:::blocked"),
"dep-blocked queued item should style as blocked: {graph}"
);
}
#[test]
fn small_graphs_stay_td_large_or_wide_fan_in_switch_to_lr() {
let small: Vec<PlanItem> = (0..5)
.map(|i| item(&format!("s{i}"), &format!("task {i}"), "queued", &[]))
.collect();
assert!(
swarm_plan_mermaid(&small)
.expect("graph")
.starts_with("flowchart TD"),
"small plans keep TD"
);
// Large connected plan (shallow fan-out from one root) switches to LR.
let mut large = vec![item("root", "kick off", "completed", &[])];
large.extend(
(0..14).map(|i| item(&format!("l{i}"), &format!("task {i}"), "queued", &["root"])),
);
assert!(
swarm_plan_mermaid(&large)
.expect("graph")
.starts_with("flowchart LR"),
"large plans switch to LR"
);
// Large but structureless (no edges at all): LR would pack one long
// row, so flat lists stay TD.
let flat: Vec<PlanItem> = (0..15)
.map(|i| item(&format!("f{i}"), &format!("task {i}"), "queued", &[]))
.collect();
assert!(
swarm_plan_mermaid(&flat)
.expect("graph")
.starts_with("flowchart TD"),
"flat lists stay TD"
);
// Large but chain-shaped (depth > LR_MAX_DEPTH): LR would render one
// endless horizontal row, so deep chains stay TD.
let chain: Vec<PlanItem> = (0..15usize)
.map(|i| {
let dep = format!("c{}", i.saturating_sub(1));
let deps: Vec<&str> = if i == 0 { vec![] } else { vec![dep.as_str()] };
item(&format!("c{i}"), &format!("step {i}"), "queued", &deps)
})
.collect();
assert!(
swarm_plan_mermaid(&chain)
.expect("graph")
.starts_with("flowchart TD"),
"deep chains stay TD"
);
// 6 nodes but one gate collecting 5 deps: fan-in forces LR.
let mut wide: Vec<PlanItem> = (0..5)
.map(|i| item(&format!("w{i}"), &format!("task {i}"), "completed", &[]))
.collect();
let deps: Vec<String> = (0..5).map(|i| format!("w{i}")).collect();
let dep_refs: Vec<&str> = deps.iter().map(String::as_str).collect();
wide.push(item("gate", "verify all", "queued", &dep_refs));
assert!(
swarm_plan_mermaid(&wide)
.expect("graph")
.starts_with("flowchart LR"),
"wide fan-in switches to LR"
);
}
#[test]
fn duplicate_sanitized_ids_get_suffixed_and_self_edges_drop() {
let items = vec![
item("a-1", "first flavor", "completed", &["a-1"]),
item("a_1", "second flavor", "running", &["a-1"]),
];
let graph = swarm_plan_mermaid(&items).expect("graph");
assert!(graph.contains("t_a_1[\"✓ first flavor\"]"), "got: {graph}");
assert!(
graph.contains("t_a_1_2[\"▶ second flavor\"]"),
"colliding sanitized id must be suffixed, not silently merged: {graph}"
);
assert!(
!graph.contains("t_a_1 --> t_a_1\n"),
"self-dependency edges must be dropped: {graph}"
);
assert!(graph.contains("t_a_1 --> t_a_1_2"), "got: {graph}");
}
#[test]
fn oversized_plans_truncate_dropping_done_first_with_linked_summary_node() {
// 25 completed + 15 queued: the queued (live) tasks must all survive
// truncation, completed ones fill the remaining slots.
let mut items: Vec<PlanItem> = (0..25)
.map(|i| {
item(
&format!("d{i}"),
&format!("done task {i}"),
"completed",
&[],
)
})
.collect();
items.extend(
(0..15).map(|i| item(&format!("q{i}"), &format!("live task {i}"), "queued", &[])),
);
let graph = swarm_plan_mermaid(&items).expect("graph");
assert!(graph.contains("…and 10 more tasks"), "got: {graph}");
for i in 0..15 {
assert!(
graph.contains(&format!("live task {i}")),
"live task {i} must survive truncation: {graph}"
);
}
assert!(
!graph.contains("done task 20"),
"oldest surplus done tasks are dropped: {graph}"
);
// The summary node is tied into the graph rather than floating.
assert!(graph.contains("-.-> more"), "got: {graph}");
}
}