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.
This commit is contained in:
@@ -41,6 +41,10 @@ struct PlannedNode {
|
||||
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
|
||||
@@ -53,11 +57,30 @@ pub(crate) enum GraphPlanError {
|
||||
TooManyNodes(usize),
|
||||
BadSlug(String),
|
||||
DuplicateSlug(String),
|
||||
EmptyField { slug: String, field: &'static str },
|
||||
UnknownDep { slug: String, dep: 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 {
|
||||
@@ -87,6 +110,19 @@ impl std::fmt::Display for GraphPlanError {
|
||||
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)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +331,214 @@ fn final_verification_node(objective: &str, planner_nodes: &[GraphNode]) -> Grap
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
@@ -424,6 +668,134 @@ mod tests {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user