Files
roboco/tests/unit/runtime/test_strategy_engine_loop.py
T

140 lines
4.5 KiB
Python
Raw Normal View History

2026-06-30 08:08:35 +02:00
"""#193: a persistently failing strategy-engine cycle must surface to the CEO.
``_strategy_engine_loop`` swallows cycle exceptions with only a log — a
persistent ``assess`` failure (bad DB, bad goals row, …) leaves the engine
silently producing nothing every tick while the CEO sees no signal. Mirror the
rate-limit probe loop: count consecutive failures, notify the CEO once per
failure episode, reset on the first success.
"""
from __future__ import annotations
import types
from typing import cast
import pytest
import roboco.db as db_mod
import roboco.services.strategy_engine as strat_mod
from roboco.config import settings as cfg
from roboco.runtime import orchestrator as orch_mod
from roboco.runtime.orchestrator import AgentOrchestrator
class _FakeDbCtx:
"""An async context manager yielding a dummy session."""
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, *_exc: object) -> bool:
return False
def _engine_that_raises(message: str) -> types.SimpleNamespace:
async def _run_cycle() -> None:
raise RuntimeError(message)
return types.SimpleNamespace(run_cycle=_run_cycle)
def _engine_that_succeeds() -> types.SimpleNamespace:
async def _run_cycle() -> None:
return None
return types.SimpleNamespace(run_cycle=_run_cycle)
def _stub(notify_spy: object) -> AgentOrchestrator:
"""A minimal orchestrator stub: running + a spied failure-notify hook."""
return cast(
"AgentOrchestrator",
types.SimpleNamespace(
_running=True,
_notify_strategy_engine_failure=notify_spy,
),
)
@pytest.mark.asyncio
async def test_strategy_engine_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "strategy_engine_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await AgentOrchestrator._strategy_engine_loop(stub)
@pytest.mark.asyncio
async def test_persistent_cycle_failure_notifies_ceo_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "strategy_engine_enabled", True)
monkeypatch.setattr(db_mod, "get_db_context", _FakeDbCtx)
raises_engine = _engine_that_raises("assess blew up")
monkeypatch.setattr(strat_mod, "get_strategy_engine", lambda _db: raises_engine)
notified: list[int] = []
async def _spy(fail_count: int) -> None:
notified.append(fail_count)
stub = _stub(_spy)
state = AgentOrchestrator._new_strategy_loop_state()
threshold = orch_mod._STRATEGY_FAIL_CEO_NOTIFY_THRESHOLD
# Threshold - 1 failures: no CEO notify yet.
for _ in range(threshold - 1):
await AgentOrchestrator._strategy_engine_cycle(stub, state)
assert notified == []
assert state.failures == threshold - 1
# The threshold-th failure fires the one-time CEO alert.
await AgentOrchestrator._strategy_engine_cycle(stub, state)
assert notified == [threshold]
assert state.notified is True
# Further failures must NOT re-notify (one per episode).
await AgentOrchestrator._strategy_engine_cycle(stub, state)
await AgentOrchestrator._strategy_engine_cycle(stub, state)
assert notified == [threshold]
@pytest.mark.asyncio
async def test_success_resets_failure_state_so_a_new_episode_re_notifies(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "strategy_engine_enabled", True)
monkeypatch.setattr(db_mod, "get_db_context", _FakeDbCtx)
engine = {"obj": _engine_that_raises("boom")}
def _get_strategy_engine(_db: object) -> types.SimpleNamespace:
return engine["obj"]
monkeypatch.setattr(strat_mod, "get_strategy_engine", _get_strategy_engine)
notified: list[int] = []
async def _spy(fail_count: int) -> None:
notified.append(fail_count)
stub = _stub(_spy)
state = AgentOrchestrator._new_strategy_loop_state()
threshold = orch_mod._STRATEGY_FAIL_CEO_NOTIFY_THRESHOLD
for _ in range(threshold):
await AgentOrchestrator._strategy_engine_cycle(stub, state)
assert notified == [threshold]
# Engine recovers → state resets, episode closes.
engine["obj"] = _engine_that_succeeds()
await AgentOrchestrator._strategy_engine_cycle(stub, state)
assert state.failures == 0
assert state.notified is False
# A fresh failure episode notifies again after the threshold.
engine["obj"] = _engine_that_raises("boom2")
for _ in range(threshold):
await AgentOrchestrator._strategy_engine_cycle(stub, state)
assert notified == [threshold, threshold]