--- status: shipped 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 that produces a structurally-merged candidate body for the agent to semantically verify before publishing. ## Status **Shipped in client/v0.12.25** (PR #101, merged 2026-05-05). The default-flip from `"fail"` to `"merge"` is a follow-up landing in client/v0.12.26. ## Motivation Original `mark_publish` was strict optimistic concurrency: version mismatch → conflict error → caller re-fetches and republishes from scratch. Two failure modes at scale: 1. **Lost content** — naive callers re-publish their stale body, clobbering the intervening writer 2. **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_publish` call 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 ### Parameter: `on_conflict` `mark_publish` accepts an optional parameter: ``` on_conflict: "merge" | "fail" ``` - `"merge"` (default since v0.12.26) — on conflict, return a diff3 merge candidate the agent verifies and republishes. - `"fail"` — opt out: surface the raw server conflict response with no merge attempt. For agents wanting strict optimistic-concurrency semantics. ### Flow When `on_conflict: "merge"`: 1. Try **PUBLISH** with the agent's body and `expected_version`. 2. If success → return `status: ok` with version metadata. Done. 3. If version conflict: a. **FETCH** `/path/v{expected_version}` — retrieve the base body the agent edited from. (For `expected_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. Return `status: merge-candidate` with the candidate body, `has-markers` flag, and `publish-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) # default 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`) — identical to default `mark_publish` shape: ```yaml status: ok version: 7 modified: 2026-05-05T20:00:00Z content-hash: sha256-... ``` **Merge candidate** (`status: merge-candidate`): ```yaml 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 - **MCP handler**: `client/cmd/demarkus-mcp/main.go`. Hosts the `on_conflict` parameter and routes to `merge.Candidate` on the merge path; delegates to `formatResult` on success so the response shape is identical to a plain publish. - **Merge package**: `client/internal/merge/` - `Diff3(base, ours, theirs string) Result` — pure function, line-based LCS + hunk walk. - `Candidate(client, path, ours, expectedVersion, meta) (Outcome, error)` — orchestrates publish-or-merge in one shot. - **Diff3 algorithm**: implemented in-package, ~250 lines, no external dependency. LCS dp table capped at 2M cells (~16 MB) — pathological inputs fall through to a single-hunk merge. - **Adapter**: `mergeClientAdapter` in `main.go` lifts the `markClient` interface into `merge.Client`, parsing version metadata via `optionalInt` (missing → 0, malformed → wrapped error). ## Decisions Log - **2026-05-05**: Conflict markers are git-style in body. Tool-side only, swappable, no protocol commitment. - **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**: 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_publish` call 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. - **2026-05-05**: LCS dp table capped at 2M cells (~16 MB). Pathological inputs (e.g. 1 MiB body of 1-byte lines) would otherwise allocate gigabytes; oversized inputs fall back to a whole-range hunk — coarser, but bounded and safe. - **2026-05-05**: `Candidate` rejects responses where `latest.Version <= 0`. Otherwise `publish-at-version: 0` would silently switch the agent's follow-up publish into create-only semantics. - **2026-05-05**: Default flipped from `"fail"` to `"merge"` (v0.12.26). Original plan defaulted to `"fail"` for backward compatibility, but that left naive callers exposed to silent content loss — the exact failure mode the feature exists to prevent. The shape change (`merge-candidate` vs `conflict`) is loud, not silent — agents that don't recognize it fail visibly. `"fail"` is now the explicit opt-out for strict optimistic-concurrency semantics.