--- status: ready-for-implementation owner: claude-code --- # Plan: Conflict-Aware Merge in MCP Tools Reduce content loss under concurrent writes by giving `mark_publish` a tool-level merge strategy. Disjoint edits succeed automatically via diff3; genuine overlap escalates to the calling agent's LLM with all three bodies in hand. ## Motivation Current `mark_publish` is strict optimistic concurrency: version mismatch → conflict error → caller re-fetches and republishes. Two failure modes at scale: 1. **Lost content** — naive callers re-publish their stale body, clobbering the intervening writer 2. **Wasted retries** — agents that edited disjoint paragraphs still take a full LLM round-trip to merge, even though a mechanical merge would have been clean Eliminating the first 80% of conflicts mechanically (diff3) and giving the agent everything it needs for the last 20% (semantic merge) keeps both content loss and LLM cost low. ## Non-Goals - **No DIFF verb.** Versions are immutable; clients can compute diffs locally from any two FETCHes. Adding it to the wire format pulls processing into the server with no protocol-level benefit. - **No server-side merge.** The server stays content-agnostic. Merge logic lives in the MCP tool (Go client code). - **No automatic semantic merge.** The MCP tool has no LLM. Semantic resolution stays the calling agent's job. - **No changes to `mark_append`.** Append is an end-of-doc stream primitive by design; auto-resolve handles its tiny race window. Two concurrent appends serialize cleanly because there is nothing to merge — the server appends to whatever is current. Keep it untouched. - **No backoff or fairness primitives in this plan.** Livelock under sustained contention is real but is a schema problem (hot single-doc writes), not a merge problem. Address separately if it shows up. ## Design ### New parameter: `on_conflict` Add an optional parameter to `mark_publish`: ``` on_conflict: "fail" | "merge" ``` - `"fail"` — current behavior. Default for backward compatibility. - `"merge"` — attempt diff3 mechanical merge on conflict, escalate on overlap. ### Merge Flow When `on_conflict: "merge"` and the initial PUBLISH returns version mismatch: 1. **FETCH** `/path/v{expected_version}` — retrieve the base body (the version the agent built its edit from). Available because versions are immutable. 2. **FETCH** `/path` — retrieve current body and current version. 3. **diff3(base, ours, theirs)** where: - `base` = body from step 1 - `ours` = body the agent passed to `mark_publish` - `theirs` = body from step 2 4. **If clean** (no conflict markers): **PUBLISH** with `expected_version` = the version from step 2's response. Return success with `merged: true`. 5. **If conflict markers**: do not publish. Return a structured conflict response with the marked body for the agent's LLM to resolve. The agent's original `expected_version` is the *only* version they ever provide. The merge loop manages all subsequent version handoffs internally — we always merge against whatever is currently latest at fetch time. The PUBLISH in step 4 can itself conflict if a *third* writer slipped in between our fetch and our publish. Retry the full merge loop (re-fetch latest, re-diff3, re-publish) up to **3** times before escalating to the caller as a contention failure. ### Conflict Marker Format Git-style markers in the body. LLMs handle this natively from training data, no protocol commitment, format lives entirely in `client/internal/merge/` Go code and can be swapped later without breaking anything. ``` some unchanged text <<<<<<< ours agent A's version of the line ======= agent B's version of the line >>>>>>> theirs more unchanged text ``` ### Response Shapes **Success after merge** (status: ok): ```yaml status: ok version: 7 merged: true base-version: 5 their-version: 6 ``` **Escalation on overlap** (status: conflict): ```yaml status: conflict your-version: 5 current-version: 6 mergeable: false ``` Body contains the diff3 output with git-style conflict markers, so the agent can either: - Send the marked body directly to its LLM for resolution - Or use the metadata to refetch base/theirs separately **Escalation on contention** (status: conflict, after 3 retries): ```yaml status: conflict your-version: 5 current-version: 9 mergeable: false reason: contention retries: 3 ``` ### Wire-Level Impact Zero. `on_conflict` is an MCP tool parameter handled in the Go client. Server protocol unchanged. Versioned FETCH (`/path/vN`) already exists. ## Implementation ### Where the code lives - **MCP handler**: `client/internal/mcp/` (or wherever `mark_publish` is dispatched). Add the `on_conflict` parameter to the tool schema and route to a merge helper. - **Merge helper**: new package `client/internal/merge/` with: - `Diff3(base, ours, theirs string) (merged string, hadConflict bool)` — pure function, easy to test - `MergeAndPublish(ctx, client, path, ours, expectedVersion int, maxRetries int) (Result, error)` — orchestrates the FETCH/FETCH/diff3/PUBLISH loop - **Diff3 algorithm**: use a vetted Go library if one exists (search `github.com/...diff3`), otherwise implement Myers-based three-way merge. Keep behind the `Diff3` function so it's swappable. ### Test coverage Table-driven tests in `client/internal/merge/`: - Disjoint paragraph edits → clean merge - Same-line edits → conflict markers - Adjacent line edits → clean merge (verify diff3 doesn't over-conflict) - One side adds, other side deletes → expected diff3 behavior documented - Empty `ours` or `theirs` → handled gracefully - `MergeAndPublish` retry exhaustion → returns contention status - `MergeAndPublish` success on second try → returns merged: true with correct versions Integration test against a real server: two clients both edit the same doc in different sections, both call `on_conflict: "merge"`, both succeed, final doc contains both edits. ### Backward compatibility `on_conflict` defaults to `"fail"`. Existing callers see no behavior change. New callers opt in. ## Open Questions 1. **Should the tool return the merged body to the caller on success, or just metadata?** Returning the body lets the agent verify the merge result; not returning it keeps the response small. Leaning toward returning it — verification is cheap and the agent already pays the bandwidth cost of having sent its own body in the request. ## Out of Scope (Future) - **`mark_update_section`** — heading-scoped writes that further reduce contention surface. Higher leverage than this plan but bigger change. Track separately. - **Server-side hot-path detection / batching window** — backpressure for sustained contention. Only build if contention shows up in practice. - **Soft leases** — agent reserves a path for N seconds. Fairness primitive, not a merge primitive. ## Success Criteria - Two agents editing different paragraphs of the same document with `on_conflict: "merge"` both succeed without LLM involvement. - Two agents editing the same line both receive structured conflict responses with git-style markers in the body. - No change to existing callers using default `on_conflict: "fail"`. - `mark_append` untouched and behavior unchanged. - Diff3 helper has full branch coverage in unit tests. - Integration test with three concurrent writers terminates with all writes reflected in final doc (or one mergeable conflict, never silent loss). ## Decisions Log - **2026-05-05**: Conflict markers are git-style in body. Tool-side only, swappable, no protocol commitment. - **2026-05-05**: Retry count default is 3. - **2026-05-05**: `mark_append` is out of scope. Append-as-stream is the right primitive there; auto-resolve already handles its race window. - **2026-05-05**: Merge always runs against current latest at fetch time. Caller's `expected_version` is the original fetch only; the merge loop owns all subsequent version handoffs.