Files
roboco/tests/unit/runtime/test_orchestrator_liveness.py
e77c3b7a63 feat(board): Board Program registry — Phase 1 (engine, LEARN ledger, per-project scoping, panel) (#689)
* feat(board): Board Program registry — generic trigger/dedup/originate/LEARN engine

One registry (foundation/policy/board_programs.py) + one BoardProgramEngine +
one orchestrator loop replace the bespoke roadmap/spotlight loops, behavior-
preserved: same sources, dispatch routing, one-open-cycle dedup (ledger rows
auto-close when their exploration task goes terminal, so x_feature's
complete-at-propose flow can't wedge), and live per-program interval
overrides with the tick capped at 1h.

program_armed() is the single arming chokepoint: the settings-store
board_program.<key>.enabled override when present, else the legacy flag —
routed through BoardProgramEngine, RoadmapEngine.run_cycle, and XEngine's
spotlight gate, so the panel toggle can never be a silent no-op against a
legacy boot flag.

LEARN: board_program_cycles (migration 087) accrues per-item CEO decisions
(exact attribution by exploration_task_id where the caller holds it) and
feeds the last closed cycles back into both exploration prompts. The
strategy engine's idle signal now opens a roadmap cycle (enabled+dedup
respected) instead of only nudging.

Per-project scoping (migration 088, projects.board_programs, dual polarity):
plain keys opt a project INTO project-scoped programs; "!key" opts it OUT
of an org-scoped program's outputs (default eligible — parity). Enforced at
propose_roadmap (names the excluded project) and defensively at materialize;
validation rejects unknown keys and meaningless polarity both directions.

API: GET /api/board-programs + POST /api/board-programs/{key}/run-now
(CEO-gated); settings keys for both migrated programs.

* feat(panel): Board Programs card + per-project program controls

Business page gains a Programs tab: per-program rows (role, trigger, scope,
open-cycle badge), enabled switch on the settings-store key, Run now
(disabled while a cycle is open). The edit-project dialog gains the
program controls next to the CI-watch/video toggles: participates-in
checkboxes for project-scoped programs, excluded-from checkboxes for
org-scoped outputs.

* test(board): full-gate hermeticity — mypy casts + shared-DB purge fixtures

make quality runs one pytest process over all suites against the shared
persistent DB: integration collects before unit, so the board-programs API
test's committed run-now state (settings-store overrides, an open cycle row,
its board_roadmap task) poisoned 13 downstream unit tests that pass in
isolation. The polluter now purges its own committed state in fixture
teardown, and the four consumer files get an autouse per-test purge
(board_program.% settings keys, ledger rows, open exploration tasks) so
they are hermetic regardless of collection order. Also the four
cast("UUID", ...) sites the tests-scope mypy run requires.

* feat(panel): re-home per-project program controls onto the settings page

Wave C deleted the edit-project dialog these controls originally landed in;
they now live on the project settings page's budget/ops card next to the
CI-watch/video toggles — participates-in switches for project-scoped
programs, excluded-from switches for org-scoped outputs, dual-polarity
tooltips, order-independent dirty tracking. Nine makeProject test fixtures
gain the required board_programs field the rebase left behind.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-25 05:39:44 +02:00

220 lines
7.7 KiB
Python

