mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user