Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions mcp-wrapper/src/sickWarning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ export async function probeDaemonDoctor(
});
}

export function emitSickWarningIfNeeded(exitCode: number | null): void {
export function emitSickWarningIfNeeded(
exitCode: number | null,
write: (message: string) => unknown = process.stderr.write.bind(process.stderr),
): void {
if (exitCode === null || exitCode === 0) {
return;
}
const msg =
`iai-mcp warning: daemon doctor reports FAIL (exit=${exitCode}). ` +
"Run `iai-mcp doctor` for details. Memory tools may fall back to bank-recall.\n";
try {
process.stderr.write(msg);
write(msg);
} catch {
}
}
32 changes: 10 additions & 22 deletions mcp-wrapper/test/sickWarning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,42 +35,36 @@ function makeMockProc(opts: {
return proc;
}

const originalStderrWrite = process.stderr.write.bind(process.stderr);
const stderrLines: string[] = [];

function captureStderr(): void {
process.stderr.write = ((line: string | Uint8Array) => {
stderrLines.push(typeof line === "string" ? line : line.toString("utf-8"));
return true;
}) as typeof process.stderr.write;
function captureStderr(line: string): boolean {
stderrLines.push(line);
return true;
}

afterEach(() => {
process.stderr.write = originalStderrWrite;
stderrLines.length = 0;
});

describe("probeDaemonDoctor", () => {
it("exit 0 -> no stderr line", async () => {
captureStderr();
const mockSpawn = (_cmd: string, _args: ReadonlyArray<string>) =>
makeMockProc({ exitCode: 0 }) as unknown as ReturnType<
typeof import("node:child_process").spawn
>;
const code = await probeDaemonDoctor(mockSpawn as any, 5_000);
emitSickWarningIfNeeded(code);
emitSickWarningIfNeeded(code, captureStderr);
assert.equal(code, 0);
assert.equal(stderrLines.length, 0, "expected zero stderr writes on PASS");
});

it("exit 1 -> exactly one stderr line with exit=1", async () => {
captureStderr();
const mockSpawn = () =>
makeMockProc({ exitCode: 1 }) as unknown as ReturnType<
typeof import("node:child_process").spawn
>;
const code = await probeDaemonDoctor(mockSpawn as any, 5_000);
emitSickWarningIfNeeded(code);
emitSickWarningIfNeeded(code, captureStderr);
assert.equal(code, 1);
assert.equal(stderrLines.length, 1, "expected one stderr write on FAIL");
const line = stderrLines[0];
Expand All @@ -81,13 +75,12 @@ describe("probeDaemonDoctor", () => {
});

it("exit 2 -> exactly one stderr line with exit=2", async () => {
captureStderr();
const mockSpawn = () =>
makeMockProc({ exitCode: 2 }) as unknown as ReturnType<
typeof import("node:child_process").spawn
>;
const code = await probeDaemonDoctor(mockSpawn as any, 5_000);
emitSickWarningIfNeeded(code);
emitSickWarningIfNeeded(code, captureStderr);
assert.equal(code, 2);
assert.equal(stderrLines.length, 1);
assert.ok(
Expand All @@ -97,31 +90,28 @@ describe("probeDaemonDoctor", () => {
});

it("spawn error -> silent, probe resolves null", async () => {
captureStderr();
const mockSpawn = () =>
makeMockProc({ emitErrorBeforeClose: true }) as unknown as ReturnType<
typeof import("node:child_process").spawn
>;
const code = await probeDaemonDoctor(mockSpawn as any, 5_000);
emitSickWarningIfNeeded(code);
emitSickWarningIfNeeded(code, captureStderr);
assert.equal(code, null, "expected null on spawn error");
assert.equal(stderrLines.length, 0, "expected silent degrade on spawn error");
});

it("timeout -> silent, probe resolves null", async () => {
captureStderr();
const mockSpawn = () =>
makeMockProc({}) as unknown as ReturnType<
typeof import("node:child_process").spawn
>;
const code = await probeDaemonDoctor(mockSpawn as any, 50);
emitSickWarningIfNeeded(code);
emitSickWarningIfNeeded(code, captureStderr);
assert.equal(code, null, "expected null on timeout");
assert.equal(stderrLines.length, 0, "expected silent degrade on timeout");
});

it("invokes spawn with `iai-mcp` and args `[\"doctor\"]` (NOT `[\"daemon\", \"doctor\"]`)", async () => {
captureStderr();
const calls: Array<{ cmd: string; args: string[] }> = [];
const mockSpawn = (cmd: string, args: ReadonlyArray<string>) => {
calls.push({ cmd, args: [...args] });
Expand Down Expand Up @@ -150,14 +140,12 @@ describe("probeDaemonDoctor", () => {

describe("emitSickWarningIfNeeded", () => {
it("null exit code is a silent no-op", () => {
captureStderr();
emitSickWarningIfNeeded(null);
emitSickWarningIfNeeded(null, captureStderr);
assert.equal(stderrLines.length, 0);
});

it("exit 0 is a silent no-op", () => {
captureStderr();
emitSickWarningIfNeeded(0);
emitSickWarningIfNeeded(0, captureStderr);
assert.equal(stderrLines.length, 0);
});
});
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,5 @@ markers = [
"slow: opt-in via --runslow (subprocess-heavy bench-shim resolution tests)",
"perf: opt-in via --perf (wall-clock latency benches, out of the default correctness gate)",
"live: opt-in via --live (real daemon-subprocess E2E gate, out of the default correctness gate)",
"literature: cross-check against published reference values",
]
13 changes: 12 additions & 1 deletion src/iai_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"""IAI-MCP -- autistic-style persistent memory MCP server."""
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
import tomllib

from iai_mcp.types import (
MemoryRecord,
MemoryHit,
Expand All @@ -8,7 +12,14 @@
TIER_ENUM,
)

__version__ = "2.0.0"
_pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
if _pyproject.is_file():
__version__ = tomllib.loads(_pyproject.read_text())["project"]["version"]
else:
try:
__version__ = version("iai-mcp")
except PackageNotFoundError:
__version__ = "0+unknown"
__all__ = [
"MemoryRecord",
"MemoryHit",
Expand Down
21 changes: 18 additions & 3 deletions src/iai_mcp/daemon/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,6 @@ def _install_warm_embedder_override(store) -> tuple[object, bool]:
orig_efs = _embed_mod.embedder_for_store
try:
warm = orig_efs(store)
warm.embed("warmup")

def _held_embedder_for_store(_store):
return warm
Expand Down Expand Up @@ -1094,8 +1093,14 @@ def _capture_handler(record: dict) -> None:
try:
from iai_mcp.daemon._boot_warmup import warm_dispatch_surface

# Shield keeps the worker alive after the bounded pre-bind wait:
# asyncio cannot cancel a running thread, and retaining the task
# makes that continuation explicit and observable until shutdown.
_wds_task = asyncio.create_task(
asyncio.to_thread(warm_dispatch_surface, store)
)
_wds_summary = await asyncio.wait_for(
asyncio.to_thread(warm_dispatch_surface, store), timeout=20.0,
asyncio.shield(_wds_task), timeout=20.0,
)
log.info(
"dispatch surface warmed pre-bind in %.0fms",
Expand All @@ -1113,7 +1118,7 @@ def _capture_handler(record: dict) -> None:

async def _boot_warmup_task() -> None:
try:
await asyncio.to_thread(run_boot_warmup, store)
await asyncio.to_thread(run_boot_warmup, store, warm_dispatch=False)
except Exception as _exc: # noqa: BLE001 -- warm-up must never crash the daemon
log.debug("boot_warmup failed: %s", _exc, exc_info=True)

Expand Down Expand Up @@ -1869,6 +1874,16 @@ def _interrupt_check() -> bool:
except (OSError, RuntimeError) as exc:
log.debug("lifecycle_lock release failed: %s", exc)
finally:
try:
if getattr(store, "_write_queue", None) is not None:
await store.disable_async_writes()
except Exception as exc: # noqa: BLE001 -- cancellation cleanup must finish
log.debug("outer async-write cleanup failed: %s", exc, exc_info=True)
try:
if lifecycle_lock.is_held_by_self():
lifecycle_lock.release()
except (OSError, RuntimeError) as exc:
log.debug("outer lifecycle-lock release failed: %s", exc)
_restore_embedder_funnel(_orig_efs, _override_installed)
return 0

Expand Down
12 changes: 7 additions & 5 deletions src/iai_mcp/daemon/_boot_warmup.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ def warm_dispatch_surface(store: Any) -> dict:
These are one-time per-process costs that otherwise land on the FIRST
real recall. The daemon runs this BEFORE binding its socket — a memory
that answers the socket before its recall surface is resident is not
awake yet — and the full warm-up re-invokes it as its first shape (an
idempotent no-op then). All read-only: an embed produces a vector and
discards it, and load_recall_structural only reads/decodes the on-disk
cache. Returns a summary dict; never raises.
awake yet — then starts the remaining warm-up shapes without repeating
this encode. All read-only: an embed produces a vector and discards it,
and load_recall_structural only reads/decodes the on-disk cache. Returns
a summary dict; never raises.
"""
_t0 = time.perf_counter()
try:
Expand All @@ -156,6 +156,7 @@ def run_boot_warmup(
*,
probe_count: int = _DEFAULT_PROBE_COUNT,
k: int = _DEFAULT_K,
warm_dispatch: bool = True,
) -> dict:
"""Fire the real first-recall read shapes once, write-free.

Expand All @@ -170,7 +171,8 @@ def run_boot_warmup(
shapes: dict[str, dict] = {}
probe_hit_ids: list[str] = []

shapes["dispatch_surface"] = warm_dispatch_surface(store)
if warm_dispatch:
shapes["dispatch_surface"] = warm_dispatch_surface(store)

try:
probes = _sample_probe_embeddings(store, probe_count)
Expand Down
5 changes: 5 additions & 0 deletions src/iai_mcp/spatial_tagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ def tag(
wing = components[i + 1]
break

if wing is None and any(
part in {"IAI-MCP", "iai-mcp", "iai_mcp"} for part in components
):
wing = "IAI-MCP"

if wing is None:
for part in components:
if part in DEFAULT_WINGS:
Expand Down
9 changes: 9 additions & 0 deletions tests/test_bench_lme_blind_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ def __init__(self, qid: str, question_type: str = "test") -> None:
def _patch_adapter(monkeypatch, qids: list[str] | None = None) -> None:
sessions = [_StubLMESession(qid) for qid in (qids or [])]

def _stub_cleaned_init(self, revision=None):
self.revision = revision or "test-revision"

def _stub_load_dataset(self, split="S"):
yield from sessions

from bench.adapters.longmemeval import LongMemEvalAdapter
from bench.adapters.longmemeval_cleaned import CleanedLongMemEvalAdapter

monkeypatch.setattr(
CleanedLongMemEvalAdapter,
"__init__",
_stub_cleaned_init,
raising=True,
)
monkeypatch.setattr(
LongMemEvalAdapter, "load_dataset", _stub_load_dataset, raising=True
)
Expand Down
30 changes: 24 additions & 6 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import plistlib
import threading
from pathlib import Path

import pytest
Expand All @@ -27,6 +28,7 @@ def _short_socket_paths(tmp_path, monkeypatch):
sock_dir.mkdir(parents=True, exist_ok=True)
sock_path = sock_dir / "d.sock"
monkeypatch.setattr(concurrency, "SOCKET_PATH", sock_path)
monkeypatch.setenv("IAI_DAEMON_SOCKET_PATH", str(sock_path))
return lock_path, sock_path, sock_dir


Expand Down Expand Up @@ -134,6 +136,7 @@ async def runner():
def test_prewarm_called_once_at_boot(tmp_path, monkeypatch):
from iai_mcp import daemon as daemon_mod
from iai_mcp import daemon_state as ds_mod
from iai_mcp.daemon import _boot_warmup

monkeypatch.setenv("IAI_MCP_STORE", str(tmp_path / "iai"))
monkeypatch.setenv("IAI_MCP_EMBED_DIM", "384")
Expand All @@ -152,16 +155,31 @@ def _fake_embedder(store):

monkeypatch.setattr("iai_mcp.embed.embedder_for_store", _fake_embedder)

warmup_finished = threading.Event()
real_warm_dispatch_surface = _boot_warmup.warm_dispatch_surface

def _observed_warm_dispatch_surface(store):
try:
return real_warm_dispatch_surface(store)
finally:
warmup_finished.set()

monkeypatch.setattr(
_boot_warmup, "warm_dispatch_surface", _observed_warm_dispatch_surface
)

async def runner():
task = asyncio.create_task(daemon_mod.main())
await asyncio.sleep(0.15)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
return await asyncio.to_thread(warmup_finished.wait, 5)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass

asyncio.run(runner())
assert asyncio.run(runner()), "daemon did not complete its pre-bind dispatch warmup"
assert prewarm_calls["n"] == 1, (
f"prewarm expected once, got {prewarm_calls['n']}"
)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_daemon_fdlimit_and_fsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def test_rendered_plist_contains_fd_floor(self, tmp_path, monkeypatch):
assert "SoftResourceLimits" in rendered
assert "NumberOfFiles" in rendered

import defusedxml.ElementTree as ET
import xml.etree.ElementTree as ET

root = ET.fromstring(rendered)
top_dict = root.find("dict")
Expand Down
14 changes: 7 additions & 7 deletions tests/test_exact_authority_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import threading

import numpy as np
import pytest

from iai_mcp.store._exact_index import ExactCosineIndex

Expand Down Expand Up @@ -51,12 +50,13 @@ def _decode_matrix(rows: list[tuple[str, bytes]], dim: int = EMBED_DIM) -> np.nd


def _normalize_rows(m: np.ndarray) -> np.ndarray:
"""L2-normalize each row; zero-norm rows stay zero."""
norms = np.linalg.norm(m, axis=1, keepdims=True)
safe = np.where(norms == 0, 1.0, norms)
out = m / safe
out[norms.squeeze(-1) == 0] = 0.0
return out.astype(np.float32)
"""L2-normalize row by row, matching build/upsert bit-for-bit."""
out = np.zeros_like(m, dtype=np.float32)
for index, row in enumerate(m):
norm = float(np.linalg.norm(row))
if norm != 0.0:
out[index] = (row / norm).astype(np.float32)
return out


def _brute_force_top_k(
Expand Down
Loading
Loading