Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/graph_plan.rs
T
ZacharyZhang-NY bb5cbff62d Add /graph G3: dynamic replan from DISCOVERED work items
Workers, verifiers, and serial node goals can now surface out-of-scope work
as line-anchored 'DISCOVERED: <text>' markers (fence-stripped and
placeholder-filtered — the templates' own examples are fenced so verbatim
echoes never parse; worker summaries embedded in verifier prompts get the
marker neutralized alongside NODE_RESULT/NODE_VERDICT). Discoveries queue on
the orchestration as persisted state and fold into the graph at dispatch
boundaries: a replanner subagent produces a strictly APPEND-ONLY appendix,
validated against the live graph (existing-id deps allowed; edges onto
gn-final rejected — they would cycle the moment the final-gating extension
lands; Blocks deps on Failed/Blocked nodes rejected as DeadDep so the
attempt-2 feedback loop repairs the artifact). Installing an appendix bumps
plan_version, freezes an immutable graph.baseline.v{N}.json next to the
prior versions, extends gn-final's gate (demoting a Ready final back to
Waiting), and recomputes readiness.

DiscoveredFrom edges are audit metadata, never scheduling gates: an origin
is always terminal at replan time, so gating on it is either a no-op or a
permanent wedge — and a failed node's discoveries are still real work.
Replanning is bounded by KIGI_GRAPH_REPLAN_CAP (default 3; 0 disables it
quietly): past the cap, after the final node has achieved, or on replan
failure, discoveries drain to history only — a working graph is never
paused for a failed enhancement pass, and it always converges. The budget
gate now precedes the replan boundary (a budget-dead graph keeps its
discoveries queued for a later --budget top-up instead of spending two
replanner runs first), and both planner runners delete stale artifacts
before spawning so a child that responds without writing can never get a
previous pass's file validated as its own output.

Tests: validate_replan unit coverage (existing-id resolution,
DiscoveredFrom dedup, collisions, dead deps vs dead origins, terminal-node
edges, combined-graph cycles), tracker appendix/regate/audit-edge tests,
and two e2e flows — discovery → replan → appended node runs to Achieved
with both baselines frozen, and cap-0 draining to history while the graph
still converges. kigi-shell 4935 lib tests green; workspace clippy clean.
2026-07-20 19:17:24 -04:00

