Add /graph G1: parallel fan-out with worktree isolation and merge-back
With KIGI_GRAPH_CONCURRENCY > 1 (default 3, clamp [1,8]) and >=2 Ready nodes, drive_graph — the single dispatch loop shared by setup/advance/resume — runs parallel batches: each node executes as a bounded worker<->verifier subagent loop (KIGI_GRAPH_NODE_ROUNDS, default 3; general-purpose children with the full implementer toolset; worktree isolation on round 1, resume keeps context and worktree on later rounds; NODE_RESULT/NODE_VERDICT terminal contracts parsed fail-closed with fence-stripping and line anchoring). Achieved nodes merge back SEQUENTIALLY via kigi_workspace apply_worktree in Merge mode; a conflict fails the node, block_dependents fires, and surviving chains keep going. gn-final always runs serially on the full goal engine. Concurrency=1 is byte-identical to the serial G0 path; non-git projects degrade to serial. Merge primitive hardened for real use (kigi-workspace): the 3-way apply is now byte-safe (binary files no longer read as UTF-8 and silently deleted) and gains the identical-content rule (ours==theirs => already present, not a conflict) — without it, any dirty file inherited via PreserveWorkingTree false-conflicted every node merge and multi-wave graphs self-poisoned. Adversarial review pass (16 confirmed findings, all fixed): in-session resume demotes orphaned Running nodes instead of wedge-pausing forever after a mid-batch Esc; cancel re-sweeps subagents AFTER the turn abort so workers spawned in the cancel window die too; empty child ids are never adopted as resume targets (no unisolated escape to the shared tree); a successful isolated round returning no worktree fails the node (soft-fallback can no longer put N writers in one tree); main-HEAD movement during a batch aborts merges instead of reverse-applying external commits; failed nodes still charge the token budget; budget trips terminally fail the in-flight node; runaway (>600s) rounds are cancelled by spawn id and retried via resume; worker summaries are marker-sanitized before verifier embedding. Merged worktrees are removed immediately (storage discipline); failed nodes keep theirs for postmortem. Tests: 4922 kigi-shell lib tests green (58 graph-specific), including fan-out proven by a held-reply gate, real-git batch merge with cleanup assertions, budget charging across verdicts, cap trimming, resume-after- cancelled-batch, and backgrounded-round cancel semantics.
This commit is contained in:
@@ -193,10 +193,12 @@ mod tests {
|
||||
]))
|
||||
}
|
||||
|
||||
fn tmp_graph_file(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("kigi-graph-planner-test-{name}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join("graph.json")
|
||||
/// 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 {
|
||||
@@ -216,8 +218,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_artifact_yields_canonical_nodes() {
|
||||
let target = tmp_graph_file("valid");
|
||||
let _ = std::fs::remove_file(&target);
|
||||
let (_tmp, target) = tmp_graph_file("valid");
|
||||
let body = serde_json::json!({
|
||||
"nodes": [
|
||||
{"id": "core", "title": "Core", "spec": "core spec", "deps": []},
|
||||
@@ -242,8 +243,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_embeds_objective_feedback_and_tool_names() {
|
||||
let target = tmp_graph_file("prompt");
|
||||
let _ = std::fs::remove_file(&target);
|
||||
let (_tmp, target) = tmp_graph_file("prompt");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Done,
|
||||
body: None,
|
||||
@@ -274,7 +274,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_artifact_is_retryable_with_reason() {
|
||||
let target = tmp_graph_file("invalid");
|
||||
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()),
|
||||
@@ -291,8 +291,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_artifact_fails_closed() {
|
||||
let target = tmp_graph_file("missing");
|
||||
let _ = std::fs::remove_file(&target);
|
||||
let (_tmp, target) = tmp_graph_file("missing");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Done,
|
||||
body: None,
|
||||
@@ -309,7 +308,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_error_fails_closed() {
|
||||
let target = tmp_graph_file("runtime");
|
||||
let (_tmp, target) = tmp_graph_file("runtime");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Runtime { cancelled: false },
|
||||
body: None,
|
||||
@@ -326,7 +325,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversize_artifact_is_invalid_with_cap_in_reason() {
|
||||
let target = tmp_graph_file("oversize");
|
||||
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 {
|
||||
@@ -345,7 +344,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_error_fails_closed() {
|
||||
let target = tmp_graph_file("transport");
|
||||
let (_tmp, target) = tmp_graph_file("transport");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Transport,
|
||||
body: None,
|
||||
@@ -362,7 +361,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_runtime_error_reports_aborted() {
|
||||
let target = tmp_graph_file("aborted");
|
||||
let (_tmp, target) = tmp_graph_file("aborted");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Runtime { cancelled: true },
|
||||
body: None,
|
||||
|
||||
Reference in New Issue
Block a user