Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""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"
|