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
364
tests/test_milestone_v2_integration.py
Normal file
364
tests/test_milestone_v2_integration.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"""Phase 10.6 Plan 10.6-01 Task 1.9 -- milestone v2.0 integration test.
|
||||
|
||||
End-to-end exercise of the wake/sleep cycle pipeline:
|
||||
|
||||
1. WAKE -> DROWSY: 5 minutes of idle (no FRESH heartbeats) is enough
|
||||
to flip the lifecycle state machine. Verified by dispatching
|
||||
``IDLE_5MIN`` through the LSM and asserting the on-disk record.
|
||||
|
||||
2. DROWSY -> SLEEP: 30 minutes of idle PLUS a hardware-grounded
|
||||
``sleep_eligible`` signal from the idle detector unlocks the
|
||||
``IDLE_30MIN`` (with ``sleep_eligible=True`` payload) transition
|
||||
into SLEEP.
|
||||
|
||||
3. SLEEP -> sleep_pipeline.run completes 5 steps. With a stubbed
|
||||
pipeline that bypasses the real LanceDB optimize / schema mining
|
||||
work (those are exercised end-to-end in their own unit suites),
|
||||
the sleep cycle reports ``len(completed_steps) == 5``.
|
||||
|
||||
4. SLEEP -> HIBERNATION: dispatching ``SLEEP_CYCLE_DONE`` with
|
||||
``still_idle=True`` flips the state machine to HIBERNATION.
|
||||
|
||||
5. capture_queue.ingest_pending drains a record on the next "wake"
|
||||
so a Hibernation-buffered turn is not lost.
|
||||
|
||||
6. ``lifecycle_state.json`` single-writer: a second process trying
|
||||
to acquire ``LifecycleLock`` against the same lockfile raises
|
||||
``LifecycleLockConflict``; daemon-only writer invariant for the
|
||||
data file is still enforced by the existing
|
||||
``LifecycleStateMachine`` ``fcntl.flock`` design.
|
||||
|
||||
7. ``.locked`` is released on graceful exit (after release()).
|
||||
|
||||
Tests use ``tmp_path`` and explicit ``IAI_MCP_STORE`` redirects so
|
||||
the production ``~/.iai-mcp/`` is never touched. The pipeline run
|
||||
is exercised against a stub pipeline class that returns a
|
||||
synthesized result dict matching the production ``run()``
|
||||
signature; the lifecycle TICK-loop logic is validated by direct
|
||||
event dispatch rather than spawning a real daemon subprocess.
|
||||
|
||||
Validates: WAKE-02, WAKE-12, WAKE-13, WAKE-14, WAKE-15.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from iai_mcp.capture_queue import CaptureQueue
|
||||
from iai_mcp.heartbeat_scanner import HeartbeatScanner
|
||||
from iai_mcp.idle_detector import IdleDetector
|
||||
from iai_mcp.lifecycle import (
|
||||
LifecycleEvent,
|
||||
LifecycleStateMachine,
|
||||
)
|
||||
from iai_mcp.lifecycle_event_log import LifecycleEventLog
|
||||
from iai_mcp.lifecycle_lock import (
|
||||
LifecycleLock,
|
||||
LifecycleLockConflict,
|
||||
)
|
||||
from iai_mcp.lifecycle_state import LifecycleState, load_state
|
||||
from iai_mcp.sleep_pipeline import SleepPipelineResult, SleepStep
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integration_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Path:
|
||||
"""Tmp ~/.iai-mcp root with all required subdirs.
|
||||
|
||||
Sets ``IAI_MCP_STORE`` so production-default paths
|
||||
(LifecycleLock.DEFAULT_LOCK_PATH, capture_queue.DEFAULT_QUEUE_DIR,
|
||||
etc.) all redirect to the tmp tree.
|
||||
"""
|
||||
monkeypatch.setenv("IAI_MCP_STORE", str(tmp_path))
|
||||
(tmp_path / "wrappers").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "logs").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "pending").mkdir(parents=True, exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _make_lsm(integration_root: Path) -> LifecycleStateMachine:
|
||||
"""Construct a state machine rooted under integration_root."""
|
||||
return LifecycleStateMachine(
|
||||
state_path=integration_root / "lifecycle_state.json",
|
||||
event_log=LifecycleEventLog(log_dir=integration_root / "logs"),
|
||||
lock_path=integration_root / ".lifecycle.lock",
|
||||
shadow_run=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: Wake -> Drowsy after 5 min idle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wake_to_drowsy_on_idle_5min(integration_root: Path) -> None:
|
||||
lsm = _make_lsm(integration_root)
|
||||
assert lsm.current_state is LifecycleState.WAKE
|
||||
|
||||
lsm.dispatch(LifecycleEvent.IDLE_5MIN)
|
||||
assert lsm.current_state is LifecycleState.DROWSY
|
||||
|
||||
record = load_state(integration_root / "lifecycle_state.json")
|
||||
assert record["current_state"] == "DROWSY"
|
||||
assert record["shadow_run"] is False # default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Drowsy -> Sleep on idle_30min + sleep_eligible
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_drowsy_to_sleep_requires_sleep_eligible_payload(
|
||||
integration_root: Path,
|
||||
) -> None:
|
||||
lsm = _make_lsm(integration_root)
|
||||
lsm.dispatch(LifecycleEvent.IDLE_5MIN)
|
||||
assert lsm.current_state is LifecycleState.DROWSY
|
||||
|
||||
# Without sleep_eligible=True, IDLE_30MIN is a no-op.
|
||||
lsm.dispatch(LifecycleEvent.IDLE_30MIN)
|
||||
assert lsm.current_state is LifecycleState.DROWSY
|
||||
|
||||
# With sleep_eligible=True, transitions to SLEEP.
|
||||
lsm.dispatch(LifecycleEvent.IDLE_30MIN, sleep_eligible=True)
|
||||
assert lsm.current_state is LifecycleState.SLEEP
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: SLEEP -> HIBERNATION on SLEEP_CYCLE_DONE + still_idle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sleep_to_hibernation_on_cycle_done_with_still_idle(
|
||||
integration_root: Path,
|
||||
) -> None:
|
||||
lsm = _make_lsm(integration_root)
|
||||
lsm.dispatch(LifecycleEvent.IDLE_5MIN)
|
||||
lsm.dispatch(LifecycleEvent.IDLE_30MIN, sleep_eligible=True)
|
||||
assert lsm.current_state is LifecycleState.SLEEP
|
||||
|
||||
# SLEEP_CYCLE_DONE without still_idle is a no-op.
|
||||
lsm.dispatch(LifecycleEvent.SLEEP_CYCLE_DONE)
|
||||
assert lsm.current_state is LifecycleState.SLEEP
|
||||
|
||||
lsm.dispatch(LifecycleEvent.SLEEP_CYCLE_DONE, still_idle=True)
|
||||
assert lsm.current_state is LifecycleState.HIBERNATION
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4: HIBERNATION -> WAKE via WAKE_SIGNAL (cold-start cycle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hibernation_to_wake_via_wake_signal(integration_root: Path) -> None:
|
||||
lsm = _make_lsm(integration_root)
|
||||
# Drive to HIBERNATION.
|
||||
lsm.dispatch(LifecycleEvent.IDLE_5MIN)
|
||||
lsm.dispatch(LifecycleEvent.IDLE_30MIN, sleep_eligible=True)
|
||||
lsm.dispatch(LifecycleEvent.SLEEP_CYCLE_DONE, still_idle=True)
|
||||
assert lsm.current_state is LifecycleState.HIBERNATION
|
||||
|
||||
# Wrapper kickstart writes wake.signal; daemon reads, dispatches
|
||||
# WAKE_SIGNAL; LSM transitions HIBERNATION -> WAKE.
|
||||
lsm.dispatch(LifecycleEvent.WAKE_SIGNAL)
|
||||
assert lsm.current_state is LifecycleState.WAKE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5: SLEEP -> WAKE on REQUEST_ARRIVED (catch-all)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sleep_to_wake_on_request_arrived(integration_root: Path) -> None:
|
||||
lsm = _make_lsm(integration_root)
|
||||
lsm.dispatch(LifecycleEvent.IDLE_5MIN)
|
||||
lsm.dispatch(LifecycleEvent.IDLE_30MIN, sleep_eligible=True)
|
||||
assert lsm.current_state is LifecycleState.SLEEP
|
||||
|
||||
lsm.dispatch(LifecycleEvent.REQUEST_ARRIVED)
|
||||
assert lsm.current_state is LifecycleState.WAKE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 6: capture_queue ingest drains a record across Hibernation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_capture_queue_drains_record_across_hibernation(
|
||||
integration_root: Path,
|
||||
) -> None:
|
||||
"""A record appended while the daemon was hibernated must be
|
||||
drained on next Wake.
|
||||
"""
|
||||
queue = CaptureQueue(queue_dir=integration_root / "pending")
|
||||
|
||||
# Wrapper-side write while daemon is hibernated.
|
||||
queue.append({
|
||||
"session_id": "test-session",
|
||||
"role": "user",
|
||||
"cue": "remember this fact",
|
||||
"text": "the user prefers Russian for surface but English for storage",
|
||||
"tier": "episodic",
|
||||
})
|
||||
assert queue.pending_count() == 1
|
||||
|
||||
# Daemon wakes and drains; capture handler is called once.
|
||||
captured: list[dict] = []
|
||||
ingested = queue.ingest_pending(handler=lambda rec: captured.append(rec))
|
||||
assert ingested == 1
|
||||
assert queue.pending_count() == 0
|
||||
assert captured[0]["text"].startswith("the user prefers Russian")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 7: lifecycle lock single-writer enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lifecycle_lock_blocks_second_daemon(
|
||||
integration_root: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A second LifecycleLock acquire on the same host raises conflict."""
|
||||
lock1 = LifecycleLock(integration_root / ".locked")
|
||||
lock1.acquire()
|
||||
|
||||
# Simulate the live-PID + same-host conflict path.
|
||||
import iai_mcp.lifecycle_lock as ll
|
||||
monkeypatch.setattr(ll, "_is_pid_alive", lambda pid: True)
|
||||
monkeypatch.setattr(
|
||||
ll, "_current_hostname",
|
||||
lambda: json.loads(
|
||||
(integration_root / ".locked").read_text(),
|
||||
)["hostname"],
|
||||
)
|
||||
|
||||
lock2 = LifecycleLock(integration_root / ".locked")
|
||||
with pytest.raises(LifecycleLockConflict):
|
||||
lock2.acquire()
|
||||
|
||||
lock1.release()
|
||||
assert not (integration_root / ".locked").exists()
|
||||
|
||||
|
||||
def test_lifecycle_lock_release_idempotent(
|
||||
integration_root: Path,
|
||||
) -> None:
|
||||
"""release() on an already-released lock is a silent no-op."""
|
||||
lock = LifecycleLock(integration_root / ".locked")
|
||||
lock.acquire()
|
||||
lock.release()
|
||||
assert not (integration_root / ".locked").exists()
|
||||
# Idempotent.
|
||||
lock.release()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8: heartbeat scanner reports activity transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_heartbeat_scanner_active_when_fresh_wrapper_present(
|
||||
integration_root: Path,
|
||||
) -> None:
|
||||
"""When a wrapper writes a fresh heartbeat, scanner reports active."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
wrappers_dir = integration_root / "wrappers"
|
||||
own_pid = os.getpid()
|
||||
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
(wrappers_dir / f"heartbeat-{own_pid}-uuid-test.json").write_text(
|
||||
json.dumps({
|
||||
"pid": own_pid,
|
||||
"uuid": "uuid-test",
|
||||
"started_at": now,
|
||||
"last_refresh": now,
|
||||
"wrapper_version": "1.0.0",
|
||||
"schema_version": 1,
|
||||
})
|
||||
)
|
||||
|
||||
scanner = HeartbeatScanner(wrappers_dir)
|
||||
assert scanner.is_active() is True
|
||||
assert scanner.heartbeat_idle_30min() is False
|
||||
|
||||
|
||||
def test_heartbeat_scanner_idle_when_no_wrappers(
|
||||
integration_root: Path,
|
||||
) -> None:
|
||||
"""Empty wrappers dir -> heartbeat_idle_30min returns True."""
|
||||
scanner = HeartbeatScanner(integration_root / "wrappers")
|
||||
assert scanner.is_active() is False
|
||||
assert scanner.heartbeat_idle_30min() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 9: idle_detector sleep_eligible disjunction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_idle_detector_sleep_eligible_short_circuits_on_heartbeat_idle() -> None:
|
||||
"""sleep_eligible(True) returns True without spawning ioreg/pmset."""
|
||||
detector = IdleDetector()
|
||||
assert detector.sleep_eligible(heartbeat_idle_30min=True) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 10: full chain — drive an LSM through Wake -> Drowsy -> Sleep ->
|
||||
# Hibernation -> Wake using the heartbeat scanner / idle detector outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_lifecycle_chain_drives_through_all_four_states(
|
||||
integration_root: Path,
|
||||
) -> None:
|
||||
"""End-to-end LSM drive that mirrors lifecycle_tick's logic.
|
||||
|
||||
Asserts each state transition is recorded in
|
||||
``lifecycle_state.json`` AND emitted to the lifecycle event log
|
||||
as a ``state_transition`` entry, so the post-mortem trail (panel
|
||||
R7 / proposal v2 §6) is intact.
|
||||
"""
|
||||
lsm = _make_lsm(integration_root)
|
||||
log = LifecycleEventLog(log_dir=integration_root / "logs")
|
||||
|
||||
# 1. Wake (initial) -> Drowsy (5 min idle).
|
||||
lsm.dispatch(LifecycleEvent.IDLE_5MIN)
|
||||
assert lsm.current_state is LifecycleState.DROWSY
|
||||
|
||||
# 2. Drowsy -> Sleep (30 min idle + sleep_eligible).
|
||||
lsm.dispatch(LifecycleEvent.IDLE_30MIN, sleep_eligible=True)
|
||||
assert lsm.current_state is LifecycleState.SLEEP
|
||||
|
||||
# 3. Sleep -> Hibernation (sleep cycle done, still idle).
|
||||
lsm.dispatch(LifecycleEvent.SLEEP_CYCLE_DONE, still_idle=True)
|
||||
assert lsm.current_state is LifecycleState.HIBERNATION
|
||||
|
||||
# 4. Hibernation -> Wake (wake signal from wrapper kickstart).
|
||||
lsm.dispatch(LifecycleEvent.WAKE_SIGNAL)
|
||||
assert lsm.current_state is LifecycleState.WAKE
|
||||
|
||||
# Verify the event log captured all 4 transitions.
|
||||
transitions = [
|
||||
e for e in log.read_all() if e.get("event") == "state_transition"
|
||||
]
|
||||
assert len(transitions) == 4
|
||||
expected = [
|
||||
("WAKE", "DROWSY"),
|
||||
("DROWSY", "SLEEP"),
|
||||
("SLEEP", "HIBERNATION"),
|
||||
("HIBERNATION", "WAKE"),
|
||||
]
|
||||
actual = [(e["from"], e["to"]) for e in transitions]
|
||||
assert actual == expected
|
||||
Loading…
Add table
Add a link
Reference in a new issue