"""Engine-loop liveness watchdog: heartbeat + 2x-interval staleness alert.
Each background engine loop records a monotonic heartbeat after a successful
cycle (and once at start); ``_check_loop_liveness`` (called from
``_check_health``) logs a warning when ``now - last_success > 2 * interval``
for any loop. The alert is the fail-direction: a dead cycle task stops
recording, so after ``2*interval`` the health loop logs "engine loop stalled".
"""
from __future__ import annotations
import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.runtime import orchestrator as orch_module
from roboco.runtime.orchestrator import AgentOrchestrator
# Test interval constants (kept symbolic so ruff PLR2004 stays quiet and the
# intent reads at the call site).
_CI_WATCH_INTERVAL = 0.01
_VIDEO_RENDER_INTERVAL = 0.05
_X_MENTIONS_INTERVAL = 0.04
def _orch() -> Any:
"""Bypass __init__ — the loop helpers under test need only the heartbeats
dict and ``_running``."""
o = AgentOrchestrator.__new__(AgentOrchestrator)
o._loop_heartbeats = {}
o._running = True
return o
def test_stale_heartbeat_logs_warning() -> None:
orch = _orch()
interval = 10.0
orch._loop_heartbeats["self_heal"] = (time.monotonic() - 3 * interval, interval)
fake = MagicMock()
with patch.object(orch_module, "logger", fake):
orch._check_loop_liveness()
fake.warning.assert_called_once()
args, kwargs = fake.warning.call_args
assert args[0] == "engine loop stalled past 2x interval"
assert kwargs["loop"] == "self_heal"
assert kwargs["interval"] == interval
assert kwargs["stall_seconds"] >= 3 * interval
def test_fresh_heartbeat_no_warning() -> None:
orch = _orch()
interval = 10.0
orch._loop_heartbeats["self_heal"] = (time.monotonic(), interval)
fake = MagicMock()
with patch.object(orch_module, "logger", fake):
orch._check_loop_liveness()
fake.warning.assert_not_called()
def test_empty_heartbeats_no_warning() -> None:
"""A fleet with all engines dormant (no heartbeats recorded) must not warn —
nothing is stalled, nothing is running."""
orch = _orch()
fake = MagicMock()
with patch.object(orch_module, "logger", fake):
orch._check_loop_liveness()
fake.warning.assert_not_called()
@pytest.mark.asyncio
async def test_successful_cycle_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Driving one engine loop (ci_watch) through a stubbed successful cycle
records a heartbeat under the loop's canonical name."""
orch = _orch()
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_interval_seconds", 0.01)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_ci_watch_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._ci_watch_loop()
assert "ci_watch" in orch._loop_heartbeats
last_success, interval = orch._loop_heartbeats["ci_watch"]
assert interval == _CI_WATCH_INTERVAL
assert last_success > 0.0
@pytest.mark.asyncio
async def test_start_heartbeat_recorded_before_first_cycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The start-of-loop heartbeat is recorded before the first cycle, so a
loop that never enters its body still has a heartbeat to age against."""
orch = _orch()
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_interval_seconds", 0.01)
# Loop body never runs: while-condition is False on first check.
orch._running = False
orch._run_ci_watch_cycle = AsyncMock()
await orch._ci_watch_loop()
assert "ci_watch" in orch._loop_heartbeats
orch._run_ci_watch_cycle.assert_not_awaited()
@pytest.mark.asyncio
async def test_failed_cycle_does_not_record_post_success_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A cycle that raises must NOT record the post-success heartbeat — the
staleness alert relies on a dead cycle stopping the heartbeat refresh."""
orch = _orch()
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_interval_seconds", 0.01)
async def _raise_then_stop() -> None:
orch._running = False
raise RuntimeError("cycle blew up")
orch._run_ci_watch_cycle = AsyncMock(side_effect=_raise_then_stop)
heartbeat_calls: list[tuple[str, float]] = []
original = orch._record_loop_heartbeat
def _spy(name: str, interval: float) -> None:
heartbeat_calls.append((name, interval))
original(name, interval)
orch._record_loop_heartbeat = _spy
with patch("asyncio.sleep", new=AsyncMock()):
await orch._ci_watch_loop()
assert "ci_watch" in orch._loop_heartbeats
# Only the start heartbeat was recorded — the post-success call was skipped
# because the cycle raised before reaching it. A second call would mean the
# heartbeat refreshed despite the failure, defeating the staleness alert.
assert len(heartbeat_calls) == 1
assert heartbeat_calls[0] == ("ci_watch", _CI_WATCH_INTERVAL)
_, interval = orch._loop_heartbeats["ci_watch"]
assert interval == _CI_WATCH_INTERVAL
@pytest.mark.asyncio
async def test_video_render_loop_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Sanity-check that a second engine loop (video_render) uses its own
canonical name and interval — guards against copy-paste name drift."""
orch = _orch()
monkeypatch.setattr(settings, "video_engine_enabled", True)
monkeypatch.setattr(settings, "video_render_interval_seconds", 0.05)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_video_render_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._video_render_loop()
assert "video_render" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["video_render"]
assert interval == _VIDEO_RENDER_INTERVAL
@pytest.mark.asyncio
async def test_x_mentions_loop_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The mentions-poll loop records under its canonical name + interval —
guards against copy-paste name drift on the heartbeat calls."""
orch = _orch()
monkeypatch.setattr(settings, "x_engine_enabled", True)
monkeypatch.setattr(settings, "x_replies_enabled", True)
monkeypatch.setattr(settings, "x_mentions_interval_seconds", 0.04)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_x_mentions_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._x_mentions_poll_loop()
assert "x_mentions" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["x_mentions"]
assert interval == _X_MENTIONS_INTERVAL
@pytest.mark.asyncio
async def test_board_program_loop_records_heartbeat() -> None:
"""The board-program loop (replaces roadmap-engine/x-feature-spotlight)
records under its canonical name + interval — guards against copy-paste
name drift on the heartbeat calls. See test_board_program_loop.py for
the rest of this loop's coverage (interval computation, tick-error
isolation)."""
orch = _orch()
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_board_program_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._board_program_loop()
assert "board_program" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["board_program"]
assert interval == orch._board_program_interval_seconds()