contextfoundry.dev

When Two Builders Edit the Same File

Conflict detection in parallel worktree builds.

Context Foundry v0.7.4

The problem

Context Foundry's dual-model arena mode runs two builders in parallel. Each builder works in its own git worktree -- an isolated copy of the project directory. They receive the same plan, but different models may implement it differently. When both finish, the orchestrator merges their worktrees back into the main working directory.

This is where things get interesting. If builder A modifies src/main.rs and builder B also modifies src/main.rs, you have a conflict. If builder A deletes src/old_module.rs and builder B modifies it, you have a different kind of conflict. If both delete the same file, that is fine -- the intent is the same.

Before v0.7.4, the merge loop did not detect these cases. It silently overwrote files. Whichever builder finished second won. File deletions from one builder could disappear entirely because the other builder's copy was written on top.

Conflict types

The merge loop now classifies every file operation into one of four categories:

Before: silent overwrites

The original merge loop was naive:

for file in worktree_0.changed_files():
    copy(worktree_0/file, main/file)

for file in worktree_1.changed_files():
    copy(worktree_1/file, main/file)  // overwrites worktree_0's version

If both builders modified src/config.rs, the second copy silently replaced the first. If builder 0 deleted a file and builder 1 modified it, the deletion was lost because the copy from builder 1 recreated the file. No log message, no warning, no record that a conflict occurred.

This produced subtle bugs. The reviewer would see the merged result and have no way to know that an alternative implementation existed in the other worktree, or that a deletion was silently undone.

The fix: detect, log, resolve

The merge loop now tracks the full set of file operations from both builders before applying any of them. It builds a map of every file that was created, modified, or deleted in each worktree, then compares the two maps to find conflicts.

// Build operation maps
let ops_0 = collect_file_ops(worktree_0);
let ops_1 = collect_file_ops(worktree_1);

// Find conflicts
for file in ops_0.keys().union(ops_1.keys()):
    match (ops_0.get(file), ops_1.get(file)):
        (Some(Copy(a)), Some(Copy(b))) if a != b =>
            log("COPY-COPY CONFLICT: {file}")
            apply(ops_0[file])  // pipeline-0 wins
        (Some(Delete), Some(Copy(_))) =>
            log("DELETE-MODIFY CONFLICT: {file}")
            apply(ops_1[file])  // keep the modification
        (Some(Copy(_)), Some(Delete)) =>
            log("DELETE-MODIFY CONFLICT: {file}")
            apply(ops_0[file])  // keep the modification
        (Some(Delete), Some(Delete)) =>
            apply(ops_0[file])  // both agree
        _ =>
            apply_non_conflicting(file)

The resolution strategy is deterministic: for COPY-COPY conflicts, pipeline-0 (the first builder) wins. For DELETE-MODIFY conflicts, the modification wins over the deletion. These defaults are safe -- they preserve work rather than destroying it -- and the conflicts are logged so you can review the decisions.

What you see

When conflicts occur, the TUI log pane shows each one:

[warn] COPY-COPY CONFLICT: src/main.rs modified by both builders, keeping pipeline-0 version
[warn] COPY-COPY CONFLICT: src/config.rs modified by both builders, keeping pipeline-0 version
[warn] DELETE-MODIFY CONFLICT: src/old_module.rs deleted by pipeline-0, modified by pipeline-1, keeping modification
[info] Merge complete: 14 files applied, 3 conflicts resolved

The warnings are persistent in the log -- they do not scroll away. After a parallel build, you can scroll up through the log to see every conflict that was resolved and how.

The merge algorithm

The full merge is more nuanced than the simplified pseudocode above. Files are not independent -- a change in one file might reference a type defined in another file that was also changed. The merge algorithm handles this in two phases:

Phase 1: Classify operations. Walk both worktrees and collect every file operation (create, modify, delete) with content hashes. Group files that have cross-references into dependency clusters.

Phase 2: Apply operations. Independent files (no cross-references) are applied directly. Files in the same dependency cluster are applied together, with conflict detection within the cluster. If a cluster has any COPY-COPY conflicts, the entire cluster is taken from pipeline-0 to maintain internal consistency.

Copy and delete failures are no longer swallowed. If a filesystem operation fails during merge (permissions, disk full, path too long), the error is logged and the merge aborts rather than producing a half-applied result.

Future: semantic merge

The current approach is file-level. If both builders modify src/main.rs but touch different functions, it is still a COPY-COPY conflict because the file content differs. A smarter approach would be AST-level merge -- parse both versions, identify which syntax nodes changed, and merge non-overlapping changes automatically.

This is possible (tree-sitter provides fast incremental parsing for most languages) but not implemented. The file-level approach is safe, observable, and covers the common case. Most parallel builder conflicts are genuine disagreements about how to implement something, not independent edits to different parts of the same file.

For now, the right response to frequent COPY-COPY conflicts is to improve the plan's specificity so both builders converge on the same implementation -- not to build a smarter merge.