Skip to content

Commit 47f7175

Browse files
committed
Fix Windows daemon compatibility, lifecycle-lock race, and schema confidence gate
1 parent 2ad7589 commit 47f7175

15 files changed

Lines changed: 218 additions & 63 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.0.3] — 2026-07-11
9+
10+
### Fixed
11+
12+
- **Windows daemon compatibility.** The background daemon now starts, stops, and
13+
reports its own liveness correctly on Windows — shutdown terminates the whole
14+
process tree so the store lock is released, the encryption key file is written
15+
in binary mode and replaced atomically, and file locking uses a Windows-native
16+
region lock so the store package imports and runs.
17+
- **Duplicate-daemon race.** The lifecycle lock is now claimed atomically, so two
18+
processes starting at the same time can no longer both become the daemon.
19+
- **Schema confidence gate.** Mid-confidence memory-schema patterns now correctly
20+
reach the user-approval path instead of being dropped.
21+
822
## [2.0.2] — 2026-07-11
923

1024
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<p align="center"><b>Persistent memory for any MCP client.</b> Local, encrypted, verbatim recall via vector search + knowledge graph. MIT.</p>
1010

1111
<p align="center">
12-
<img src="https://img.shields.io/badge/release-v2.0.0-1f6feb?style=flat-square" alt="Release v2.0.0">
12+
<img src="https://img.shields.io/badge/release-v2.0.3-1f6feb?style=flat-square" alt="Release v2.0.3">
1313
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-1f6feb?style=flat-square" alt="License: MIT"></a>
1414
<img src="https://img.shields.io/badge/python-3.11%20|%203.12-3776ab?style=flat-square&logo=python&logoColor=white" alt="Python 3.11 | 3.12">
1515
<img src="https://img.shields.io/badge/platform-macOS%20|%20Linux-555?style=flat-square" alt="Platform: macOS and Linux">

README_zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<p align="center">每一项声明都附带可复现的基准测试脚本。你可以自己跑一遍验证。</p>
1515

