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:
2026-07-20 19:17:24 -04:00
parent 1579558b56
commit bb5cbff62d
22 changed files with 1272 additions and 10 deletions
@@ -95,6 +95,8 @@ pub use types::{TodoGateDecision, TodoGateReason};
mod goal;
#[path = "acp_session_impl/graph.rs"]
mod graph;
#[path = "acp_session_impl/graph_replan.rs"]
mod graph_replan;
#[path = "acp_session_impl/graph_workers.rs"]
mod graph_workers;
#[path = "acp_session_impl/interjection.rs"]
@@ -607,6 +609,9 @@ pub(crate) struct SessionActor {
/// Max worker↔verifier rounds per parallel graph node before the
/// node fails. Cached at actor construction.
pub(crate) graph_node_rounds: u32,
/// Max replan passes per graph (0 = replanning off). Cached at
/// actor construction.
pub(crate) graph_replan_cap: u32,
/// `task_id`s of background tasks (and monitors) that originated during
/// the goal turn — either spawned by the goal model itself or reparented
/// from a harness verifier/planner subagent on its exit. Their late
@@ -49,7 +49,10 @@ pub(super) fn node_goal_objective(
{spec}\n\n\
This node is one unit of a larger graph objective:\n\
{graph_objective}\n\n\
Complete ONLY this node's scope; other nodes cover the rest.",
Complete ONLY this node's scope; other nodes cover the rest. If you \
find NECESSARY work outside this node's scope, do NOT do it — list \
each item on its own line as `DISCOVERED: <description>` in your \
final summary; the harness turns these into new graph nodes.",
title = node.title,
spec = node.spec,
)
@@ -339,7 +342,7 @@ impl SessionActor {
/// Write the immutable plan baseline for the current version.
/// `create_new` guarantees a frozen baseline is never overwritten —
/// an existing file is the infra failure it looks like.
async fn write_graph_baseline(&self, nodes: &[GraphNode]) -> std::io::Result<()> {
pub(super) async fn write_graph_baseline(&self, nodes: &[GraphNode]) -> std::io::Result<()> {
let path = {
let tracker = self.graph_tracker.lock();
let version = tracker.snapshot().map(|s| s.plan_version).unwrap_or(1);
@@ -543,6 +546,22 @@ impl SessionActor {
.snapshot()
.map(|o| o.total_worker_rounds)
.unwrap_or(0);
// Serial-path discovery capture: the node's final assistant text
// may carry `DISCOVERED:` items (same contract as the parallel
// workers, zero extra tool surface).
if let Some(text) = self.chat_state_handle.get_last_assistant_text().await {
let found = super::graph_workers::parse_discovered_lines(&text);
if !found.is_empty() {
let ds: Vec<super::super::graph_tracker::Discovery> = found
.into_iter()
.map(|description| super::super::graph_tracker::Discovery {
from_node: node_id.clone(),
description,
})
.collect();
self.graph_tracker.lock().queue_discoveries(ds);
}
}
self.archive_node_artifacts(&node_id).await;
tracing::info!(%node_id, rounds, node_tokens, "graph: node achieved");
self.graph_tracker
@@ -565,6 +584,9 @@ impl SessionActor {
// serial-launch failure — the pauser already messaged.
return None;
}
// Replan boundary: fold queued discoveries into the graph
// (bounded; failure degrades to history-only).
self.maybe_replan_graph().await;
if self.graph_tracker.lock().remaining_budget() == Some(0) {
tracing::warn!("graph: budget exhausted at dispatch");
self.graph_tracker.lock().budget_limit();
@@ -0,0 +1,233 @@
//! Dynamic replan (G3): fold `DISCOVERED:` items surfaced during node
//! execution into the graph at dispatch boundaries.
//!
//! SGH version discipline: the running plan is immutable inside a
//! version — a replan appends new nodes (never edits existing ones),
//! bumps `plan_version`, and freezes a new immutable baseline. The pass
//! is BOUNDED by `KIGI_GRAPH_REPLAN_CAP` (default 3, 0 = off): past the
//! cap, discoveries drain to history only, so the graph always
//! converges. Replan failure DEGRADES (discoveries kept in history, the
//! graph keeps running) — unlike initial planning, a working graph is
//! never paused because an enhancement pass failed.
use std::sync::Arc;
use super::super::goal_planner::{ChannelSpawner, GoalPlannerSpawner};
use super::super::graph_planner::{
GRAPH_REPLANNER_SUBAGENT_DESCRIPTION, GraphPlannerOutcome, GraphReplannerInputs,
run_graph_replanner,
};
use super::SessionActor;
impl SessionActor {
/// Replan boundary, called at the top of every `drive_graph`
/// iteration. No-op without pending discoveries.
pub(super) async fn maybe_replan_graph(&self) {
let (pending, replan_runs) = {
let tracker = self.graph_tracker.lock();
let Some(state) = tracker.snapshot() else {
return;
};
(state.pending_discoveries.clone(), state.replan_runs)
};
if pending.is_empty() {
return;
}
// Budget first: a budget-dead graph must not spend two replanner
// runs right before the dispatch loop trips BudgetLimited. The
// discoveries STAY QUEUED (persisted) — a later
// `/graph resume --budget` top-up re-enters and replans with
// budget actually available.
if self.graph_tracker.lock().remaining_budget() == Some(0) {
tracing::info!("graph replan: budget exhausted; keeping discoveries queued");
return;
}
let final_achieved = self
.graph_tracker
.lock()
.node(super::super::graph_tracker::FINAL_NODE_ID)
.is_some_and(|n| n.status == super::super::graph_tracker::NodeStatus::Achieved);
if final_achieved {
// The whole-objective gate already passed; late discoveries
// (typically from the final verification itself) are
// advisory — appending nodes now would ship work the
// terminal gate never re-verified.
let n = self.graph_tracker.lock().drain_discoveries_to_history();
self.persist_graph_state();
tracing::info!(
drained = n,
"graph replan: final already achieved; history only"
);
return;
}
if self.graph_replan_cap == 0 {
// Feature off: quiet drain (history keeps the audit trail).
let n = self.graph_tracker.lock().drain_discoveries_to_history();
self.persist_graph_state();
tracing::info!(drained = n, "graph replan: disabled (cap 0); history only");
return;
}
if replan_runs >= self.graph_replan_cap {
let n = self.graph_tracker.lock().drain_discoveries_to_history();
self.persist_graph_state();
tracing::warn!(
drained = n,
replan_runs,
cap = self.graph_replan_cap,
"graph replan: cap exhausted; discoveries recorded in history only"
);
self.send_slash_command_output(&format!(
"Graph replan cap reached ({replan_runs}/{}); {n} discover{} recorded in \
history only — the graph will converge on the current plan.",
self.graph_replan_cap,
if n == 1 { "y" } else { "ies" },
))
.await;
return;
}
let Some(event_tx) = self.tool_context.subagent_event_tx.clone() else {
tracing::warn!("graph replan: no subagent coordinator; keeping discoveries queued");
return;
};
let (existing, objective, current_graph, discoveries_text, graph_file, next_version) = {
let tracker = self.graph_tracker.lock();
let Some(state) = tracker.snapshot() else {
return;
};
let compact: Vec<serde_json::Value> = state
.nodes
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"title": n.title,
"status": format!("{:?}", n.status),
"deps": n.deps.iter().map(|d| d.on.clone()).collect::<Vec<_>>(),
})
})
.collect();
let discoveries_text = pending
.iter()
.map(|d| format!("- (from {}) {}", d.from_node, d.description))
.collect::<Vec<_>>()
.join("\n");
(
state.nodes.clone(),
state.objective.clone(),
serde_json::to_string(&compact).unwrap_or_default(),
discoveries_text,
tracker
.artifacts_dir()
.join(format!("replan.v{}.json", state.plan_version + 1)),
state.plan_version + 1,
)
};
let parent_prompt_id = self
.current_prompt_id
.lock()
.expect("current_prompt_id mutex poisoned")
.clone();
let spawner: Arc<dyn GoalPlannerSpawner> = Arc::new(ChannelSpawner {
event_tx,
parent_session_id: self.session_id_string(),
parent_prompt_id,
cwd: Some(self.tool_context.cwd.as_str().to_owned()),
role_override: Default::default(),
events: Some(self.events.writer()),
});
let tool_names = self.resolve_inherit_role_tool_names().await;
let mut feedback = String::new();
for attempt in 1..=2u32 {
tracing::info!(
attempt,
next_version,
role = GRAPH_REPLANNER_SUBAGENT_DESCRIPTION,
pending = pending.len(),
"graph replan: firing"
);
match run_graph_replanner(
spawner.clone(),
&existing,
GraphReplannerInputs {
objective: &objective,
current_graph: &current_graph,
discoveries: &discoveries_text,
feedback: &feedback,
graph_file: &graph_file,
tool_names: &tool_names,
inherit_tool_names: &tool_names,
},
)
.await
{
GraphPlannerOutcome::Planned(appendix) if appendix.is_empty() => {
// Escape hatch: everything already covered. The pass
// still counts against the cap.
tracing::info!("graph replan: empty appendix (already covered)");
{
let mut tracker = self.graph_tracker.lock();
tracker.drain_discoveries_to_history();
if let Some(state) = tracker.snapshot_mut() {
state.replan_runs += 1;
}
}
self.persist_graph_state();
return;
}
GraphPlannerOutcome::Planned(appendix) => {
let added = appendix.len();
self.graph_tracker.lock().append_replan_nodes(appendix);
// Freeze the new version's immutable baseline (full
// node set post-append; create_new keeps v{N-1}
// byte-identical forever).
let all_nodes = self
.graph_tracker
.lock()
.snapshot()
.map(|s| s.nodes.clone())
.unwrap_or_default();
if let Err(err) = self.write_graph_baseline(&all_nodes).await {
tracing::warn!(%err, "graph replan: baseline write failed (audit gap only)");
}
self.persist_graph_state();
tracing::info!(added, next_version, "graph replan: appendix installed");
self.send_slash_command_output(&format!(
"Graph replanned (v{next_version}): {added} node(s) added from \
discovered work."
))
.await;
return;
}
GraphPlannerOutcome::Invalid { reason } if attempt == 1 => {
tracing::warn!(%reason, "graph replan: invalid appendix; retrying with feedback");
feedback = format!(
"Your previous replan JSON failed validation:\n{reason}\n\
Rewrite the file fixing exactly this."
);
}
GraphPlannerOutcome::Invalid { reason }
| GraphPlannerOutcome::FailClosed { reason } => {
// Degrade, never pause a working graph for a failed
// enhancement pass. The run still counts.
tracing::warn!(%reason, "graph replan: failed; draining discoveries to history");
{
let mut tracker = self.graph_tracker.lock();
tracker.drain_discoveries_to_history();
if let Some(state) = tracker.snapshot_mut() {
state.replan_runs += 1;
}
}
self.persist_graph_state();
self.send_slash_command_output(&format!(
"Graph replan failed ({reason}); discovered work recorded in \
history only."
))
.await;
return;
}
}
}
}
}
@@ -136,6 +136,26 @@ pub(crate) fn parse_node_verdict(output: &str) -> NodeVerdict {
}
}
/// Line-anchored `DISCOVERED:` items outside fenced blocks. The
/// placeholder filter (`<`) drops template echoes ("<one-line
/// description …>") a child may parrot back.
pub(crate) fn parse_discovered_lines(output: &str) -> Vec<String> {
strip_fenced_blocks(output)
.lines()
.filter_map(|l| {
l.trim_start()
.trim_start_matches('`')
.strip_prefix("DISCOVERED:")
})
.map(str::trim)
// Placeholder-echo defense: drop only the templates' literal
// "<one-line description …>" shape, not every '<' (legit Rust
// discoveries mention generics like Vec<String>).
.filter(|d| !d.is_empty() && !d.starts_with('<'))
.map(str::to_owned)
.collect()
}
// Spawner seam (mockable in tests)
pub(crate) struct WorkerSpawnSpec {
@@ -258,6 +278,8 @@ pub(crate) struct NodeRunReport {
pub worktree_path: Option<String>,
/// Last worker child session id (audit link, stored on the node).
pub worker_session_id: Option<String>,
/// `DISCOVERED:` items surfaced by the workers/verifiers (deduped).
pub discoveries: Vec<String>,
}
fn worker_prompt(node_objective: &str, gaps: &[String]) -> String {
@@ -282,7 +304,8 @@ fn verifier_prompt(node_objective: &str, worker_summary: &str) -> String {
// into the verifier's context.
let safe_summary = worker_summary
.replace("NODE_VERDICT", "NODE-VERDICT")
.replace("NODE_RESULT", "NODE-RESULT");
.replace("NODE_RESULT", "NODE-RESULT")
.replace("DISCOVERED", "DISCOVERED-");
format!(
"{VERIFIER_PROMPT_TEMPLATE}\n\nNODE OBJECTIVE (the contract to judge):\n{node_objective}\n\n\
IMPLEMENTER'S CLAIM (audit it, do not trust it):\n{safe_summary}\n"
@@ -298,6 +321,7 @@ pub(crate) async fn run_node_to_verdict(
rounds_cap: u32,
) -> NodeRunReport {
let mut tokens: i64 = 0;
let mut discoveries: Vec<String> = Vec::new();
let mut gaps: Vec<String> = Vec::new();
let mut resume_from: Option<String> = None;
let mut worktree_path: Option<String> = None;
@@ -326,6 +350,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
};
@@ -341,6 +366,11 @@ pub(crate) async fn run_node_to_verdict(
if !outcome.child_session_id.is_empty() {
resume_from = Some(outcome.child_session_id.clone());
}
for d in parse_discovered_lines(&outcome.output) {
if !discoveries.contains(&d) {
discoveries.push(d);
}
}
if outcome.cancelled {
return NodeRunReport {
@@ -351,6 +381,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries,
};
}
if outcome.backgrounded {
@@ -393,6 +424,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens,
worktree_path: None,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
@@ -406,6 +438,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
WorkerClaim::Unparseable => {
@@ -429,6 +462,11 @@ pub(crate) async fn run_node_to_verdict(
let verdict = match spawner.spawn(&verify_id, verify_spec).await {
Ok(v) => {
tokens = tokens.saturating_add(v.tokens_used as i64);
for d in parse_discovered_lines(&v.output) {
if !discoveries.contains(&d) {
discoveries.push(d);
}
}
if v.success {
parse_node_verdict(&v.output)
} else {
@@ -456,6 +494,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
NodeVerdict::NotAchieved { gaps: new_gaps } => {
@@ -477,6 +516,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries,
}
}
@@ -583,6 +623,18 @@ impl SessionActor {
{
node.goal_id = Some(worker_id.clone());
}
if !report.discoveries.is_empty() {
// A failed node's discoveries are still real work.
let ds: Vec<crate::session::graph_tracker::Discovery> = report
.discoveries
.iter()
.map(|d| crate::session::graph_tracker::Discovery {
from_node: report.node_id.clone(),
description: d.clone(),
})
.collect();
self.graph_tracker.lock().queue_discoveries(ds);
}
if !report.achieved {
failed += 1;
{
@@ -1101,6 +1101,7 @@ pub(crate) async fn spawn_session_actor(
graph_tracker,
graph_concurrency: effective_config.resolve_graph_concurrency(),
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -218,6 +218,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -660,6 +661,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -911,6 +913,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1895,6 +1898,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1619,3 +1619,184 @@ async fn graph_slash_terminal_outcomes_through_handle_prompt() {
.await;
unsafe { std::env::remove_var(ENV_FLAG) };
}
// ── G3: dynamic replan ─────────────────────────────────────────────
/// Route replies for the replan flow: planner writes the diamond,
/// node-a's worker reports a discovery, the replanner appends a `docs`
/// node discovered_from gn-a, everything else is happy-path.
fn replan_reply(
req: &kigi_tools::implementations::kigi::task::types::SubagentRequest,
dag: &[u8],
) -> String {
if req.prompt.contains("Graph Replanner") {
let path = req
.prompt
.find("/replan.v")
.map(|start_idx| {
let end = req.prompt[start_idx..]
.find(".json")
.map(|e| start_idx + e + ".json".len())
.unwrap();
let start = req.prompt[..start_idx]
.rfind(|c: char| !c.is_ascii_graphic() || c == '`')
.map(|i| i + 1)
.unwrap_or(0);
req.prompt[start..end].to_string()
})
.expect("replanner prompt embeds the artifact path");
let a_id = crate::session::graph_plan::node_id_for_slug("a");
let body = serde_json::json!({
"nodes": [{
"id": "docs",
"title": "Docs page",
"spec": "write the docs page",
"deps": [],
"discovered_from": [a_id],
}]
})
.to_string();
std::fs::create_dir_all(std::path::Path::new(&path).parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
return "Done".to_owned();
}
if req.prompt.contains("Graph Node Worker") && req.prompt.contains("do a") {
return "NODE_RESULT: done\nBuilt a.\nDISCOVERED: also need a docs page".to_owned();
}
happy_reply(req, dag)
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn discovery_triggers_replan_and_appended_node_runs_to_achieved() {
unsafe { std::env::set_var(ENV_FLAG, "0") };
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dag = diamond_graph_json();
let (mut actor, _tmp, _prx) = make_graph_actor_detached().await;
let (coord_tx, captured) = spawn_scripted_coordinator(
_tmp.path().to_path_buf(),
move |req| replan_reply(req, &dag),
false,
);
actor.tool_context.subagent_event_tx = Some(coord_tx);
actor.graph_concurrency = 2;
let outcome = actor.setup_graph("build the diamond", None).await;
// Batch(a+b) → replan appends docs → batch/serial drains the
// rest → the serial tail is gn-final (5 nodes total now).
let reminder = match outcome {
graph::GraphSetupOutcome::Inference { reminder, .. } => reminder,
graph::GraphSetupOutcome::Message(msg) => panic!("expected Inference: {msg}"),
};
let s = actor.graph_tracker.lock().snapshot().cloned().unwrap();
assert_eq!(s.nodes.len(), 5, "diamond(3) + final + appended docs");
assert_eq!(s.plan_version, 2, "replan bumped the version");
assert_eq!(s.replan_runs, 1);
assert!(s.pending_discoveries.is_empty());
let docs_id = crate::session::graph_plan::node_id_for_slug("docs");
let docs = s.nodes.iter().find(|n| n.id == docs_id).expect("docs node");
assert!(
docs.deps.iter().any(|d| {
d.on == crate::session::graph_plan::node_id_for_slug("a")
&& d.kind == crate::session::graph_tracker::DepKind::DiscoveredFrom
}),
"appendix carries the DiscoveredFrom edge: {:?}",
docs.deps
);
// gn-final gates on the appendix too.
let final_node = s
.nodes
.iter()
.find(|n| n.id == crate::session::graph_tracker::FINAL_NODE_ID)
.unwrap();
assert!(final_node.deps.iter().any(|d| d.on == docs_id));
// docs already ran in the second batch (parallel with c) or
// serially; by the time a serial reminder surfaces it must be
// for gn-final with everything else achieved.
assert!(reminder.contains("Final verification"), "{reminder}");
// Both baselines exist; v1 stayed byte-identical.
let b1 = actor.graph_tracker.lock().baseline_path(1);
let b2 = actor.graph_tracker.lock().baseline_path(2);
assert!(b1.is_file() && b2.is_file());
let v1: Vec<crate::session::graph_tracker::GraphNode> =
serde_json::from_slice(&std::fs::read(&b1).unwrap()).unwrap();
assert_eq!(v1.len(), 4, "v1 baseline must not gain the appendix");
let v2: Vec<crate::session::graph_tracker::GraphNode> =
serde_json::from_slice(&std::fs::read(&b2).unwrap()).unwrap();
assert_eq!(v2.len(), 5);
// Finish gn-final serially → graph Complete.
drive_node_goal_to_complete(&actor).await;
let end = actor.run_graph_round_end().await;
assert!(end.is_none());
assert_eq!(
actor.graph_tracker.lock().status(),
Some(GoalStatus::Complete)
);
// Exactly one replanner spawn.
let replans = captured
.lock()
.unwrap()
.iter()
.filter(|c| c.prompt.contains("Graph Replanner"))
.count();
assert_eq!(replans, 1);
})
.await;
unsafe { std::env::remove_var(ENV_FLAG) };
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn replan_cap_zero_drains_discoveries_to_history_and_converges() {
unsafe { std::env::set_var(ENV_FLAG, "0") };
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dag = diamond_graph_json();
let (mut actor, _tmp, _prx) = make_graph_actor_detached().await;
let (coord_tx, captured) = spawn_scripted_coordinator(
_tmp.path().to_path_buf(),
move |req| replan_reply(req, &dag),
false,
);
actor.tool_context.subagent_event_tx = Some(coord_tx);
actor.graph_concurrency = 2;
actor.graph_replan_cap = 0;
let _ = actor.setup_graph("build the diamond", None).await;
let s = actor.graph_tracker.lock().snapshot().cloned().unwrap();
assert_eq!(s.nodes.len(), 4, "no appendix with the cap at 0");
assert_eq!(s.plan_version, 1);
assert!(s.pending_discoveries.is_empty(), "drained to history");
assert!(
s.history.iter().any(|h| h
.detail
.as_deref()
.is_some_and(|d| d.contains("discovered: also need a docs page"))),
"discovery must survive in history"
);
let replans = captured
.lock()
.unwrap()
.iter()
.filter(|c| c.prompt.contains("Graph Replanner"))
.count();
assert_eq!(replans, 0, "cap 0 must never spawn the replanner");
// Graph still converges (serial tail: c, then gn-final).
for _ in 0..2 {
drive_node_goal_to_complete(&actor).await;
let _ = actor.run_graph_round_end().await;
}
assert_eq!(
actor.graph_tracker.lock().status(),
Some(GoalStatus::Complete)
);
})
.await;
unsafe { std::env::remove_var(ENV_FLAG) };
}
@@ -247,6 +247,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -179,6 +179,7 @@ async fn create_test_actor(
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -623,6 +624,7 @@ async fn create_test_actor_with_memory(
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1378,6 +1380,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -241,6 +241,7 @@ async fn create_test_actor_with_memory(
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -187,6 +187,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -292,6 +292,7 @@ pub(crate) async fn create_test_actor_ex(
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -2274,6 +2274,7 @@ mod inline_auto_compact_flow_tests {
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -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]
@@ -26,7 +26,9 @@ use super::graph_plan::{self, MAX_GRAPH_JSON_BYTES};
use super::graph_tracker::GraphNode;
const GRAPH_PLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_planner_prompt.md");
const GRAPH_REPLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_replanner_prompt.md");
pub(crate) const GRAPH_PLANNER_SUBAGENT_DESCRIPTION: &str = "graph plan writer";
pub(crate) const GRAPH_REPLANNER_SUBAGENT_DESCRIPTION: &str = "graph replanner";
#[derive(Debug)]
pub(crate) enum GraphPlannerOutcome {
@@ -63,6 +65,16 @@ pub(crate) async fn run_graph_planner(
};
}
// A stale artifact from a prior pass would satisfy the
// missing-file guard below and be trusted as this pass's output.
// Delete first; only NotFound is benign.
if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await
&& err.kind() != std::io::ErrorKind::NotFound
{
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to clear stale graph artifact: {err}"),
};
}
let graph_file_str = inputs.graph_file.to_string_lossy();
let with_graph_file = GRAPH_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
let render = |tool_names: &RoleToolNames| -> String {
@@ -141,6 +153,122 @@ pub(crate) async fn run_graph_planner(
}
}
pub(crate) struct GraphReplannerInputs<'a> {
pub objective: &'a str,
/// Compact JSON of the existing nodes (id/title/status/deps).
pub current_graph: &'a str,
/// The queued discoveries, one per line with their origin node ids.
pub discoveries: &'a str,
/// On retry, the previous artifact's validation error.
pub feedback: &'a str,
pub graph_file: &'a Path,
pub tool_names: &'a RoleToolNames,
pub inherit_tool_names: &'a RoleToolNames,
}
/// Run one REPLAN attempt: render, spawn, read the artifact, validate
/// against the existing graph (append-only). An empty `{"nodes": []}`
/// appendix is the sanctioned "everything already covered" escape hatch
/// and returns `Planned(vec![])`.
pub(crate) async fn run_graph_replanner(
spawner: Arc<dyn GoalPlannerSpawner>,
existing: &[GraphNode],
inputs: GraphReplannerInputs<'_>,
) -> GraphPlannerOutcome {
if let Some(parent) = inputs.graph_file.parent()
&& let Err(err) = tokio::fs::create_dir_all(parent).await
{
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to create graph dir {}: {err}", parent.display()),
};
}
// A stale artifact from a prior pass would satisfy the
// missing-file guard below and be trusted as this pass's output.
// Delete first; only NotFound is benign.
if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await
&& err.kind() != std::io::ErrorKind::NotFound
{
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to clear stale graph artifact: {err}"),
};
}
let graph_file_str = inputs.graph_file.to_string_lossy();
let with_graph_file = GRAPH_REPLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
let render = |tool_names: &RoleToolNames| -> String {
let rendered = tool_names.apply(&with_graph_file);
format!(
"{rendered}\n\nOBJECTIVE:\n{}\n\nCURRENT GRAPH:\n{}\n\nDISCOVERIES:\n{}\n\nCONTEXT:\n{}\n",
inputs.objective, inputs.current_graph, inputs.discoveries, inputs.feedback
)
};
let prompt = RoleRenderedPrompt {
primary: render(inputs.tool_names),
fallback: render(inputs.inherit_tool_names),
};
let spawn_id = uuid::Uuid::now_v7().to_string();
let response = match spawner.spawn_planner(&spawn_id, prompt).await {
Ok(text) => text,
Err(SpawnError::Transport(detail)) => {
return GraphPlannerOutcome::FailClosed {
reason: format!("graph replanner transport error: {detail}"),
};
}
Err(SpawnError::Runtime { message, cancelled }) => {
return GraphPlannerOutcome::FailClosed {
reason: if cancelled {
format!("graph replanner aborted: {message}")
} else {
format!("graph replanner runtime error: {message}")
},
};
}
};
match tokio::fs::metadata(inputs.graph_file).await {
Ok(meta) if meta.is_file() && meta.len() > 0 => {
if meta.len() > MAX_GRAPH_JSON_BYTES {
return GraphPlannerOutcome::Invalid {
reason: format!(
"replan JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
meta.len()
),
};
}
}
_ => {
tracing::info!(
graph_file = %graph_file_str,
terminal_token_ok = parse_terminal_response(&response),
"graph replanner: artifact missing or empty; failing closed",
);
return GraphPlannerOutcome::FailClosed {
reason: "graph replanner produced no artifact".to_owned(),
};
}
}
let json = match tokio::fs::read_to_string(inputs.graph_file).await {
Ok(json) => json,
Err(err) => {
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to read replan artifact: {err}"),
};
}
};
// Escape hatch: an explicitly empty appendix means "already covered".
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json)
&& v.get("nodes")
.and_then(|n| n.as_array())
.is_some_and(Vec::is_empty)
{
return GraphPlannerOutcome::Planned(Vec::new());
}
match graph_plan::validate_replan(existing, &json) {
Ok(nodes) => GraphPlannerOutcome::Planned(nodes),
Err(err) => GraphPlannerOutcome::Invalid {
reason: err.to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -91,9 +91,13 @@ impl NodeStatus {
}
/// Dependency edge kind. `Blocks` is the planner-authored ordering
/// dependency; `DiscoveredFrom` marks a node appended by a replan (G3)
/// pointing back at the node whose execution surfaced it. Both gate
/// scheduling identically; the kind is audit/render metadata.
/// dependency and the ONLY kind that gates scheduling.
/// `DiscoveredFrom` marks a node appended by a replan (G3) pointing
/// back at the node whose execution surfaced it — pure audit/render
/// metadata: its origin is always terminal at replan time, so gating on
/// it would either be a no-op (origin Achieved) or a permanent wedge
/// (origin Failed — and a failed node's discoveries are still real
/// work).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DepKind {
@@ -185,6 +189,16 @@ impl GraphHistoryEntry {
}
}
/// One piece of out-of-scope work surfaced during node execution
/// (`DISCOVERED:` marker from a worker, verifier, or the serial node's
/// final text). Queued until the next replan boundary.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Discovery {
/// Node whose execution surfaced this work.
pub from_node: String,
pub description: String,
}
// GraphOrchestration (full persisted state)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -220,6 +234,13 @@ pub struct GraphOrchestration {
/// on resume/complete (mirrors `GoalOrchestration::pause_message`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pause_message: Option<String>,
/// Discoveries queued for the next replan boundary. Drained by a
/// replan pass; past the replan cap they drain to history only.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_discoveries: Vec<Discovery>,
/// Replan passes consumed (bounded by `KIGI_GRAPH_REPLAN_CAP`).
#[serde(default)]
pub replan_runs: u32,
}
fn default_plan_version() -> u32 {
@@ -366,6 +387,8 @@ impl GraphTracker {
tokens_spent_nodes: 0,
history: Vec::new(),
pause_message: None,
pending_discoveries: Vec::new(),
replan_runs: 0,
};
state
.history
@@ -654,6 +677,80 @@ impl GraphTracker {
}
}
/// Queue discoveries for the next replan boundary (records each in
/// history so the audit trail survives even past the replan cap).
pub fn queue_discoveries(&mut self, discoveries: Vec<Discovery>) {
let Some(state) = self.state.as_mut() else {
return;
};
for d in discoveries {
push_history(
state,
GraphHistoryEntry::now(
GraphEvent::Unknown,
Some(d.from_node.clone()),
Some(format!("discovered: {}", d.description)),
),
);
state.pending_discoveries.push(d);
}
}
/// Install a validated replan appendix: bump `plan_version`, append
/// the new nodes, gate the terminal node on them too (demoting it
/// back to `Waiting` if it was already `Ready`), consume the pending
/// discoveries, and recompute readiness. SGH discipline: versions
/// are immutable — existing nodes are never touched here (the
/// validator guarantees the appendix references them read-only).
pub fn append_replan_nodes(&mut self, new_nodes: Vec<GraphNode>) {
let Some(state) = self.state.as_mut() else {
return;
};
let new_ids: Vec<String> = new_nodes.iter().map(|n| n.id.clone()).collect();
state.nodes.extend(new_nodes);
if let Some(final_node) = state.nodes.iter_mut().find(|n| n.id == FINAL_NODE_ID) {
for id in &new_ids {
if id != FINAL_NODE_ID && !final_node.deps.iter().any(|d| &d.on == id) {
final_node.deps.push(NodeDep {
on: id.clone(),
kind: DepKind::Blocks,
});
}
}
// A Ready terminal node whose gate just grew must wait again.
if final_node.status == NodeStatus::Ready {
final_node.status = NodeStatus::Waiting;
}
}
state.plan_version += 1;
state.replan_runs += 1;
state.pending_discoveries.clear();
push_history(
state,
GraphHistoryEntry::now(
GraphEvent::PlanningCompleted,
None,
Some(format!(
"replan v{}: +{} node(s)",
state.plan_version,
new_ids.len()
)),
),
);
self.recompute_ready();
}
/// Drain pending discoveries WITHOUT replanning (cap exhausted):
/// they stay in history (queued there at capture time) only.
pub fn drain_discoveries_to_history(&mut self) -> usize {
let Some(state) = self.state.as_mut() else {
return 0;
};
let n = state.pending_discoveries.len();
state.pending_discoveries.clear();
n
}
/// Demote in-flight (`Running`/`Verifying`) nodes whose executor is
/// gone back to `Ready`, keeping `keep` (the node whose goal still
/// lives in the engine, if any). Used by the IN-SESSION resume path:
@@ -704,7 +801,11 @@ impl GraphTracker {
.collect();
for node in &mut state.nodes {
if node.status == NodeStatus::Waiting
&& node.deps.iter().all(|d| achieved.contains(&d.on))
&& node
.deps
.iter()
.filter(|d| d.kind == DepKind::Blocks)
.all(|d| achieved.contains(&d.on))
{
node.status = NodeStatus::Ready;
}
@@ -734,7 +835,12 @@ fn block_dependents(state: &mut GraphOrchestration, failed_id: &str) {
if node.status.is_terminal() || blocked.contains(&node.id) {
continue;
}
if node.deps.iter().any(|d| blocked.contains(&d.on)) {
if node
.deps
.iter()
.filter(|d| d.kind == DepKind::Blocks)
.any(|d| blocked.contains(&d.on))
{
node.status = NodeStatus::Blocked;
node.failure = Some(format!("blocked: dependency chain failed at {failed_id}"));
blocked.insert(node.id.clone());
@@ -921,6 +1027,59 @@ mod tests {
assert_eq!(NodeStatus::from_wire_str("achieved"), NodeStatus::Achieved);
}
#[test]
fn discovered_from_edges_never_gate_scheduling() {
let mut t = tracker_with(vec![node("a", &[]), node("b", &[])]);
t.mark_node_running("a", "g".into());
t.mark_node_failed("a", "dead".into());
// Appendix node whose ONLY edge is DiscoveredFrom on the failed
// origin: must become Ready (audit edge, not a gate) and must
// not be swept by block_dependents.
t.append_replan_nodes(vec![GraphNode {
id: "gn-doc".into(),
title: "Docs".into(),
spec: "s".into(),
deps: vec![NodeDep {
on: "a".into(),
kind: DepKind::DiscoveredFrom,
}],
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}]);
assert_eq!(t.node("gn-doc").unwrap().status, NodeStatus::Ready);
}
#[test]
fn replan_appendix_regates_the_final_node_and_bumps_version() {
let mut t = tracker_with(vec![node("a", &[]), node(FINAL_NODE_ID, &["a"])]);
t.mark_node_running("a", "g1".into());
t.mark_node_achieved("a", 1, 10);
// Final node unlocked…
assert_eq!(t.node(FINAL_NODE_ID).unwrap().status, NodeStatus::Ready);
assert_eq!(t.snapshot().unwrap().plan_version, 1);
// …then a replan appendix lands: final must wait again.
t.queue_discoveries(vec![Discovery {
from_node: "a".into(),
description: "docs".into(),
}]);
t.append_replan_nodes(vec![node("docs", &[])]);
let s = t.snapshot().unwrap();
assert_eq!(s.plan_version, 2);
assert_eq!(s.replan_runs, 1);
assert!(s.pending_discoveries.is_empty());
let final_node = t.node(FINAL_NODE_ID).unwrap();
assert_eq!(
final_node.status,
NodeStatus::Waiting,
"a Ready terminal node whose gate grew must wait again"
);
assert!(final_node.deps.iter().any(|d| d.on == "docs"));
assert_eq!(t.next_ready_node().unwrap().id, "docs");
}
#[test]
fn history_is_capped_dropping_the_oldest() {
let mut t = tracker_with(vec![node("a", &[])]);
@@ -2778,6 +2778,8 @@ async fn graph_mode_state_round_trips_and_tombstones() {
tokens_spent_nodes: 42,
history: vec![],
pause_message: None,
pending_discoveries: vec![],
replan_runs: 0,
};
adapter.write_graph_mode_state(&info, Some(&state)).await.unwrap();
let loaded = adapter.load_session_without_updates(&info).await.unwrap();
@@ -22,3 +22,11 @@ GAPS:
Be strict but fair: judge ONLY this node's contract, not sibling nodes'
scope and not style preferences.
If you notice NECESSARY work that lies OUTSIDE this node's contract, it
is NOT a gap — do not fail the node for it. Report it instead, each item
on its own line before your verdict:
```
DISCOVERED: <one-line description of the out-of-scope work>
```
@@ -13,6 +13,16 @@ Rules:
- If a GAPS section appears below, a verifier rejected the previous round —
close exactly those gaps first, then re-check the whole node contract.
- Do not commit; the harness owns version control.
- If you find NECESSARY work outside this node's contract (a missing
prerequisite, a broken sibling area, follow-up the objective implies),
do NOT do it. Report each item on its own line, anywhere in your final
message:
```
DISCOVERED: <one-line description of the out-of-scope work>
```
The harness turns these into new graph nodes.
Your final message MUST end with exactly one of:
@@ -0,0 +1,55 @@
You are the Graph Replanner for the Kigi harness. A running dependency
graph surfaced NEW out-of-scope work (DISCOVERIES below). Your job is to
extend the graph with the FEWEST additional nodes that cover exactly that
work — nothing else.
## Inputs (below this prompt)
- OBJECTIVE: the overall graph objective, verbatim.
- CURRENT GRAPH: the existing nodes as JSON (id, title, status, deps).
These are IMMUTABLE — you cannot edit, remove, or reorder them.
- DISCOVERIES: the queued out-of-scope items, each with the node id that
surfaced it.
## Rules
- Append-only: output ONLY new nodes. Merge related discoveries into one
node where a single coherent unit of work covers them.
- A discovery already covered by an existing non-terminal node's spec
needs NO new node — cover only genuine gaps. If nothing needs a new
node, still write a file with an empty check? NO — see the escape
hatch below.
- `deps` may reference EXISTING node ids (the `gn-…` strings from
CURRENT GRAPH) and/or other new nodes. Only true ordering constraints.
- Each new node MUST set `discovered_from` to the existing node id(s)
whose discoveries it covers.
- Specs are outcome contracts in the OBJECTIVE's vocabulary, sized for
one focused autonomous run.
## Output contract — STRICT
Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}`:
```
{
"nodes": [
{
"id": "short-kebab-slug",
"title": "One-line human title",
"spec": "Outcome contract for this node alone.",
"deps": ["gn-existing-or-new-slug"],
"discovered_from": ["gn-originating-node"]
}
]
}
```
Escape hatch: when every discovery is already covered by existing nodes,
write `{"nodes": []}` — the harness treats an empty appendix as "nothing
to add" and drains the discoveries.
Your terminal response must be exactly:
```
Done
```