181 lines
7.6 KiB
Python
181 lines
7.6 KiB
Python
|
|
"""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
|