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:
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user