Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
281
tests/test_daemon_no_silent_zero_exit.py
Normal file
281
tests/test_daemon_no_silent_zero_exit.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""Phase 10.6 Plan 10.6-01 Task 1.8 -- rewritten contract tests.
|
||||
|
||||
Old contract (Phase 07.8 + bug-fix 2026-05-01):
|
||||
Every non-RSS, non-user shutdown path returned exit 75. The
|
||||
`user_requested_shutdown` sentinel + `_resolve_shutdown_exit_code`
|
||||
helper differentiated explicit `iai-mcp daemon stop` (exit 0,
|
||||
plist suppresses respawn) from every other shutdown path
|
||||
(exit 75, plist respawns).
|
||||
|
||||
New contract:
|
||||
Daemon main() exits 0 uniformly on graceful shutdown, regardless
|
||||
of who triggered it. The plist's `KeepAlive={"Crashed": true}`
|
||||
ensures graceful exit 0 stays DEAD until wrapper kickstart fires.
|
||||
Only path returning a non-zero exit is `LifecycleLockConflict`
|
||||
(a same-host live-PID conflict) which returns 1.
|
||||
|
||||
Cross-process invariant PRESERVED from 541c874:
|
||||
The CLI `iai-mcp daemon stop` runs in a SEPARATE process from
|
||||
the daemon. CLI writes the `user_requested_shutdown=True`
|
||||
sentinel to `.daemon-state.json` BEFORE sending SIGTERM. The
|
||||
daemon's main() finally block calls
|
||||
`_clear_user_shutdown_sentinel(state)` which:
|
||||
1. Reads the on-disk state file (the source of truth, since
|
||||
the in-memory state was loaded at boot).
|
||||
2. Pops the sentinel from disk + memory.
|
||||
3. Re-saves the cleaned state record.
|
||||
|
||||
The sentinel is now informational rather than control: its presence
|
||||
on disk no longer changes the exit code. Tests E + F still verify
|
||||
the CLI write-before-SIGTERM ordering -- that ordering is what
|
||||
makes the daemon's later cleanup symmetric across boots.
|
||||
|
||||
Validates: WAKE-14.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from iai_mcp import cli as cli_mod
|
||||
from iai_mcp import daemon as daemon_mod
|
||||
from iai_mcp import daemon_state as state_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test A -- _clear_user_shutdown_sentinel: clean state -> in-memory pop only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_sentinel_no_disk_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""No sentinel on disk + no in-memory flag -> helper is a no-op.
|
||||
|
||||
Locks the regression where a clean shutdown without an explicit
|
||||
`iai-mcp daemon stop` must leave the on-disk record consistent
|
||||
(no spurious sentinel write, no exception).
|
||||
"""
|
||||
state_path = tmp_path / ".daemon-state.json"
|
||||
monkeypatch.setattr(state_mod, "STATE_PATH", state_path, raising=True)
|
||||
|
||||
state: dict = {"fsm_state": "WAKE", "daemon_pid": 12345}
|
||||
snapshot = dict(state)
|
||||
daemon_mod._clear_user_shutdown_sentinel(state)
|
||||
# In-memory dict shape is preserved (no spurious keys / drops).
|
||||
assert state == snapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test B -- sentinel True on disk -> cleared from disk + memory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_sentinel_true_on_disk(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Production flow: CLI process wrote sentinel to disk; daemon
|
||||
clears it on graceful exit so it does not leak across boots.
|
||||
"""
|
||||
state_path = tmp_path / ".daemon-state.json"
|
||||
monkeypatch.setattr(state_mod, "STATE_PATH", state_path, raising=True)
|
||||
state_mod.save_state(
|
||||
{"user_requested_shutdown": True, "fsm_state": "WAKE"}
|
||||
)
|
||||
|
||||
daemon_in_memory: dict = {
|
||||
"fsm_state": "DREAMING",
|
||||
"daemon_pid": 999,
|
||||
# No "user_requested_shutdown" key here -- production reality.
|
||||
}
|
||||
daemon_mod._clear_user_shutdown_sentinel(daemon_in_memory)
|
||||
|
||||
# Disk-side sentinel is gone.
|
||||
on_disk = state_mod.load_state()
|
||||
assert "user_requested_shutdown" not in on_disk
|
||||
# In-memory dict picked up no spurious flag.
|
||||
assert "user_requested_shutdown" not in daemon_in_memory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test C -- helper does not mutate unrelated keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_sentinel_preserves_unrelated_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The helper does exactly one in-memory mutation
|
||||
(`state.pop(_USER_SHUTDOWN_FLAG, None)`). Any future refactor
|
||||
that adds drive-by mutations would silently drop fields like
|
||||
daemon_pid / fsm_state / pending_digest, which main()'s finally
|
||||
block depends on for the doctor / next-boot pipeline.
|
||||
"""
|
||||
state_path = tmp_path / ".daemon-state.json"
|
||||
monkeypatch.setattr(state_mod, "STATE_PATH", state_path, raising=True)
|
||||
state_mod.save_state({"user_requested_shutdown": True, "fsm_state": "WAKE"})
|
||||
|
||||
snapshot = {
|
||||
"fsm_state": "DREAMING",
|
||||
"daemon_pid": 42,
|
||||
"pending_digest": {"rem_cycles_completed": 79},
|
||||
"user_requested_shutdown": True,
|
||||
"fsm_transition_at": "2026-05-01T10:17:54+00:00",
|
||||
}
|
||||
state = dict(snapshot)
|
||||
daemon_mod._clear_user_shutdown_sentinel(state)
|
||||
expected = {
|
||||
k: v for k, v in snapshot.items() if k != "user_requested_shutdown"
|
||||
}
|
||||
assert state == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test D -- read failure during shutdown is fail-safe (in-memory pop only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_sentinel_disk_read_failure_is_fail_safe(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If load_state() raises (transient FS error / corrupt file),
|
||||
the helper must NOT propagate -- shutdown must always proceed.
|
||||
"""
|
||||
|
||||
def boom() -> dict:
|
||||
raise OSError("simulated transient read error")
|
||||
|
||||
monkeypatch.setattr(daemon_mod, "load_state", boom)
|
||||
|
||||
state: dict = {"fsm_state": "WAKE", "user_requested_shutdown": True}
|
||||
daemon_mod._clear_user_shutdown_sentinel(state)
|
||||
# In-memory still gets popped even when disk read fails.
|
||||
assert "user_requested_shutdown" not in state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test E -- cmd_daemon_stop writes the sentinel BEFORE launchctl (macOS)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_e_cmd_daemon_stop_writes_sentinel_before_launchctl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Cross-process invariant from 541c874 PRESERVED:
|
||||
`iai-mcp daemon stop` writes user_requested_shutdown=True to
|
||||
.daemon-state.json BEFORE sending SIGTERM. The daemon's later
|
||||
`_clear_user_shutdown_sentinel` then cleans up. Phase 10.6
|
||||
no longer branches the exit code on the sentinel, but the
|
||||
write-before-SIGTERM ordering is still part of the wakeup-
|
||||
safe shutdown protocol (a hung CLI write must not delay the
|
||||
SIGTERM the user expects).
|
||||
"""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
|
||||
state_path = tmp_path / ".daemon-state.json"
|
||||
monkeypatch.setattr(state_mod, "STATE_PATH", state_path, raising=True)
|
||||
|
||||
call_log: list[str] = []
|
||||
|
||||
real_save_state = state_mod.save_state
|
||||
|
||||
def tracking_save_state(state: dict) -> None:
|
||||
call_log.append(f"save_state:{state.get('user_requested_shutdown')}")
|
||||
real_save_state(state)
|
||||
|
||||
monkeypatch.setattr(state_mod, "save_state", tracking_save_state)
|
||||
|
||||
def fake_run(argv, **_kwargs):
|
||||
call_log.append(f"subprocess.run:{argv[0]}:{argv[1]}")
|
||||
return type("R", (), {"returncode": 0})()
|
||||
|
||||
monkeypatch.setattr(cli_mod.subprocess, "run", fake_run)
|
||||
|
||||
rc = cli_mod.main(["daemon", "stop"])
|
||||
assert rc == 0
|
||||
|
||||
import json as json_mod
|
||||
persisted = json_mod.loads(state_path.read_text())
|
||||
assert persisted.get("user_requested_shutdown") is True
|
||||
|
||||
assert call_log[0].startswith("save_state:True"), call_log
|
||||
assert any(
|
||||
entry.startswith("subprocess.run:launchctl") for entry in call_log
|
||||
), call_log
|
||||
save_idx = next(
|
||||
i for i, e in enumerate(call_log) if e.startswith("save_state:")
|
||||
)
|
||||
launchctl_idx = next(
|
||||
i for i, e in enumerate(call_log)
|
||||
if e.startswith("subprocess.run:launchctl")
|
||||
)
|
||||
assert save_idx < launchctl_idx, call_log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test F -- cmd_daemon_stop writes the sentinel BEFORE systemctl (Linux)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_f_cmd_daemon_stop_writes_sentinel_before_systemctl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Linux variant of Test E. Same ordering invariant, different
|
||||
process-supervisor command.
|
||||
"""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
|
||||
state_path = tmp_path / ".daemon-state.json"
|
||||
monkeypatch.setattr(state_mod, "STATE_PATH", state_path, raising=True)
|
||||
|
||||
call_log: list[str] = []
|
||||
|
||||
real_save_state = state_mod.save_state
|
||||
|
||||
def tracking_save_state(state: dict) -> None:
|
||||
call_log.append(f"save_state:{state.get('user_requested_shutdown')}")
|
||||
real_save_state(state)
|
||||
|
||||
monkeypatch.setattr(state_mod, "save_state", tracking_save_state)
|
||||
|
||||
def fake_run(argv, **_kwargs):
|
||||
call_log.append(f"subprocess.run:{argv[0]}")
|
||||
return type("R", (), {"returncode": 0})()
|
||||
|
||||
monkeypatch.setattr(cli_mod.subprocess, "run", fake_run)
|
||||
|
||||
rc = cli_mod.main(["daemon", "stop"])
|
||||
assert rc == 0
|
||||
|
||||
import json as json_mod
|
||||
persisted = json_mod.loads(state_path.read_text())
|
||||
assert persisted.get("user_requested_shutdown") is True
|
||||
|
||||
save_idx = next(
|
||||
i for i, e in enumerate(call_log) if e.startswith("save_state:")
|
||||
)
|
||||
systemctl_idx = next(
|
||||
i for i, e in enumerate(call_log)
|
||||
if e.startswith("subprocess.run:systemctl")
|
||||
)
|
||||
assert save_idx < systemctl_idx, call_log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test G -- _USER_SHUTDOWN_FLAG constant pinned (cross-process protocol)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_g_user_shutdown_flag_constant_is_stable() -> None:
|
||||
"""The CLI (separate process) and daemon both reference this
|
||||
string literal in different code paths; renaming it would silently
|
||||
break the cross-process protocol from 541c874.
|
||||
"""
|
||||
assert daemon_mod._USER_SHUTDOWN_FLAG == "user_requested_shutdown"
|
||||
Loading…
Add table
Add a link
Reference in a new issue