2026-06-15 20:47:41 +02:00
|
|
|
"""roboco.services.strategy_engine — assessment + notify (dormant by default)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-07-25 05:39:44 +02:00
|
|
|
from typing import TYPE_CHECKING, Any
|
2026-06-15 20:47:41 +02:00
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-07-25 05:39:44 +02:00
|
|
|
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
|
2026-06-15 20:47:41 +02:00
|
|
|
from roboco.services import strategy_engine as se_module
|
|
|
|
|
from roboco.services.strategy_engine import StrategyEngine
|
2026-07-25 05:39:44 +02:00
|
|
|
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
|
2026-06-15 20:47:41 +02:00
|
|
|
|
|
|
|
|
_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": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 05:39:44 +02:00
|
|
|
@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()
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 20:47:41 +02:00
|
|
|
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()
|
2026-07-25 05:39:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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
|