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
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:
@@ -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)."
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user