fix: address PR review — per-palace lock, MCP server path, hook timeout, tests
Addresses the six Copilot review comments on the initial commit. 1) #6 (critical) — mcp_server.py `_get_collection` bypassed ChromaBackend The MCP server creates its palace collection directly via `chromadb.PersistentClient.get_or_create_collection` in `_get_collection`, not through `ChromaBackend.get_collection`. That path was missing the `hnsw:num_threads=1` metadata, so the primary crash surface for #974 and #965 was untouched by the original patch. Fixed by passing `hnsw:num_threads=1` at the mcp_server create site too. Documented in a code comment that the setting is only honored at creation time — existing palaces created before this fix still need a `mempalace nuke` + re-mine to gain the protection. 2) #3 — mine_global_lock over-serialized mines across unrelated palaces Replaced the single global lock file `mine_global.lock` with a per-palace lock keyed by `sha256(os.path.abspath(palace_path))` (`mine_palace_<hash>.lock`). Mines against the same palace still collapse to a single runner (the correctness boundary), but mines against *different* palaces are now free to run in parallel. `mine_global_lock` is kept as a backward-compatible alias for `mine_palace_lock` so any external callers that imported the previous name keep working. 3) #1 — hook_precompact swallowed OSError but not subprocess.TimeoutExpired `subprocess.run(..., timeout=60)` raises `TimeoutExpired` on slow palaces. The previous `except OSError` clause didn't catch it, so the hook could raise and fail to emit any JSON decision — leaving the harness without a block/passthrough signal. Fixed by catching `(OSError, subprocess.TimeoutExpired)` together and always falling through to the block decision so the hook reliably emits a response. 4) #2 + #4 — tests - tests/test_hooks_cli.py: added `test_precompact_first_two_attempts_block`, `test_precompact_passes_through_after_cap`, and `test_precompact_counter_is_per_session` to lock in the #955 deadlock fix. - tests/test_palace_locks.py (new): covers `mine_palace_lock` single-acquire, reuse-after-release, cross-process serialization on the same palace, non-interference across different palaces, path normalization, and the `mine_global_lock` back-compat alias. 5) #5 — known limitation, documented but not auto-fixed Copilot suggested detecting collections missing `hnsw:num_threads=1` and calling `collection.modify(metadata=...)` to retrofit existing palaces. Verified against chromadb 1.5.7: `modify(metadata=...)` replaces metadata rather than merging, and re-passing `hnsw:space="cosine"` then raises `ValueError: Changing the distance function of a collection once it is created is not supported currently.` The HNSW runtime configuration (`configuration_json`) also does not expose `num_threads` in chromadb 1.5.x, so the flag appears to be read only at creation time. Rather than paper over the limitation with a best-effort `modify` that silently drops `hnsw:space`, documented in the mcp_server comment that pre-existing palaces need a `mempalace nuke` + re-mine to gain the protection. Fresh palaces are always protected. Testing - pytest tests/test_palace_locks.py tests/test_hooks_cli.py tests/test_backends.py tests/test_cli.py → **98 passed, 0 failed**. - Runtime validation with two concurrent `mempalace mine` calls: - Different palaces → both complete in parallel ✓ - Same palace → one completes, the other exits with "another `mine` is already running against <palace> — exiting cleanly." ✓
This commit is contained in:
committed by
Igor Lins e Silva
parent
7e18a70796
commit
99b820cb42
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from mempalace.hooks_cli import (
|
||||
MAX_PRECOMPACT_BLOCK_ATTEMPTS,
|
||||
SAVE_INTERVAL,
|
||||
_count_human_messages,
|
||||
_extract_recent_messages,
|
||||
@@ -59,6 +60,85 @@ def test_sanitize_empty_returns_unknown():
|
||||
assert _sanitize_session_id("!!!") == "unknown"
|
||||
|
||||
|
||||
# --- hook_precompact attempt cap (regression for #955 deadlock fix) ---
|
||||
|
||||
|
||||
def _call_precompact(session_id: str) -> dict:
|
||||
"""Invoke hook_precompact with a deterministic session_id, capture stdout.
|
||||
|
||||
Returns the parsed JSON decision emitted by the hook.
|
||||
"""
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
hook_precompact({"session_id": session_id}, "claude-code")
|
||||
raw = stdout.getvalue().strip()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def test_precompact_first_two_attempts_block(tmp_path, monkeypatch):
|
||||
"""First MAX_PRECOMPACT_BLOCK_ATTEMPTS calls must block with a reason."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MEMPAL_DIR", raising=False)
|
||||
|
||||
import mempalace.hooks_cli as hooks_cli
|
||||
monkeypatch.setattr(
|
||||
hooks_cli, "STATE_DIR", tmp_path / "hook_state", raising=False
|
||||
)
|
||||
|
||||
sid = "test-session-block"
|
||||
for i in range(MAX_PRECOMPACT_BLOCK_ATTEMPTS):
|
||||
decision = _call_precompact(sid)
|
||||
assert decision.get("decision") == "block", (
|
||||
f"attempt {i + 1}/{MAX_PRECOMPACT_BLOCK_ATTEMPTS}: expected block, "
|
||||
f"got {decision}"
|
||||
)
|
||||
assert decision.get("reason") == PRECOMPACT_BLOCK_REASON
|
||||
|
||||
|
||||
def test_precompact_passes_through_after_cap(tmp_path, monkeypatch):
|
||||
"""After the cap is reached, the hook must stop blocking (fix for #955)."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MEMPAL_DIR", raising=False)
|
||||
|
||||
import mempalace.hooks_cli as hooks_cli
|
||||
monkeypatch.setattr(
|
||||
hooks_cli, "STATE_DIR", tmp_path / "hook_state", raising=False
|
||||
)
|
||||
|
||||
sid = "test-session-passthrough"
|
||||
for _ in range(MAX_PRECOMPACT_BLOCK_ATTEMPTS):
|
||||
_call_precompact(sid) # exhaust the budget
|
||||
|
||||
# Next call must pass through (empty JSON decision)
|
||||
decision = _call_precompact(sid)
|
||||
assert decision == {}, (
|
||||
f"after {MAX_PRECOMPACT_BLOCK_ATTEMPTS} attempts, hook must pass "
|
||||
f"through to avoid deadlock; got {decision}"
|
||||
)
|
||||
|
||||
|
||||
def test_precompact_counter_is_per_session(tmp_path, monkeypatch):
|
||||
"""A fresh session_id must get a fresh attempt budget."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MEMPAL_DIR", raising=False)
|
||||
|
||||
import mempalace.hooks_cli as hooks_cli
|
||||
monkeypatch.setattr(
|
||||
hooks_cli, "STATE_DIR", tmp_path / "hook_state", raising=False
|
||||
)
|
||||
|
||||
sid_a = "session-a"
|
||||
sid_b = "session-b"
|
||||
|
||||
# Exhaust session A
|
||||
for _ in range(MAX_PRECOMPACT_BLOCK_ATTEMPTS):
|
||||
_call_precompact(sid_a)
|
||||
assert _call_precompact(sid_a) == {} # A is done blocking
|
||||
|
||||
# Session B must still block on its first call — isolation between sessions
|
||||
assert _call_precompact(sid_b).get("decision") == "block"
|
||||
|
||||
|
||||
# --- _count_human_messages ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user