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:
@@ -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),
|
||||
|
||||
+3
@@ -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),
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user