1616
<p align="center">
17-
<img src="https://img.shields.io/badge/release-v2.0.0-1f6feb?style=flat-square" alt="Release v2.0.0">
17+
<img src="https://img.shields.io/badge/release-v2.0.3-1f6feb?style=flat-square" alt="Release v2.0.3">
1818
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-1f6feb?style=flat-square" alt="License: MIT"></a>
1919
<img src="https://img.shields.io/badge/python-3.11%20|%203.12-3776ab?style=flat-square&logo=python&logoColor=white" alt="Python 3.11 | 3.12">
2020
<img src="https://img.shields.io/badge/platform-macOS%20|%20Linux-555?style=flat-square" alt="Platform: macOS and Linux">

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "iai-mcp"
3-
version = "2.0.0"
3+
version = "2.0.3"
44
description = "MCP server giving Claude persistent autistic-style memory + learning"
55
requires-python = ">=3.11,<3.13"
66
dependencies = [

src/iai_mcp/_flock.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""flock() compatibility seam.
2+
3+
POSIX re-exports ``fcntl.flock`` and its constants verbatim. Windows maps the
4+
same surface onto an ``msvcrt`` 1-byte region lock: every holder locks the
5+
first byte, which is all flock's whole-file-mutex semantics need. Windows has
6+
no shared locks — ``LOCK_SH`` degrades to exclusive, and ``SHARED_IS_EXCLUSIVE``
7+
tells lock-mode transitions (shared→exclusive and back) to relabel their
8+
bookkeeping instead of re-locking a region this process already owns (a
9+
same-process re-lock raises on Windows). A non-blocking failure is re-raised
10+
with ``EAGAIN`` so POSIX-shaped retry loops work unchanged.
11+
"""
12+
from __future__ import annotations
13+
14+
try:
15+
from fcntl import LOCK_EX, LOCK_NB, LOCK_SH, LOCK_UN, flock # noqa: F401
16+
17+
SHARED_IS_EXCLUSIVE = False
18+
except ImportError: # Windows
19+
import errno as _errno
20+
import msvcrt as _msvcrt
21+
import os as _os
22+
23+
LOCK_SH = 0x1
24+
LOCK_EX = 0x2
25+
LOCK_NB = 0x4
26+
LOCK_UN = 0x8
27+
SHARED_IS_EXCLUSIVE = True
28+
29+
def flock(fd: int, operation: int) -> None:
30+
_os.lseek(fd, 0, _os.SEEK_SET)
31+
if operation & LOCK_UN:
32+
try:
33+
_msvcrt.locking(fd, _msvcrt.LK_UNLCK, 1)
34+
except OSError:
35+
# flock(LOCK_UN) on an unheld lock is a no-op; mirror that.
36+
pass
37+
return
38+
mode = _msvcrt.LK_NBLCK if operation & LOCK_NB else _msvcrt.LK_LOCK
39+
try:
40+
_msvcrt.locking(fd, mode, 1)
41+
except OSError as exc:
42+
raise OSError(_errno.EAGAIN, "lock held (msvcrt)") from exc

src/iai_mcp/cli/_daemon.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,10 @@ def cmd_daemon_stop(args: argparse.Namespace) -> int:
284284
except (OSError, ValueError, RuntimeError) as exc:
285285
logger.debug("sentinel write failed (non-blocking): %s", exc)
286286

287-
uid = os.getuid()
288287
if _cli._is_macos():
289288
from iai_mcp.lifecycle_lock import LifecycleLock, _is_pid_alive
290289

290+
uid = os.getuid()
291291
payload = LifecycleLock().read()
292292
pid = payload["pid"] if payload else None
293293

@@ -324,6 +324,19 @@ def cmd_daemon_stop(args: argparse.Namespace) -> int:
324324
["systemctl", "--user", "stop", _cli.SERVICE_NAME],
325325
check=False,
326326
)
327+
elif os.name == "nt":
328+
# Windows: signals cannot stop the tree — a plain terminate orphans
329+
# the child that still holds the hippo lock. taskkill /T fells the
330+
# whole process tree; /F because there is no SIGTERM grace concept.
331+
from iai_mcp.lifecycle_lock import LifecycleLock, _is_pid_alive
332+
333+
payload = LifecycleLock().read()
334+
pid = payload["pid"] if payload else None
335+
if pid is not None and _is_pid_alive(pid):
336+
_cli.subprocess.run(
337+
["taskkill", "/F", "/T", "/PID", str(pid)],
338+
check=False, capture_output=True,
339+
)
327340
else:
328341
print(f"Unsupported OS: {platform.system()}", file=sys.stderr)
329342
return 1

src/iai_mcp/crypto.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,18 @@ def _try_file_set(self, key: bytes) -> None:
221221
except OSError:
222222
pass
223223
tmp = final.parent / f"{final.name}.tmp.{os.getpid()}"
224-
fd = os.open(str(tmp), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
224+
# O_BINARY: without it the Windows CRT opens the fd in text mode and
225+
# rewrites any 0x0A key byte as 0x0D0A — a silently corrupted AES key.
226+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0)
227+
fd = os.open(str(tmp), flags, 0o600)
225228
try:
226-
os.fchmod(fd, 0o600)
229+
if hasattr(os, "fchmod"):
230+
os.fchmod(fd, 0o600)
227231
os.write(fd, key)
228232
os.fsync(fd)
229233
finally:
230234
os.close(fd)
231-
os.rename(str(tmp), str(final))
235+
os.replace(str(tmp), str(final))
232236

233237

234238
def get_or_create(self) -> bytes:

src/iai_mcp/daemon/_watchdog.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,11 @@ def _self_kill(reason: str, kind: str) -> None:
620620
_write_breadcrumb(line)
621621
except Exception: # noqa: BLE001 -- breadcrumb is best-effort ONLY
622622
pass
623-
os.kill(os.getpid(), signal.SIGKILL)
623+
if hasattr(signal, "SIGKILL"):
624+
os.kill(os.getpid(), signal.SIGKILL)
625+
# No SIGKILL on this platform (Windows): os.kill from a background thread
626+
# cannot deliver it, so a wedged daemon would never die — hard-exit.
627+
os._exit(1)
624628

625629

626630
def _capture_blackbox(

src/iai_mcp/hippo/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import contextlib
44
import enum
55
import errno
6-
import fcntl
6+
from iai_mcp import _flock as fcntl
77
import logging
88
import os
99
import re

src/iai_mcp/hippo/_db.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import contextlib
66
import errno
7-
import fcntl
7+
from iai_mcp import _flock as fcntl
88
import logging
99
import os
1010
import re
@@ -788,10 +788,17 @@ def downgrade_to_shared(self) -> None:
788788
if held is None:
789789
return
790790
base_fd, refcount = held
791-
try:
792-
fcntl.flock(base_fd, fcntl.LOCK_SH)
793-
except OSError:
794-
return
791+
if fcntl.SHARED_IS_EXCLUSIVE:
792+
# No shared locks on this platform: a re-lock of the region
793+
# this process already owns raises (and a blocking one polls
794+
# forever against itself) — keep the held exclusive region and
795+
# change only the bookkeeping label.
796+
pass
797+
else:
798+
try:
799+
fcntl.flock(base_fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
800+
except OSError:
801+
return
795802
del _PROCESS_LOCKS[self._lock_key]
796803
_PROCESS_LOCKS_SHARED[self._lock_key] = (base_fd, refcount)
797804
self._access_mode = AccessMode.SHARED
@@ -843,7 +850,11 @@ def escalate_to_exclusive(self, intent_budget_ms: int = 4000) -> None:
843850

844851
deadline = time.monotonic() + intent_budget_ms / 1000.0
845852
acquired = False
846-
while time.monotonic() < deadline:
853+
if held is not None and fcntl.SHARED_IS_EXCLUSIVE:
854+
# The "shared" label on this platform already holds the exclusive
855+
# region — re-locking it would fail against ourselves. Relabel.
856+
acquired = True
857+
while not acquired and time.monotonic() < deadline:
847858
try:
848859
fcntl.flock(base_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
849860
acquired = True

0 commit comments

Comments
 (0)