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
28 changes: 16 additions & 12 deletions src/iai_mcp/retrieve.py
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,20 @@ def _flush_buffered_edges_for_build(store: MemoryStore) -> None:
log.debug("edge-buffer flush before graph build failed: %s", exc)


def _replay_runtime_edge(graph, row: dict) -> None:
"""Replay an edge only when both endpoints are active graph nodes."""
src = UUID(row["src"])
dst = UUID(row["dst"])
if not graph.has_node(src) or not graph.has_node(dst):
return
graph.add_edge(
src,
dst,
weight=float(row["weight"]),
edge_type=row["edge_type"],
)


def build_runtime_graph(store: MemoryStore):
# Memoize the corpus COUNT(*) probes for the duration of this one build. The
# cache-key derivation, the drift gate, and the impl each ask for the active
Expand Down Expand Up @@ -1198,12 +1212,7 @@ def _build_runtime_graph_impl(store: MemoryStore):
if _edge_rows_since_yield >= _edge_chunk_rows:
_yield_to_event_loop()
_edge_rows_since_yield = 0
graph.add_edge(
UUID(row["src"]),
UUID(row["dst"]),
weight=float(row["weight"]),
edge_type=row["edge_type"],
)
_replay_runtime_edge(graph, row)

try:
deg_values = [d for _, d in graph.degrees()]
Expand Down Expand Up @@ -1609,12 +1618,7 @@ def build_runtime_graph_incremental(store: MemoryStore):
if edge_rows_since_yield >= edge_chunk_rows:
_yield_to_event_loop()
edge_rows_since_yield = 0
graph.add_edge(
UUID(row["src"]),
UUID(row["dst"]),
weight=float(row["weight"]),
edge_type=row["edge_type"],
)
_replay_runtime_edge(graph, row)

try:
deg_values = [d for _, d in graph.degrees()]
Expand Down
2 changes: 1 addition & 1 deletion src/iai_mcp/runtime_graph_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
rebuild_ready: threading.Event = threading.Event()


CACHE_VERSION: str = "62-02-v6"
CACHE_VERSION: str = "62-02-v7"

_STALENESS_WINDOW: int = 10

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
7 changes: 7 additions & 0 deletions tests/test_boot_rebuild_offpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import ast
import time
from pathlib import Path
from uuid import uuid4

import pytest

Expand Down Expand Up @@ -434,6 +435,12 @@ def test_incremental_delta_equals_full_rebuild_after_small_change(
store.boost_edges(
[(base_ids[0], new_recs[0].id)], delta=1.0, edge_type="hebbian",
)
# A stale edge can outlive a tombstoned/deleted record until the next
# maintenance sweep. Replaying it must not recreate the missing endpoint
# as a phantom runtime-graph node in either rebuild path.
store.boost_edges(
[(base_ids[0], uuid4())], delta=1.0, edge_type="hebbian",
)
# Equivalence is defined over ONE corpus state: drain the in-process edge
# buffer so both builds stream the identical committed edge set (buffer
# drain timing is the daemon's concern, not this comparison's).
Expand Down
2 changes: 1 addition & 1 deletion tests/test_centrality_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def embed(self, t):


def test_C4_cache_version_bumped_to_05_13_v1():
assert runtime_graph_cache.CACHE_VERSION == "62-02-v6"
assert runtime_graph_cache.CACHE_VERSION == "62-02-v7"


def test_C4_legacy_cache_invalidated(seeded_store, tmp_path: Path):
Expand Down
Loading
Loading