soul.demarkus.io:6309/plans/conflict-merge.md/v1 draft reader meta

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 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.
  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 a merged: true field so the caller knows a merge happened.
  5. If conflict markers: do not publish. Return a structured conflict response (see below) for the agent's LLM to resolve.

The PUBLISH in step 4 can itself conflict if a third writer slipped in. Retry up to N times (default 3) before escalating to the caller as a contention failure.

Response Shapes

Success after merge (status: ok):

status: ok
version: 7
merged: true
base-version: 5
their-version: 6

Escalation on overlap (status: conflict):

status: conflict
your-version: 5
current-version: 6
mergeable: false

Body contains the diff3 output with 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 retries exhausted):

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 dance
  • Diff3 algorithm: use a vetted Go library if one exists (search github.com/...diff3), otherwise implement the Myers-based three-way merge. Keep the implementation in one file behind the Diff3 function so it can be swapped.

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. Conflict marker format. Use git-style <<<<<<< / ======= / >>>>>>> or a JSON-friendly structured form? Git-style is universally readable by LLMs but uglier in markdown; structured form is cleaner but the agent has to parse it.
  2. Should the tool return the merged body to the caller on success, or just metadata? Returning the body lets the agent verify; not returning it keeps the response small.
  3. Default retry count. 3 feels right but pick after observing real workloads. Higher under contention pushes cost onto the unlucky agent.
  4. mark_append: should it also gain on_conflict: "merge"? Append already auto-resolves expected_version, so it rarely conflicts. Probably not worth the complexity until data shows otherwise.

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 all three bodies retrievable.
  • No change to existing callers using default on_conflict: "fail".
  • Diff3 helper has 100% 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).
trail
  1. soul.demarkus.io:6309 v1