1533 lines
61 KiB
Python
1533 lines
61 KiB
Python
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from _chroma_palace_helper import make_minimal_chroma_sqlite
|
|
|
|
from mempalace import daemon
|
|
from mempalace import service
|
|
|
|
_LOCK_CONTENDER = """
|
|
from mempalace.palace import MineAlreadyRunning, mine_palace_lock
|
|
import sys
|
|
try:
|
|
with mine_palace_lock(sys.argv[1]):
|
|
raise SystemExit(0)
|
|
except MineAlreadyRunning:
|
|
raise SystemExit(23)
|
|
"""
|
|
|
|
# POSIX file-mode bits (0600/0700) are not representable on Windows: os.chmod
|
|
# can only toggle the read-only attribute, so a "private" file still reports
|
|
# 0o666. The daemon relies on the user-profile directory ACLs for privacy
|
|
# there, so the owner-only assertions only make sense on POSIX.
|
|
_posix_only_perms = pytest.mark.skipif(
|
|
os.name == "nt",
|
|
reason="POSIX 0600/0700 file-mode bits are not representable on Windows (ACL-based privacy)",
|
|
)
|
|
|
|
# Env keys run_server mutates from its background thread, plus umask. If a
|
|
# lifecycle test times out before the server comes up, run_server's finally
|
|
# never runs and those mutations leak into the rest of the suite — every later
|
|
# test that reads MempalaceConfig().palace_path sees a stale deleted tmp path and
|
|
# fails (the 60+ test cascade seen on slow CI runners). The fixtures below force a
|
|
# clean baseline around every daemon test so a leaked thread can't poison the
|
|
# process for tests/test_mcp_server.py and friends (which have no such guard).
|
|
_LEAK_ENV_KEYS = ("MEMPALACE_PALACE_PATH", "MEMPALACE_BACKEND", "MEMPALACE_BACKEND_EXPLICIT")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def _clean_env_snapshot():
|
|
"""Capture the true pre-suite values once, before any daemon test runs."""
|
|
return {key: os.environ.get(key) for key in _LEAK_ENV_KEYS}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_process_global_state(_clean_env_snapshot):
|
|
"""Restore the process-global env + umask to the pre-suite baseline after every
|
|
daemon test, even if a leaked run_server thread is still holding them mutated.
|
|
"""
|
|
prev_umask = os.umask(0o022)
|
|
os.umask(prev_umask) # read current umask without changing it
|
|
yield
|
|
for key, value in _clean_env_snapshot.items():
|
|
if value is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = value
|
|
os.umask(prev_umask)
|
|
|
|
|
|
def _raise_not_ready(*a, **kw):
|
|
"""Stand-in for DaemonClient when the spawned daemon must never come up."""
|
|
raise daemon.DaemonError("not ready")
|
|
|
|
|
|
def test_prune_terminal_drops_old_terminal_jobs_keeps_active(tmp_path, monkeypatch):
|
|
"""Terminal jobs older than the retention window are pruned; queued/running
|
|
and fresh terminal jobs are untouched. Bounded queue growth for the DB that
|
|
holds verbatim payloads."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
|
|
old_term = store.enqueue("mine", {"source": "old"})
|
|
store.finish(old_term.id, state="succeeded", result={"success": True})
|
|
fresh_term = store.enqueue("mine", {"source": "fresh"})
|
|
store.finish(fresh_term.id, state="succeeded", result={"success": True})
|
|
queued = store.enqueue("mine", {"source": "queued"})
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
|
with store._lock, store._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE jobs SET finished_at = ? WHERE id = ?",
|
|
(cutoff, old_term.id),
|
|
)
|
|
|
|
pruned = store.prune_terminal(older_than_days=7)
|
|
assert pruned == 1
|
|
# The old terminal job is gone; the fresh terminal and queued jobs survive.
|
|
with pytest.raises(daemon.DaemonError):
|
|
store.get(old_term.id)
|
|
assert store.get(fresh_term.id).state == "succeeded"
|
|
assert store.get(queued.id).state == "queued"
|
|
|
|
|
|
def test_queue_dedupes_and_recovers_running_jobs(tmp_path, monkeypatch):
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
first = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
|
|
second = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
|
|
|
|
assert second.id == first.id
|
|
|
|
claimed = store.claim_next()
|
|
assert claimed.id == first.id
|
|
assert claimed.state == "running"
|
|
|
|
recovered = store.recover_running()
|
|
assert recovered == 1
|
|
assert store.get(first.id).state == "queued"
|
|
|
|
|
|
def test_daemon_http_lifecycle_executes_job(tmp_path, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_execute(kind, payload):
|
|
calls.append((kind, payload))
|
|
return {"success": True, "exit_code": 0, "stdout": "done\n"}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
|
|
health = client.health()
|
|
assert health["ok"] is True
|
|
assert health["palace_path"] == daemon.canonical_palace_path(str(palace))
|
|
|
|
job = client.submit("mine", {"source": "src"}, dedupe_key="job")
|
|
finished = client.wait(job["id"], timeout=5)
|
|
|
|
assert finished["state"] == "succeeded"
|
|
assert finished["result"]["stdout"] == "done\n"
|
|
assert calls == [("mine", {"source": "src", "palace_path": str(palace.resolve())})]
|
|
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_daemon_holds_local_backend_writer_lease_for_lifetime(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
client, thread, palace, holders = _start_server(
|
|
tmp_path, monkeypatch, lambda kind, payload: {"success": True, "exit_code": 0}
|
|
)
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
|
|
check=False,
|
|
env=os.environ.copy(),
|
|
timeout=10,
|
|
)
|
|
assert result.returncode == 23
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
released = subprocess.run(
|
|
[sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
|
|
check=False,
|
|
env=os.environ.copy(),
|
|
timeout=10,
|
|
)
|
|
assert released.returncode == 0
|
|
|
|
|
|
def test_submit_job_uses_client_and_waits(monkeypatch, tmp_path):
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
|
|
class DummyClient:
|
|
def __init__(self):
|
|
self.submitted = None
|
|
|
|
def submit(self, kind, payload, dedupe_key=None, priority=0):
|
|
self.submitted = (kind, payload, dedupe_key, priority)
|
|
return {"id": "job-1", "state": "queued"}
|
|
|
|
def wait(self, job_id, timeout=daemon.DEFAULT_WAIT_TIMEOUT, stop_on_lock_deferral=False):
|
|
assert job_id == "job-1"
|
|
return {
|
|
"id": "job-1",
|
|
"state": "succeeded",
|
|
"result": {"success": True, "exit_code": 0},
|
|
}
|
|
|
|
dummy = DummyClient()
|
|
monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: dummy)
|
|
|
|
job = daemon.submit_job(
|
|
"mine",
|
|
{"source": "src"},
|
|
palace_path=str(palace),
|
|
dedupe_key="dedupe",
|
|
wait=True,
|
|
)
|
|
|
|
assert job["state"] == "succeeded"
|
|
assert dummy.submitted[0] == "mine"
|
|
# palace_path is overridden (not trusted from the payload), never appended.
|
|
assert dummy.submitted[1]["palace_path"] == daemon.canonical_palace_path(str(palace))
|
|
assert dummy.submitted[2] == "dedupe"
|
|
|
|
|
|
def test_service_tool_classification():
|
|
assert service.classify_tool("mempalace_search") == "read"
|
|
assert service.classify_tool("mempalace_add_drawer") == "write"
|
|
assert service.classify_tool("mempalace_checkpoint") == "write"
|
|
assert service.classify_tool("mempalace_delete_by_source") == "write"
|
|
assert service.classify_tool("mempalace_mine") == "maintenance"
|
|
assert service.classify_tool("unknown") == "unknown"
|
|
|
|
|
|
# --- helpers for HTTP-lifecycle tests ---
|
|
|
|
|
|
def _capture_httpd(monkeypatch):
|
|
"""Capture the httpd instance run_server creates.
|
|
|
|
run_server defines a local ``class _Server(ThreadingHTTPServer)``; by
|
|
monkeypatching ``daemon.ThreadingHTTPServer`` before run_server runs, that
|
|
subclass inherits from a capturing base that records each instance. The
|
|
httpd can then be force-stopped from the test thread (see _stop_server) so a
|
|
slow/failed ``client.shutdown()`` POST can never leave the server thread
|
|
alive — which on Windows hangs the interpreter at process exit on an open
|
|
listening socket.
|
|
"""
|
|
holders: list = []
|
|
base = daemon.ThreadingHTTPServer
|
|
|
|
class _CapturingServer(base):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
holders.append(self)
|
|
|
|
monkeypatch.setattr(daemon, "ThreadingHTTPServer", _CapturingServer)
|
|
return holders
|
|
|
|
|
|
def _stop_server(client, thread, holders, *, join_timeout=5.0):
|
|
"""Shut the daemon down deterministically and assert the thread died.
|
|
|
|
First try the normal path (POST /shutdown). If the server thread is still
|
|
alive afterwards — the POST was slow, lost, or the drain overran the join —
|
|
call httpd.shutdown() directly from this thread (stdlib-safe: it is a
|
|
different thread than serve_forever) to force serve_forever to return, then
|
|
re-join. The assert turns a leak into a visible failure instead of a silent
|
|
interpreter-exit hang.
|
|
"""
|
|
try:
|
|
client.shutdown()
|
|
except Exception: # noqa: BLE001 - best-effort; we force-shutdown below
|
|
pass
|
|
thread.join(timeout=join_timeout)
|
|
if thread.is_alive() and holders:
|
|
httpd = holders[-1]
|
|
try:
|
|
httpd.shutdown()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
try:
|
|
httpd.server_close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
thread.join(timeout=join_timeout)
|
|
assert not thread.is_alive(), "daemon server thread did not shut down"
|
|
|
|
|
|
def _start_server(tmp_path, monkeypatch, execute_fn):
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
monkeypatch.setattr(service, "execute_job", execute_fn)
|
|
holders = _capture_httpd(monkeypatch)
|
|
|
|
# Capture any exception run_server raises in its thread. Without this a
|
|
# startup crash is invisible: the poll below would just spin for 30s and
|
|
# fail with a bare ``assert client is not None`` giving no cause.
|
|
server_error: list = []
|
|
|
|
def _serve():
|
|
try:
|
|
daemon.run_server(palace_path=str(palace), port=0)
|
|
except BaseException as exc: # noqa: BLE001 - re-surfaced to the test thread
|
|
server_error.append(exc)
|
|
|
|
thread = threading.Thread(target=_serve, name="test-daemon-server", daemon=True)
|
|
thread.start()
|
|
client = None
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
if server_error:
|
|
raise AssertionError(f"run_server crashed during startup: {server_error[0]!r}")
|
|
client = daemon.get_client_if_running(str(palace))
|
|
if client is not None:
|
|
break
|
|
time.sleep(0.05)
|
|
if client is None:
|
|
raise AssertionError(
|
|
"daemon did not become ready within 30s "
|
|
f"(thread_alive={thread.is_alive()}, httpd_bound={bool(holders)}, "
|
|
f"endpoint_exists={daemon.endpoint_path(str(palace)).exists()})"
|
|
)
|
|
return client, thread, palace, holders
|
|
|
|
|
|
# --- ship-blocker regressions ---
|
|
|
|
|
|
def test_systemexit_in_job_does_not_kill_worker(tmp_path, monkeypatch):
|
|
"""A SystemExit (BaseException, not Exception) must be caught, the job
|
|
marked failed, and the worker kept alive for the next job. Regression for
|
|
the critical worker-death bug."""
|
|
state = {"first": True}
|
|
|
|
def fake_execute(kind, payload):
|
|
if state["first"]:
|
|
state["first"] = False
|
|
raise SystemExit("boom")
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
first = client.submit("mine", {"source": "src"})
|
|
finished_first = client.wait(first["id"], timeout=5)
|
|
assert finished_first["state"] == "failed"
|
|
assert finished_first["error"]["error_class"] == "SystemExit"
|
|
|
|
# Worker must still be alive — health reports it and a second job runs.
|
|
assert client.health()["worker_alive"] is True
|
|
second = client.submit("mine", {"source": "src2"})
|
|
finished_second = client.wait(second["id"], timeout=5)
|
|
assert finished_second["state"] == "succeeded"
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_shutdown_cancels_active_job(tmp_path, monkeypatch):
|
|
"""POST /shutdown must not leave an in-flight job 'running' for blind
|
|
re-queue on next start. The worker is drained (bounded), then the active
|
|
job is marked 'cancelled' so recover_running won't re-run it.
|
|
|
|
In production the serve process exits immediately after run_server returns,
|
|
killing the daemon worker thread before it can overwrite the cancelled
|
|
state. The test mirrors that by asserting the cancelled state *before*
|
|
releasing the blocked worker.
|
|
"""
|
|
block = threading.Event()
|
|
|
|
def fake_execute(kind, payload):
|
|
# Simulate a long-running job that never finishes on its own.
|
|
block.wait(30)
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
monkeypatch.setattr(daemon, "SHUTDOWN_DRAIN_SECONDS", 0.2)
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
job = client.submit("mine", {"source": "src"}, dedupe_key="x")
|
|
# Wait until the worker has claimed it (state flips to running).
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
if client.get_job(job["id"])["state"] == "running":
|
|
break
|
|
time.sleep(0.02)
|
|
assert client.get_job(job["id"])["state"] == "running"
|
|
|
|
_stop_server(client, thread, holders)
|
|
|
|
# The interrupted job must be cancelled (terminal), not left running.
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
final = store.get(job["id"])
|
|
assert final.state == "cancelled"
|
|
# And recover_running must not re-queue a cancelled job.
|
|
assert store.recover_running() == 0
|
|
|
|
# The server thread is gone, but its timed-out worker is still executing.
|
|
# Writer ownership must stay with that worker until it truly exits.
|
|
contender = subprocess.run(
|
|
[sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
|
|
check=False,
|
|
env=os.environ.copy(),
|
|
timeout=10,
|
|
)
|
|
assert contender.returncode == 23
|
|
|
|
# Release the blocked worker so it (and the daemon thread) can exit.
|
|
block.set()
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
released = subprocess.run(
|
|
[sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
|
|
check=False,
|
|
env=os.environ.copy(),
|
|
timeout=10,
|
|
)
|
|
if released.returncode == 0:
|
|
break
|
|
time.sleep(0.02)
|
|
assert released.returncode == 0
|
|
|
|
|
|
def test_recover_running_dead_letters_exhausted_jobs(tmp_path, monkeypatch):
|
|
"""A job that has crashed MAX_ATTEMPTS times must be dead-lettered to
|
|
'failed', not re-queued — non-idempotent kinds (diary_write) would
|
|
otherwise duplicate verbatim content on every restart."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("diary_write", {"entry": "x"})
|
|
# Simulate MAX_ATTEMPTS claims that each crashed (running, attempts=MAX).
|
|
with store._lock, store._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE jobs SET state='running', attempts=? WHERE id=?",
|
|
(daemon.MAX_ATTEMPTS, job.id),
|
|
)
|
|
|
|
recovered = store.recover_running()
|
|
assert recovered == 0 # not re-queued
|
|
final = store.get(job.id)
|
|
assert final.state == "failed"
|
|
assert final.attempts == daemon.MAX_ATTEMPTS
|
|
|
|
|
|
# --- #2014: a lease refusal is a deferral, not a failure ---
|
|
|
|
|
|
def test_lease_refusal_defers_job_and_runs_it_on_the_next_claim(tmp_path, monkeypatch):
|
|
"""A LockHeldByOtherProcess refusal means the palace write never landed, so
|
|
re-running it cannot duplicate content. It must be deferred and picked up
|
|
again, not dead-lettered. Regression for #2014, where a lock conflict ended
|
|
terminal 'failed' and only ever ran again if some hook happened to re-submit
|
|
equivalent work."""
|
|
calls = {"n": 0}
|
|
|
|
def fake_execute(kind, payload):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
return {
|
|
"success": False,
|
|
"error": "palace /p is held by PID 999 (mempalace-mcp)",
|
|
"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
|
|
"exit_code": 1,
|
|
}
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 0.05)
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
job = client.submit("diary_write", {"entry": "verbatim"})
|
|
finished = client.wait(job["id"], timeout=10)
|
|
assert finished["state"] == "succeeded"
|
|
assert calls["n"] == 2 # refused once, then executed for real
|
|
# The refusal spent no attempt: only the successful claim counted.
|
|
assert finished["attempts"] == 1
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_repeated_lease_refusal_never_exhausts_attempts(tmp_path, monkeypatch):
|
|
"""#2014's actual promise: a palace locked across many claims must never
|
|
dead-letter the work. One refusal cannot show that -- MAX_ATTEMPTS is 3, so
|
|
the job has to survive more refusals than that and still run."""
|
|
calls = {"n": 0}
|
|
refusals = daemon.MAX_ATTEMPTS + 2
|
|
|
|
def fake_execute(kind, payload):
|
|
calls["n"] += 1
|
|
if calls["n"] <= refusals:
|
|
return {
|
|
"success": False,
|
|
"error": "palace /p is held by PID 999",
|
|
"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
|
|
"exit_code": 1,
|
|
}
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 0.02)
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
job = client.submit("mine", {"source": "src"})
|
|
finished = client.wait(job["id"], timeout=20)
|
|
assert finished["state"] == "succeeded"
|
|
assert calls["n"] == refusals + 1
|
|
assert finished["attempts"] == 1 # every refusal refunded its claim
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_wait_returns_a_lock_deferred_job_instead_of_waiting_out_the_holder(tmp_path, monkeypatch):
|
|
"""An interactive caller must not block until a deferred job goes terminal --
|
|
it never will while the holder lives, so the default hour-long wait would
|
|
strand the terminal behind a peer session. Background waiters keep waiting."""
|
|
|
|
def fake_execute(kind, payload):
|
|
return {
|
|
"success": False,
|
|
"error": "palace /p is held by PID 999 (mempalace-mcp)",
|
|
"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
|
|
"exit_code": 1,
|
|
}
|
|
|
|
monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 5.0)
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
job = client.submit("mine", {"source": "src"})
|
|
parked = client.wait(job["id"], timeout=10, stop_on_lock_deferral=True)
|
|
assert parked["state"] == "queued" # returned early, not terminal
|
|
assert daemon.job_deferred_by_lock(parked)
|
|
assert "PID 999" in parked["error"]["message"]
|
|
|
|
# Without the flag the same job is not terminal, so the wait times out
|
|
# rather than returning -- the hang a naive deferral would introduce.
|
|
with pytest.raises(daemon.DaemonError):
|
|
client.wait(job["id"], timeout=0.5)
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_job_deferred_by_lock_ignores_ordinary_queued_and_failed_jobs(tmp_path, monkeypatch):
|
|
"""The predicate must not mistake a job merely awaiting its turn -- or a
|
|
genuinely failed one -- for a lock deferral, or the CLI would bail out of a
|
|
healthy queue."""
|
|
assert not daemon.job_deferred_by_lock({"state": "queued", "error": None})
|
|
assert not daemon.job_deferred_by_lock({"state": "queued", "error": {}})
|
|
assert not daemon.job_deferred_by_lock(
|
|
{"state": "failed", "error": {"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS}}
|
|
)
|
|
assert not daemon.job_deferred_by_lock(
|
|
{"state": "queued", "error": {"error_class": "ValueError"}}
|
|
)
|
|
assert daemon.job_deferred_by_lock(
|
|
{"state": "queued", "error": {"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS}}
|
|
)
|
|
|
|
|
|
def test_claim_clears_a_stale_deferral_reason(tmp_path, monkeypatch):
|
|
"""The reason defer parks on a queued job describes the claim that ended.
|
|
Leaving it would outlive its cause: the predicate would report a lock
|
|
refusal for a job that later crashed, and recover_running's dead-letter
|
|
(which COALESCEs error_json) would blame the lock instead of
|
|
MaxAttemptsExceeded."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("diary_write", {"entry": "x"})
|
|
|
|
store.claim_next()
|
|
store.defer(job.id, error={"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS, "message": "held"})
|
|
assert daemon.job_deferred_by_lock(daemon.job_to_dict(store.get(job.id)))
|
|
|
|
claimed = store.claim_next()
|
|
assert claimed.error is None # the refusal belonged to the previous claim
|
|
|
|
# Now crash out at MAX_ATTEMPTS: the dead-letter must name the real cause.
|
|
with store._lock, store._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE jobs SET state='running', attempts=? WHERE id=?",
|
|
(daemon.MAX_ATTEMPTS, job.id),
|
|
)
|
|
store.recover_running()
|
|
final = store.get(job.id)
|
|
assert final.state == "failed"
|
|
assert final.error["error_class"] == "MaxAttemptsExceeded"
|
|
|
|
|
|
def test_defer_records_why_the_job_went_back_to_the_queue(tmp_path, monkeypatch):
|
|
"""A job parked behind the palace lock must be distinguishable from one
|
|
merely awaiting its turn.
|
|
|
|
The reason is what job_deferred_by_lock keys on, and what the foreground CLI
|
|
and the hook log quote back at the operator; without it a parked job and a
|
|
job simply waiting its turn are the same row. (`daemon jobs` prints only
|
|
id/state/kind/created_at, so it does not show this -- surfacing it there is
|
|
a separate change.)"""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("mine", {"source": "src"})
|
|
store.claim_next()
|
|
|
|
deferred = store.defer(
|
|
job.id,
|
|
error={"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS, "message": "held by PID 999"},
|
|
)
|
|
assert deferred.state == "queued"
|
|
assert deferred.error["error_class"] == daemon.LOCK_REFUSAL_ERROR_CLASS
|
|
assert "PID 999" in deferred.error["message"]
|
|
|
|
|
|
def test_invalid_lock_backoff_env_falls_back_to_the_default(monkeypatch):
|
|
"""Anything that is not a positive, finite number yields the default.
|
|
|
|
A typo must not crash the daemon at import; 0 or a negative value would make
|
|
the cooldown expire instantly and spin the worker against a lock it cannot
|
|
take; and inf/nan are the sharp edges of float() -- inf parses fine and is
|
|
> 0, so a bare positivity check would park the job until the daemon
|
|
restarts, which is exactly the dropped work #2014 is about."""
|
|
for bad in ("not-a-float", "-5", "0", "", "inf", "-inf", "nan"):
|
|
monkeypatch.setenv("MEMPALACE_DAEMON_LOCK_BACKOFF_SECONDS", bad)
|
|
assert daemon._lock_defer_backoff_seconds() == daemon._DEFAULT_LOCK_BACKOFF_SECONDS, bad
|
|
|
|
monkeypatch.setenv("MEMPALACE_DAEMON_LOCK_BACKOFF_SECONDS", "0.5")
|
|
assert daemon._lock_defer_backoff_seconds() == 0.5
|
|
|
|
monkeypatch.delenv("MEMPALACE_DAEMON_LOCK_BACKOFF_SECONDS")
|
|
assert daemon._lock_defer_backoff_seconds() == daemon._DEFAULT_LOCK_BACKOFF_SECONDS
|
|
|
|
|
|
def test_defer_returns_job_to_queued_without_spending_an_attempt(tmp_path, monkeypatch):
|
|
"""defer must undo claim_next's increment, so a palace held across many
|
|
claims cannot walk a job to MAX_ATTEMPTS on work that never landed."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("mine", {"source": "src"})
|
|
|
|
claimed = store.claim_next()
|
|
assert claimed.attempts == 1
|
|
assert claimed.state == "running"
|
|
|
|
deferred = store.defer(job.id)
|
|
assert deferred.state == "queued"
|
|
assert deferred.attempts == 0
|
|
assert deferred.started_at is None
|
|
# Still claimable -- the work is held, not lost.
|
|
assert store.claim_next().id == job.id
|
|
|
|
|
|
def test_defer_scoped_to_a_stale_claim_is_a_no_op(tmp_path, monkeypatch):
|
|
"""defer is scoped to the claim that was refused. If the row was meanwhile
|
|
re-queued and re-claimed (recover_running on another daemon start), a late
|
|
defer keyed to the old claim must not flip the newer claim back to 'queued'
|
|
-- that would refund its attempt and re-run work whose outcome the newer
|
|
claimant already owns."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("mine", {"source": "s"})
|
|
|
|
# started_at is the claim token this test compares, and adjacent claims can
|
|
# land inside one clock tick on hosts with a coarse wall clock (Windows
|
|
# datetime.now() ticks at ~15.6ms before 3.13). Make every stamp unique so
|
|
# the assertions exercise the guard, not the clock.
|
|
real_now = daemon._now
|
|
stamps = iter(range(10_000))
|
|
monkeypatch.setattr(daemon, "_now", lambda: f"{real_now()}#{next(stamps):04d}")
|
|
|
|
stale = store.claim_next() # the claim that got the refusal
|
|
deferred = store.defer(job.id, claimed_started_at=stale.started_at)
|
|
assert deferred.state == "queued" # scoping to one's own claim still defers
|
|
|
|
fresh = store.claim_next() # the row is claimed again behind the first claimant
|
|
assert fresh.state == "running"
|
|
|
|
late = store.defer(job.id, claimed_started_at=stale.started_at)
|
|
assert late.state == "running", "a defer scoped to a stale claim must not fire"
|
|
assert late.attempts == fresh.attempts
|
|
|
|
|
|
def test_defer_preserves_attempts_spent_on_earlier_real_executions(tmp_path, monkeypatch):
|
|
"""The refund undoes one claim, not the job's whole history. An execution
|
|
that crashed left an unknown outcome and must keep costing its attempt --
|
|
otherwise an alternating crash/refusal pattern would defeat MAX_ATTEMPTS and
|
|
re-file verbatim content forever."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("diary_write", {"entry": "x"})
|
|
|
|
store.claim_next() # attempt 1: crashes mid-execution (outcome unknown)
|
|
assert store.recover_running() == 1 # re-queued, attempt NOT refunded
|
|
assert store.get(job.id).attempts == 1
|
|
|
|
store.claim_next() # attempt 2: refused the lock this time
|
|
deferred = store.defer(job.id)
|
|
assert deferred.attempts == 1 # only the refused claim was refunded, not the crash
|
|
|
|
|
|
def test_shutdown_while_a_job_is_cooling_leaves_it_queued(tmp_path, monkeypatch):
|
|
"""A job already deferred must survive shutdown as 'queued', not be swept
|
|
into a terminal state and dropped.
|
|
|
|
The worker does not hold a deferred job: it records the cooldown and moves
|
|
on, so `finally` clears active_job_id and the drain (which joins the worker
|
|
before reading it) finds nothing in flight to cancel. Pin that, because a
|
|
shutdown that cancelled a deferred job would be #2014 again."""
|
|
monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 5.0)
|
|
|
|
def fake_execute(kind, payload):
|
|
return {
|
|
"success": False,
|
|
"error": "palace /p is held by PID 999",
|
|
"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
|
|
"exit_code": 1,
|
|
}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
job = client.submit("diary_write", {"entry": "verbatim"})
|
|
# Wait for the REFUSAL, not merely for 'queued': a job is queued the moment
|
|
# it is submitted, so waiting on the state alone would pass this test before
|
|
# any deferral had happened. The marker only appears once defer ran.
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
if daemon.job_deferred_by_lock(client.get_job(job["id"])):
|
|
break
|
|
time.sleep(0.02)
|
|
assert daemon.job_deferred_by_lock(client.get_job(job["id"])), "never reached a deferral"
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
final = store.get(job["id"])
|
|
assert final.state == "queued", "a deferred job must not be cancelled by shutdown"
|
|
assert store.claim_next().id == job["id"] # still runnable on the next start
|
|
|
|
|
|
def test_defer_does_not_resurrect_a_cancelled_job(tmp_path, monkeypatch):
|
|
"""defer mirrors finish(only_if_running=True): once shutdown cancelled an
|
|
in-flight job, a late deferral must not queue it for re-execution."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("diary_write", {"entry": "x"})
|
|
store.claim_next()
|
|
store.finish(job.id, state="cancelled", result={})
|
|
|
|
after = store.defer(job.id)
|
|
assert after.state == "cancelled"
|
|
assert store.claim_next() is None
|
|
|
|
|
|
def test_claim_next_skips_excluded_jobs_without_binding_a_parameter_each(tmp_path, monkeypatch):
|
|
"""The cooling set must not be spliced into the SQL as one host parameter per
|
|
job: SQLITE_MAX_VARIABLE_NUMBER defaults to 999 below SQLite 3.32 and this
|
|
project still supports Python 3.9. Over the cap the query raises into the
|
|
worker's catch-all, which retries against a set that only ages out on the
|
|
cooldown, so the queue stalls in cooldown-long fits. It also has to keep
|
|
picking the right row: the oldest queued job that is not excluded."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
|
|
first = store.enqueue("mine", {"source": "first"})
|
|
second = store.enqueue("mine", {"source": "second"})
|
|
|
|
# Excluding the oldest hands back the next one, not None.
|
|
claimed = store.claim_next(exclude={first.id})
|
|
assert claimed is not None
|
|
assert claimed.id == second.id
|
|
|
|
# Excluding every queued job yields nothing rather than the wrong job.
|
|
store.defer(second.id)
|
|
assert store.claim_next(exclude={first.id, second.id}) is None
|
|
|
|
if not hasattr(sqlite3.Connection, "setlimit"): # setlimit is 3.11+
|
|
pytest.skip("sqlite3.Connection.setlimit needed to pin the parameter cap")
|
|
|
|
# Force the pre-3.32 cap of 999 rather than trusting whatever SQLite this
|
|
# interpreter bundles: on a build with a large cap the old
|
|
# `id NOT IN (?, ?, ...)` shape passes and this test would guard nothing.
|
|
real_connect = sqlite3.connect
|
|
|
|
def capped_connect(*args, **kwargs):
|
|
conn = real_connect(*args, **kwargs)
|
|
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, 999)
|
|
return conn
|
|
|
|
monkeypatch.setattr(sqlite3, "connect", capped_connect)
|
|
|
|
huge = {f"cooling-{i:07d}" for i in range(5_000)} # 5x the cap
|
|
claimed = store.claim_next(exclude=huge)
|
|
assert claimed is not None
|
|
assert claimed.id == first.id # priority DESC, created_at ASC still honoured
|
|
|
|
# And priority still wins over age, with an exclusion in play.
|
|
store.defer(first.id)
|
|
urgent = store.enqueue("diary_write", {"entry": "x"}, priority=10)
|
|
assert store.claim_next(exclude=huge).id == urgent.id
|
|
|
|
|
|
def test_submit_job_forwards_stop_on_lock_deferral_to_wait(monkeypatch):
|
|
"""submit_job is the only door the CLI and the hooks use, so if it drops the
|
|
flag on the floor every caller silently goes back to waiting out a holder
|
|
that may never let go. Nothing else in the suite covers the forwarding."""
|
|
seen = {}
|
|
|
|
class _Client:
|
|
def submit(self, kind, payload, dedupe_key=None, priority=0):
|
|
return {"id": "job-1", "state": "queued"}
|
|
|
|
def wait(self, job_id, *, timeout=None, stop_on_lock_deferral=False):
|
|
seen["stop_on_lock_deferral"] = stop_on_lock_deferral
|
|
return {"id": job_id, "state": "queued"}
|
|
|
|
monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: _Client())
|
|
|
|
daemon.submit_job("mine", {"source": "s"}, palace_path="/p", stop_on_lock_deferral=True)
|
|
assert seen["stop_on_lock_deferral"] is True
|
|
|
|
daemon.submit_job("mine", {"source": "s"}, palace_path="/p")
|
|
assert seen["stop_on_lock_deferral"] is False # default stays the old waiting behaviour
|
|
|
|
|
|
def test_a_deferred_job_does_not_stall_unrelated_queued_work(tmp_path, monkeypatch):
|
|
"""The worker is the only one, and claim_next hands back the oldest queued
|
|
job -- which, after a refusal, is the deferred job itself. If the worker
|
|
waited out the cooldown in line, that one job would hold the queue hostage
|
|
for as long as the holder lived (the flock is held while the write runs, and
|
|
a wedged write holds it indefinitely, #2024), and nothing else would run.
|
|
The cooldown must skip the job, not block the loop."""
|
|
monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 30.0)
|
|
ran = []
|
|
|
|
def fake_execute(kind, payload):
|
|
ran.append(kind)
|
|
if kind == "mine": # older, and refused for the whole test
|
|
return {
|
|
"success": False,
|
|
"error": "palace /p is held by PID 999",
|
|
"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
|
|
"exit_code": 1,
|
|
}
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
blocked = client.submit("mine", {"source": "src"})
|
|
unrelated = client.submit("diary_write", {"entry": "verbatim"})
|
|
|
|
# The unrelated job must run despite a 30s cooldown on the older one.
|
|
done = client.wait(unrelated["id"], timeout=10)
|
|
assert done["state"] == "succeeded"
|
|
assert "diary_write" in ran
|
|
|
|
# And the blocked job is still held, not lost.
|
|
parked = client.get_job(blocked["id"])
|
|
assert parked["state"] == "queued"
|
|
assert daemon.job_deferred_by_lock(parked)
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_a_job_queued_behind_a_deferred_one_gets_its_own_refusal_marker(tmp_path, monkeypatch):
|
|
"""A caller waiting on a job that is merely queued behind a deferred job has
|
|
nothing to key on: the job was never claimed, so it carries no marker, and
|
|
stop_on_lock_deferral cannot fire. It would wait out its full timeout and
|
|
then report a failure that never happened -- #2014 through the back door.
|
|
Because the cooldown skips the deferred job instead of blocking the worker,
|
|
the second job is claimed, refused, and marked on its own."""
|
|
monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 30.0)
|
|
|
|
def fake_execute(kind, payload):
|
|
return {
|
|
"success": False,
|
|
"error": "palace /p is held by PID 999 (mempalace-mcp)",
|
|
"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
|
|
"exit_code": 1,
|
|
}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
first = client.submit("mine", {"source": "a"})
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
if daemon.job_deferred_by_lock(client.get_job(first["id"])):
|
|
break
|
|
time.sleep(0.02)
|
|
assert daemon.job_deferred_by_lock(client.get_job(first["id"]))
|
|
|
|
# Submitted while the first job is cooling: it must still be claimed.
|
|
second = client.submit("mine", {"source": "b"})
|
|
parked = client.wait(second["id"], timeout=10, stop_on_lock_deferral=True)
|
|
assert parked["state"] == "queued"
|
|
assert daemon.job_deferred_by_lock(parked), "the CLI/hook short-circuit cannot fire"
|
|
assert "PID 999" in parked["error"]["message"]
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_failure_that_is_not_a_lease_refusal_stays_terminal(tmp_path, monkeypatch):
|
|
"""Only a lease refusal defers. A genuine failure has an unknown outcome --
|
|
the daemon may have written before dying -- which is exactly what MAX_ATTEMPTS
|
|
guards, so it must stay terminal and never be blindly re-run."""
|
|
|
|
def fake_execute(kind, payload):
|
|
return {"success": False, "error": "boom", "error_class": "ValueError", "exit_code": 1}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
job = client.submit("diary_write", {"entry": "x"})
|
|
finished = client.wait(job["id"], timeout=10)
|
|
assert finished["state"] == "failed"
|
|
assert finished["error"]["message"] == "boom"
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_claim_next_does_not_reclaim_running_job(tmp_path, monkeypatch):
|
|
"""The conditional UPDATE (WHERE state='queued') means a job already
|
|
flipped to 'running' cannot be claimed again — the cross-process
|
|
double-execution guard."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("mine", {"source": "src"})
|
|
first = store.claim_next()
|
|
assert first.id == job.id
|
|
# Manually re-mark it queued but leave a second claim attempt: claim_next
|
|
# should still only ever return one running job per claim. After finishing
|
|
# the first, the next claim returns None (queue empty).
|
|
store.finish(first.id, state="succeeded", result={"success": True})
|
|
assert store.claim_next() is None
|
|
|
|
|
|
@_posix_only_perms
|
|
def test_queue_db_file_is_owner_only(tmp_path, monkeypatch):
|
|
"""The queue DB holds verbatim payloads — it must be 0600, not the sqlite
|
|
default 0644. Regression for the privacy-principle violation."""
|
|
import os as _os
|
|
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
store.enqueue("diary_write", {"entry": "secret verbatim content"})
|
|
mode = _os.stat(str(store.path)).st_mode & 0o777
|
|
assert mode == 0o600, f"queue.sqlite3 is {oct(mode)}, expected 0600"
|
|
|
|
|
|
@_posix_only_perms
|
|
def test_token_file_is_owner_only(tmp_path, monkeypatch):
|
|
import os as _os
|
|
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
daemon.ensure_token(str(palace))
|
|
token_path = daemon.state_dir(str(palace)) / "token"
|
|
assert (_os.stat(str(token_path)).st_mode & 0o777) == 0o600
|
|
|
|
|
|
def test_health_rejects_missing_and_wrong_token(tmp_path, monkeypatch):
|
|
from urllib import error as urlerror
|
|
from urllib import request as urlrequest
|
|
|
|
client, thread, palace, holders = _start_server(
|
|
tmp_path, monkeypatch, lambda k, p: {"success": True}
|
|
)
|
|
try:
|
|
base = f"http://127.0.0.1:{client.port}"
|
|
# No Authorization header → 401.
|
|
with pytest.raises(urlerror.HTTPError):
|
|
urlrequest.urlopen(urlrequest.Request(base + "/health"), timeout=3)
|
|
# Wrong token → 401.
|
|
with pytest.raises(urlerror.HTTPError):
|
|
urlrequest.urlopen(
|
|
urlrequest.Request(base + "/health", headers={"Authorization": "Bearer wrong"}),
|
|
timeout=3,
|
|
)
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_worker_overrides_client_palace_path(tmp_path, monkeypatch):
|
|
"""An authenticated client must not be able to retarget the daemon at a
|
|
different palace by stuffing palace_path into the payload."""
|
|
seen = {}
|
|
|
|
def fake_execute(kind, payload):
|
|
seen["palace_path"] = payload.get("palace_path")
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
job = client.submit(
|
|
"mine", {"source": "src", "palace_path": "/tmp/other-palace"}, dedupe_key="p"
|
|
)
|
|
client.wait(job["id"], timeout=5)
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
assert seen["palace_path"] == daemon.canonical_palace_path(str(palace))
|
|
assert seen["palace_path"] != "/tmp/other-palace"
|
|
|
|
|
|
def test_mcp_tool_allowlist_rejects_non_write_tools(tmp_path, monkeypatch):
|
|
"""The daemon queue is a durable write surface; read/maintenance/unknown
|
|
tools must be rejected so verbatim content can't be exfiltrated into the
|
|
queue or retried destructively."""
|
|
# read tool → rejected
|
|
out = service.run_mcp_tool({"name": "mempalace_search", "arguments": {}})
|
|
assert out["success"] is False
|
|
assert "only accepts write tools" in out["error"]
|
|
# maintenance tool → rejected (has its own kinds: mine/sync)
|
|
out = service.run_mcp_tool({"name": "mempalace_mine", "arguments": {}})
|
|
assert out["success"] is False
|
|
# unknown tool → rejected
|
|
out = service.run_mcp_tool({"name": "mempalace_bogus", "arguments": {}})
|
|
assert out["success"] is False
|
|
# write tool → passes the allowlist (handler not called here since TOOLS
|
|
# won't have it under the test name; but classification must let it through)
|
|
assert service.classify_tool("mempalace_add_drawer") == "write"
|
|
|
|
|
|
def test_execute_job_isolates_env_per_job(monkeypatch):
|
|
"""A job that mutates MEMPALACE_BACKEND must not leak into the next job's
|
|
env. Regression for the per-job isolation bug (_apply_backend poisoning)."""
|
|
import os as _os
|
|
|
|
monkeypatch.delenv("MEMPALACE_BACKEND", raising=False)
|
|
monkeypatch.delenv("MEMPALACE_PALACE_PATH", raising=False)
|
|
|
|
def fake_mine(payload):
|
|
_os.environ["MEMPALACE_BACKEND"] = "leaked-backend"
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
monkeypatch.setattr(service, "run_mine", fake_mine)
|
|
service.execute_job("mine", {"palace_path": "/tmp/p", "source": "s"})
|
|
assert _os.environ.get("MEMPALACE_BACKEND") is None
|
|
|
|
|
|
def test_daemon_client_raises_on_endpoint_missing_port(tmp_path, monkeypatch):
|
|
"""A malformed endpoint.json must raise DaemonError, not a bare KeyError."""
|
|
import json as _json
|
|
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
daemon.ensure_token(str(palace))
|
|
# endpoint with no port
|
|
daemon.state_dir(str(palace)).mkdir(parents=True, exist_ok=True)
|
|
(daemon.state_dir(str(palace)) / "endpoint.json").write_text(
|
|
_json.dumps({"host": "127.0.0.1", "pid": 1}) + "\n", encoding="utf-8"
|
|
)
|
|
|
|
with pytest.raises(daemon.DaemonError):
|
|
daemon.DaemonClient(str(palace))
|
|
|
|
|
|
def test_pid_alive_probe_is_signal_free_and_correct():
|
|
"""``_pid_alive`` must be a pure liveness probe.
|
|
|
|
On Windows ``os.kill(pid, 0)`` is NOT harmless — signal 0 is
|
|
``CTRL_C_EVENT``, so it emits a console Ctrl-C to the target's process
|
|
group. The daemon client polls a same-process endpoint, so that Ctrl-C was
|
|
delivered back to the interpreter and surfaced as a spurious
|
|
``KeyboardInterrupt`` that hung the whole test session on CI runners (which,
|
|
unlike a detached dev shell, have an attached console). Assert the probe is
|
|
both correct and emits no SIGINT even when hammered like the poll loop.
|
|
"""
|
|
import signal
|
|
|
|
assert daemon._pid_alive(os.getpid()) is True
|
|
assert daemon._pid_alive(0) is False
|
|
assert daemon._pid_alive(-1) is False
|
|
# A pid that is almost certainly not running.
|
|
assert daemon._pid_alive(2_000_000_000) is False
|
|
|
|
# pytest runs tests on the main thread, so installing a SIGINT handler is
|
|
# allowed. If the probe regresses to os.kill(pid, 0) on Windows, the repeated
|
|
# calls below deliver CTRL_C_EVENT and this handler fires.
|
|
fired = []
|
|
previous = signal.getsignal(signal.SIGINT)
|
|
signal.signal(signal.SIGINT, lambda *_: fired.append(1))
|
|
try:
|
|
for _ in range(25):
|
|
daemon._pid_alive(os.getpid())
|
|
time.sleep(0.25)
|
|
finally:
|
|
signal.signal(signal.SIGINT, previous)
|
|
assert fired == [], "_pid_alive delivered a console control event (CTRL_C_EVENT)"
|
|
|
|
|
|
def test_start_daemon_kills_orphan_on_readiness_timeout(tmp_path, monkeypatch):
|
|
"""If the spawned daemon never becomes ready, start_daemon must kill and
|
|
reap the orphaned subprocess rather than leaking it with the port/token."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
daemon.ensure_token(str(palace))
|
|
|
|
monkeypatch.setattr(daemon, "get_client_if_running", lambda *a, **kw: None)
|
|
|
|
class FakeProc:
|
|
def __init__(self):
|
|
self.killed = False
|
|
self.returncode = None
|
|
|
|
def poll(self):
|
|
return self.returncode # None == still alive
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
def wait(self):
|
|
self.returncode = -9
|
|
return self.returncode
|
|
|
|
fake = FakeProc()
|
|
|
|
def fake_popen(*a, **kw):
|
|
return fake
|
|
|
|
monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(daemon, "DaemonClient", _raise_not_ready)
|
|
monkeypatch.setattr(daemon.time, "sleep", lambda *a, **kw: None)
|
|
|
|
with pytest.raises(daemon.DaemonError):
|
|
daemon.start_daemon(str(palace), timeout=0.05)
|
|
assert fake.killed is True
|
|
|
|
|
|
# --- service.run_* happy-path coverage ---
|
|
# These close the draft PR's follow-up ("Add focused happy-path tests for
|
|
# service.run_mine / run_diary_write / run_mcp_tool") and, now that the daemon
|
|
# tests complete reliably, keep service.py's coverage above the CI gate. The
|
|
# capsys-using tests come first; the two that import mempalace.mcp_server (which
|
|
# rebinds sys.stdout) come last and do not use capsys, so the rebind can't break
|
|
# capture in this file or later files (capsys activates after the rebind).
|
|
|
|
|
|
def test_print_job_result_replays_stdout_stderr_and_returns_exit_code(capsys):
|
|
from mempalace import service
|
|
|
|
code = service.print_job_result(
|
|
{"success": False, "error": "boom", "stdout": "out\n", "stderr": "err\n", "exit_code": 3}
|
|
)
|
|
assert code == 3
|
|
captured = capsys.readouterr()
|
|
assert "out" in captured.out
|
|
assert "err" in captured.err
|
|
|
|
|
|
def test_print_job_result_prints_error_to_stderr_when_no_stderr(capsys):
|
|
from mempalace import service
|
|
|
|
code = service.print_job_result({"success": False, "error": "boom", "exit_code": 1})
|
|
assert code == 1
|
|
captured = capsys.readouterr()
|
|
assert "mempalace: boom" in captured.err
|
|
|
|
|
|
def test_run_sync_returns_success_when_palace_dir_missing(tmp_path):
|
|
from mempalace import service
|
|
|
|
result = service.run_sync({"palace_path": str(tmp_path / "nope"), "dry_run": True})
|
|
assert result["success"] is True
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_run_sync_returns_success_when_palace_has_no_backend_artifact(tmp_path):
|
|
from mempalace import service
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
result = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert result["success"] is True
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_run_mine_invalid_mode_returns_structured_error(tmp_path):
|
|
from mempalace import service
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
out = service.run_mine({"palace_path": str(palace), "mode": "bogus"})
|
|
assert out["success"] is False
|
|
assert "invalid mine mode" in out["error"]
|
|
assert out["exit_code"] == 2
|
|
|
|
|
|
def test_run_mine_source_adapter_dispatches_through_adapter_runner(tmp_path, monkeypatch):
|
|
"""Daemon mine jobs preserve the CLI's explicit source-adapter dispatch."""
|
|
from mempalace import cli, service
|
|
|
|
received = {}
|
|
|
|
def fake_mine_source_adapter(**kwargs):
|
|
received.update(kwargs)
|
|
return 3
|
|
|
|
monkeypatch.setattr(cli, "mine_source_adapter", fake_mine_source_adapter)
|
|
palace = tmp_path / "palace"
|
|
out = service.run_mine(
|
|
{
|
|
"palace_path": str(palace),
|
|
"source": "/source",
|
|
"source_adapter": "fixture",
|
|
"dry_run": True,
|
|
}
|
|
)
|
|
|
|
assert received == {
|
|
"source_name": "fixture",
|
|
"source_path": "/source",
|
|
"palace_path": str(palace.resolve()),
|
|
"dry_run": True,
|
|
}
|
|
assert out == {
|
|
"success": True,
|
|
"kind": "mine",
|
|
"mode": "source",
|
|
"dry_run": True,
|
|
"exit_code": 0,
|
|
}
|
|
|
|
|
|
def test_run_mcp_tool_rejects_non_dict_arguments():
|
|
from mempalace import service
|
|
|
|
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": "nope"})
|
|
assert out["success"] is False
|
|
assert "must be an object" in out["error"]
|
|
assert out["exit_code"] == 2
|
|
|
|
|
|
def test_run_mcp_tool_dispatches_write_tool(monkeypatch):
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
captured = {}
|
|
|
|
def fake_handler(**arguments):
|
|
captured["arguments"] = arguments
|
|
return {"success": True, "written": True}
|
|
|
|
monkeypatch.setattr(mcp, "TOOLS", {"mempalace_add_drawer": {"handler": fake_handler}})
|
|
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": {"x": 1}})
|
|
assert out["success"] is True
|
|
assert out["written"] is True
|
|
assert out["exit_code"] == 0
|
|
assert captured["arguments"] == {"x": 1}
|
|
|
|
|
|
def test_run_diary_write_forwards_args_and_sets_exit_code(monkeypatch):
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
captured = {}
|
|
|
|
def fake_diary(agent_name, entry, topic, wing):
|
|
captured.update(agent_name=agent_name, entry=entry, topic=topic, wing=wing)
|
|
return {"success": True}
|
|
|
|
monkeypatch.setattr(mcp, "tool_diary_write", fake_diary)
|
|
out = service.run_diary_write(
|
|
{"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
|
|
)
|
|
assert out["success"] is True
|
|
assert out["exit_code"] == 0
|
|
assert captured == {"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
|
|
|
|
|
|
def test_run_mine_applies_backend_before_mode_validation(tmp_path):
|
|
"""Covers _apply_backend (env set + get_backend_class validation) on the daemon
|
|
path; the invalid mode short-circuits before any mining runs."""
|
|
from mempalace import service
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
out = service.run_mine({"palace_path": str(palace), "mode": "bogus", "backend": "chroma"})
|
|
assert out["success"] is False
|
|
assert out["exit_code"] == 2
|
|
|
|
|
|
def test_execute_job_dispatches_diary_write_mcp_tool_and_unknown(monkeypatch):
|
|
"""Covers execute_job's kind dispatch for diary_write, mcp_tool, and the
|
|
unknown-kind fallback."""
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
monkeypatch.setattr(mcp, "tool_diary_write", lambda **kw: {"success": True})
|
|
monkeypatch.setattr(
|
|
mcp, "TOOLS", {"mempalace_add_drawer": {"handler": lambda **kw: {"success": True}}}
|
|
)
|
|
assert service.execute_job("diary_write", {"entry": "x"})["success"] is True
|
|
assert (
|
|
service.execute_job("mcp_tool", {"name": "mempalace_add_drawer", "arguments": {}})[
|
|
"success"
|
|
]
|
|
is True
|
|
)
|
|
unknown = service.execute_job("bogus_kind", {})
|
|
assert unknown["success"] is False
|
|
assert unknown["exit_code"] == 2
|
|
|
|
|
|
def test_run_sync_structured_errors_on_sync_failures(tmp_path, monkeypatch):
|
|
"""Covers run_sync's three exception handlers (MineAlreadyRunning, ValueError,
|
|
generic Exception) so a failing sync_palace returns a structured error instead
|
|
of propagating."""
|
|
import mempalace.sync as sync_module
|
|
from mempalace import service
|
|
from mempalace.palace import MineAlreadyRunning
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
make_minimal_chroma_sqlite(palace)
|
|
|
|
def _raise(exc):
|
|
def fn(**kw):
|
|
raise exc
|
|
|
|
return fn
|
|
|
|
monkeypatch.setattr(sync_module, "sync_palace", _raise(MineAlreadyRunning("locked")))
|
|
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert r["success"] is False
|
|
assert r["error_class"] == "LockHeldByOtherProcess"
|
|
|
|
monkeypatch.setattr(sync_module, "sync_palace", _raise(ValueError("bad scope")))
|
|
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert r["success"] is False
|
|
assert r["exit_code"] == 2
|
|
|
|
monkeypatch.setattr(sync_module, "sync_palace", _raise(RuntimeError("boom")))
|
|
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert r["success"] is False
|
|
assert "sync failed" in r["error"]
|
|
|
|
|
|
def test_run_mine_lease_refusal_returns_the_lock_error_class(tmp_path, monkeypatch):
|
|
"""run_mine is the sole source of the refusal marker for kind 'mine' -- the
|
|
primary #2014 vector, submitted by the hooks and the CLI. The worker's
|
|
defer-vs-dead-letter gate keys on this error_class, so if a refactor drops
|
|
it (say, a broad handler reordered above MineAlreadyRunning), the daemon
|
|
silently goes back to dead-lettering refused mines while every worker test
|
|
stays green: they all inject the marker by hand."""
|
|
import mempalace.miner as miner_module
|
|
from mempalace import service
|
|
from mempalace.palace import MineAlreadyRunning
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
|
|
def _locked(**kw):
|
|
raise MineAlreadyRunning("palace is held by PID 999")
|
|
|
|
monkeypatch.setattr(miner_module, "mine", _locked)
|
|
r = service.run_mine(
|
|
{"palace_path": str(palace), "source": str(tmp_path / "src"), "mode": "projects"}
|
|
)
|
|
assert r["success"] is False
|
|
assert r["error_class"] == daemon.LOCK_REFUSAL_ERROR_CLASS
|
|
assert r["exit_code"] == 1
|
|
|
|
|
|
def test_safe_defer_swallows_store_errors_and_leaves_the_job_running(tmp_path, monkeypatch):
|
|
"""_safe_defer mirrors _safe_finish: a queue-DB hiccup during defer must not
|
|
kill the worker. The job stays 'running' for recover_running to re-queue on
|
|
the next start. Dropping the swallow would let the error reach the worker's
|
|
outer except, which dead-letters the lock-refused job -- the #2014 outcome
|
|
this branch exists to remove."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
runtime = daemon.DaemonRuntime(str(palace))
|
|
job = runtime.store.enqueue("mine", {"source": "s"})
|
|
runtime.store.claim_next()
|
|
|
|
def _boom(*args, **kwargs):
|
|
raise sqlite3.OperationalError("database is locked")
|
|
|
|
monkeypatch.setattr(runtime.store, "defer", _boom)
|
|
runtime._safe_defer(job.id, error={"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS})
|
|
assert runtime.store.get(job.id).state == "running"
|
|
|
|
|
|
# --- post-merge review follow-ups (Copilot review on #1826) ---
|
|
|
|
|
|
@_posix_only_perms
|
|
def test_run_server_tightens_umask_before_building_queue(tmp_path, monkeypatch):
|
|
"""The owner-only umask must be active BEFORE the queue DB is built.
|
|
|
|
SQLite's WAL/SHM sidecars hold un-checkpointed verbatim payloads and are
|
|
created with the process umask, so a loose umask at DaemonRuntime/QueueStore
|
|
construction time would leave them world-readable. Capture the umask at the
|
|
moment DaemonRuntime is constructed and assert it is already 0o077.
|
|
"""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
|
|
captured = {}
|
|
|
|
def _spy_runtime(*args, **kwargs):
|
|
current = os.umask(0o022)
|
|
os.umask(current) # restore without changing
|
|
captured["umask"] = current
|
|
raise RuntimeError("stop before binding a real socket")
|
|
|
|
monkeypatch.setattr(daemon, "DaemonRuntime", _spy_runtime)
|
|
with pytest.raises(RuntimeError):
|
|
daemon.run_server(str(palace), port=0)
|
|
assert captured["umask"] == 0o077
|
|
|
|
|
|
def test_negative_content_length_is_rejected_without_blocking(tmp_path, monkeypatch):
|
|
"""A POST with Content-Length: -1 must get a prompt 400, not hang the worker.
|
|
|
|
rfile.read(-1) would read until the client closes the socket (an auth-gated
|
|
DoS) and bypass the MAX_BODY_BYTES cap. The recv timeout below turns a
|
|
regression into a failure instead of a hang.
|
|
"""
|
|
import socket
|
|
|
|
client, thread, palace, holders = _start_server(
|
|
tmp_path, monkeypatch, lambda k, p: {"success": True}
|
|
)
|
|
try:
|
|
sock = socket.create_connection((client.host, client.port), timeout=5)
|
|
sock.settimeout(5)
|
|
request = (
|
|
"POST /jobs HTTP/1.1\r\n"
|
|
"Host: daemon\r\n"
|
|
f"Authorization: Bearer {client.token}\r\n"
|
|
"Content-Length: -1\r\n"
|
|
"Connection: close\r\n\r\n"
|
|
)
|
|
sock.sendall(request.encode("ascii"))
|
|
status_line = sock.recv(4096).decode("latin-1").split("\r\n", 1)[0]
|
|
sock.close()
|
|
assert "400" in status_line, f"expected 400, got {status_line!r}"
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_run_mcp_tool_marks_bare_error_dict_as_failure(monkeypatch):
|
|
"""A write tool that returns {"error": ...} with no success flag must be
|
|
recorded as a failed job, not succeeded (Copilot review)."""
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
monkeypatch.setattr(
|
|
mcp,
|
|
"TOOLS",
|
|
{"mempalace_create_tunnel": {"handler": lambda **kw: {"error": "bad endpoint"}}},
|
|
)
|
|
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
|
|
assert out["success"] is False
|
|
assert out["exit_code"] == 1
|
|
assert out["error"] == "bad endpoint"
|
|
|
|
# A result with neither an explicit success flag nor an error is a success.
|
|
monkeypatch.setattr(
|
|
mcp, "TOOLS", {"mempalace_create_tunnel": {"handler": lambda **kw: {"tunnel_id": "t1"}}}
|
|
)
|
|
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
|
|
assert out["success"] is True
|
|
assert out["exit_code"] == 0
|
|
|
|
|
|
def test_get_client_if_running_uses_short_probe_timeout(monkeypatch):
|
|
"""The hook liveness precheck must pass a short health timeout so a wedged
|
|
daemon can't stall the hook past its budget (Copilot review)."""
|
|
captured = {}
|
|
|
|
class _FakeClient:
|
|
def __init__(self, palace_path):
|
|
pass
|
|
|
|
def health(self, *, timeout):
|
|
captured["timeout"] = timeout
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(daemon, "DaemonClient", _FakeClient)
|
|
|
|
assert daemon.HOOK_PROBE_TIMEOUT <= 0.5
|
|
client = daemon.get_client_if_running("/p", health_timeout=daemon.HOOK_PROBE_TIMEOUT)
|
|
assert client is not None
|
|
assert captured["timeout"] == daemon.HOOK_PROBE_TIMEOUT
|
|
|
|
|
|
# --- _detached_kwargs ---
|
|
|
|
|
|
def test_detached_kwargs_posix(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("mempalace.daemon.os.name", "posix")
|
|
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
|
|
fh = kwargs["stdout"]
|
|
try:
|
|
assert kwargs.get("start_new_session") is True
|
|
assert kwargs.get("stdin") is subprocess.DEVNULL
|
|
assert kwargs.get("close_fds") is True
|
|
assert "creationflags" not in kwargs
|
|
finally:
|
|
fh.close()
|
|
|
|
|
|
def test_detached_kwargs_windows(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("mempalace.daemon.os.name", "nt")
|
|
monkeypatch.setattr("mempalace.daemon.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False)
|
|
monkeypatch.setattr("mempalace.daemon.subprocess.DETACHED_PROCESS", 0x00000008, raising=False)
|
|
monkeypatch.setattr(
|
|
"mempalace.daemon.subprocess.CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
"mempalace.daemon.subprocess.CREATE_BREAKAWAY_FROM_JOB", 0x01000000, raising=False
|
|
)
|
|
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
|
|
fh = kwargs["stdout"]
|
|
try:
|
|
flags = kwargs.get("creationflags", 0)
|
|
assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
|
|
assert not (flags & 0x00000008), (
|
|
"DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
|
|
)
|
|
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP preserved (Ctrl-Break group isolation)"
|
|
assert flags & 0x01000000, (
|
|
"CREATE_BREAKAWAY_FROM_JOB preserved (survive parent Job-Object close)"
|
|
)
|
|
assert kwargs.get("stdin") is subprocess.DEVNULL
|
|
assert kwargs.get("close_fds") is True
|
|
# creationflags + start_new_session are mutually exclusive on Windows
|
|
# (Popen raises ValueError); the nt branch must not set the latter.
|
|
assert "start_new_session" not in kwargs
|
|
finally:
|
|
fh.close()
|