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>
This commit is contained in:
Renzo F
2026-07-25 05:39:44 +02:00
committed by GitHub
co-authored by Renn F
parent cbddfc7cb3
commit e77c3b7a63
52 changed files with 3662 additions and 213 deletions
+244
View File
@@ -0,0 +1,244 @@
"""Scenario: the generic Board Program loop end to end (Task 9).
Drives the REAL ``BoardProgramEngine`` (not a mock) against a REAL Postgres
through the harness: arming the roadmap program via the new per-program
settings-store key opens ONE held exploration task the delivery dispatcher's
pending-claim filter skips (it's board-dispatched, not delivery work); a
second tick dedups (no second task/cycle row); and approving a fake item
through the real ``RoadmapService`` moves the LEARN ledger's counters.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from roboco.foundation import identity as _foundation
from tests.e2e_smoke.arcs import Company, seed_company, seed_project
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from tests.e2e_smoke.harness import E2EStack
ONE = 1
ZERO = 0
def _seed_system_and_po(stack: E2EStack) -> None:
"""Seed ``system`` + ``product-owner`` at their FIXED foundation UUIDs.
``RoadmapEngine._originate`` stamps ``assigned_to``/``created_by`` from
the static identity registry (not a DB lookup keyed by role), so those
exact ids must exist as real agent rows for the FK to resolve —
``seed_company``'s random ``uuid4()`` agents don't cover this (mirrors
``test_feature_spotlight.py``'s ``_seed_system_and_secretary``)."""
from roboco.db.tables import AgentTable
from roboco.foundation import identity as _foundation
from roboco.models import AgentRole, AgentStatus, Team
async def _run(session: AsyncSession) -> None:
for agent_uuid, slug, role, team in (
(_foundation.AGENTS["system"].uuid, "system", AgentRole.SYSTEM, None),
(
_foundation.AGENTS["product-owner"].uuid,
"product-owner",
AgentRole.PRODUCT_OWNER,
Team.BOARD,
),
):
if await session.get(AgentTable, agent_uuid) is not None:
continue
session.add(
AgentTable(
id=agent_uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
)
stack.run_db(_run)
def _arm_roadmap(stack: E2EStack, project_slug: str) -> None:
"""Arm via the settings-store key ONLY — ``RoadmapEngine.run_cycle`` now
routes through ``roboco.services.board_programs.program_armed``, the
same resolver ``BoardProgramEngine.enabled`` consults, so the legacy
``roadmap_engine_enabled`` flag stays False here on purpose: this is the
end-to-end guard against the double-flag regression where the settings
store alone used to be silently overridden by a False legacy flag."""
from roboco.config import settings as cfg
from roboco.db.tables import SystemSettingTable
cfg.self_heal_project_slug = project_slug
async def _run(session: AsyncSession) -> None:
session.add(
SystemSettingTable(key="board_program.roadmap.enabled", value="true")
)
stack.run_db(_run)
def _run_due_programs(stack: E2EStack) -> list[str]:
from roboco.services.board_programs import get_board_program_engine
async def _run(session: AsyncSession) -> list[str]:
return await get_board_program_engine(session).run_due_programs()
result: list[str] = stack.run_db(_run)
return result
def _find_roadmap_task(stack: E2EStack) -> dict[str, Any]:
from roboco.db.tables import TaskTable
from roboco.services.task import ROADMAP_SOURCE
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
rows = (
(
await session.execute(
select(TaskTable).where(TaskTable.source == ROADMAP_SOURCE)
)
)
.scalars()
.all()
)
return {
"count": len(rows),
"rows": [
{
"id": r.id,
"status": str(r.status),
"assigned_to": r.assigned_to,
"source": r.source,
"confirmed_by_human": r.confirmed_by_human,
}
for r in rows
],
}
state: dict[str, Any] = stack.run_db(_run)
return state
def _cycle_counters(stack: E2EStack) -> dict[str, Any]:
from roboco.db.tables import BoardProgramCycleTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
row = (
(
await session.execute(
select(BoardProgramCycleTable)
.where(BoardProgramCycleTable.program_key == "roadmap")
.order_by(BoardProgramCycleTable.opened_at.desc())
.limit(1)
)
)
.scalars()
.first()
)
assert row is not None
return {
"items_proposed": row.items_proposed,
"items_approved": row.items_approved,
"items_rejected": row.items_rejected,
}
state: dict[str, Any] = stack.run_db(_run)
return state
def _approve_fake_item(
stack: E2EStack, task_id: Any, project_slug: str, company: Company
) -> str:
from roboco.db.tables import TaskTable
from roboco.foundation.policy.content import markers
from roboco.services.roadmap_service import get_roadmap_service
from sqlalchemy import select
async def _run(session: AsyncSession) -> str:
task = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
markers.set_roadmap_cycle(
task,
{
"goal": "Close onboarding friction",
"items": [
{
"id": "item-0",
"title": "Streamline signup",
"description": "Cut the signup form from 8 fields to 3",
"acceptance_criteria": ["signup takes < 30s"],
"project_slug": project_slug,
"team": "backend",
"priority": 2,
"rationale": "signup drop-off is the top funnel leak",
"status": "proposed",
"reject_reason": None,
"materialized_task_id": None,
}
],
},
)
await session.flush()
result = await get_roadmap_service(session).approve_item(
task_id, "item-0", created_by=company.ceo_id
)
assert result is not None
return result.status
status: str = stack.run_db(_run)
return status
def test_board_program_loop_originates_dedups_and_records(
e2e_stack: E2EStack,
) -> None:
stack = e2e_stack
company = seed_company(stack)
_seed_system_and_po(stack)
_project_id, project_slug = seed_project(stack, company)
_arm_roadmap(stack, project_slug)
opened = _run_due_programs(stack)
assert opened == ["roadmap"], opened
state = _find_roadmap_task(stack)
assert state["count"] == ONE, state
row = state["rows"][0]
assert row["status"] == "pending"
assert row["assigned_to"] == _foundation.AGENTS["product-owner"].uuid
assert row["confirmed_by_human"] is False
# The dispatcher's own dev-work skip recognizes this exact task shape —
# board_roadmap is board-dispatched (one-shot PO spawn), never handed to
# the generic dev dispatch loop's give_me_work/claim path.
from roboco.runtime.orchestrator import _is_non_dev_dispatch_source
assert _is_non_dev_dispatch_source({"source": row["source"]}) is True
# Second tick: the open cycle blocks re-origination — no second task.
opened_again = _run_due_programs(stack)
assert opened_again == [], opened_again
state_after = _find_roadmap_task(stack)
assert state_after["count"] == ONE, state_after
# Approve a fake item on the open cycle through the real RoadmapService —
# the LEARN ledger's counters move.
status = _approve_fake_item(stack, row["id"], project_slug, company)
assert status == "approved"
counters = _cycle_counters(stack)
assert counters["items_proposed"] == ONE
assert counters["items_approved"] == ONE
assert counters["items_rejected"] == ZERO
@@ -0,0 +1,58 @@
"""Migration 087 tests — board_program_cycles table.
NOT a real alembic round-trip — the suite builds the test DB via
Base.metadata.create_all (see conftest); a real `alembic upgrade head` +
`downgrade -1` round trip against a scratch Postgres (:55432) was run
manually and confirmed clean (create + drop, no errors) as part of building
this migration. See `alembic/versions/087_board_program_cycles.py`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.db.tables import BoardProgramCycleTable
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.mark.asyncio
async def test_board_program_cycle_row_defaults(db_session: AsyncSession) -> None:
"""A freshly-inserted row gets zeroed counters and an empty decisions list."""
row = BoardProgramCycleTable(program_key="roadmap")
db_session.add(row)
await db_session.flush()
await db_session.refresh(row)
assert row.items_proposed == 0
assert row.items_approved == 0
assert row.items_rejected == 0
assert row.decisions == []
assert row.opened_at is not None
assert row.closed_at is None
assert row.exploration_task_id is None
@pytest.mark.asyncio
async def test_board_program_cycle_round_trips_decisions(
db_session: AsyncSession,
) -> None:
"""The decisions JSON column stores/returns a list of dicts byte-for-byte."""
decisions = [
{"item_ref": "item-1", "verdict": "approved", "reason": None},
{"item_ref": "item-2", "verdict": "rejected", "reason": "not now"},
]
row = BoardProgramCycleTable(
program_key="roadmap",
items_proposed=2,
items_approved=1,
items_rejected=1,
decisions=decisions,
)
db_session.add(row)
await db_session.flush()
await db_session.refresh(row)
assert row.decisions == decisions
@@ -0,0 +1,202 @@
"""Board Programs API route coverage — CEO-only list + run-now."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.board_programs import router as board_programs_router
from roboco.config import settings as cfg
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.models import AgentRole, AgentStatus, TaskStatus, Team
from roboco.models.permissions import AgentContext
from roboco.services.task import ROADMAP_SOURCE
from sqlalchemy import delete, update
CEO_UUID = _foundation.AGENTS["ceo"].uuid
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_agents(session: AsyncSession) -> None:
for uuid, slug, role in (
(CEO_UUID, "ceo", AgentRole.CEO),
(SYSTEM_UUID, "system", AgentRole.SYSTEM),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER),
):
if await session.get(AgentTable, uuid) is not None:
continue
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _arm_roadmap(session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
"""Arms roadmap two ways: the new per-program settings-store key (what
Task 7 makes writable, consulted by ``BoardProgramEngine.enabled``) AND
the legacy ``roadmap_engine_enabled`` config flag — ``RoadmapEngine.
run_cycle`` (the migrated originator itself, unchanged since before the
registry existed) independently checks the legacy flag and no-ops
without it, regardless of the registry-level settings-store override.
Also seeds the project ``RoadmapEngine._roboco_project`` resolves against
(a unique slug per call + a matching ``self_heal_project_slug`` override —
``db_session`` is a real, cross-test-persistent database within one
pytest run, so a fixed slug like "roboco-api" would collide the second
a sibling test also arms roadmap)."""
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
key = "board_program.roadmap.enabled"
existing = await session.get(SystemSettingTable, key)
if existing is None:
session.add(SystemSettingTable(key=key, value="true"))
else:
existing.value = "true"
slug = f"roboco-api-{uuid4().hex[:8]}"
monkeypatch.setattr(cfg, "self_heal_project_slug", slug)
session.add(
ProjectTable(
id=uuid4(),
name="RoboCo",
slug=slug,
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
)
await session.flush()
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(board_programs_router, prefix="/api/board-programs")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
await _seed_agents(db_session)
app = _build_app(db_session, AgentRole.CEO, CEO_UUID)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# run-now's route handler commits explicitly (write-route convention), so
# anything a test wrote through it (settings-store overrides, an opened
# ledger row, the board_roadmap task it originates) would otherwise
# outlive this test in the shared, cross-test-persistent DB and poison
# every later real-DB roadmap/board-program unit test (dedup checks,
# settings-store PK collisions, ledger scalar_one() lookups). Purge
# unconditionally — a no-op for the tests here that never wrote anything.
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 == ROADMAP_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
.values(status=TaskStatus.CANCELLED)
)
await db_session.commit()
@pytest.mark.asyncio
async def test_list_returns_both_migrated_programs(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get("/api/board-programs")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert {p["key"] for p in body} == {"roadmap", "x_feature"}
roadmap = next(p for p in body if p["key"] == "roadmap")
assert roadmap["role"] == "product_owner"
assert roadmap["trigger"] == "cron"
assert roadmap["scope"] == "org"
assert roadmap["open_cycle"] is False
assert roadmap["last_opened_at"] is None
# Not asserted == [] — org-scoped "eligible" means every active project
# (default-eligible, opt-out only), and db_session is a real,
# cross-test-persistent database within one pytest run: sibling suites
# seed their own projects that legitimately show up here too.
assert isinstance(roadmap["opted_in_project_slugs"], list)
@pytest.mark.asyncio
async def test_run_now_opens_a_cycle_then_conflicts_on_retry(
db_session: AsyncSession,
ceo_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One test, not two — ``RoadmapEngine.run_cycle``'s own dedup
(``list_open_roadmap_cycles``) is system-wide (any open ``board_roadmap``
task, not scoped to this test's project), and ``db_session`` is a real,
cross-test-persistent database within one pytest run: a leftover open
cycle from a sibling test would make the FIRST call here 409 too."""
await _arm_roadmap(db_session, monkeypatch)
first = await ceo_client.post("/api/board-programs/roadmap/run-now")
assert first.status_code == HTTPStatus.OK
body = first.json()
assert body["open_cycle"] is True
assert body["last_opened_at"] is not None
second = await ceo_client.post("/api/board-programs/roadmap/run-now")
assert second.status_code == HTTPStatus.CONFLICT
@pytest.mark.asyncio
async def test_run_now_unknown_key_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.post("/api/board-programs/not-a-real-program/run-now")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
await _seed_agents(db_session)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/board-programs")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@@ -76,15 +76,23 @@ async def board_gate_setup(
db_session: AsyncSession, _test_database_url: str
) -> AsyncIterator[dict]:
"""Seed agents + a board/coordination task and point the global DB holder
at the test database so the orchestrator's own session writes land here."""
db_session.add_all(
[
_agent(_SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
_agent(_CEO_UUID, "ceo", AgentRole.CEO, None),
_agent(_PO_UUID, _PO_SLUG, AgentRole.PRODUCT_OWNER, Team.BOARD),
_agent(_HOM_UUID, _HOM_SLUG, AgentRole.HEAD_MARKETING, Team.BOARD),
]
)
at the test database so the orchestrator's own session writes land here.
Existence-checked per row (mirrors ``test_roadmap_routes.py``'s
``_seed_ceo`` / ``test_feature_spotlight.py``'s
``_seed_system_and_secretary``): ``db_session`` is a real,
cross-test-persistent database within one pytest run, and these are the
same fixed foundation UUIDs another integration suite may have already
committed (a write-route test that explicitly commits, e.g. the Board
Programs API's run-now) — an unconditional insert would collide."""
for uuid, slug, role, team in (
(_SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(_CEO_UUID, "ceo", AgentRole.CEO, None),
(_PO_UUID, _PO_SLUG, AgentRole.PRODUCT_OWNER, Team.BOARD),
(_HOM_UUID, _HOM_SLUG, AgentRole.HEAD_MARKETING, Team.BOARD),
):
if await db_session.get(AgentTable, UUID(uuid)) is None:
db_session.add(_agent(uuid, slug, role, team))
await db_session.flush()
# A board/coordination task: project_id NULL (git-exempt), team=board,
@@ -0,0 +1,149 @@
"""tests/unit/foundation/test_board_programs.py"""
from datetime import UTC, datetime, timedelta
import pytest
from roboco.config import Settings
from roboco.foundation.policy.board_programs import (
PROGRAMS,
BoardProgram,
TriggerKind,
program_due,
project_participates,
validate_board_programs_field,
)
def test_x_feature_default_interval_matches_settings_field_default() -> None:
"""Guards the registry cadence and the live due-check's cadence
(``Settings.x_feature_spotlight_interval_seconds``) from drifting apart
again — reads the pydantic field default, never a live env-configured
instance, so this can't pass by accident in a differently-configured
environment."""
field_default = Settings.model_fields["x_feature_spotlight_interval_seconds"]
assert PROGRAMS["x_feature"].default_interval_seconds == field_default.default
def test_registry_carries_the_two_migrated_programs() -> None:
assert set(PROGRAMS) == {"roadmap", "x_feature"}
rm = PROGRAMS["roadmap"]
assert rm.role == "product_owner"
assert rm.source == "board_roadmap"
assert rm.trigger is TriggerKind.CRON
xf = PROGRAMS["x_feature"]
assert xf.role == "head_marketing"
assert xf.source == "x_feature_exploration"
def test_program_due_cron_interval() -> None:
now = datetime(2026, 7, 24, tzinfo=UTC)
p = PROGRAMS["roadmap"]
assert program_due(p, now=now, last_opened_at=None, interval_override=None)
recent = now - timedelta(seconds=10)
assert not program_due(p, now=now, last_opened_at=recent, interval_override=None)
old = now - timedelta(seconds=p.default_interval_seconds + 1)
assert program_due(p, now=now, last_opened_at=old, interval_override=None)
def test_program_due_event_never_cron_fires() -> None:
p = BoardProgram(
key="k",
role="auditor",
trigger=TriggerKind.EVENT,
source="s",
default_interval_seconds=0,
)
assert not program_due(
p,
now=datetime(2026, 7, 24, tzinfo=UTC),
last_opened_at=None,
interval_override=None,
)
def test_program_due_interval_override_wins_over_default() -> None:
now = datetime(2026, 7, 24, tzinfo=UTC)
p = PROGRAMS["roadmap"]
recent = now - timedelta(seconds=100)
# Default interval (a week) would still block; a short override fires.
assert program_due(p, now=now, last_opened_at=recent, interval_override=50)
assert not program_due(p, now=now, last_opened_at=recent, interval_override=200)
# ---------------------------------------------------------------------------
# Task 6b: per-project program scoping
# ---------------------------------------------------------------------------
def test_registry_entries_default_to_org_scope() -> None:
assert PROGRAMS["roadmap"].scope == "org"
assert PROGRAMS["x_feature"].scope == "org"
_PROJECT_PROGRAM = BoardProgram(
key="pest_control",
role="product_owner",
trigger=TriggerKind.CRON,
source="board_pest_control",
default_interval_seconds=7 * 24 * 3600,
scope="project",
)
_ORG_PROGRAM = PROGRAMS["roadmap"] # scope="org"
def test_project_scoped_program_is_affirmative_opt_in() -> None:
assert not project_participates(_PROJECT_PROGRAM, None)
assert not project_participates(_PROJECT_PROGRAM, [])
assert not project_participates(_PROJECT_PROGRAM, ["some_other_key"])
assert project_participates(_PROJECT_PROGRAM, ["pest_control"])
def test_org_scoped_program_is_default_eligible_opt_out() -> None:
assert project_participates(_ORG_PROGRAM, None)
assert project_participates(_ORG_PROGRAM, [])
assert project_participates(_ORG_PROGRAM, ["some_other_key"])
assert not project_participates(_ORG_PROGRAM, ["!roadmap"])
def test_validate_board_programs_field_accepts_none() -> None:
assert validate_board_programs_field(None) is None
def test_validate_board_programs_field_accepts_known_org_exclusion() -> None:
assert validate_board_programs_field(["!roadmap"]) == ["!roadmap"]
def test_validate_board_programs_field_rejects_plain_key_on_org_scoped_program() -> (
None
):
"""Both registered programs are org-scoped today, so a plain "roadmap"
entry (the project-scoped opt-in form) is meaningless — org-scoped
programs run against every project by default and are only ever
excluded via '!key'. See test_validate_board_programs_field_allows_
plain_project_scoped_key below for the positive case on a synthetic
project-scoped program."""
with pytest.raises(ValueError, match="meaningless"):
validate_board_programs_field(["roadmap"])
def test_validate_board_programs_field_rejects_unknown_key() -> None:
with pytest.raises(ValueError, match="unknown board program key"):
validate_board_programs_field(["not_a_real_program"])
def test_validate_board_programs_field_rejects_unknown_excluded_key() -> None:
with pytest.raises(ValueError, match="unknown board program key"):
validate_board_programs_field(["!not_a_real_program"])
def test_validate_board_programs_field_rejects_bang_on_project_scoped_key() -> None:
registry = {"pest_control": _PROJECT_PROGRAM}
with pytest.raises(ValueError, match="meaningless"):
validate_board_programs_field(["!pest_control"], programs=registry)
def test_validate_board_programs_field_allows_plain_project_scoped_key() -> None:
registry = {"pest_control": _PROJECT_PROGRAM}
assert validate_board_programs_field(["pest_control"], programs=registry) == [
"pest_control"
]
@@ -62,6 +62,20 @@ def _valid_items(n: int) -> list[dict[str, Any]]:
return [_valid_item(i) for i in range(n)]
@pytest.fixture(autouse=True)
def _default_project_lookup(monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 6b's exclusion check (``_reject_excluded_roadmap_project``) looks
up ``project_slug`` on every propose_roadmap call. Every test predating
that check builds its ``ContentActions`` with a bare ``MagicMock``
session, so default the lookup to "unresolvable" (None -> not rejected,
same as an unknown slug always behaved) instead of making every one of
them mock a project service it isn't testing. Tests exercising the
exclusion check override this target explicitly."""
stub = MagicMock()
stub.get_by_slug = AsyncMock(return_value=None)
monkeypatch.setattr("roboco.services.project.get_project_service", lambda _s: stub)
@pytest.mark.asyncio
async def test_propose_roadmap_forbidden_for_non_po() -> None:
env = await _actions("head_marketing").propose_roadmap(
@@ -251,6 +265,67 @@ async def test_propose_roadmap_ignores_cycle_assigned_to_another_agent(
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_roadmap_rejects_item_targeting_excluded_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Task 6b: an item targeting a project carrying '!roadmap' is refused
at propose time, naming the excluded project — before the PO even
finishes authoring the cycle, not just at the CEO's later approve."""
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
excluded_project = MagicMock(board_programs=["!roadmap"])
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=excluded_project)
monkeypatch.setattr(
"roboco.services.project.get_project_service", lambda _s: project_svc
)
bad = _valid_item(0)
bad["project_slug"] = "excluded-proj"
env = await _actions("product_owner").propose_roadmap(
agent_id=agent_id, cycle_goal="Close onboarding friction", items=[bad]
)
assert env.error == "invalid_state"
assert "excluded-proj" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_roadmap_allows_unresolvable_project_slug_through(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unknown project_slug is not this check's job — it surfaces
downstream at approve/materialize time, unchanged from before Task 6b."""
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=None)
monkeypatch.setattr(
"roboco.services.project.get_project_service", lambda _s: project_svc
)
actions = _actions("product_owner")
actions.task.session.flush = AsyncMock()
bad = _valid_item(0)
bad["project_slug"] = "no-such-project"
env = await actions.propose_roadmap(
agent_id=agent_id, cycle_goal="Close onboarding friction", items=[bad]
)
assert env.error is None
@pytest.mark.asyncio
async def test_propose_roadmap_ignores_already_authored_cycle(
monkeypatch: pytest.MonkeyPatch,
@@ -0,0 +1,61 @@
"""The generic Board Program orchestrator loop — replaces
``_roadmap_engine_loop`` / ``_x_feature_spotlight_loop`` (see
tests/unit/services/test_board_program_engine.py for the engine's own
trigger/dedup/LEARN coverage; this file covers the orchestrator-loop shell
only: interval computation + the sleep/tick/heartbeat wiring).
Unlike the two collapsed loops, ``_board_program_loop`` has no single static
disablement gate — each program's enablement is checked per-tick inside
``BoardProgramEngine``, DB-backed — so there is no "returns immediately when
disabled" behavior to test here; that guarantee now lives in
test_board_program_engine.py's disabled-program coverage.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
TWO_TICKS = 2
ONE_HOUR_SECONDS = 3600
def _orch() -> Any:
"""Bypass __init__ — the loop helper under test needs only the
heartbeats dict and ``_running``."""
o = AgentOrchestrator.__new__(AgentOrchestrator)
o._loop_heartbeats = {}
o._running = True
return o
def test_board_program_interval_is_shortest_registered_cadence_capped() -> None:
orch = _orch()
interval = orch._board_program_interval_seconds()
# x_feature's 1-day default is the shortest of the two registered
# programs, well above the 300s floor — capped at the 3600s ceiling.
assert interval == ONE_HOUR_SECONDS
@pytest.mark.asyncio
async def test_board_program_loop_one_tick_exception_does_not_crash_loop() -> None:
"""A raising cycle is logged and the loop keeps ticking — mirrors every
other engine loop's ``except Exception: logger.exception(...)`` shape."""
orch = _orch()
calls = {"n": 0}
async def _cycle() -> None:
calls["n"] += 1
if calls["n"] >= TWO_TICKS:
orch._running = False
raise RuntimeError("boom")
orch._run_board_program_cycle = AsyncMock(side_effect=_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._board_program_loop()
assert calls["n"] == TWO_TICKS
@@ -189,3 +189,41 @@ def test_feature_spotlight_prompt_brief_fallbacks_when_marker_missing() -> None:
prompt = orch._build_feature_spotlight_prompt(_feature_task())
assert "nothing new since the last cycle" in prompt
assert "(none)" in prompt
def test_feature_spotlight_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_feature_spotlight_prompt(_feature_task())
assert "## Prior cycles" not in prompt
def test_feature_spotlight_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_feature_spotlight_prompt(
_feature_task(), "proposed 1, approved 0; rejected: org-memory — too soon"
)
assert "## Prior cycles" in prompt
assert "proposed 1, approved 0; rejected: org-memory — too soon" in prompt
@pytest.mark.asyncio
async def test_feature_spotlight_dispatch_injects_prior_context_into_prompt() -> None:
"""The dispatcher fetches LEARN context (best-effort) and threads it into
the prompt builder — proving the wiring, not just the builder in
isolation."""
orch = _make_orch()
task = _feature_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 1, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_feature_spotlight_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 1, approved 1" in prompt
@@ -1,60 +0,0 @@
"""The feature-spotlight orchestrator loop is fully dormant unless BOTH the X
engine and the feature-spotlight sub-switch are enabled (both default off).
With either flag off, ``_x_feature_spotlight_loop`` must return immediately —
no sleep, no HTTP, no DB, no Head-of-Marketing spawn — so a standard
deployment (or one running only release posts / mention replies) behaves
exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "x_engine_enabled", False)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off -> returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_dormant_when_only_subswitch_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""x_engine_enabled on but the feature-spotlight sub-switch off: still
dormant — the engine still runs release posts/mention replies via their
own loops, unaffected."""
monkeypatch.setattr(cfg, "x_engine_enabled", True)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_dormant_when_only_engine_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The subswitch alone is not enough — x_engine_enabled must also be on."""
monkeypatch.setattr(cfg, "x_engine_enabled", False)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", True)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@@ -23,7 +23,6 @@ from roboco.runtime.orchestrator import AgentOrchestrator
_CI_WATCH_INTERVAL = 0.01
_VIDEO_RENDER_INTERVAL = 0.05
_X_MENTIONS_INTERVAL = 0.04
_ROADMAP_INTERVAL = 0.06
def _orch() -> Any:
@@ -199,23 +198,22 @@ async def test_x_mentions_loop_records_heartbeat(
@pytest.mark.asyncio
async def test_roadmap_engine_loop_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The roadmap-engine loop records under its canonical name + interval —
guards against copy-paste name drift on the heartbeat calls."""
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()
monkeypatch.setattr(settings, "roadmap_engine_enabled", True)
monkeypatch.setattr(settings, "roadmap_interval_seconds", 0.06)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_roadmap_engine_cycle = AsyncMock(side_effect=_stop_after_cycle)
orch._run_board_program_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._roadmap_engine_loop()
await orch._board_program_loop()
assert "roadmap_engine" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["roadmap_engine"]
assert interval == _ROADMAP_INTERVAL
assert "board_program" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["board_program"]
assert interval == orch._board_program_interval_seconds()
@@ -153,3 +153,54 @@ def test_roadmap_prompt_names_solo_po_and_real_verbs() -> None:
assert "Head of Marketing is not" in prompt
assert "involved in this cycle" in prompt
assert "do not" in prompt.lower()
def test_roadmap_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_roadmap_prompt(_roadmap_task())
assert "## Prior cycles" not in prompt
def test_roadmap_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_roadmap_prompt(
_roadmap_task(), "proposed 5, approved 3; rejected: item-2 — too risky"
)
assert "## Prior cycles" in prompt
assert "proposed 5, approved 3; rejected: item-2 — too risky" in prompt
@pytest.mark.asyncio
async def test_roadmap_dispatch_injects_prior_context_into_prompt() -> None:
"""The dispatcher fetches LEARN context (best-effort) and threads it into
the prompt builder — proving the wiring, not just the builder in
isolation."""
orch = _make_orch()
task = _roadmap_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 2, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_roadmap_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt
@pytest.mark.asyncio
async def test_board_program_prior_context_survives_db_failure() -> None:
"""A DB hiccup fetching prior context degrades to '' — never raises, so
the caller (the dispatcher) never needs its own safety net around it."""
orch = _make_orch()
with patch(
"roboco.services.board_programs.get_board_program_engine",
side_effect=RuntimeError("db down"),
):
result = await orch._board_program_prior_context("roadmap")
assert result == ""
@@ -1,28 +0,0 @@
"""The roadmap-engine orchestrator loop is fully dormant when disabled
(default).
With ``roadmap_engine_enabled`` off, ``_roadmap_engine_loop`` must return
immediately — no sleep, no HTTP, no DB, no Product-Owner spawn — so a
standard deployment behaves exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_roadmap_engine_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off -> returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(AgentOrchestrator._roadmap_engine_loop(stub), timeout=1.0)
@@ -0,0 +1,559 @@
"""BoardProgramEngine: trigger/dedup/originate/LEARN over the registry.
Mirrors test_roadmap_engine.py's seeding + real-Postgres style, but swaps in
fake originators (monkeypatched into board_programs._ORIGINATORS) so this
suite tests the ENGINE's own dedup/cron/LEARN logic in isolation from
RoadmapEngine/XEngine's own internal guards (covered by their own suites).
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, cast
import pytest
import pytest_asyncio
from roboco.config import settings as cfg
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.board_programs import PROGRAMS, BoardProgram, TriggerKind
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
TaskNature,
TaskType,
Team,
)
from roboco.models.base import (
TaskStatus as TS,
)
from roboco.services import board_programs as bp_module
from roboco.services.board_programs import BoardProgramEngine
from roboco.services.task import (
ROADMAP_SOURCE,
X_FEATURE_EXPLORATION_SOURCE,
TaskCreateRequest,
get_task_service,
)
from sqlalchemy import delete, select, update
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
@pytest_asyncio.fixture(autouse=True)
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
"""Board Program state (settings-store overrides, ledger rows, open
roadmap/x_feature exploration tasks) is shared, cross-test-persistent DB
state — this module's own tests write it mid-test, and the write-route
integration suite (``test_board_programs_api.py``'s run-now, which
commits) can leave it behind too. Purge before every test in this file
so a leftover row never reads back as a false "already open"/"already
armed" state or collides on a settings-store primary key."""
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()
async def _seed(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()
project = 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,
)
session.add(project)
await session.flush()
async def _make_exploration(
session: AsyncSession, *, source: str, status: TS = TS.PENDING
) -> TaskTable:
project = (
await session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one()
task = await get_task_service(session).create(
TaskCreateRequest(
title="exploration cycle",
description="x",
acceptance_criteria=["propose once"],
team=Team.BOARD,
assigned_to=PO_UUID,
created_by=SYSTEM_UUID,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=cast("UUID", project.id),
status=TS.PENDING,
source=source,
confirmed_by_human=False,
)
)
if status != TS.PENDING:
task.status = status
await session.flush()
return task
def _fake_originator(
holder: dict[str, TaskTable | None],
) -> Callable[[AsyncSession], Awaitable[TaskTable | None]]:
async def _originate(_session: AsyncSession) -> TaskTable | None:
return holder["task"]
return _originate
def _patch_roadmap_originator(
monkeypatch: pytest.MonkeyPatch, holder: dict[str, TaskTable | None]
) -> None:
monkeypatch.setitem(bp_module._ORIGINATORS, "roadmap", _fake_originator(holder))
@pytest.mark.asyncio
async def test_disabled_program_never_originates(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
holder: dict[str, TaskTable | None] = {"task": None}
_patch_roadmap_originator(monkeypatch, holder)
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert "roadmap" not in opened
@pytest.mark.asyncio
async def test_dormant_with_no_settings_store_rows_and_legacy_flags_off(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No settings-store overrides + both legacy boot flags False: a tick
originates nothing and writes ZERO ledger rows — the guarantee the
deleted per-engine dormant-loop tests covered, now at the engine layer
every program's arming decision routes through."""
await _seed(db_session)
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
monkeypatch.setattr(cfg, "x_engine_enabled", False)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert opened == []
rows = (await db_session.execute(select(BoardProgramCycleTable))).scalars().all()
assert rows == []
@pytest.mark.asyncio
async def test_open_cycle_blocks_reorigination(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
db_session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await db_session.flush()
holder: dict[str, TaskTable | None] = {"task": None}
_patch_roadmap_originator(monkeypatch, holder)
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert "roadmap" not in opened
@pytest.mark.asyncio
async def test_due_program_originates_and_opens_cycle_row(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
new_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
holder: dict[str, TaskTable | None] = {"task": new_task}
_patch_roadmap_originator(monkeypatch, holder)
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert opened == ["roadmap"]
rows = (
(
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "roadmap"
)
)
)
.scalars()
.all()
)
assert len(rows) == ONE
assert rows[0].exploration_task_id == new_task.id
assert rows[0].closed_at is None
@pytest.mark.asyncio
async def test_closed_cycle_past_interval_allows_reorigination(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
monkeypatch.setattr(cfg, "roadmap_interval_seconds", 300)
old_task = await _make_exploration(
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
)
db_session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=old_task.id,
opened_at=datetime.now(UTC) - timedelta(seconds=301),
closed_at=datetime.now(UTC) - timedelta(seconds=200),
)
)
await db_session.flush()
new_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
holder: dict[str, TaskTable | None] = {"task": new_task}
_patch_roadmap_originator(monkeypatch, holder)
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert opened == ["roadmap"]
@pytest.mark.asyncio
async def test_auto_closes_open_row_once_task_goes_terminal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A stale open row whose exploration task already went terminal is
reconciled (auto-closed) rather than permanently blocking dedup."""
await _seed(db_session)
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
monkeypatch.setattr(cfg, "roadmap_interval_seconds", 1)
stale_task = await _make_exploration(
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
)
db_session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=stale_task.id,
opened_at=datetime.now(UTC) - timedelta(seconds=5),
)
)
await db_session.flush()
new_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
holder: dict[str, TaskTable | None] = {"task": new_task}
_patch_roadmap_originator(monkeypatch, holder)
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert opened == ["roadmap"]
rows = (
(
await db_session.execute(
select(BoardProgramCycleTable)
.where(BoardProgramCycleTable.program_key == "roadmap")
.order_by(BoardProgramCycleTable.opened_at)
)
)
.scalars()
.all()
)
assert len(rows) == TWO
assert rows[0].closed_at is not None # reconciled
@pytest.mark.asyncio
async def test_record_decision_bumps_counters_and_closes_when_done(
db_session: AsyncSession,
) -> None:
await _seed(db_session)
task = await _make_exploration(
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
)
db_session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await db_session.flush()
engine = BoardProgramEngine(db_session)
await engine.record_decision("roadmap", "item-1", "approved")
await engine.record_decision("roadmap", "item-2", "rejected", reason="not now")
row = await engine._latest_cycle("roadmap")
assert row is not None
assert row.items_proposed == TWO
assert row.items_approved == 1
assert row.items_rejected == 1
assert row.closed_at is not None # exploration task was already terminal
assert {
"item_ref": "item-1",
"verdict": "approved",
"reason": None,
} in row.decisions
@pytest.mark.asyncio
async def test_record_decision_targets_named_exploration_over_most_recent(
db_session: AsyncSession,
) -> None:
"""Two cycle rows exist for "roadmap": the FIRST auto-closed via a
terminal exploration task with items still undecided (the admin-cancel
edge), the SECOND opened after it and is the most-recent row. A decision
carrying the FIRST task's id must land on the FIRST row, not silently
fall through to the most-recent-cycle fallback."""
await _seed(db_session)
first_task = await _make_exploration(
db_session, source=ROADMAP_SOURCE, status=TS.CANCELLED
)
db_session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=first_task.id,
opened_at=datetime.now(UTC) - timedelta(hours=2),
)
)
second_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
db_session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=second_task.id,
opened_at=datetime.now(UTC) - timedelta(hours=1),
)
)
await db_session.flush()
engine = BoardProgramEngine(db_session)
await engine.record_decision(
"roadmap",
"item-1",
"approved",
exploration_task_id=cast("UUID", first_task.id),
)
first_row = await engine._cycle_for_exploration(
"roadmap", cast("UUID", first_task.id)
)
second_row = await engine._cycle_for_exploration(
"roadmap", cast("UUID", second_task.id)
)
assert first_row is not None
assert second_row is not None
assert first_row.items_proposed == ONE
assert first_row.items_approved == ONE
assert {
"item_ref": "item-1",
"verdict": "approved",
"reason": None,
} in first_row.decisions
assert first_row.closed_at is not None # reconciled: task was terminal
assert second_row.items_proposed == 0
assert second_row.decisions == []
@pytest.mark.asyncio
async def test_prior_cycle_context_renders_rejections_with_reasons(
db_session: AsyncSession,
) -> None:
await _seed(db_session)
assert await BoardProgramEngine(db_session).prior_cycle_context("roadmap") == ""
task = await _make_exploration(
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
)
db_session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
closed_at=datetime.now(UTC),
items_proposed=2,
items_approved=1,
items_rejected=1,
decisions=[
{"item_ref": "item-1", "verdict": "approved", "reason": None},
{"item_ref": "item-2", "verdict": "rejected", "reason": "too risky"},
],
)
)
await db_session.flush()
context = await BoardProgramEngine(db_session).prior_cycle_context("roadmap")
assert "proposed 2, approved 1" in context
assert "item-2 — too risky" in context
def test_originators_cover_exactly_the_registry() -> None:
assert set(bp_module._ORIGINATORS) == set(PROGRAMS)
def test_program_sources_match_service_layer_constants() -> None:
assert PROGRAMS["roadmap"].source == ROADMAP_SOURCE
assert PROGRAMS["x_feature"].source == X_FEATURE_EXPLORATION_SOURCE
# ---------------------------------------------------------------------------
# Task 6b: per-project program scoping
# ---------------------------------------------------------------------------
_PEST_CONTROL = BoardProgram(
key="pest_control",
role="product_owner",
trigger=TriggerKind.CRON,
source="board_pest_control",
default_interval_seconds=1,
scope="project",
)
def _arm_setting(session: AsyncSession, key: str) -> None:
"""Bypass ``SettingsService.set``'s key allowlist (a project-scoped test
program is never a real writable key) and write the raw row directly —
``get_bool`` only reads it, it never validates."""
session.add(SystemSettingTable(key=key, value="true"))
@pytest.mark.asyncio
async def test_opted_in_projects_filters_by_project_participates(
db_session: AsyncSession,
) -> None:
await _seed(db_session)
opted_in = ProjectTable(
name="Opted In",
slug="opted-in-proj",
git_url="https://github.com/x/opted-in.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
board_programs=["pest_control"],
)
db_session.add(opted_in)
await db_session.flush()
engine = BoardProgramEngine(db_session)
projects = await engine.opted_in_projects(_PEST_CONTROL)
# SLUG ("roboco", seeded by _seed) never opted in — only the new project.
assert {p.slug for p in projects} == {"opted-in-proj"}
@pytest.mark.asyncio
async def test_run_due_programs_skips_project_scoped_program_with_no_opt_in(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _PEST_CONTROL)
_arm_setting(db_session, "board_program.pest_control.enabled")
holder: dict[str, TaskTable | None] = {"task": None}
monkeypatch.setitem(
bp_module._ORIGINATORS, "pest_control", _fake_originator(holder)
)
await db_session.flush()
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert "pest_control" not in opened
rows = (
(
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "pest_control"
)
)
)
.scalars()
.all()
)
assert rows == []
@pytest.mark.asyncio
async def test_open_program_cycle_returns_none_with_no_project_opted_in(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _PEST_CONTROL)
_arm_setting(db_session, "board_program.pest_control.enabled")
await db_session.flush()
engine = BoardProgramEngine(db_session)
assert await engine.open_program_cycle("pest_control") is None
@pytest.mark.asyncio
async def test_run_due_programs_originates_project_scoped_program_with_opt_in(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
project = (
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one()
project.board_programs = ["pest_control"]
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _PEST_CONTROL)
_arm_setting(db_session, "board_program.pest_control.enabled")
new_task = await _make_exploration(db_session, source="board_pest_control")
holder: dict[str, TaskTable | None] = {"task": new_task}
monkeypatch.setitem(
bp_module._ORIGINATORS, "pest_control", _fake_originator(holder)
)
await db_session.flush()
engine = BoardProgramEngine(db_session)
opened = await engine.run_due_programs()
assert opened == ["pest_control"]
+68 -2
View File
@@ -13,13 +13,25 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
import pytest_asyncio
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable
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.roadmap_engine import RoadmapEngine
from roboco.services.task import ROADMAP_SOURCE, get_task_service
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
@@ -30,6 +42,28 @@ SLUG = "roboco"
ONE = 1
@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 tests, or the write-route ``test_board_programs_api.py``
run-now test) can leave behind. 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()
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
@@ -116,6 +150,38 @@ async def test_dedupe_one_open_cycle(
assert len(await get_task_service(db_session).list_open_roadmap_cycles()) == ONE
@pytest.mark.asyncio
async def test_settings_store_true_overrides_legacy_false(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The double-flag regression this guards: a settings-store True must
win over a False legacy flag, not be silently overridden by it."""
await _seed(db_session)
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
db_session.add(
SystemSettingTable(key="board_program.roadmap.enabled", value="true")
)
await db_session.flush()
engine = RoadmapEngine(db_session)
assert await engine.run_cycle() is not None
@pytest.mark.asyncio
async def test_settings_store_false_overrides_legacy_true(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
db_session.add(
SystemSettingTable(key="board_program.roadmap.enabled", value="false")
)
await db_session.flush()
engine = RoadmapEngine(db_session)
assert await engine.run_cycle() is None
assert await get_task_service(db_session).list_open_roadmap_cycles() == []
@pytest.mark.asyncio
async def test_unresolvable_project_no_cycle(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+144 -3
View File
@@ -7,11 +7,20 @@ Mirrors the X-post-service / release-proposal-service tests.
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, cast
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, AuditLogTable, ProjectTable, TaskTable
import pytest_asyncio
from roboco.db.tables import (
AgentTable,
AuditLogTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
@@ -23,9 +32,14 @@ from roboco.models.base import (
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services import board_programs as bp_module
from roboco.services.roadmap_service import RoadmapService, get_roadmap_service
from roboco.services.task import ROADMAP_ITEM_SOURCE, ROADMAP_SOURCE
from sqlalchemy import select
from roboco.services.task import (
ROADMAP_ITEM_SOURCE,
ROADMAP_SOURCE,
X_FEATURE_EXPLORATION_SOURCE,
)
from sqlalchemy import delete, select, update
if TYPE_CHECKING:
from uuid import UUID
@@ -39,6 +53,30 @@ ONE = 1
TWO = 2
@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 ``_seed_cycle_ledger_row`` rows, or the write-route
``test_board_programs_api.py`` run-now test) can leave behind — this
module's ``scalar_one()`` ledger lookups need exactly one row. 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 _item(idx: int, *, status: str = "proposed", project_slug: str) -> dict:
return {
"id": f"item-{idx}",
@@ -260,6 +298,25 @@ async def test_approve_unknown_project_slug_is_invalid_state(
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_approve_excluded_project_is_invalid_state(
db_session: AsyncSession,
) -> None:
"""Task 6b: a project carrying '!roadmap' refuses materialize-side, even
if propose_roadmap's own point-in-time check somehow let the item
through (e.g. the project was excluded AFTER the PO proposed it)."""
project = await _seed_project(db_session, "excluded-svc")
project.board_programs = ["!roadmap"]
await db_session.flush()
task = await _seed_cycle(db_session, project_slug="excluded-svc")
result = await _svc(db_session).approve_item(
_id(task), "item-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "invalid_state"
assert "excluded" in result.detail
@pytest.mark.asyncio
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
result = await _svc(db_session).approve_item(uuid4(), "item-0", created_by=CEO_UUID)
@@ -321,3 +378,87 @@ async def test_maybe_complete_cycle_emits_audit(db_session: AsyncSession) -> Non
assert audit, (
"expected a task.completed audit row for the PENDING -> COMPLETED transition"
)
# --------------------------------------------------------------------------- #
# LEARN wiring (Task 5): approve/reject best-effort record onto the open
# board_program_cycles row for "roadmap" — see test_board_program_engine.py
# for record_decision's own counter/close-on-terminal coverage.
# --------------------------------------------------------------------------- #
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
session.add(
BoardProgramCycleTable(
program_key="roadmap",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await session.flush()
@pytest.mark.asyncio
async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
await _seed_project(db_session, "backend-svc")
task = await _seed_cycle(db_session, project_slug="backend-svc")
await _seed_cycle_ledger_row(db_session, task)
await _svc(db_session).approve_item(_id(task), "item-0", created_by=CEO_UUID)
row = (
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "roadmap"
)
)
).scalar_one()
assert row.items_approved == ONE
assert {
"item_ref": "item-0",
"verdict": "approved",
"reason": None,
} in row.decisions
@pytest.mark.asyncio
async def test_reject_records_learn_decision_with_reason(
db_session: AsyncSession,
) -> None:
await _seed_project(db_session, "backend-svc")
task = await _seed_cycle(db_session, project_slug="backend-svc")
await _seed_cycle_ledger_row(db_session, task)
await _svc(db_session).reject_item(_id(task), "item-0", "not a priority")
row = (
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "roadmap"
)
)
).scalar_one()
assert row.items_rejected == ONE
assert {
"item_ref": "item-0",
"verdict": "rejected",
"reason": "not a priority",
} in row.decisions
@pytest.mark.asyncio
async def test_approve_survives_learn_recording_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A record_decision blow-up must never break the CEO's approve."""
await _seed_project(db_session, "backend-svc")
task = await _seed_cycle(db_session, project_slug="backend-svc")
await _seed_cycle_ledger_row(db_session, task)
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("learn boom")
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
result = await _svc(db_session).approve_item(
_id(task), "item-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
+179 -1
View File
@@ -2,12 +2,32 @@
from __future__ import annotations
from typing import Any
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",
@@ -23,6 +43,30 @@ _GOALS_EMPTY: dict[str, Any] = {
}
@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,
*,
@@ -109,3 +153,137 @@ async def test_run_cycle_enabled_no_observations_no_notify(
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
+34
View File
@@ -21,6 +21,7 @@ from roboco.db.tables import (
AgentTable,
NotificationTable,
ProjectTable,
SystemSettingTable,
TaskTable,
XSeenFeatureTable,
XSeenMentionTable,
@@ -749,6 +750,39 @@ async def test_feature_spotlight_subswitch_off_creates_no_exploration(
assert await get_task_service(db_session).list_open_feature_explorations() == []
@pytest.mark.asyncio
async def test_feature_spotlight_settings_store_true_overrides_legacy_false(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The double-flag regression this guards: a settings-store True must
win over a False legacy flag pair, not be silently overridden by it."""
await _seed(db_session)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
db_session.add(
SystemSettingTable(key="board_program.x_feature.enabled", value="true")
)
await db_session.flush()
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
task = await engine.open_feature_spotlight_exploration()
assert task is not None
@pytest.mark.asyncio
async def test_feature_spotlight_settings_store_false_overrides_legacy_true(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True)
db_session.add(
SystemSettingTable(key="board_program.x_feature.enabled", value="false")
)
await db_session.flush()
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
task = await engine.open_feature_spotlight_exploration()
assert task is None
assert await get_task_service(db_session).list_open_feature_explorations() == []
@pytest.mark.asyncio
async def test_feature_spotlight_no_credentials_creates_no_exploration(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+134 -2
View File
@@ -10,13 +10,14 @@ from __future__ import annotations
import asyncio
import contextlib
from contextlib import contextmanager
from datetime import UTC, datetime
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.db.tables import AgentTable, BoardProgramCycleTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
@@ -28,6 +29,7 @@ from roboco.models.base import (
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services import board_programs as bp_module
from roboco.services import x_engine as x_engine_module
from roboco.services.company_goals import get_company_goals_service
from roboco.services.task import (
@@ -44,7 +46,7 @@ from roboco.services.x_post_service import (
XPostService,
get_x_post_service,
)
from sqlalchemy import delete
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
@@ -662,6 +664,136 @@ async def test_approve_does_not_flush_edited_body_before_lock(
assert markers.get_x_draft_body(task) == original_body
# --------------------------------------------------------------------------- #
# LEARN wiring (Task 5): approve/reject of an X_FEATURE_SOURCE draft best-
# effort records onto the open board_program_cycles row for "x_feature" —
# other X sources (x_post/x_reply) are not board-program-backed and must
# never record. See test_board_program_engine.py for record_decision's own
# counter/close-on-terminal coverage.
# --------------------------------------------------------------------------- #
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
session.add(
BoardProgramCycleTable(
program_key="x_feature",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await session.flush()
async def _cycle_row_for_task(
session: AsyncSession, task_id: UUID
) -> BoardProgramCycleTable:
"""The board_program_cycles row THIS task's approve/reject decided —
scoped by exploration_task_id rather than a bare program_key filter, since
``_post()``'s real ``session.commit()`` durably leaks rows from earlier
tests into this file's shared session-scoped test DB (documented above
`_delete_tasks`); a global program_key query would collide across tests."""
return (
await session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.exploration_task_id == task_id
)
)
).scalar_one()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_records_learn_decision(
db_session: AsyncSession,
) -> None:
task = await _seed_feature_draft(db_session)
await _seed_cycle_ledger_row(db_session, task)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(task))
row = await _cycle_row_for_task(db_session, _id(task))
assert row.items_approved == ONE
assert {
"item_ref": _FEATURE_SLUG,
"verdict": "approved",
"reason": None,
} in row.decisions
@pytest.mark.asyncio
async def test_reject_feature_spotlight_records_learn_decision_with_reason(
db_session: AsyncSession,
) -> None:
task = await _seed_feature_draft(db_session)
await _seed_cycle_ledger_row(db_session, task)
with _lock_free():
await _svc(db_session).reject(_id(task), "not on-brand")
row = await _cycle_row_for_task(db_session, _id(task))
assert row.items_rejected == ONE
assert {
"item_ref": _FEATURE_SLUG,
"verdict": "rejected",
"reason": "not on-brand",
} in row.decisions
@pytest.mark.asyncio
async def test_approve_plain_x_post_does_not_record_learn(
db_session: AsyncSession,
) -> None:
"""x_post/x_reply drafts are not board-program-backed — approving one
must never touch the board_program_cycles ledger."""
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(task))
rows = (
(
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.exploration_task_id == task.id
)
)
)
.scalars()
.all()
)
assert rows == []
@pytest.mark.asyncio
async def test_approve_feature_spotlight_survives_learn_recording_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A record_decision blow-up must never break the already-succeeded post."""
task = await _seed_feature_draft(db_session)
await _seed_cycle_ledger_row(db_session, task)
client = _StubClient()
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("learn boom")
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
async def _fresh_session(url: str) -> tuple[AsyncSession, AsyncEngine]:
"""A session on a brand-new engine/connection (caller disposes)."""
engine = create_async_engine(url, future=True)