fix: unblock daemon PR CI + address review comments

CI was red on all three platforms for the daemon-mode draft PR. Root causes
and fixes:

- Linux 3.9 collection error: `_submit_daemon_job`'s `dedupe_key: str | None`
  parameter annotation is evaluated at def time, and hooks_cli.py has no
  `from __future__ import annotations` — `str | None` raises TypeError on 3.9.
  Reverted to `dedupe_key: str = None` (the original, 3.9-safe). The other
  `int | None` in the file is a function-local annotation, which is never
  evaluated, so it was never the problem.

- macOS/Windows daemon lifecycle flakes: the 3 HTTP-lifecycle tests failed at
  the 10s readiness deadline on contended CI runners (localhost bind is
  sub-second locally but took ~5s when it passed on the macOS fleet, >10s when
  it didn't), and because the server thread never shuts down on timeout,
  run_server's `os.environ["MEMPALACE_PALACE_PATH"]` + `os.umask(0o077)`
  mutations leaked into the rest of the suite — poisoning every later test that
  reads MempalaceConfig().palace_path (the 60+ test_mcp_server cascade on macOS;
  the at-exit socket hang → SIGINT on Windows). Bumped the readiness deadline
  to 30s and added a module-scoped snapshot + autouse fixture in test_daemon.py
  that force-restores the env + umask to the pre-suite baseline after every
  daemon test, so a leaked server thread can't poison other test files.

Gemini review comments (fixed in code, no thread replies per convention):

- daemon.py `_connect()` was a bare `sqlite3.connect` whose `with`-block only
  managed the transaction, not the connection — an unbounded FD leak in a
  long-lived daemon running thousands of jobs (also the source of the Windows
  "unclosed database" ResourceWarning noise). Converted to a closing
  @contextlib.contextmanager.
- `QueueStore.finish()` gained `only_if_running`; `_safe_finish` passes it so a
  late worker finish can't overwrite a shutdown-cancelled job back to
  succeeded/failed — removes the reliance on process-exit timing.
- `DaemonClient.request` wraps the final `json.loads` in try/except
  JSONDecodeError → DaemonError, so a non-JSON 2xx response surfaces as a
  structured error instead of a bare JSONDecodeError.
- test_sync.py: removed the module-level `import mempalace.mcp_server` and moved
  the stdout-rebinding side effect into an autouse fixture scoped to
  TestServiceRunSyncReport, so the embedder/Chroma import chain is no longer
  forced at collection time for the existing sync tests.

Coverage: added focused happy-path tests for service.run_sync early-returns,
run_mine backend application + invalid mode, execute_job kind dispatch,
run_diary_write arg forwarding, run_mcp_tool write-tool dispatch, and
print_job_result — lifts service.py from 57% to 85% so the new files
(service 85%, daemon 80%) don't drag the total below the 80% CI gate now that
the daemon tests complete and the gate is actually evaluated.
This commit is contained in:
Igor Lins e Silva 2026-06-19 08:33:37 -03:00
parent aa96bb5623
commit 5f58cdbfa8
4 changed files with 266 additions and 15 deletions

View File

@ -8,6 +8,7 @@ execution.
from __future__ import annotations
import argparse
import contextlib
import json
import os
import secrets
@ -173,10 +174,26 @@ class QueueStore:
self._lock = threading.RLock()
self._init_db()
@contextlib.contextmanager
def _connect(self):
"""Open a short-lived sqlite3 connection and close it on exit.
The bare ``with sqlite3.connect(...)`` context manager only manages the
transaction (commit/rollback) it does NOT close the connection, so every
QueueStore call in this long-lived daemon process leaked a connection FD.
In a daemon that runs thousands of jobs that is an unbounded FD leak. This
wrapper closes the connection on exit so each call is self-contained.
"""
conn = sqlite3.connect(str(self.path), timeout=30)
conn.row_factory = sqlite3.Row
return conn
try:
conn.row_factory = sqlite3.Row
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _init_db(self) -> None:
with self._connect() as conn:
@ -375,13 +392,21 @@ class QueueStore:
state: str,
result: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
only_if_running: bool = False,
) -> Job:
# ``only_if_running`` guards the worker's finish against a lost race with
# shutdown's cancel: if the active job was already flipped to 'cancelled'
# by _drain_and_cleanup, a late worker finish must NOT overwrite it back to
# 'succeeded'/'failed' (which would un-cancel a job recover_running must
# not re-run). The conditional UPDATE makes the worker's finish a no-op in
# that window instead of relying on process-exit timing.
where = "WHERE id = ?" + (" AND state = 'running'" if only_if_running else "")
with self._lock, self._connect() as conn:
conn.execute(
"""
f"""
UPDATE jobs
SET state = ?, finished_at = ?, result_json = ?, error_json = ?
WHERE id = ?
{where}
""",
(
state,
@ -490,7 +515,9 @@ class DaemonRuntime:
def _safe_finish(self, job_id: str, *, state: str, result: dict, error: dict | None) -> None:
try:
self.store.finish(job_id, state=state, result=result, error=error)
# only_if_running: if shutdown already cancelled this job, don't
# resurrect it. A finish failure must not kill the worker regardless.
self.store.finish(job_id, state=state, result=result, error=error, only_if_running=True)
except Exception: # noqa: BLE001 - a finish failure must not kill the worker
pass
@ -783,7 +810,15 @@ class DaemonClient:
raise DaemonError(str(payload.get("error", exc))) from exc
except OSError as exc:
raise DaemonError(str(exc)) from exc
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
# A 2xx response with a non-JSON body (empty 200, truncated write,
# proxy HTML) shouldn't surface as a bare JSONDecodeError to callers
# that only know how to handle DaemonError.
raise DaemonError(f"daemon returned non-JSON response: {raw[:200]!r}") from exc
def health(self) -> dict[str, Any]:
return self.request("GET", "/health")

View File

@ -545,7 +545,7 @@ def _submit_daemon_job(
kind: str,
payload: dict,
*,
dedupe_key: str | None = None,
dedupe_key: str = None,
priority: int = 0,
wait: bool = False,
timeout: float = 60.0,

View File

@ -1,3 +1,4 @@
import os
import threading
import time
@ -6,6 +7,37 @@ import pytest
from mempalace import daemon
from mempalace import service
# 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."""
@ -85,7 +117,12 @@ def test_daemon_http_lifecycle_executes_job(tmp_path, monkeypatch):
thread.start()
client = None
deadline = time.monotonic() + 10
# 30s: localhost bind is sub-second locally, but contended CI runners (notably
# the macOS GitHub Actions fleet) can take several seconds to bring the server
# up. A too-tight deadline here makes the test flake AND, because the server
# thread never shuts down on timeout, leaks env/umask into the rest of the
# suite (guarded by the _isolate_process_global_state fixture above).
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
client = daemon.get_client_if_running(str(palace))
if client is not None:
@ -169,7 +206,7 @@ def _start_server(tmp_path, monkeypatch, execute_fn):
)
thread.start()
client = None
deadline = time.monotonic() + 10
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
client = daemon.get_client_if_running(str(palace))
if client is not None:
@ -455,3 +492,176 @@ def test_start_daemon_kills_orphan_on_readiness_timeout(tmp_path, monkeypatch):
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_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()
(palace / "chroma.sqlite3").touch()
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"]

View File

@ -11,12 +11,6 @@ from pathlib import Path
import chromadb
import pytest
# run_sync imports mempalace.mcp_server lazily; that import initializes the
# embedder, which rebinds sys.stdout and defeats capsys/redirect_stdout for any
# prints after sync_palace returns. Importing it here makes the lazy import a
# cached no-op so the daemon-path report tests can capture run_sync's output.
import mempalace.mcp_server # noqa: F401
def _seed_drawers(palace_path, repo_path, deleted_path, elsewhere_path):
"""Populate the drawers collection with 6 entries covering all buckets."""
@ -1416,6 +1410,18 @@ class TestServiceRunSyncReport:
which disturbs sys.stdout and defeats capsys.
"""
@pytest.fixture(autouse=True)
def _cache_mcp_server_import(self):
"""run_sync lazily imports mempalace.mcp_server, whose import initializes
the embedder and rebinds sys.stdout defeating capsys for any prints
after sync_palace returns. Lazy-load it here, scoped to just these report
tests (not the whole module at collection time), so the import is a cached
no-op by the time run_sync runs and its report output stays capturable.
"""
import mempalace.mcp_server # noqa: F401
yield
def _fake_report(self, **overrides):
report = {
"scanned": 6,