Add /graph G6: plan-boundary topology optimizer
A restricted optimizer pass now reviews the graph at plan boundaries —
right after initial planning and piggybacked on each replan version
boundary, never mid-execution. An optimizer subagent may emit four ops
over Waiting/Ready nodes only: remove_dep (delete a false dependency,
restoring parallelism — the highest-value edit), reorder (pending
priority for the serial scheduler), merge (fold two tiny nodes; specs
concatenate, deps union, dependents re-point, absorbed self-deps drop),
and split (2-3 focused replacements inheriting the original's deps and
dependents). The optimizer changes graph DATA only; the executor stays
pure deterministic Rust. KIGI_GRAPH_OPTIMIZER=0 disables it entirely.
apply_optimization enforces the contract twice: per-op checks
(pending-only targets, known ids, terminal node untouchable, dead-node
deps rejected as DeadDep, merge/split targets with non-pending
dependents rejected with the true reason instead of tripping the
immutable invariant later), then FINAL invariants — every non-pending
node byte-identical in the result, the gn-final gate rebuilt over all
survivors, node cap, whole-graph acyclicity, and a BIDIRECTIONAL status
re-derivation for pending nodes (adversarial review caught the critical
hole: a merge grafting unsatisfied deps onto a Ready node would
otherwise dispatch it ahead of its new prerequisites, since
recompute_ready is promote-only). Applied passes bump plan_version,
freeze an immutable baseline, and consume a slot of the SHARED replan
cap; an explicit {"ops": []} is a respected free no-op; any failure
degrades to keeping the current plan. Plumbing reuses a new shared
artifact-pass runner (stale-artifact delete, size cap, missing-file
fail-closed) extracted from the replanner.
Tests: remove_dep parallelism restore + loud no-such-dep, immutable and
terminal-node rejections across all four ops, merge/split dependent
rewiring incl. final-gate rebuild and intra-split dep resolution,
result-cycle rejection, dead-dep splits, Ready-demote-on-merge, and
three e2e flows — false-dep removal proven ACTUALLY parallel by the
held-reply fan-out gate, OPTIMIZER=0 spawning zero passes, and the
shared-cap guard. kigi-shell 4959 lib tests green; clippy clean.
This commit is contained in:
@@ -219,6 +219,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
@@ -664,6 +665,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,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
@@ -918,6 +920,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
@@ -1905,6 +1908,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
|
||||
@@ -1951,3 +1951,155 @@ async fn graph_show_renders_dag_through_handle_prompt() {
|
||||
.await;
|
||||
unsafe { std::env::remove_var(ENV_FLAG) };
|
||||
}
|
||||
|
||||
// ── G6: topology optimizer ─────────────────────────────────────────
|
||||
|
||||
/// Chain a→b→c where b's dep on a is FALSE; the optimizer removes it,
|
||||
/// unlocking a+b as parallel roots — proven by the held-reply fan-out
|
||||
/// gate (the test hangs if the batch is not truly concurrent).
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn optimizer_removes_false_dep_and_unlocks_real_parallelism() {
|
||||
unsafe { std::env::set_var(ENV_FLAG, "0") };
|
||||
let local = tokio::task::LocalSet::new();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
local.run_until(async {
|
||||
let dag = serde_json::json!({
|
||||
"nodes": [
|
||||
{"id": "a", "title": "Node A", "spec": "do a", "deps": []},
|
||||
{"id": "b", "title": "Node B", "spec": "do b", "deps": ["a"]},
|
||||
]
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes();
|
||||
let b_id = crate::session::graph_plan::node_id_for_slug("b");
|
||||
let a_id = crate::session::graph_plan::node_id_for_slug("a");
|
||||
let (mut actor, _tmp, _prx) = make_graph_actor_detached().await;
|
||||
let (coord_tx, captured) = spawn_scripted_coordinator(
|
||||
_tmp.path().to_path_buf(),
|
||||
move |req| {
|
||||
if req.prompt.contains("Graph Topology Optimizer") {
|
||||
let path = req
|
||||
.prompt
|
||||
.find("/optimize.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("optimizer prompt embeds artifact path");
|
||||
let ops = serde_json::json!({
|
||||
"ops": [{"op": "remove_dep", "node": b_id, "dep": a_id}]
|
||||
})
|
||||
.to_string();
|
||||
std::fs::create_dir_all(std::path::Path::new(&path).parent().unwrap())
|
||||
.unwrap();
|
||||
std::fs::write(&path, ops).unwrap();
|
||||
return "Done".to_owned();
|
||||
}
|
||||
happy_reply(req, &dag)
|
||||
},
|
||||
// Held-reply gate: the first WORKER's reply is withheld
|
||||
// until the second worker spawn arrives — sequential
|
||||
// execution would deadlock (timeout catches regression).
|
||||
true,
|
||||
);
|
||||
actor.tool_context.subagent_event_tx = Some(coord_tx);
|
||||
actor.graph_concurrency = 2;
|
||||
actor.graph_optimizer_enabled = true;
|
||||
|
||||
let outcome = actor.setup_graph("two independent tasks", None).await;
|
||||
let reminder = match outcome {
|
||||
graph::GraphSetupOutcome::Inference { reminder, .. } => reminder,
|
||||
graph::GraphSetupOutcome::Message(msg) => panic!("expected Inference: {msg}"),
|
||||
};
|
||||
assert!(
|
||||
reminder.contains("Final verification"),
|
||||
"a+b batched in parallel; serial tail is gn-final: {reminder}"
|
||||
);
|
||||
let s = actor.graph_tracker.lock().snapshot().cloned().unwrap();
|
||||
assert_eq!(s.plan_version, 2, "optimizer pass bumped the version");
|
||||
assert_eq!(s.replan_runs, 1, "optimizer consumed a shared cap slot");
|
||||
let workers = captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c.prompt.contains("Graph Node Worker"))
|
||||
.count();
|
||||
assert_eq!(workers, 2, "both nodes ran as batch workers");
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("optimizer parallelism test starved (fan-out regression)");
|
||||
unsafe { std::env::remove_var(ENV_FLAG) };
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn optimizer_disabled_never_spawns_a_pass() {
|
||||
unsafe { std::env::set_var(ENV_FLAG, "0") };
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let dag = chain_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| happy_reply(req, &dag),
|
||||
false,
|
||||
);
|
||||
actor.tool_context.subagent_event_tx = Some(coord_tx);
|
||||
actor.graph_optimizer_enabled = false;
|
||||
let _ = actor.setup_graph("chain", None).await;
|
||||
let passes = captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c.prompt.contains("Graph Topology Optimizer"))
|
||||
.count();
|
||||
assert_eq!(passes, 0, "KIGI_GRAPH_OPTIMIZER=0 must be a hard off");
|
||||
assert_eq!(
|
||||
actor.graph_tracker.lock().snapshot().unwrap().plan_version,
|
||||
1
|
||||
);
|
||||
})
|
||||
.await;
|
||||
unsafe { std::env::remove_var(ENV_FLAG) };
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn optimizer_skips_when_shared_cap_is_exhausted() {
|
||||
unsafe { std::env::set_var(ENV_FLAG, "0") };
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let dag = chain_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| happy_reply(req, &dag),
|
||||
false,
|
||||
);
|
||||
actor.tool_context.subagent_event_tx = Some(coord_tx);
|
||||
actor.graph_optimizer_enabled = true;
|
||||
actor.graph_replan_cap = 0; // shared cap already exhausted
|
||||
let _ = actor.setup_graph("chain", None).await;
|
||||
let passes = captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c.prompt.contains("Graph Topology Optimizer"))
|
||||
.count();
|
||||
assert_eq!(passes, 0, "cap guard must gate the optimizer too");
|
||||
})
|
||||
.await;
|
||||
unsafe { std::env::remove_var(ENV_FLAG) };
|
||||
}
|
||||
|
||||
@@ -248,6 +248,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
|
||||
+3
@@ -180,6 +180,7 @@ async fn create_test_actor(
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
@@ -627,6 +628,7 @@ async fn create_test_actor_with_memory(
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
@@ -1385,6 +1387,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
|
||||
@@ -242,6 +242,7 @@ async fn create_test_actor_with_memory(
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
|
||||
+1
@@ -188,6 +188,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
|
||||
@@ -293,6 +293,7 @@ pub(crate) async fn create_test_actor_ex(
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
|
||||
Reference in New Issue
Block a user