Plan: Conflict-Aware Merge in MCP Tools
Reduce content loss under concurrent writes by giving mark_publish a tool-level merge strategy that produces a structurally-merged candidate body for the agent to semantically verify before publishing.
Motivation
Current mark_publish is strict optimistic concurrency: version mismatch → conflict error → caller re-fetches and republishes from scratch. Two failure modes at scale:
- Lost content — naive callers re-publish their stale body, clobbering the intervening writer
- Wasted reasoning — agents that conflicted on disjoint paragraphs still have to mentally re-apply their edits onto the new latest, even though a mechanical merge would have done the structural work
Diff3 produces a structurally-correct merge candidate. The agent reviews it semantically (looking for duplicate bullets, contradictions, list reorder collisions that line-level merge cannot detect), refines if needed, and publishes. The agent stops doing structural work; the agent keeps owning semantic correctness.
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 tool-side semantic merge. The MCP tool has no LLM. Diff3 is mechanical (line-level). Semantic verification — duplicate bullet detection, contradiction reconciliation, marker resolution — is always the calling agent's job.
- No tool-side auto-publish after merge. Earlier drafts of this plan auto-published clean diff3 results. That silently allowed semantic content loss (two agents adding the same idea as different bullets, etc.). The current design always returns the candidate to the agent, who publishes after verifying. Diff3's value is "skip the structural work", not "skip the LLM".
- No internal retry loop in the tool. Each
mark_publishcall is one-shot: try the publish, return either success or a fresh merge candidate. Iteration lives in the agent's calling code (the agent already does fetch-modify-publish loops). - 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.
Design
New parameter: on_conflict
Add an optional parameter to mark_publish:
on_conflict: "fail" | "merge"
"fail"— current behavior. Default for backward compatibility."merge"— on conflict, return a diff3 merge candidate the agent verifies and republishes.
Flow
When on_conflict: "merge":
- Try PUBLISH with the agent's body and
expected_version. - If success → return
status: okwith version metadata. Done. - If version conflict:
a. FETCH
/path/v{expected_version}— retrieve the base body the agent edited from. (Forexpected_version: 0, base is empty — "create" conflicts are merged against an empty base.) b. FETCH/path— retrieve current body and current version. c. Run diff3(base, ours, theirs) to produce a candidate body. Conflict markers appear in the body wherever both sides changed the same lines differently. d. Returnstatus: merge-candidatewith the candidate body,has-markersflag, andpublish-at-version(the current version the agent should target on the follow-up publish).
Agent-side loop
The agent owns iteration. Pseudocode:
loop:
result = mark_publish(body, expected_version=N, on_conflict=merge)
if result.status == "ok": done
if result.status == "merge-candidate":
body = semantic_review(result.body) # resolve markers, dedupe semantically, etc.
N = result.publish_at_version
continue
If the agent's follow-up publish itself conflicts (a fourth writer slipped in), that's just another mark_publish call returning a new candidate. No special contention handling — the agent decides when to give up or escalate.
Conflict Marker Format
Git-style markers in the body. LLMs handle this natively from training data; 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
First-try success (status: ok) — unchanged from default mark_publish shape:
status: ok
version: 7
modified: 2026-05-05T20:00:00Z
content-hash: sha256-...
Merge candidate (status: merge-candidate):
status: merge-candidate
your-version: 5
current-version: 6
publish-at-version: 6
has-markers: false
base-version: 5
[diff3 candidate body, with or without markers]
Agent action: review the body. If has-markers: true, resolve them. Either way, publish with expected_version: publish-at-version.
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/cmd/demarkus-mcp/main.go. Addson_conflictparameter to themark_publishtool schema and routes to a merge helper on the merge path. - Merge package:
client/internal/merge/Diff3(base, ours, theirs string) Result— pure function, easy to testMergeCandidate(client, path, ours, expectedVersion, meta) (Outcome, error)— orchestrates publish-or-merge in one shot
- Diff3 algorithm: implemented in-package as a line-based LCS + hunk walk. ~250 lines, no external dependency. Available libraries (
epiclabs-io/diff3,nasdf/diff3) considered and rejected — adding a niche dep for a stable, well-understood algorithm doesn't pay for itself.
Test coverage
Table-driven tests in client/internal/merge/:
- Disjoint paragraph edits → clean candidate
- Same-line edits → candidate with markers
- Adjacent line edits → clean candidate (verify diff3 doesn't over-conflict)
- One side adds, other side deletes → documented behavior
- Empty
oursortheirs→ handled gracefully MergeCandidatefirst-try success → no diff3 invokedMergeCandidateconflict path → returns candidate with correct versions
Integration test (out of scope for the initial PR): two clients both edit the same doc in different sections with on_conflict: "merge", both follow the agent loop, final doc contains both edits.
Backward compatibility
on_conflict defaults to "fail". Existing callers see no behavior change. New callers opt in.
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"each receive a clean candidate, semantically verify, and successfully publish — final doc contains both edits. - Two agents editing the same line each receive a candidate with git-style markers in the body.
- No change to existing callers using default
on_conflict: "fail". mark_appenduntouched and behavior unchanged.- Diff3 helper has full branch coverage in unit tests.
Decisions Log
- 2026-05-05: Conflict markers are git-style in body. Tool-side only, swappable, no protocol commitment.
- 2026-05-05:
mark_appendis out of scope. Append-as-stream is the right primitive there; auto-resolve already handles its race window. - 2026-05-05: Tool never auto-publishes a diff3 result. Always returns the candidate to the agent for semantic verification. Earlier draft auto-published clean merges; rejected because line-disjoint ≠ semantically disjoint (duplicate bullets, contradictions, list reorder collisions slip through line-based merge).
- 2026-05-05: No internal retry loop in the tool. Each
mark_publishcall is one-shot. Iteration lives in the agent's natural fetch-modify-publish loop. Removes the contention-status escape valve and simplifies the tool. - 2026-05-05: For
expected_version: 0(create-only) conflicts, base is an empty string. Diff3 of (empty, ours, current) merges naturally — non-overlapping insertions both make it through, overlapping insertions get markers. - 2026-05-05: Diff3 implemented in-package (no external library). Niche libraries exist but the project values minimal deps; diff3 is a stable, well-understood algorithm with full test coverage of our own.