Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/graph_planner.rs
T
ZacharyZhang-NY 02cf5deebd 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.
2026-07-20 20:21:49 -04:00

572 lines
21 KiB
Rust

//! Graph planner runner: one attempt at decomposing an objective into a
//! validated node DAG.
//!
//! Deliberately thin: the spawn plumbing (harness-internal subagent,
//! verbatim-fork, fail-open model retry) is reused from
//! [`goal_planner`](super::goal_planner) via the same
//! [`GoalPlannerSpawner`] contract; this module only swaps the template
//! and replaces "plan file exists" with "graph JSON parses and passes
//! the static DAG gate" ([`graph_plan::parse_and_validate`]).
//!
//! Outcome split (both are loud, nothing is papered over):
//! - [`GraphPlannerOutcome::Invalid`] — the planner wrote an artifact
//! that failed validation. Retryable ONCE by the caller, feeding the
//! precise validation error back as CONTEXT.
//! - [`GraphPlannerOutcome::FailClosed`] — spawn/transport/missing-file
//! failure. The caller pauses the graph; `/graph resume` retries.
use std::path::Path;
use std::sync::Arc;
use super::goal_planner::{
GoalPlannerSpawner, RoleRenderedPrompt, SpawnError, parse_terminal_response,
};
use super::goal_role_tools::RoleToolNames;
use super::graph_plan::{self, MAX_GRAPH_JSON_BYTES};
use super::graph_tracker::GraphNode;
const GRAPH_PLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_planner_prompt.md");
const GRAPH_REPLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_replanner_prompt.md");
pub(crate) const GRAPH_PLANNER_SUBAGENT_DESCRIPTION: &str = "graph plan writer";
pub(crate) const GRAPH_REPLANNER_SUBAGENT_DESCRIPTION: &str = "graph replanner";
#[derive(Debug)]
pub(crate) enum GraphPlannerOutcome {
/// Validated, canonicalized nodes (topo-ordered, final node appended).
Planned(Vec<GraphNode>),
/// Artifact written but rejected by the static gate; retry once with
/// the reason as feedback.
Invalid { reason: String },
/// Infrastructure/spawn failure or missing artifact; pause the graph.
FailClosed { reason: String },
}
pub(crate) struct GraphPlannerInputs<'a> {
pub objective: &'a str,
/// Empty on the first attempt; the previous attempt's validation
/// error on the retry.
pub feedback: &'a str,
pub graph_file: &'a Path,
pub tool_names: &'a RoleToolNames,
pub inherit_tool_names: &'a RoleToolNames,
}
/// Run one graph-planner attempt end to end: render, spawn, read the
/// artifact (size-capped), validate, canonicalize.
pub(crate) async fn run_graph_planner(
spawner: Arc<dyn GoalPlannerSpawner>,
inputs: GraphPlannerInputs<'_>,
) -> 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_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
let render = |tool_names: &RoleToolNames| -> String {
let rendered = tool_names.apply(&with_graph_file);
let mut full = String::with_capacity(rendered.len() + inputs.objective.len() + 256);
full.push_str(&rendered);
full.push_str("\n\nOBJECTIVE:\n");
full.push_str(inputs.objective);
full.push_str("\n\nCONTEXT:\n");
full.push_str(inputs.feedback);
full.push('\n');
full
};
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 planner transport error: {detail}"),
};
}
Err(SpawnError::Runtime { message, cancelled }) => {
return GraphPlannerOutcome::FailClosed {
reason: if cancelled {
format!("graph planner aborted: {message}")
} else {
format!("graph planner 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!(
"graph 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),
response_snippet = %response.chars().take(120).collect::<String>(),
"graph planner: graph file missing or empty; failing closed",
);
return GraphPlannerOutcome::FailClosed {
reason: "graph planner produced no graph file".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 graph file: {err}"),
};
}
};
match graph_plan::parse_and_validate(&json, inputs.objective) {
Ok(nodes) => GraphPlannerOutcome::Planned(nodes),
Err(err) => GraphPlannerOutcome::Invalid {
reason: err.to_string(),
},
}
}
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(),
},
}
}
/// Generic single-shot artifact pass: render `template` + `sections`,
/// spawn the role child, enforce the stale-artifact/size/missing-file
/// discipline, and return the artifact's raw JSON for the caller to
/// validate. Shared by the optimizer (and any future boundary pass).
pub(crate) struct ArtifactPassSpec<'a> {
pub template: &'a str,
pub sections: &'a str,
pub graph_file: &'a Path,
pub tool_names: &'a RoleToolNames,
pub role: &'a str,
}
pub(crate) async fn run_graph_artifact_pass(
spawner: Arc<dyn GoalPlannerSpawner>,
spec: ArtifactPassSpec<'_>,
) -> Result<String, String> {
if let Some(parent) = spec.graph_file.parent()
&& let Err(err) = tokio::fs::create_dir_all(parent).await
{
return Err(format!("failed to create graph dir: {err}"));
}
if let Err(err) = tokio::fs::remove_file(spec.graph_file).await
&& err.kind() != std::io::ErrorKind::NotFound
{
return Err(format!("failed to clear stale artifact: {err}"));
}
let graph_file_str = spec.graph_file.to_string_lossy();
let rendered = spec
.tool_names
.apply(&spec.template.replace("{GRAPH_FILE}", &graph_file_str));
let prompt_text = format!("{rendered}\n\n{}", spec.sections);
let prompt = RoleRenderedPrompt {
primary: prompt_text.clone(),
fallback: prompt_text,
};
let spawn_id = uuid::Uuid::now_v7().to_string();
match spawner.spawn_planner(&spawn_id, prompt).await {
Ok(_) => {}
Err(SpawnError::Transport(detail)) => {
return Err(format!("{} transport error: {detail}", spec.role));
}
Err(SpawnError::Runtime { message, cancelled }) => {
return Err(if cancelled {
format!("{} aborted: {message}", spec.role)
} else {
format!("{} runtime error: {message}", spec.role)
});
}
}
match tokio::fs::metadata(spec.graph_file).await {
Ok(meta) if meta.is_file() && meta.len() > 0 && meta.len() <= MAX_GRAPH_JSON_BYTES => {}
Ok(meta) if meta.len() > MAX_GRAPH_JSON_BYTES => {
return Err(format!(
"{} artifact is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
spec.role,
meta.len()
));
}
_ => return Err(format!("{} produced no artifact", spec.role)),
}
tokio::fs::read_to_string(spec.graph_file)
.await
.map_err(|err| format!("failed to read {} artifact: {err}", spec.role))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::goal_role_tools::tests::summary_with;
use kigi_tools::types::tool::ToolKind;
use std::path::PathBuf;
use std::sync::Mutex;
enum MockReply {
Done,
Transport,
Runtime { cancelled: bool },
}
struct MockSpawner {
response: MockReply,
body: Option<Vec<u8>>,
target: PathBuf,
last_prompt: Mutex<Option<String>>,
}
#[async_trait::async_trait]
impl GoalPlannerSpawner for MockSpawner {
async fn spawn_planner(
&self,
_id: &str,
prompt: RoleRenderedPrompt,
) -> Result<String, SpawnError> {
*self.last_prompt.lock().unwrap() = Some(prompt.primary.clone());
if let Some(body) = &self.body {
std::fs::write(&self.target, body).unwrap();
}
match &self.response {
MockReply::Done => Ok("Done".to_owned()),
MockReply::Transport => Err(SpawnError::Transport("channel closed".into())),
MockReply::Runtime { cancelled } => Err(SpawnError::Runtime {
message: "boom".into(),
cancelled: *cancelled,
}),
}
}
}
fn tool_names() -> RoleToolNames {
RoleToolNames::from_summary(&summary_with(&[
(ToolKind::Read, "read_file"),
(ToolKind::Search, "grep"),
(ToolKind::List, "list_files"),
(ToolKind::Write, "write"),
]))
}
/// Self-cleaning temp home per test: never leak dirs into the OS
/// temp root (storage discipline — see AGENTS.md gates).
fn tmp_graph_file(_name: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("graph.json");
(dir, path)
}
async fn run(spawner: MockSpawner, graph_file: &Path) -> GraphPlannerOutcome {
let names = tool_names();
run_graph_planner(
Arc::new(spawner),
GraphPlannerInputs {
objective: "build the thing",
feedback: "",
graph_file,
tool_names: &names,
inherit_tool_names: &names,
},
)
.await
}
#[tokio::test]
async fn valid_artifact_yields_canonical_nodes() {
let (_tmp, target) = tmp_graph_file("valid");
let body = serde_json::json!({
"nodes": [
{"id": "core", "title": "Core", "spec": "core spec", "deps": []},
{"id": "ui", "title": "UI", "spec": "ui spec", "deps": ["core"]},
]
})
.to_string();
let spawner = MockSpawner {
response: MockReply::Done,
body: Some(body.into_bytes()),
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::Planned(nodes) => {
assert_eq!(nodes.len(), 3, "2 planner nodes + appended final");
assert_eq!(nodes[2].id, crate::session::graph_tracker::FINAL_NODE_ID);
}
other => panic!("expected Planned, got {other:?}"),
}
}
#[tokio::test]
async fn prompt_embeds_objective_feedback_and_tool_names() {
let (_tmp, target) = tmp_graph_file("prompt");
let spawner = MockSpawner {
response: MockReply::Done,
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
let prompt_cell = std::sync::Arc::new(spawner);
let names = tool_names();
let _ = run_graph_planner(
prompt_cell.clone(),
GraphPlannerInputs {
objective: "OBJ-MARKER",
feedback: "FEEDBACK-MARKER",
graph_file: &target,
tool_names: &names,
inherit_tool_names: &names,
},
)
.await;
let prompt = prompt_cell.last_prompt.lock().unwrap().clone().unwrap();
assert!(prompt.contains("OBJ-MARKER"));
assert!(prompt.contains("FEEDBACK-MARKER"));
assert!(prompt.contains(&target.to_string_lossy().into_owned()));
assert!(prompt.contains("read_file"), "placeholders rendered");
assert!(!prompt.contains("{READ_TOOL}"), "no leftover placeholder");
assert!(!prompt.contains("{GRAPH_FILE}"), "no leftover placeholder");
}
#[tokio::test]
async fn invalid_artifact_is_retryable_with_reason() {
let (_tmp, target) = tmp_graph_file("invalid");
let spawner = MockSpawner {
response: MockReply::Done,
body: Some(br#"{"nodes":[{"id":"a","title":"A","spec":"s","deps":["a"]}]}"#.to_vec()),
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::Invalid { reason } => {
assert!(reason.contains("depends on itself"), "{reason}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[tokio::test]
async fn missing_artifact_fails_closed() {
let (_tmp, target) = tmp_graph_file("missing");
let spawner = MockSpawner {
response: MockReply::Done,
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("no graph file"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
#[tokio::test]
async fn runtime_error_fails_closed() {
let (_tmp, target) = tmp_graph_file("runtime");
let spawner = MockSpawner {
response: MockReply::Runtime { cancelled: false },
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("runtime error"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
#[tokio::test]
async fn oversize_artifact_is_invalid_with_cap_in_reason() {
let (_tmp, target) = tmp_graph_file("oversize");
let mut body = vec![b'x'; (MAX_GRAPH_JSON_BYTES as usize) + 1];
body[0] = b'{'; // content is irrelevant; the size gate fires first
let spawner = MockSpawner {
response: MockReply::Done,
body: Some(body),
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::Invalid { reason } => {
assert!(reason.contains("the cap is"), "{reason}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[tokio::test]
async fn transport_error_fails_closed() {
let (_tmp, target) = tmp_graph_file("transport");
let spawner = MockSpawner {
response: MockReply::Transport,
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("transport error"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
#[tokio::test]
async fn cancelled_runtime_error_reports_aborted() {
let (_tmp, target) = tmp_graph_file("aborted");
let spawner = MockSpawner {
response: MockReply::Runtime { cancelled: true },
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("aborted"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
}