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
+12
View File
@@ -118,6 +118,18 @@ edges stay deterministic Rust. The harness appends a terminal
`persist_graph_state` chokepoint (checkpoint ⇔ badge tick); old `persist_graph_state` chokepoint (checkpoint ⇔ badge tick); old
pagers degrade via `#[serde(other)] Unknown`. PTY scenarios: pagers degrade via `#[serde(other)] Unknown`. PTY scenarios:
`graph_slash_presession{,_disabled}.yaml`. `graph_slash_presession{,_disabled}.yaml`.
- G3: dynamic replan. `DISCOVERED: <text>` line markers (fence-stripped,
placeholder-filtered) from workers/verifiers/the serial node's final
text queue as `pending_discoveries`; at each dispatch boundary
`maybe_replan_graph` (`acp_session_impl/graph_replan.rs`) runs a
replanner subagent producing an APPEND-ONLY appendix
(`validate_replan`: existing-id deps allowed, edges onto `gn-final`
rejected — they would cycle after the final-gating extension),
bumps `plan_version`, freezes `graph.baseline.v{N}.json`, and regates
`gn-final` (Ready→Waiting). Bounded by `KIGI_GRAPH_REPLAN_CAP`
(default 3, 0 = off); past the cap — and after the final node has
achieved — discoveries drain to history only. Replan failure degrades
(history + notice); it never pauses a working graph.
## Milestones (PRD §8.3) ## Milestones (PRD §8.3)
@@ -1929,6 +1929,16 @@ impl Config {
.unwrap_or(3) .unwrap_or(3)
.clamp(1, 8) .clamp(1, 8)
} }
/// Max replan passes per graph (`KIGI_GRAPH_REPLAN_CAP`); 0 turns
/// dynamic replanning off. Past the cap, discoveries drain to
/// history only — the graph must still converge. Clamped to [0, 10].
pub(crate) fn resolve_graph_replan_cap(&self) -> u32 {
std::env::var("KIGI_GRAPH_REPLAN_CAP")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(3)
.clamp(0, 10)
}
/// Max worker↔verifier rounds per parallel graph node /// Max worker↔verifier rounds per parallel graph node
/// (`KIGI_GRAPH_NODE_ROUNDS`); exhausting them fails the node. /// (`KIGI_GRAPH_NODE_ROUNDS`); exhausting them fails the node.
/// Clamped to [1, 8]. /// Clamped to [1, 8].
@@ -95,6 +95,8 @@ pub use types::{TodoGateDecision, TodoGateReason};
mod goal; mod goal;
#[path = "acp_session_impl/graph.rs"] #[path = "acp_session_impl/graph.rs"]
mod graph; mod graph;
#[path = "acp_session_impl/graph_replan.rs"]
mod graph_replan;
#[path = "acp_session_impl/graph_workers.rs"] #[path = "acp_session_impl/graph_workers.rs"]
mod graph_workers; mod graph_workers;
#[path = "acp_session_impl/interjection.rs"] #[path = "acp_session_impl/interjection.rs"]
@@ -607,6 +609,9 @@ pub(crate) struct SessionActor {
/// Max worker↔verifier rounds per parallel graph node before the /// Max worker↔verifier rounds per parallel graph node before the
/// node fails. Cached at actor construction. /// node fails. Cached at actor construction.
pub(crate) graph_node_rounds: u32, 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 /// `task_id`s of background tasks (and monitors) that originated during
/// the goal turn — either spawned by the goal model itself or reparented /// the goal turn — either spawned by the goal model itself or reparented
/// from a harness verifier/planner subagent on its exit. Their late /// from a harness verifier/planner subagent on its exit. Their late
@@ -49,7 +49,10 @@ pub(super) fn node_goal_objective(
{spec}\n\n\ {spec}\n\n\
This node is one unit of a larger graph objective:\n\ This node is one unit of a larger graph objective:\n\
{graph_objective}\n\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, title = node.title,
spec = node.spec, spec = node.spec,
) )
@@ -339,7 +342,7 @@ impl SessionActor {
/// Write the immutable plan baseline for the current version. /// Write the immutable plan baseline for the current version.
/// `create_new` guarantees a frozen baseline is never overwritten — /// `create_new` guarantees a frozen baseline is never overwritten —
/// an existing file is the infra failure it looks like. /// 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 path = {
let tracker = self.graph_tracker.lock(); let tracker = self.graph_tracker.lock();
let version = tracker.snapshot().map(|s| s.plan_version).unwrap_or(1); let version = tracker.snapshot().map(|s| s.plan_version).unwrap_or(1);
@@ -543,6 +546,22 @@ impl SessionActor {
.snapshot() .snapshot()
.map(|o| o.total_worker_rounds) .map(|o| o.total_worker_rounds)
.unwrap_or(0); .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; self.archive_node_artifacts(&node_id).await;
tracing::info!(%node_id, rounds, node_tokens, "graph: node achieved"); tracing::info!(%node_id, rounds, node_tokens, "graph: node achieved");
self.graph_tracker self.graph_tracker
@@ -565,6 +584,9 @@ impl SessionActor {
// serial-launch failure — the pauser already messaged. // serial-launch failure — the pauser already messaged.
return None; 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) { if self.graph_tracker.lock().remaining_budget() == Some(0) {
tracing::warn!("graph: budget exhausted at dispatch"); tracing::warn!("graph: budget exhausted at dispatch");
self.graph_tracker.lock().budget_limit(); 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) // Spawner seam (mockable in tests)
pub(crate) struct WorkerSpawnSpec { pub(crate) struct WorkerSpawnSpec {
@@ -258,6 +278,8 @@ pub(crate) struct NodeRunReport {
pub worktree_path: Option<String>, pub worktree_path: Option<String>,
/// Last worker child session id (audit link, stored on the node). /// Last worker child session id (audit link, stored on the node).
pub worker_session_id: Option<String>, 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 { 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. // into the verifier's context.
let safe_summary = worker_summary let safe_summary = worker_summary
.replace("NODE_VERDICT", "NODE-VERDICT") .replace("NODE_VERDICT", "NODE-VERDICT")
.replace("NODE_RESULT", "NODE-RESULT"); .replace("NODE_RESULT", "NODE-RESULT")
.replace("DISCOVERED", "DISCOVERED-");
format!( format!(
"{VERIFIER_PROMPT_TEMPLATE}\n\nNODE OBJECTIVE (the contract to judge):\n{node_objective}\n\n\ "{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" 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, rounds_cap: u32,
) -> NodeRunReport { ) -> NodeRunReport {
let mut tokens: i64 = 0; let mut tokens: i64 = 0;
let mut discoveries: Vec<String> = Vec::new();
let mut gaps: Vec<String> = Vec::new(); let mut gaps: Vec<String> = Vec::new();
let mut resume_from: Option<String> = None; let mut resume_from: Option<String> = None;
let mut worktree_path: 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, tokens_used: tokens,
worktree_path, worktree_path,
worker_session_id: resume_from, 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() { if !outcome.child_session_id.is_empty() {
resume_from = Some(outcome.child_session_id.clone()); 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 { if outcome.cancelled {
return NodeRunReport { return NodeRunReport {
@@ -351,6 +381,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens, tokens_used: tokens,
worktree_path, worktree_path,
worker_session_id: resume_from, worker_session_id: resume_from,
discoveries,
}; };
} }
if outcome.backgrounded { if outcome.backgrounded {
@@ -393,6 +424,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens, tokens_used: tokens,
worktree_path: None, worktree_path: None,
worker_session_id: resume_from, worker_session_id: resume_from,
discoveries: discoveries.clone(),
}; };
} }
@@ -406,6 +438,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens, tokens_used: tokens,
worktree_path, worktree_path,
worker_session_id: resume_from, worker_session_id: resume_from,
discoveries: discoveries.clone(),
}; };
} }
WorkerClaim::Unparseable => { WorkerClaim::Unparseable => {
@@ -429,6 +462,11 @@ pub(crate) async fn run_node_to_verdict(
let verdict = match spawner.spawn(&verify_id, verify_spec).await { let verdict = match spawner.spawn(&verify_id, verify_spec).await {
Ok(v) => { Ok(v) => {
tokens = tokens.saturating_add(v.tokens_used as i64); 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 { if v.success {
parse_node_verdict(&v.output) parse_node_verdict(&v.output)
} else { } else {
@@ -456,6 +494,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens, tokens_used: tokens,
worktree_path, worktree_path,
worker_session_id: resume_from, worker_session_id: resume_from,
discoveries: discoveries.clone(),
}; };
} }
NodeVerdict::NotAchieved { gaps: new_gaps } => { NodeVerdict::NotAchieved { gaps: new_gaps } => {
@@ -477,6 +516,7 @@ pub(crate) async fn run_node_to_verdict(
tokens_used: tokens, tokens_used: tokens,
worktree_path, worktree_path,
worker_session_id: resume_from, worker_session_id: resume_from,
discoveries,
} }
} }
@@ -583,6 +623,18 @@ impl SessionActor {
{ {
node.goal_id = Some(worker_id.clone()); 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 { if !report.achieved {
failed += 1; failed += 1;
{ {
@@ -1101,6 +1101,7 @@ pub(crate) async fn spawn_session_actor(
graph_tracker, graph_tracker,
graph_concurrency: effective_config.resolve_graph_concurrency(), graph_concurrency: effective_config.resolve_graph_concurrency(),
graph_node_rounds: effective_config.resolve_graph_node_rounds(), 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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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; .await;
unsafe { std::env::remove_var(ENV_FLAG) }; 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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_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_concurrency: 1,
graph_node_rounds: 3, graph_node_rounds: 3,
graph_replan_cap: 3,
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -41,6 +41,10 @@ struct PlannedNode {
spec: String, spec: String,
#[serde(default)] #[serde(default)]
deps: Vec<String>, 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 /// Why a planner artifact was rejected. Rendered verbatim into the
@@ -53,11 +57,30 @@ pub(crate) enum GraphPlanError {
TooManyNodes(usize), TooManyNodes(usize),
BadSlug(String), BadSlug(String),
DuplicateSlug(String), DuplicateSlug(String),
EmptyField { slug: String, field: &'static str }, EmptyField {
UnknownDep { slug: String, dep: String }, slug: String,
field: &'static str,
},
UnknownDep {
slug: String,
dep: String,
},
SelfDep(String), SelfDep(String),
Cycle(Vec<String>), Cycle(Vec<String>),
IdCollision(String, 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 { impl std::fmt::Display for GraphPlanError {
@@ -87,6 +110,19 @@ impl std::fmt::Display for GraphPlanError {
f, f,
"hash id collision between slugs {a:?} and {b:?}; rename one" "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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -424,6 +668,134 @@ mod tests {
assert_eq!(nodes[1].id, node_id_for_slug("a")); 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 /// A repeated dep entry is harmless planner redundancy: it must be
/// deduped, NOT misreported as a cycle by the indegree seed. /// deduped, NOT misreported as a cycle by the indegree seed.
#[test] #[test]
@@ -26,7 +26,9 @@ use super::graph_plan::{self, MAX_GRAPH_JSON_BYTES};
use super::graph_tracker::GraphNode; use super::graph_tracker::GraphNode;
const GRAPH_PLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_planner_prompt.md"); 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_PLANNER_SUBAGENT_DESCRIPTION: &str = "graph plan writer";
pub(crate) const GRAPH_REPLANNER_SUBAGENT_DESCRIPTION: &str = "graph replanner";
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum GraphPlannerOutcome { 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 graph_file_str = inputs.graph_file.to_string_lossy();
let with_graph_file = GRAPH_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str); let with_graph_file = GRAPH_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
let render = |tool_names: &RoleToolNames| -> String { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -91,9 +91,13 @@ impl NodeStatus {
} }
/// Dependency edge kind. `Blocks` is the planner-authored ordering /// Dependency edge kind. `Blocks` is the planner-authored ordering
/// dependency; `DiscoveredFrom` marks a node appended by a replan (G3) /// dependency and the ONLY kind that gates scheduling.
/// pointing back at the node whose execution surfaced it. Both gate /// `DiscoveredFrom` marks a node appended by a replan (G3) pointing
/// scheduling identically; the kind is audit/render metadata. /// 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)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DepKind { 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) // GraphOrchestration (full persisted state)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -220,6 +234,13 @@ pub struct GraphOrchestration {
/// on resume/complete (mirrors `GoalOrchestration::pause_message`). /// on resume/complete (mirrors `GoalOrchestration::pause_message`).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pause_message: Option<String>, 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 { fn default_plan_version() -> u32 {
@@ -366,6 +387,8 @@ impl GraphTracker {
tokens_spent_nodes: 0, tokens_spent_nodes: 0,
history: Vec::new(), history: Vec::new(),
pause_message: None, pause_message: None,
pending_discoveries: Vec::new(),
replan_runs: 0,
}; };
state state
.history .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 /// Demote in-flight (`Running`/`Verifying`) nodes whose executor is
/// gone back to `Ready`, keeping `keep` (the node whose goal still /// gone back to `Ready`, keeping `keep` (the node whose goal still
/// lives in the engine, if any). Used by the IN-SESSION resume path: /// lives in the engine, if any). Used by the IN-SESSION resume path:
@@ -704,7 +801,11 @@ impl GraphTracker {
.collect(); .collect();
for node in &mut state.nodes { for node in &mut state.nodes {
if node.status == NodeStatus::Waiting 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; 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) { if node.status.is_terminal() || blocked.contains(&node.id) {
continue; 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.status = NodeStatus::Blocked;
node.failure = Some(format!("blocked: dependency chain failed at {failed_id}")); node.failure = Some(format!("blocked: dependency chain failed at {failed_id}"));
blocked.insert(node.id.clone()); blocked.insert(node.id.clone());
@@ -921,6 +1027,59 @@ mod tests {
assert_eq!(NodeStatus::from_wire_str("achieved"), NodeStatus::Achieved); 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] #[test]
fn history_is_capped_dropping_the_oldest() { fn history_is_capped_dropping_the_oldest() {
let mut t = tracker_with(vec![node("a", &[])]); 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, tokens_spent_nodes: 42,
history: vec![], history: vec![],
pause_message: None, pause_message: None,
pending_discoveries: vec![],
replan_runs: 0,
}; };
adapter.write_graph_mode_state(&info, Some(&state)).await.unwrap(); adapter.write_graph_mode_state(&info, Some(&state)).await.unwrap();
let loaded = adapter.load_session_without_updates(&info).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' Be strict but fair: judge ONLY this node's contract, not sibling nodes'
scope and not style preferences. 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 — - If a GAPS section appears below, a verifier rejected the previous round —
close exactly those gaps first, then re-check the whole node contract. close exactly those gaps first, then re-check the whole node contract.
- Do not commit; the harness owns version control. - 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: 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
```