Files
roboco/tests/unit/services/test_strategy_engine.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

290 lines
11 KiB
Python

"""roboco.services.strategy_engine — assessment + notify (dormant by default)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.models.base import TaskStatus as TS
from roboco.services import strategy_engine as se_module
from roboco.services.strategy_engine import StrategyEngine
from roboco.services.task import (
ROADMAP_SOURCE,
X_FEATURE_EXPLORATION_SOURCE,
get_task_service,
)
from sqlalchemy import delete, update
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
_GOALS_WITH_DIRECTION: dict[str, Any] = {
"north_star": "Win the market",
"objectives": [{"metric": "NPS", "target": 50}],
"constraints": [],
"operating_policy": {},
}
_GOALS_EMPTY: dict[str, Any] = {
"north_star": "",
"objectives": [],
"constraints": [],
"operating_policy": {},
}
@pytest_asyncio.fixture(autouse=True)
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
"""See ``test_board_program_engine.py``'s identical fixture: Board
Program settings-store rows / ledger rows / open exploration tasks are
shared, cross-test-persistent DB state that a sibling suite (this
module's own idle-trigger tests, or the write-route
``test_board_programs_api.py`` run-now test) can leave behind — this
module's idle-trigger tests need the roadmap dedup gate genuinely open.
Purge before every test in this file."""
await db_session.execute(
delete(SystemSettingTable).where(SystemSettingTable.key.like("board_program.%"))
)
await db_session.execute(delete(BoardProgramCycleTable))
await db_session.execute(
update(TaskTable)
.where(
TaskTable.source.in_([ROADMAP_SOURCE, X_FEATURE_EXPLORATION_SOURCE]),
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
)
.values(status=TS.CANCELLED)
)
await db_session.commit()
def _engine(
monkeypatch: pytest.MonkeyPatch,
*,
in_flight: list[Any],
blocked: list[Any],
goals: dict[str, Any],
) -> StrategyEngine:
task_svc = MagicMock()
task_svc.list_in_progress_or_claimed = AsyncMock(return_value=in_flight)
task_svc.list_long_running_blocked = AsyncMock(return_value=blocked)
monkeypatch.setattr(se_module, "get_task_service", lambda _s: task_svc)
goals_svc = MagicMock()
goals_svc.get = AsyncMock(return_value=goals)
monkeypatch.setattr(se_module, "get_company_goals_service", lambda _s: goals_svc)
return StrategyEngine(MagicMock())
@pytest.mark.asyncio
async def test_idle_with_goals_observed(monkeypatch: pytest.MonkeyPatch) -> None:
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
kinds = {o.kind for o in await eng.assess()}
assert "idle" in kinds
@pytest.mark.asyncio
async def test_no_idle_when_work_in_flight(monkeypatch: pytest.MonkeyPatch) -> None:
eng = _engine(
monkeypatch, in_flight=[MagicMock()], blocked=[], goals=_GOALS_WITH_DIRECTION
)
assert all(o.kind != "idle" for o in await eng.assess())
@pytest.mark.asyncio
async def test_no_observations_when_idle_without_goals(
monkeypatch: pytest.MonkeyPatch,
) -> None:
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_EMPTY)
assert await eng.assess() == []
@pytest.mark.asyncio
async def test_stranded_blocked_observed(monkeypatch: pytest.MonkeyPatch) -> None:
eng = _engine(
monkeypatch,
in_flight=[MagicMock()],
blocked=[MagicMock(), MagicMock()],
goals=_GOALS_EMPTY,
)
assert any(o.kind == "stranded_blocked" for o in await eng.assess())
@pytest.mark.asyncio
async def test_run_cycle_disabled_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", False)
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
assert await eng.run_cycle() == []
@pytest.mark.asyncio
async def test_run_cycle_enabled_notifies_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
observations = await eng.run_cycle()
assert observations
notifier.send_ack_notification.assert_awaited()
_, kwargs = notifier.send_ack_notification.call_args
assert kwargs["to_agent"] == "ceo"
@pytest.mark.asyncio
async def test_run_cycle_enabled_no_observations_no_notify(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
eng = _engine(monkeypatch, in_flight=[MagicMock()], blocked=[], goals=_GOALS_EMPTY)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
assert await eng.run_cycle() == []
notifier.send_ack_notification.assert_not_awaited()
# --------------------------------------------------------------------------- #
# Task 6: idle -> roadmap Board Program trigger (real DB — BoardProgramEngine
# dedup is what makes the second tick a no-op, so a fully-mocked session
# can't exercise it; see test_board_program_engine.py for the engine's own
# isolated trigger/dedup coverage).
# --------------------------------------------------------------------------- #
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
SLUG = "roboco"
ONE = 1
async def _seed_roadmap_fixture(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await session.flush()
def _mock_idle_assessment(monkeypatch: pytest.MonkeyPatch) -> None:
task_svc = MagicMock()
task_svc.list_in_progress_or_claimed = AsyncMock(return_value=[])
task_svc.list_long_running_blocked = AsyncMock(return_value=[])
monkeypatch.setattr(se_module, "get_task_service", lambda _s: task_svc)
goals_svc = MagicMock()
goals_svc.get = AsyncMock(return_value=_GOALS_WITH_DIRECTION)
monkeypatch.setattr(se_module, "get_company_goals_service", lambda _s: goals_svc)
@pytest.mark.asyncio
async def test_idle_triggers_roadmap_cycle(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roadmap_fixture(db_session)
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
monkeypatch.setattr(se_module.settings, "roadmap_engine_enabled", True)
monkeypatch.setattr(se_module.settings, "self_heal_project_slug", SLUG)
_mock_idle_assessment(monkeypatch)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
eng = StrategyEngine(db_session)
await eng.run_cycle()
open_cycles = await get_task_service(db_session).list_open_roadmap_cycles()
assert len(open_cycles) == ONE
body = notifier.send_ack_notification.call_args.kwargs["body"]
assert "roadmap exploration cycle was opened" in body
@pytest.mark.asyncio
async def test_idle_triggers_roadmap_cycle_armed_via_settings_store_only(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The roadmap program armed ONLY through the settings-store key (the
legacy ``roadmap_engine_enabled`` flag left at its False default) still
reaches origination through the full chain: strategy engine ->
``BoardProgramEngine.open_program_cycle`` -> ``program_armed``."""
await _seed_roadmap_fixture(db_session)
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
monkeypatch.setattr(se_module.settings, "self_heal_project_slug", SLUG)
db_session.add(
SystemSettingTable(key="board_program.roadmap.enabled", value="true")
)
await db_session.flush()
_mock_idle_assessment(monkeypatch)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
eng = StrategyEngine(db_session)
await eng.run_cycle()
open_cycles = await get_task_service(db_session).list_open_roadmap_cycles()
assert len(open_cycles) == ONE
body = notifier.send_ack_notification.call_args.kwargs["body"]
assert "roadmap exploration cycle was opened" in body
@pytest.mark.asyncio
async def test_idle_second_tick_is_a_dedup_noop(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roadmap_fixture(db_session)
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
monkeypatch.setattr(se_module.settings, "roadmap_engine_enabled", True)
monkeypatch.setattr(se_module.settings, "self_heal_project_slug", SLUG)
_mock_idle_assessment(monkeypatch)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
eng = StrategyEngine(db_session)
await eng.run_cycle()
await eng.run_cycle()
open_cycles = await get_task_service(db_session).list_open_roadmap_cycles()
assert len(open_cycles) == ONE
second_body = notifier.send_ack_notification.call_args.kwargs["body"]
assert "already open" in second_body