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:
2026-07-20 15:32:35 -04:00
parent 4361b03259
commit 4d1e4fdc52
20 changed files with 1986 additions and 68 deletions
@@ -2090,16 +2090,22 @@ async fn get_apply_context(worktree_path: &str) -> Result<ApplyContext> {
.await?
}
async fn get_file_at_commit(worktree_path: &str, commit: &str, path: &str) -> Option<String> {
git_cli(
Path::new(worktree_path),
&["show", &format!("{}:{}", commit, path)],
)
.await
.ok()
/// Raw blob bytes at `commit:path`, or `None` when absent. Byte-exact
/// (NOT `git_cli`, which lossy-decodes and trims): the 3-way merge below
/// must compare content byte-for-byte or binary files mis-merge.
async fn get_file_at_commit(worktree_path: &str, commit: &str, path: &str) -> Option<Vec<u8>> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(worktree_path)
.arg("show")
.arg(format!("{}:{}", commit, path))
.output()
.await
.ok()?;
output.status.success().then_some(output.stdout)
}
async fn apply_file_content(dest: &Path, content: Option<&String>) -> bool {
async fn apply_file_content(dest: &Path, content: Option<&Vec<u8>>) -> bool {
match content {
Some(data) => {
if let Some(parent) = dest.parent() {
@@ -2114,6 +2120,14 @@ async fn apply_file_content(dest: &Path, content: Option<&String>) -> bool {
}
}
/// Lossy decode for the `FileConflict` wire payload only — comparisons
/// above stay byte-exact.
fn conflict_text(content: &Option<Vec<u8>>) -> Option<String> {
content
.as_ref()
.map(|b| String::from_utf8_lossy(b).into_owned())
}
pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeResponse> {
let worktree_path = &req.worktree_path;
let git_root = find_main_repo_root_from_path(Path::new(worktree_path))?;
@@ -2133,7 +2147,7 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
for file_change in ctx.changed_files {
let worktree_file = Path::new(worktree_path).join(&file_change.path);
let main_file = git_root.join(&file_change.path);
let theirs = tokio::fs::read_to_string(&worktree_file).await.ok();
let theirs = tokio::fs::read(&worktree_file).await.ok();
if req.mode == ApplyMode::Overwrite {
if apply_file_content(&main_file, theirs.as_ref()).await {
@@ -2142,21 +2156,28 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
continue;
}
// Merge mode
// Merge mode — 3-way-lite over raw bytes.
let base = get_file_at_commit(worktree_path, &ctx.base_commit, &file_change.path).await;
let ours = tokio::fs::read_to_string(&main_file).await.ok();
let ours = tokio::fs::read(&main_file).await.ok();
if base == ours {
// Main side untouched: take the worktree's version.
if apply_file_content(&main_file, theirs.as_ref()).await {
files.push(file_change);
}
} else if ours == theirs {
// Both sides hold identical content (e.g. dirty state the
// worktree inherited at creation, or an earlier sequential
// apply already landed the same change): already present —
// not a conflict, nothing to write.
files.push(file_change);
} else if base != theirs {
conflicts.push(FileConflict {
path: file_change.path,
change_type: file_change.change_type,
base,
ours,
theirs,
base: conflict_text(&base),
ours: conflict_text(&ours),
theirs: conflict_text(&theirs),
});
}
}