812 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Graph planner output contract: parsing, static validation, and
//! canonicalization.
//!
//! The graph planner subagent writes a JSON file shaped as
//! `{"nodes": [{"id": "<slug>", "title": "...", "spec": "...",
//! "deps": ["<slug>", ...]}]}`. Before anything executes, the harness
//! runs the Agentproof-style static gate in [`parse_and_validate`]:
//! parse errors, empty graphs, duplicate/malformed slugs, unknown or
//! self dependencies, and cycles all fail CLOSED with a precise reason
//! (the caller retries planning once, then pauses the graph).
//!
//! Canonicalization: slugs become stable content-derived ids
//! (`gn-<fnv1a32 hex>` of the slug) so the same planned node keeps the
//! same id across replans and across machines (line-mergeable in the
//! G4 project-level graph file), nodes are re-ordered into a
//! planner-order-stable topological order (deterministic serial
//! scheduling), and the harness appends the terminal
//! [`FINAL_NODE_ID`](super::graph_tracker::FINAL_NODE_ID) verification
//! node depending on every planner node — the whole-objective gate is
//! structural, never left to the planner's discretion.
use super::graph_tracker::{DepKind, FINAL_NODE_ID, GraphNode, NodeDep, NodeStatus};
/// Hard cap on planner nodes (the prompt guides 310; this bound is the
/// fail-fast backstop against a runaway planner, not a target).
pub(crate) const MAX_GRAPH_NODES: usize = 24;
/// Byte cap for reading the planner's JSON file — same defensive posture
/// as the goal nudge reader: a runaway artifact must not blow up memory.
pub(crate) const MAX_GRAPH_JSON_BYTES: u64 = 256 * 1024;
#[derive(Debug, serde::Deserialize)]
struct PlannedGraph {
nodes: Vec<PlannedNode>,
}
#[derive(Debug, serde::Deserialize)]
struct PlannedNode {
id: String,
title: String,
spec: String,
#[serde(default)]
deps: Vec<String>,
/// Replan artifacts only: EXISTING node ids (`gn-…`) whose execution
/// surfaced this node. Ignored by the initial-plan path.
#[serde(default)]
discovered_from: Vec<String>,
}
/// Why a planner artifact was rejected. Rendered verbatim into the
/// planning-failure pause message and the retry prompt, so each variant
/// states the fix.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum GraphPlanError {
Parse(String),
Empty,
TooManyNodes(usize),
BadSlug(String),
DuplicateSlug(String),
EmptyField {
slug: String,
field: &'static str,
},
UnknownDep {
slug: String,
dep: String,
},
SelfDep(String),
Cycle(Vec<String>),
IdCollision(String, String),
/// Replan: a new node's canonical id collides with an existing node.
ExistingCollision(String),
/// Replan: a `deps` entry targets a Failed/Blocked node — the new
/// node could never become Ready.
DeadDep {
slug: String,
dep: String,
},
/// Replan: `discovered_from` references a node id not in the graph.
UnknownOrigin {
slug: String,
origin: String,
},
}
impl std::fmt::Display for GraphPlanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(e) => write!(f, "graph JSON failed to parse: {e}"),
Self::Empty => write!(f, "graph has no nodes"),
Self::TooManyNodes(n) => {
write!(f, "graph has {n} nodes; the cap is {MAX_GRAPH_NODES}")
}
Self::BadSlug(s) => write!(
f,
"node id {s:?} is invalid: use 1-64 chars of [A-Za-z0-9_-]"
),
Self::DuplicateSlug(s) => write!(f, "duplicate node id {s:?}"),
Self::EmptyField { slug, field } => {
write!(f, "node {slug:?} has an empty {field}")
}
Self::UnknownDep { slug, dep } => {
write!(f, "node {slug:?} depends on unknown node {dep:?}")
}
Self::SelfDep(s) => write!(f, "node {s:?} depends on itself"),
Self::Cycle(nodes) => {
write!(f, "dependency cycle among nodes: {}", nodes.join(", "))
}
Self::IdCollision(a, b) => write!(
f,
"hash id collision between slugs {a:?} and {b:?}; rename one"
),
Self::ExistingCollision(s) => write!(
f,
"new node {s:?} collides with an existing graph node; rename it"
),
Self::UnknownOrigin { slug, origin } => write!(
f,
"node {slug:?} claims discovered_from unknown node {origin:?}"
),
Self::DeadDep { slug, dep } => write!(
f,
"node {slug:?} depends on {dep:?}, which already failed; depend on \
live nodes only (or none)"
),
}
}
}
/// FNV-1a 32-bit over the slug, rendered as 8 lowercase hex chars.
/// Stable across builds, platforms, and Rust versions — the property
/// the project-level graph file (G4) needs for line-level merges.
fn fnv1a32_hex(s: &str) -> String {
let mut hash: u32 = 0x811c_9dc5;
for byte in s.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
format!("{hash:08x}")
}
/// Canonical node id for a planner slug.
pub(crate) fn node_id_for_slug(slug: &str) -> String {
format!("gn-{}", fnv1a32_hex(slug))
}
fn valid_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug.len() <= 64
&& slug
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Parse, statically validate, and canonicalize a planner artifact.
///
/// On success the returned nodes are in planner-order-stable
/// topological order, carry `gn-` hash ids (`title` is kept verbatim;
/// the slug survives only inside the id hash), all start `Waiting`,
/// and end with the harness-appended final verification node.
pub(crate) fn parse_and_validate(
json: &str,
objective: &str,
) -> Result<Vec<GraphNode>, GraphPlanError> {
let mut planned: PlannedGraph =
serde_json::from_str(json).map_err(|e| GraphPlanError::Parse(e.to_string()))?;
if planned.nodes.is_empty() {
return Err(GraphPlanError::Empty);
}
// Dedup repeated dep entries (first occurrence kept): harmless
// planner redundancy, and the indegree seed below would otherwise
// misreport a duplicated edge as a cycle.
for node in &mut planned.nodes {
let mut seen_deps = std::collections::HashSet::new();
node.deps.retain(|d| seen_deps.insert(d.clone()));
}
if planned.nodes.len() > MAX_GRAPH_NODES {
return Err(GraphPlanError::TooManyNodes(planned.nodes.len()));
}
// Slug hygiene + uniqueness + non-empty payload fields.
let mut seen = std::collections::HashSet::new();
for node in &planned.nodes {
if !valid_slug(&node.id) {
return Err(GraphPlanError::BadSlug(node.id.clone()));
}
if !seen.insert(node.id.as_str()) {
return Err(GraphPlanError::DuplicateSlug(node.id.clone()));
}
if node.title.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "title",
});
}
if node.spec.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "spec",
});
}
}
// Dependency resolution.
for node in &planned.nodes {
for dep in &node.deps {
if dep == &node.id {
return Err(GraphPlanError::SelfDep(node.id.clone()));
}
if !seen.contains(dep.as_str()) {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: dep.clone(),
});
}
}
}
// Kahn's algorithm, planner-order-stable: each round takes the
// FIRST remaining zero-indegree node in planner order, so the
// serial scheduler's "first Ready in storage order" rule inherits
// the planner's intent.
let order = stable_topo_order(&planned)?;
// Canonical ids; collisions between distinct slugs fail fast.
let mut id_of: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
let mut owner_of_id: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
for node in &planned.nodes {
let id = node_id_for_slug(&node.id);
if let Some(prior) = owner_of_id.insert(id.clone(), node.id.as_str()) {
return Err(GraphPlanError::IdCollision(
prior.to_owned(),
node.id.clone(),
));
}
id_of.insert(node.id.as_str(), id);
}
let mut nodes: Vec<GraphNode> = order
.into_iter()
.map(|idx| {
let p = &planned.nodes[idx];
GraphNode {
id: id_of[p.id.as_str()].clone(),
title: p.title.trim().to_owned(),
spec: p.spec.trim().to_owned(),
deps: p
.deps
.iter()
.map(|d| NodeDep {
on: id_of[d.as_str()].clone(),
kind: DepKind::Blocks,
})
.collect(),
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}
})
.collect();
nodes.push(final_verification_node(objective, &nodes));
Ok(nodes)
}
/// Planner-order-stable Kahn topological sort; `Err(Cycle)` lists the
/// slugs left when no zero-indegree node remains.
fn stable_topo_order(planned: &PlannedGraph) -> Result<Vec<usize>, GraphPlanError> {
let n = planned.nodes.len();
let index_of: std::collections::HashMap<&str, usize> = planned
.nodes
.iter()
.enumerate()
.map(|(i, node)| (node.id.as_str(), i))
.collect();
let mut indegree = vec![0usize; n];
for node in &planned.nodes {
let i = index_of[node.id.as_str()];
indegree[i] = node.deps.len();
}
let mut done = vec![false; n];
let mut order = Vec::with_capacity(n);
while order.len() < n {
let Some(next) = (0..n).find(|&i| !done[i] && indegree[i] == 0) else {
let cycle: Vec<String> = (0..n)
.filter(|&i| !done[i])
.map(|i| planned.nodes[i].id.clone())
.collect();
return Err(GraphPlanError::Cycle(cycle));
};
done[next] = true;
order.push(next);
let slug = planned.nodes[next].id.as_str();
for node in &planned.nodes {
if node.deps.iter().any(|d| d == slug) {
indegree[index_of[node.id.as_str()]] -= 1;
}
}
}
Ok(order)
}
/// The harness-appended terminal gate: a normal goal whose objective is
/// to independently re-verify the WHOLE graph objective. Depends on
/// every planner node, so it is always the last schedulable node.
fn final_verification_node(objective: &str, planner_nodes: &[GraphNode]) -> GraphNode {
GraphNode {
id: FINAL_NODE_ID.to_owned(),
title: "Final verification of the overall objective".to_owned(),
spec: format!(
"Independently verify that the OVERALL objective below is fully achieved, \
end to end, in the current state of the project. Re-run the relevant \
builds/tests/commands yourself; do not trust prior claims. If you find a \
gap, close it. Do not add features beyond the objective.\n\n\
OVERALL OBJECTIVE:\n{objective}"
),
deps: planner_nodes
.iter()
.map(|n| NodeDep {
on: n.id.clone(),
kind: DepKind::Blocks,
})
.collect(),
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}
}
/// Parse and validate a REPLAN artifact against the existing graph:
/// strictly append-only. New nodes may depend on existing `gn-…` ids or
/// on each other; the combined graph must stay acyclic; existing nodes
/// are never modified. Returns the canonicalized appendix — `Waiting`
/// status, `Blocks` deps, plus one `DiscoveredFrom` edge per validated
/// `discovered_from` origin.
pub(crate) fn validate_replan(
existing: &[GraphNode],
json: &str,
) -> Result<Vec<GraphNode>, GraphPlanError> {
let mut planned: PlannedGraph =
serde_json::from_str(json).map_err(|e| GraphPlanError::Parse(e.to_string()))?;
if planned.nodes.is_empty() {
return Err(GraphPlanError::Empty);
}
// Whole-graph cap: MAX_GRAPH_NODES planner nodes + gn-final. The
// payload excludes the final node so "the cap is N" stays truthful
// for replans too.
if planned.nodes.len() + existing.len() > MAX_GRAPH_NODES + 1 {
return Err(GraphPlanError::TooManyNodes(
planned.nodes.len() + existing.len() - 1,
));
}
for node in &mut planned.nodes {
let mut seen_deps = std::collections::HashSet::new();
node.deps.retain(|d| seen_deps.insert(d.clone()));
}
let existing_ids: std::collections::HashSet<&str> =
existing.iter().map(|n| n.id.as_str()).collect();
let mut seen = std::collections::HashSet::new();
for node in &planned.nodes {
if !valid_slug(&node.id) {
return Err(GraphPlanError::BadSlug(node.id.clone()));
}
if !seen.insert(node.id.as_str()) {
return Err(GraphPlanError::DuplicateSlug(node.id.clone()));
}
if node.title.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "title",
});
}
if node.spec.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "spec",
});
}
for origin in &node.discovered_from {
if !existing_ids.contains(origin.as_str()) {
return Err(GraphPlanError::UnknownOrigin {
slug: node.id.clone(),
origin: origin.clone(),
});
}
// Any edge onto the terminal node would cycle the moment
// append_replan_nodes gates it on the appendix. Fail fast.
if origin == FINAL_NODE_ID {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: FINAL_NODE_ID.to_owned(),
});
}
}
if node.deps.iter().any(|d| d == FINAL_NODE_ID) {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: FINAL_NODE_ID.to_owned(),
});
}
}
// Canonical ids for the appendix; must not collide with anything.
let mut id_of: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
let mut owner_of_id: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
for node in &planned.nodes {
let id = node_id_for_slug(&node.id);
if existing_ids.contains(id.as_str()) {
return Err(GraphPlanError::ExistingCollision(node.id.clone()));
}
if let Some(prior) = owner_of_id.insert(id.clone(), node.id.as_str()) {
return Err(GraphPlanError::IdCollision(
prior.to_owned(),
node.id.clone(),
));
}
id_of.insert(node.id.as_str(), id);
}
// Deps resolve against existing ids (verbatim) or new slugs.
let resolve = |dep: &str| -> Option<String> {
if existing_ids.contains(dep) {
Some(dep.to_owned())
} else {
id_of.get(dep).cloned()
}
};
let dead_ids: std::collections::HashSet<&str> = existing
.iter()
.filter(|n| matches!(n.status, NodeStatus::Failed | NodeStatus::Blocked))
.map(|n| n.id.as_str())
.collect();
for node in &planned.nodes {
for dep in &node.deps {
if dep == &node.id {
return Err(GraphPlanError::SelfDep(node.id.clone()));
}
if resolve(dep).is_none() {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: dep.clone(),
});
}
// An ordering dep on a dead node can never satisfy; fail
// fast so the attempt-2 feedback loop repairs the artifact.
// (`discovered_from` origins are exempt — audit-only edges,
// and failed origins are the NORMAL salvage case.)
if dead_ids.contains(dep.as_str()) {
return Err(GraphPlanError::DeadDep {
slug: node.id.clone(),
dep: dep.clone(),
});
}
}
}
// Combined-graph acyclicity (Kahn over existing edges + appendix).
// Existing nodes only ever depend on existing nodes, so seeding
// their edges verbatim is sound.
{
let mut ids: Vec<String> = existing.iter().map(|n| n.id.clone()).collect();
ids.extend(planned.nodes.iter().map(|n| id_of[n.id.as_str()].clone()));
let index_of: std::collections::HashMap<&str, usize> = ids
.iter()
.enumerate()
.map(|(i, id)| (id.as_str(), i))
.collect();
let mut edges: Vec<(usize, usize)> = Vec::new();
for n in existing {
for d in &n.deps {
edges.push((index_of[d.on.as_str()], index_of[n.id.as_str()]));
}
}
for n in &planned.nodes {
let to = index_of[id_of[n.id.as_str()].as_str()];
for d in &n.deps {
edges.push((index_of[resolve(d).expect("validated").as_str()], to));
}
}
let mut indegree = vec![0usize; ids.len()];
for (_, to) in &edges {
indegree[*to] += 1;
}
let mut done = vec![false; ids.len()];
for _ in 0..ids.len() {
let Some(next) = (0..ids.len()).find(|&i| !done[i] && indegree[i] == 0) else {
let cycle: Vec<String> = (0..ids.len())
.filter(|&i| !done[i])
.map(|i| ids[i].clone())
.collect();
return Err(GraphPlanError::Cycle(cycle));
};
done[next] = true;
for (from, to) in &edges {
if *from == next {
indegree[*to] -= 1;
}
}
}
}
Ok(planned
.nodes
.iter()
.map(|p| {
let mut deps: Vec<NodeDep> = p
.deps
.iter()
.map(|d| NodeDep {
on: resolve(d).expect("validated"),
kind: DepKind::Blocks,
})
.collect();
for origin in &p.discovered_from {
if !deps.iter().any(|d| &d.on == origin) {
deps.push(NodeDep {
on: origin.clone(),
kind: DepKind::DiscoveredFrom,
});
}
}
GraphNode {
id: id_of[p.id.as_str()].clone(),
title: p.title.trim().to_owned(),
spec: p.spec.trim().to_owned(),
deps,
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn plan_json(nodes: &[(&str, &[&str])]) -> String {
let nodes: Vec<serde_json::Value> = nodes
.iter()
.map(|(id, deps)| {
serde_json::json!({
"id": id,
"title": format!("Title {id}"),
"spec": format!("Spec for {id}"),
"deps": deps,
})
})
.collect();
serde_json::json!({ "nodes": nodes }).to_string()
}
#[test]
fn valid_plan_canonicalizes_topologically_and_appends_final_node() {
// Planner order deliberately lists a dependent before its dep.
let json = plan_json(&[("b", &["a"]), ("a", &[]), ("c", &["a", "b"])]);
let nodes = parse_and_validate(&json, "ship the feature").unwrap();
assert_eq!(nodes.len(), 4);
let ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
// a before b before c; final last.
assert_eq!(ids[0], node_id_for_slug("a"));
assert_eq!(ids[1], node_id_for_slug("b"));
assert_eq!(ids[2], node_id_for_slug("c"));
assert_eq!(ids[3], FINAL_NODE_ID);
// Final node depends on all three, and carries the objective.
assert_eq!(nodes[3].deps.len(), 3);
assert!(nodes[3].spec.contains("ship the feature"));
// Deps rewritten to canonical ids.
assert_eq!(nodes[1].deps[0].on, node_id_for_slug("a"));
}
#[test]
fn ids_are_stable_content_hashes() {
assert_eq!(node_id_for_slug("auth-flow"), node_id_for_slug("auth-flow"));
assert_ne!(node_id_for_slug("auth-flow"), node_id_for_slug("auth_flow"));
assert!(node_id_for_slug("x").starts_with("gn-"));
assert_eq!(node_id_for_slug("x").len(), 3 + 8);
}
#[test]
fn cycle_is_rejected_with_members_listed() {
let json = plan_json(&[("a", &["b"]), ("b", &["a"]), ("c", &[])]);
match parse_and_validate(&json, "o") {
Err(GraphPlanError::Cycle(members)) => {
assert!(members.contains(&"a".to_owned()));
assert!(members.contains(&"b".to_owned()));
assert!(!members.contains(&"c".to_owned()));
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn structural_errors_are_precise() {
assert_eq!(
parse_and_validate(r#"{"nodes":[]}"#, "o").unwrap_err(),
GraphPlanError::Empty
);
assert!(matches!(
parse_and_validate("not json", "o"),
Err(GraphPlanError::Parse(_))
));
let dup = plan_json(&[("a", &[]), ("a", &[])]);
assert_eq!(
parse_and_validate(&dup, "o").unwrap_err(),
GraphPlanError::DuplicateSlug("a".into())
);
let self_dep = plan_json(&[("a", &["a"])]);
assert_eq!(
parse_and_validate(&self_dep, "o").unwrap_err(),
GraphPlanError::SelfDep("a".into())
);
let unknown = plan_json(&[("a", &["ghost"])]);
assert_eq!(
parse_and_validate(&unknown, "o").unwrap_err(),
GraphPlanError::UnknownDep {
slug: "a".into(),
dep: "ghost".into()
}
);
let bad = plan_json(&[("has space", &[])]);
assert_eq!(
parse_and_validate(&bad, "o").unwrap_err(),
GraphPlanError::BadSlug("has space".into())
);
}
#[test]
fn empty_title_or_spec_rejected() {
let json = serde_json::json!({
"nodes": [{"id": "a", "title": " ", "spec": "s", "deps": []}]
})
.to_string();
assert_eq!(
parse_and_validate(&json, "o").unwrap_err(),
GraphPlanError::EmptyField {
slug: "a".into(),
field: "title"
}
);
}
#[test]
fn node_cap_enforced() {
let slugs: Vec<String> = (0..MAX_GRAPH_NODES + 1).map(|i| format!("n{i}")).collect();
let pairs: Vec<(&str, &[&str])> = slugs.iter().map(|s| (s.as_str(), &[][..])).collect();
let json = plan_json(&pairs);
assert_eq!(
parse_and_validate(&json, "o").unwrap_err(),
GraphPlanError::TooManyNodes(MAX_GRAPH_NODES + 1)
);
}
#[test]
fn planner_order_breaks_topo_ties() {
// Two independent roots: planner listed z first, so z schedules first.
let json = plan_json(&[("z", &[]), ("a", &[])]);
let nodes = parse_and_validate(&json, "o").unwrap();
assert_eq!(nodes[0].id, node_id_for_slug("z"));
assert_eq!(nodes[1].id, node_id_for_slug("a"));
}
fn existing_graph() -> Vec<GraphNode> {
parse_and_validate(&plan_json(&[("a", &[]), ("b", &["a"])]), "objective").unwrap()
}
#[test]
fn replan_appendix_resolves_existing_ids_and_adds_discovered_from_edges() {
let existing = existing_graph();
let a_id = node_id_for_slug("a");
let json = serde_json::json!({
"nodes": [{
"id": "docs",
"title": "Docs",
"spec": "write docs",
"deps": [a_id.clone()],
"discovered_from": [a_id.clone()],
}]
})
.to_string();
let appendix = validate_replan(&existing, &json).unwrap();
assert_eq!(appendix.len(), 1);
let node = &appendix[0];
assert_eq!(node.status, NodeStatus::Waiting);
// Blocks dep on the existing id, deduped against the
// DiscoveredFrom edge (same target keeps the Blocks edge only).
assert_eq!(node.deps.len(), 1);
assert_eq!(node.deps[0].on, a_id);
assert_eq!(node.deps[0].kind, DepKind::Blocks);
// Distinct origin gets its own DiscoveredFrom edge.
let b_id = node_id_for_slug("b");
let json = serde_json::json!({
"nodes": [{
"id": "docs2",
"title": "Docs 2",
"spec": "s",
"deps": [a_id.clone()],
"discovered_from": [b_id.clone()],
}]
})
.to_string();
let appendix = validate_replan(&existing, &json).unwrap();
let node = &appendix[0];
assert_eq!(node.deps.len(), 2);
assert!(
node.deps
.iter()
.any(|d| d.on == b_id && d.kind == DepKind::DiscoveredFrom)
);
}
#[test]
fn replan_rejects_blocks_deps_on_dead_nodes_but_allows_dead_origins() {
let mut existing = existing_graph();
let a_id = node_id_for_slug("a");
existing.iter_mut().find(|n| n.id == a_id).unwrap().status = NodeStatus::Failed;
let dead_dep = serde_json::json!({
"nodes": [{"id": "x", "title": "T", "spec": "s", "deps": [a_id.clone()]}]
})
.to_string();
assert!(matches!(
validate_replan(&existing, &dead_dep).unwrap_err(),
GraphPlanError::DeadDep { .. }
));
// A dead ORIGIN is the normal salvage case — allowed, and the
// audit-only DiscoveredFrom edge never gates scheduling.
let dead_origin = serde_json::json!({
"nodes": [{"id": "x", "title": "T", "spec": "s", "deps": [],
"discovered_from": [a_id]}]
})
.to_string();
assert!(validate_replan(&existing, &dead_origin).is_ok());
}
#[test]
fn replan_rejects_edges_onto_the_terminal_node() {
let existing = existing_graph();
for json in [
serde_json::json!({"nodes": [{"id": "x", "title": "T", "spec": "s",
"deps": [FINAL_NODE_ID]}]}),
serde_json::json!({"nodes": [{"id": "x", "title": "T", "spec": "s",
"deps": [], "discovered_from": [FINAL_NODE_ID]}]}),
] {
assert!(
matches!(
validate_replan(&existing, &json.to_string()).unwrap_err(),
GraphPlanError::UnknownDep { .. }
),
"an edge onto gn-final would cycle after the final-gating extension"
);
}
}
#[test]
fn replan_rejects_collisions_unknown_origins_and_cycles() {
let existing = existing_graph();
// Re-using an existing slug collides on the canonical id.
let dup = serde_json::json!({
"nodes": [{"id": "a", "title": "T", "spec": "s", "deps": []}]
})
.to_string();
assert_eq!(
validate_replan(&existing, &dup).unwrap_err(),
GraphPlanError::ExistingCollision("a".into())
);
// Unknown discovered_from origin.
let bad_origin = serde_json::json!({
"nodes": [{"id": "x", "title": "T", "spec": "s", "deps": [],
"discovered_from": ["gn-ghost"]}]
})
.to_string();
assert!(matches!(
validate_replan(&existing, &bad_origin).unwrap_err(),
GraphPlanError::UnknownOrigin { .. }
));
// New-node cycle.
let cyc = serde_json::json!({
"nodes": [
{"id": "x", "title": "T", "spec": "s", "deps": ["y"]},
{"id": "y", "title": "T", "spec": "s", "deps": ["x"]},
]
})
.to_string();
assert!(matches!(
validate_replan(&existing, &cyc).unwrap_err(),
GraphPlanError::Cycle(_)
));
}
/// A repeated dep entry is harmless planner redundancy: it must be
/// deduped, NOT misreported as a cycle by the indegree seed.
#[test]
fn duplicate_dep_entries_are_deduped_not_a_cycle() {
let json = plan_json(&[("a", &[]), ("b", &["a", "a"])]);
let nodes = parse_and_validate(&json, "o").unwrap();
assert_eq!(nodes.len(), 3, "a, b, final");
let b = &nodes[1];
assert_eq!(b.id, node_id_for_slug("b"));
assert_eq!(b.deps.len(), 1, "duplicate edge collapsed");
assert_eq!(b.deps[0].on, node_id_for_slug("a"));
}
}