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:
1 parent
1579558b56
commit
bb5cbff62d
22 files changed
+1272
-10
No files matched your search
@@ -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: ¤t_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),
|
||||
|
||||
Reference in new issue
Block a user