fix(mcp): release the writer lease after a mutating-idle window (#1888) - #1966
fix(mcp): release the writer lease after a mutating-idle window (#1888)#1966JasonAiassist wants to merge 1 commit into
Conversation
…alace#1888) The per-palace writer lease (MemPalace#1818/MemPalace#1823) is held for the whole MCP process lifetime, so a single interactive session starves every other writer on the palace — hook and manual mines exit with MineAlreadyRunning, daemon jobs fail, and a second session's mutating tools are refused — for hours at a time. Release the lease once no mutating tool has run for MEMPALACE_MCP_WRITER_LEASE_IDLE_S seconds (default 300; 0 restores the legacy hold-until-exit behavior). The release also runs _force_chroma_cache_reset() so the next mutating call reopens the palace from disk: a re-acquire is only safe when no stale in-memory HNSW state survives, which is the reason the lease was lifetime-scoped in the first place. Re-acquisition rides the existing self-heal retry path, so the first mutating tool after a release transparently wins the lease back. The idle-exit watchdog thread drives the release check and now also starts when only the lease release is enabled. The atexit hook is registered once and reads the current lease, replacing the per-acquire lambda that would go stale across release/re-acquire cycles. Part of the MemPalace#1963 concurrent-writer cluster. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fatkobra
left a comment
There was a problem hiding this comment.
Blocking: resetting Chroma caches only when the lease is released does not make lease handoff safe. A local read can reopen and cache a PersistentClient while this process is lease-free; a peer can then write; and when this process reacquires the lease, the first mutation reuses that stale client because no reset occurs after acquisition.
There is also a handoff race because the OS lease is released before _force_chroma_cache_reset() completes, so a new local mutator can acquire the lease and begin using a client that the previous releaser is about to reset.
Please reset and reopen after every successful lease acquisition before the first mutation, and coordinate release, reset, and reacquisition under one process-wide lifecycle guard. Add a regression covering: release -> local read reopens -> peer writes -> reacquire -> mutate.
|
Thanks for this contribution, and apologies for the slow turnaround.
If you'd rather not pick it back up, no problem at all — just say so and I'll close it out, and thanks either way for taking the time to send it. |
Problem
The per-palace writer lease (#1818/#1823) is held for the whole MCP process lifetime. As #1888 reports, one interactive session therefore starves every other writer on the palace for hours:
mempalace mineruns exit withMineAlreadyRunning-32001until the first session endsWe hit this in production daily: multiple Claude Code sessions plus lifecycle hooks against one palace produced recurring sqlite→HNSW divergence (362 drawers in one day) before 3.5.0's lease, and with the lease the starvation above.
Fix — cooperative lease
Release the lease once no mutating tool has run for
MEMPALACE_MCP_WRITER_LEASE_IDLE_Sseconds (default 300;0restores the legacy hold-until-exit behavior).The critical correctness piece: the release also runs
_force_chroma_cache_reset(), so the next mutating call reopens the palace from disk. Stale in-memory HNSW state is why the lease was lifetime-scoped; dropping every cached Chroma handle at release time is what makes a re-acquire safe. Re-acquisition rides the existing self-heal retry path (#1960), so the first mutating tool after a release transparently wins the lease back — no restart, no user action.Implementation notes:
threading.Lock) drive the idle clock; the lease is never released mid-write.MEMPALACE_MCP_IDLE_HOURS=0no longer disables it).Tests
8 new tests in
tests/test_mcp_server.pycovering: idle release + cache reset, no release before the window / while a mutation is in flight / when disabled via env, env fallback on invalid values, re-acquire after release, in-flight tracking, and atexit idempotency. Full suite: 3267 passed, 20 skipped; ruff check/format clean.Scope
Part of the #1963 concurrent-writer cluster (tactical tier — no architectural change; fully compatible with both the queued-writes daemon and the #1270 bridge direction). Fixes the starvation half of #1888; the thread-local re-entrancy half was already fixed by the process-wide holder set in
palace.py.Follow-up candidate (separate PR if wanted):
service.run_mcp_toolexecutes write handlers without takingmine_palace_lock, so daemon-executedmcp_tooljobs could still interleave with a live lease holder once something starts submitting them.🤖 Generated with Claude Code