Skip to content

fix(web/ctx): conflict dialog + force-save on 409 mtime mismatch (#763)#800

Merged
memtomem merged 2 commits intomainfrom
fix/issue-763-mtime-conflict-dialog
May 5, 2026
Merged

fix(web/ctx): conflict dialog + force-save on 409 mtime mismatch (#763)#800
memtomem merged 2 commits intomainfrom
fix/issue-763-mtime-conflict-dialog

Conversation

@memtomem
Copy link
Copy Markdown
Owner

@memtomem memtomem commented May 5, 2026

Summary

  • Replaces silent-reload behavior on PUT /api/context/{type}/{name} 409 mtime mismatch with a 3-button conflict dialog (Reload / Open diff editor / Force save) that previews the user buffer side-by-side with the freshly-fetched on-disk content. Buffer is stashed in sessionStorage so an Escape-out / tab close no longer destroys work.
  • Adds opt-in force: bool = False to SkillUpdateRequest / AgentUpdateRequest / CommandUpdateRequest. When force=True the mtime guard inside _gateway_lock is bypassed and the override is logged at WARNING with the path plus both client and server mtime_ns values, so the audit trail is reconstructable from logs alone.
  • 409 response shape is unchanged — RFC-761 PR-2 (test(context-gateway): pin Settings sync route + soft-abort contract (RFC-761 PR-2) #770) just pinned {"status":"aborted","reason":...,"mtime_ns":"<str>"} across all three routes, and shipping current_content on every 409 would have bloated payloads. The dialog fetches fresh content via a normal GET when it opens.

Closes #763.

Why this change

With the RFC-761 Settings prod expansion landing more concurrent writers and parallel Claude / Web sessions becoming routine, the previous default ("toast + loadCtxDetail overwrites the user's text") destroyed work whose only sin was racing another editor. The dialog forces an explicit user choice; the new force-save path preserves that choice's audit trail at the server log layer.

Test plan

  • uv run pytest packages/memtomem/tests/test_web_routes_context_mutators.py -k force — 9 new tests pass (3 force scenarios × agents / commands / skills).
  • uv run pytest -m "not ollama" — 4199 pass / 11 skipped / 0 fail.
  • uv run ruff check packages/memtomem/src packages/memtomem/tests + ruff format --check — clean.
  • uv run pytest packages/memtomem/tests/test_i18n.py — 19 pass; new keys parity-checked against en.json/ko.json.
  • End-to-end backend round-trip via curl against mm web --port 8181: create skill → stale PUT (no force) returns 409 with mtime_ns echo → stale PUT with force: true returns 200 + content overwritten → WARNING force-save bypassed mtime check on … (client_mtime_ns=… server_mtime_ns=…) emitted on the server.
  • Playwright MCP UI sanity: navigated to /, 0 console errors, modal element + 3 buttons + 7 helper functions all resolved on window. KO i18n keys render correctly. _ctxResolveConflict round-trip resolved to 'force' and 'diff' choices when buttons clicked. Inline _ctxRenderConflictBanner produces diff-add/diff-del lines with the 내 수정 ↔ 디스크 현재 내용 heading.
  • Reviewers: please drive the full UI flow once (create skill → edit → externally echo "drift" >> .memtomem/skills/<name>/SKILL.md from another shell → click Save → walk through each of Reload / Force / Open diff editor) to confirm UX feels right under real conflict timing.

Notes for reviewers

🤖 Generated with Claude Code

pandas-studio and others added 2 commits May 5, 2026 19:05
The Context Gateway editor's only response to a 409 mtime_ns mismatch
was a toast (`settings.ctx.mtime_conflict`) followed by a silent
`loadCtxDetail` that replaced whatever the user had typed with on-disk
content. This was tolerable when conflicts were rare; with parallel
Web sessions and the RFC-761 Settings prod expansion landing more
concurrent writers, the default of "destroy the user's work whose
only sin was racing another editor" was wrong.

The save handler now opens `#ctx-conflict-modal` with side-by-side
previews of the user's buffer vs the freshly-fetched on-disk content,
plus three explicit buttons:

  * Reload (discard my edits) — fetch fresh, drop the draft.
  * Open diff editor — render `diffLines(buffer, freshContent)`
    inline above the textarea so the user can hand-merge; refresh
    `dataset.mtimeNs` to the freshly-read value so the next Save
    no longer 409s.
  * Force save (overwrite) — re-PUT with the new opt-in
    `force: true` body field, then `conflict_force_done` toast.

The buffer is stashed in `sessionStorage` on every 409 entry so an
Escape-out / accidental tab close does not destroy work — the next
mount of the same `(type, name)` rehydrates the textarea and surfaces
a `conflict_draft_restored` info toast.

Backend: `SkillUpdateRequest` / `AgentUpdateRequest` /
`CommandUpdateRequest` each gain a `force: bool = False` field. When
`force=True` the mtime guard inside `_gateway_lock` is skipped and
the bypass is logged at WARNING with the path plus both client and
server `mtime_ns` values, so the audit trail is reconstructable from
logs alone. The 409 response shape itself is unchanged — RFC-761 PR-2
(#770) just pinned `{"status":"aborted","reason":...,"mtime_ns":"<str>"}`
across all three routes, and adding `current_content` to every 409
response would have bloated payloads on a hot error path. The dialog
fetches fresh content via a normal GET when it opens.

The legacy `settings.ctx.mtime_conflict` i18n key is no longer used by
code but kept for one release as a deprecated alias to avoid churn
for downstream translators.

Tests: parametrize over the 3 mutator types (`agents` / `commands` /
`skills`) for `test_PUT_force_bypasses_mtime_check`,
`test_PUT_force_logs_warning_with_both_mtimes`, and
`test_PUT_force_default_false_still_409s` — the last one is the
symmetric pin (positive marker + negative `!=`) for the opt-in
default per `feedback_pin_invert_symmetric_assertion.md`.

Co-Authored-By: Claude <[email protected]>
…PUT (#763)

Two review-driven fixes on top of the conflict-dialog change:

1. ``_ctxResolveConflict`` was auto-focusing the destructive Force-save
   button after opening the modal. The modal exists precisely to make
   the override an explicit choice; auto-focusing the danger button let
   a reflexive Enter-press silently overwrite the other writer's work.
   Focus the Reload button instead — Reload preserves on-disk content,
   and the user can still tab to Force.

2. The force PUT was sending ``fresh.mtime_ns`` (just-fetched, equal to
   the server's current value) as the body's ``mtime_ns``. Result: the
   server-side WARNING log printed ``client_mtime_ns ==
   server_mtime_ns`` in nearly every case, defeating the audit trail's
   "what was being overridden" purpose. Thread the original stale
   ``mtime_ns`` (the value ``detailEl.dataset.mtimeNs`` held when the
   user first hit Save and got 409) through ``_ctxHandleConflict`` and
   send that instead, so the WARNING captures distinct endpoints of
   the override.

Backend tests already pin ``client_mtime_ns=0`` vs ``server_mtime_ns
> 0`` so the contract "log whatever the client sent, both endpoints
visible" is covered. Regression risk for the JS contract itself is
documented in the function-level comment block on
``_ctxHandleConflict``.

Cache-bust ``context-gateway.js?v=15`` → ``?v=16`` per
``feedback_static_asset_cache_bust.md`` (JS body changed).

Co-Authored-By: Claude <[email protected]>
@memtomem memtomem force-pushed the fix/issue-763-mtime-conflict-dialog branch from 65b307a to b00dbe0 Compare May 5, 2026 10:06
@memtomem memtomem merged commit 6e0932c into main May 5, 2026
11 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators May 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: 409 mtime_ns conflict — diff/merge/force-save UX

2 participants