"""Phase 07.2-03 R2 / A2 regression test — cascade poll cooldown. Mechanism: mock `iai_mcp.daemon.time.monotonic` (the daemon-side cooldown clock) AND monkeypatch `HIPPEA_CASCADE_POLL_SEC` to 0.05s so the loop body re-enters fast on the real event loop, while the cooldown is gated by the mocked simulated-time clock. Drive the loop forward by advancing the mock clock in 5-second simulated steps; assert the body ran at most ceil(window/60)+1 = 6 times across the simulated 5-minute window. Both monkeypatches are required for the test to have teeth: - Without `HIPPEA_CASCADE_POLL_SEC=0.05`, the real-wall-time poll wait (5s) limits real iterations to ~1 in a 1.2s test window → `n==1` passes the `n <= 6` assertion trivially without any cooldown. - Without `time.monotonic` mocking, the cooldown gate sees real elapsed wall time (~1s in test) and never gates anything (60s threshold). Project async-test idiom (mandatory): sync `def test_*` + `asyncio.run`. """ from __future__ import annotations import asyncio from unittest.mock import patch import pytest @pytest.mark.skip( reason=( "Plan 07.2-03 documented fallback (Task 2 'Note on test pragmatism'): " "patching `iai_mcp.daemon.time.monotonic` deadlocks asyncio's internal " "scheduler — `BaseEventLoop.time()` reads `time.monotonic()` for every " "deadline, so frozen clock => `await asyncio.wait_for(...)` never " "expires. Plan explicitly pre-authorizes simplifying to " "`test_cooldown_clears_after_min_interval_elapsed` only (which proves " "the underlying elapsed-comparison gate logic without asyncio). The " "plan also forbids swapping to pytest-asyncio. R2 acceptance is " "carried by the unit test below + the gate code path's exclusive " "dependence on `time.monotonic - _last_cascade_completed_at` " "(mechanically equivalent under any clock that advances)." ) ) def test_at_most_six_cascades_over_five_minute_window_with_continuous_pending(monkeypatch): """R2 acceptance: cooldown caps cascade rate to ≤ 6 in 5 min.""" asyncio.run(_at_most_six_cascades_body(monkeypatch)) async def _at_most_six_cascades_body(monkeypatch): import iai_mcp.daemon as daemon_mod cascade_invocations: list[float] = [] sentinel_assignment = type("Asgmt", (), {"top_communities": [], "mid_regions": {}})() # Mock clock that we control. Initial value 1000.0; test advances it. clock = [1000.0] def fake_monotonic(): return clock[0] def counting_stub(store): cascade_invocations.append(fake_monotonic()) return (None, sentinel_assignment, []) async def fast_cascade_stub(store, assignment, **kwargs): return {"communities_selected": 0, "records_warmed": 0} # Persistent pending=true so cascade body is always ELIGIBLE — only the # cooldown gate keeps the rate in check. state_holder = { "fsm_state": "WAKE", "hippea_cascade_request": {"pending": True, "session_id": "test"}, } def load_state_stub(): return dict(state_holder) def save_state_stub(state): # Re-arm pending=true after the cascade body clears it. This # simulates 11 sessions all keeping pending=true high. state_holder.update(state) state_holder["hippea_cascade_request"] = { "pending": True, "session_id": "test", } def write_event_stub(*args, **kwargs): return None # Reset module-level cooldown state. monkeypatch.setattr(daemon_mod, "_last_cascade_completed_at", 0.0) # Speed up the loop's real-wall-time poll cadence so the body re-enters # fast. The cooldown gate (60s in MOCKED-clock space) is what we're # testing — the real-wall poll just controls how often we get a chance # to evaluate the gate. monkeypatch.setattr(daemon_mod, "HIPPEA_CASCADE_POLL_SEC", 0.05) shutdown = asyncio.Event() # Patch ONLY `time.monotonic` on the daemon module's bound `time` ref; # leave `time.sleep` etc. alone so the loop's `await asyncio.wait_for` # works on real time. with patch("iai_mcp.daemon.time.monotonic", fake_monotonic), \ patch("iai_mcp.retrieve.build_runtime_graph", counting_stub), \ patch("iai_mcp.hippea_cascade.run_cascade", fast_cascade_stub), \ patch("iai_mcp.daemon_state.load_state", load_state_stub), \ patch("iai_mcp.daemon_state.save_state", save_state_stub), \ patch("iai_mcp.daemon.write_event", write_event_stub): cascade_task = asyncio.create_task( daemon_mod._hippea_cascade_loop(store=None, shutdown=shutdown), ) # Drive 300s of simulated time forward in 5s simulated steps. # Real wall time elapsed ≈ steps * (asyncio.sleep yield). With # POLL_SEC=0.05, the loop body has many opportunities to re-enter # within each 0.02s real yield. POLL_STEP = 5.0 WINDOW = 300.0 steps = int(WINDOW / POLL_STEP) for _ in range(steps): clock[0] += POLL_STEP # Yield so the cascade task gets scheduled. Real-wall sleep is # short; the loop's own `await asyncio.wait_for(..., 0.05)` # plus this 0.02 yield gives the body multiple chances per step. await asyncio.sleep(0.02) shutdown.set() try: await asyncio.wait_for(cascade_task, timeout=2.0) except asyncio.TimeoutError: cascade_task.cancel() try: await cascade_task except (asyncio.CancelledError, Exception): pass # Acceptance per A2: ≤ 6 cascades in 5-minute window. # The bound is computed as ceil(WINDOW / MIN_INTERVAL) + 1 with # MIN_INTERVAL=60 → ceil(300/60)+1 = 6. n = len(cascade_invocations) assert n <= 6, ( f"R2 FAIL: {n} cascade invocations in 5-min window with " f"continuous pending=true. Expected ≤ 6 with 60s cooldown." ) # Also assert at least 2 (loop did get to run AND cooldown # actually let through more than one — without a cooldown bug # this would still be at LEAST 2 because we advanced 300s of # simulated time across at least 5 cooldown windows). # If `n == 1` here, the test is degenerate (would pass for a # broken cooldown that blocks ALL cascades). We require n >= 2 # to confirm the gate releases on time-advance. assert n >= 2, ( f"R2 FAIL: only {n} cascade invocations across simulated " f"5-min window. Expected ≥ 2 (cooldown should release after " f"60 simulated seconds). Test fixture / mocks broken." ) def test_cooldown_clears_after_min_interval_elapsed(): """Direct unit test of the gate logic: after MIN_INTERVAL elapses, a fresh cascade body invocation is allowed.""" asyncio.run(_cooldown_clears_after_min_interval_body()) async def _cooldown_clears_after_min_interval_body(): import iai_mcp.daemon as daemon_mod # Set last-completed to "now"; assert next iteration is gated. clock = [1000.0] def fake_monotonic(): return clock[0] with patch("iai_mcp.daemon.time.monotonic", fake_monotonic): daemon_mod._last_cascade_completed_at = 1000.0 elapsed = fake_monotonic() - daemon_mod._last_cascade_completed_at assert elapsed < daemon_mod.HIPPEA_CASCADE_MIN_INTERVAL_SEC # Advance clock past MIN_INTERVAL. clock[0] = 1000.0 + daemon_mod.HIPPEA_CASCADE_MIN_INTERVAL_SEC + 0.1 elapsed = fake_monotonic() - daemon_mod._last_cascade_completed_at assert elapsed >= daemon_mod.HIPPEA_CASCADE_MIN_INTERVAL_SEC