mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(board): give Board Program explorers a nothing_to_propose exit Every propose_* verb requires at least one item, so an explorer that legitimately found nothing — Barfly with no worthwhile X conversations, Coroner with no autopsy subject — had no way to close its exploration task. It declined, called i_am_idle(), and the task stayed PENDING forever: the dispatcher re-matched it every tick and respawned the board agent (~$0.61 a spawn, ~3 per 5-minute respawn-breaker cooldown window, indefinitely), and BoardProgramEngine's one-open-cycle dedup wedged that whole program shut, since the ledger row only closes once its exploration task goes terminal. nothing_to_propose(task_id, reason) is the explicit exit. task_id is required rather than inferred: one explorer role owns several independently-cadenced programs (head_marketing owns six) and each assigns its exploration task to the same agent, so several are open at once by design and guessing "the caller's oldest" completes the WRONG cycle — stamping its reason onto an unrelated program's ledger while the task actually being worked stays wedged. Resolution validates the named task exists, carries a registered program source, is assigned to the caller, and is non-terminal, then gates on the program's declared explorer role from the registry, so a program registered later needs no edit here. The reason lands on board_program_cycles (migration 089) and renders into the next cycle's LEARN context, replacing a bare "proposed 0, approved 0" with why. That write runs in its own savepoint: it flushes on the same session as the completion, and a bare try/except around a same-session flush leaves the transaction pending-rollback, so a DB blip there would discard the completion at the post-response commit while the verb reported success. All fourteen exploration prompts offer the exit, pinned by a registry-parametrized test that fails when a future program is unwired. * ci: fire PR checks on slave-based PRs, not master alone All five gating workflows declared `pull_request: branches: [master]`, but every fleet PR targets slave — cell->root, root->slave, and the CEO's own. So `pull_request` never fired for any of them, and their only coverage was the `push` trigger, which is gated on branch PREFIX (feature/bug/chore/docs/hotfix). A branch named anything else got zero checks — not a red run, an absent one — and a PR with no required check present merges on a false green. PR #711 shipped that way on a `fix/` branch. Basing on the branch a PR merges INTO rather than what its head is named makes coverage independent of branch naming, so a non-conforming prefix can only ever cost the redundant push run, never the whole gate. The same five also omitted slave from `push` (ci.yml aside, which added it for the release gate's fail-closed CI read), so the panel suite, both CodeQL analyses, and the e2e smoke never ran on the trunk master is cut from. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
1213 lines
43 KiB
Python
1213 lines
43 KiB
Python
"""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
|
|
from uuid import uuid4
|
|
|
|
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,
|
|
WEEK_SECONDS,
|
|
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.gateway.content_actions import ContentActions, ContentActionsDeps
|
|
from roboco.services.task import (
|
|
BARFLY_SOURCE,
|
|
CORONER_SOURCE,
|
|
DOGFOOD_SOURCE,
|
|
LIBRARIAN_SOURCE,
|
|
MEGAPHONE_SOURCE,
|
|
MIRROR_SOURCE,
|
|
PERISCOPE_SOURCE,
|
|
PEST_CONTROL_SOURCE,
|
|
ROADMAP_SOURCE,
|
|
SCALES_SOURCE,
|
|
SENTINEL_SOURCE,
|
|
SPACKLE_SOURCE,
|
|
WAR_ROOM_SOURCE,
|
|
X_FEATURE_EXPLORATION_SOURCE,
|
|
TaskCreateRequest,
|
|
TaskService,
|
|
get_task_service,
|
|
)
|
|
from sqlalchemy import delete, select, text, 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,
|
|
PEST_CONTROL_SOURCE,
|
|
PERISCOPE_SOURCE,
|
|
CORONER_SOURCE,
|
|
SENTINEL_SOURCE,
|
|
SCALES_SOURCE,
|
|
SPACKLE_SOURCE,
|
|
MIRROR_SOURCE,
|
|
MEGAPHONE_SOURCE,
|
|
LIBRARIAN_SOURCE,
|
|
WAR_ROOM_SOURCE,
|
|
BARFLY_SOURCE,
|
|
DOGFOOD_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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ContentActions.nothing_to_propose's task resolution — a real-DB regression
|
|
# suite. ``task_id`` is now REQUIRED and resolved by a direct fetch-by-id
|
|
# (mirrors ``curate_vault``), replacing the old ``get_open_board_program_
|
|
# exploration_task`` oldest-wins-across-programs query, which was unsound:
|
|
# one explorer role owns SEVERAL independently-cadenced programs at once
|
|
# (e.g. product_owner owns roadmap/pest_control/spackle/scales/dogfood), so
|
|
# several of an agent's exploration tasks are open simultaneously by design.
|
|
# The mock-based per-check envelope tests (reason validation, role gate,
|
|
# LEARN recording, best-effort failure) live in
|
|
# tests/unit/gateway/test_content_actions_nothing_to_propose.py; this suite
|
|
# proves the real DB resolution instead.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _nothing_to_propose_actions(session: AsyncSession) -> ContentActions:
|
|
return ContentActions(
|
|
ContentActionsDeps(
|
|
task=TaskService(session),
|
|
git=None,
|
|
a2a=None,
|
|
journal=None,
|
|
workspace=None,
|
|
notifications=None,
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nothing_to_propose_resolves_named_task_not_older_sibling(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""DEFECT regression: the SAME agent has two open exploration tasks from
|
|
two DIFFERENT programs at once (product_owner owns both roadmap and
|
|
pest_control) — nothing_to_propose(task_id=...) must complete the task
|
|
NAMED, never an older sibling from an unrelated program."""
|
|
await _seed(db_session)
|
|
older = await _make_exploration(db_session, source=ROADMAP_SOURCE)
|
|
older.created_at = datetime.now(UTC) - timedelta(hours=1)
|
|
await db_session.flush()
|
|
newer = await _make_exploration(db_session, source=PEST_CONTROL_SOURCE)
|
|
|
|
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
|
|
agent_id=PO_UUID,
|
|
task_id=cast("UUID", newer.id),
|
|
reason="checked recent rework/findings evidence; nothing rose to a bug",
|
|
)
|
|
|
|
assert env.error is None, env.message
|
|
assert env.task_id == str(newer.id)
|
|
assert env.context_briefing["program"] == "pest_control"
|
|
await db_session.refresh(newer)
|
|
await db_session.refresh(older)
|
|
assert newer.status == TS.COMPLETED
|
|
assert older.status == TS.PENDING
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nothing_to_propose_rejects_task_not_assigned_to_caller(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
await _seed(db_session)
|
|
task = await _make_exploration(db_session, source=PEST_CONTROL_SOURCE)
|
|
other_agent = uuid4()
|
|
|
|
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
|
|
agent_id=other_agent,
|
|
task_id=cast("UUID", task.id),
|
|
reason="reviewed the candidate list; none were worth a reply",
|
|
)
|
|
|
|
assert env.error == "not_authorized"
|
|
await db_session.refresh(task)
|
|
assert task.status == TS.PENDING
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nothing_to_propose_rejects_terminal_task(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
await _seed(db_session)
|
|
task = await _make_exploration(
|
|
db_session, source=PEST_CONTROL_SOURCE, status=TS.COMPLETED
|
|
)
|
|
|
|
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
|
|
agent_id=PO_UUID,
|
|
task_id=cast("UUID", task.id),
|
|
reason="reviewed the candidate list; none were worth a reply",
|
|
)
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "completed" in (env.message or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nothing_to_propose_missing_task_is_not_found(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
await _seed(db_session)
|
|
|
|
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
|
|
agent_id=PO_UUID,
|
|
task_id=uuid4(),
|
|
reason="reviewed the candidate list; none were worth a reply",
|
|
)
|
|
|
|
assert env.error == "not_found"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nothing_to_propose_learn_failure_does_not_poison_completion(
|
|
db_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""DEFECT 2 regression: a genuine DB-level failure inside the LEARN
|
|
write's own flush() must not poison the outer transaction — the task
|
|
completion flushed just before it must still survive the real commit
|
|
that follows (mirrors DbCommitMiddleware's post-response commit).
|
|
|
|
Uses an actual failing SQL statement, not a bare ``raise RuntimeError``
|
|
— a plain Python exception never touches the DBAPI connection, so it
|
|
would pass even without the ``begin_nested()`` savepoint fix and prove
|
|
nothing. Only a real aborted transaction exercises the isolation.
|
|
|
|
A real commit is unavoidable here — every other test in this module
|
|
relies on ``db_session``'s teardown rollback for isolation, so this test
|
|
deletes its own committed rows afterward (the ``_seed``-created project
|
|
has no idempotent-reuse guard, so a leaked commit collides with the next
|
|
test's ``_seed`` call on the unique project slug)."""
|
|
await _seed(db_session)
|
|
task = await _make_exploration(db_session, source=PEST_CONTROL_SOURCE)
|
|
|
|
class _PoisonedEngine:
|
|
async def record_nothing_to_propose(self, *_a: object, **_kw: object) -> None:
|
|
await db_session.execute(text("SELECT 1/0"))
|
|
|
|
monkeypatch.setattr(
|
|
bp_module, "get_board_program_engine", lambda _s: _PoisonedEngine()
|
|
)
|
|
|
|
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
|
|
agent_id=PO_UUID,
|
|
task_id=cast("UUID", task.id),
|
|
reason="reviewed the candidate list; none were worth a reply",
|
|
)
|
|
assert env.error is None, env.message
|
|
|
|
try:
|
|
# Without the savepoint, this commit would raise (the connection is
|
|
# still in Postgres's aborted-transaction state) and everything
|
|
# above, including the task completion, would be lost.
|
|
await db_session.commit()
|
|
|
|
refetched = await get_task_service(db_session).get(cast("UUID", task.id))
|
|
assert refetched is not None
|
|
assert refetched.status == TS.COMPLETED
|
|
finally:
|
|
await db_session.execute(delete(TaskTable).where(TaskTable.id == task.id))
|
|
await db_session.execute(delete(ProjectTable).where(ProjectTable.slug == SLUG))
|
|
await db_session.commit()
|
|
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# record_nothing_to_propose — the nothing_to_propose verb's LEARN write.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_record_nothing_to_propose_sets_reason_without_touching_counters(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
await _seed(db_session)
|
|
task = await _make_exploration(
|
|
db_session, source=BARFLY_SOURCE, status=TS.COMPLETED
|
|
)
|
|
db_session.add(
|
|
BoardProgramCycleTable(
|
|
program_key="barfly",
|
|
exploration_task_id=task.id,
|
|
opened_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
engine = BoardProgramEngine(db_session)
|
|
await engine.record_nothing_to_propose(
|
|
"barfly", cast("UUID", task.id), "no worthwhile conversations this cycle"
|
|
)
|
|
|
|
row = await engine._latest_cycle("barfly")
|
|
assert row is not None
|
|
assert row.nothing_to_propose_reason == "no worthwhile conversations this cycle"
|
|
assert row.items_proposed == 0
|
|
assert row.items_approved == 0
|
|
assert row.items_rejected == 0
|
|
assert row.decisions == []
|
|
# Does not close the row itself — that's still _maybe_close's job.
|
|
assert row.closed_at is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_record_nothing_to_propose_noop_when_no_cycle_row(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""No cycle row exists for this program/task at all — a best-effort
|
|
no-op, mirroring record_decision's own producers' best-effort wrapping."""
|
|
engine = BoardProgramEngine(db_session)
|
|
await engine.record_nothing_to_propose("barfly", uuid4(), "no candidates")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prior_cycle_context_renders_nothing_to_propose_reason(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""The load-bearing render: a proposed-0 closed cycle with a recorded
|
|
reason shows WHY in the next cycle's LEARN context, not a bare
|
|
"proposed 0, approved 0"."""
|
|
await _seed(db_session)
|
|
task = await _make_exploration(
|
|
db_session, source=BARFLY_SOURCE, status=TS.COMPLETED
|
|
)
|
|
db_session.add(
|
|
BoardProgramCycleTable(
|
|
program_key="barfly",
|
|
exploration_task_id=task.id,
|
|
opened_at=datetime.now(UTC),
|
|
closed_at=datetime.now(UTC),
|
|
nothing_to_propose_reason="no worthwhile conversations this cycle",
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
context = await BoardProgramEngine(db_session).prior_cycle_context("barfly")
|
|
assert (
|
|
"proposed 0 — nothing to propose: no worthwhile conversations this cycle"
|
|
in context
|
|
)
|
|
assert "proposed 0, approved 0" not in context
|
|
|
|
|
|
def test_learn_ref_names_the_item_not_its_per_cycle_index() -> None:
|
|
"""The ref reaches the next cycle's prompt, so it must say WHAT was
|
|
decided — ``item-1`` means something different in every cycle."""
|
|
assert bp_module.learn_ref({"id": "item-1", "title": "Fix the CLA gate"}) == (
|
|
"Fix the CLA gate"
|
|
)
|
|
# Scales items name the live task they mutate instead of a draft title.
|
|
assert bp_module.learn_ref(
|
|
{"id": "item-0", "target_task_title": "Panel UX wave"}
|
|
) == ("Panel UX wave")
|
|
# A title-less item degrades to the old behaviour rather than an empty ref.
|
|
assert bp_module.learn_ref({"id": "item-2"}) == "item-2"
|
|
assert bp_module.learn_ref({"id": "item-3", "title": " "}) == "item-3"
|
|
cap = 80
|
|
assert len(bp_module.learn_ref({"id": "item-4", "title": "x" * 200})) == cap
|
|
|
|
|
|
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
|
|
assert PROGRAMS["pest_control"].source == PEST_CONTROL_SOURCE
|
|
assert PROGRAMS["periscope"].source == PERISCOPE_SOURCE
|
|
assert PROGRAMS["coroner"].source == CORONER_SOURCE
|
|
assert PROGRAMS["sentinel"].source == SENTINEL_SOURCE
|
|
assert PROGRAMS["spackle"].source == SPACKLE_SOURCE
|
|
assert PROGRAMS["scales"].source == SCALES_SOURCE
|
|
assert PROGRAMS["mirror"].source == MIRROR_SOURCE
|
|
assert PROGRAMS["megaphone"].source == MEGAPHONE_SOURCE
|
|
assert PROGRAMS["librarian"].source == LIBRARIAN_SOURCE
|
|
assert PROGRAMS["war_room"].source == WAR_ROOM_SOURCE
|
|
assert PROGRAMS["barfly"].source == BARFLY_SOURCE
|
|
assert PROGRAMS["dogfood"].source == DOGFOOD_SOURCE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_coroner_originator_is_a_never_originating_stub(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""EVENT programs are never cron/metric-originated — the registered
|
|
``_ORIGINATORS["coroner"]`` callable exists only so the parity test above
|
|
holds; it must always return None (a real cycle opens via
|
|
``CoronerEngine.open_for_incident``, bypassing this dict — see
|
|
``_originate_coroner``'s docstring)."""
|
|
assert await bp_module._ORIGINATORS["coroner"](db_session) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_due_programs_never_opens_coroner_even_when_armed(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""EVENT programs are opened only by their own hooks, never the loop —
|
|
``run_due_programs`` must skip ``coroner`` entirely regardless of
|
|
arming, mirroring ``test_program_due_event_never_cron_fires`` at the
|
|
foundation layer."""
|
|
db_session.add(
|
|
SystemSettingTable(key="board_program.coroner.enabled", value="true")
|
|
)
|
|
await db_session.flush()
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert "coroner" not in opened
|
|
|
|
|
|
def _patch_war_room_originator(
|
|
monkeypatch: pytest.MonkeyPatch, holder: dict[str, TaskTable | None]
|
|
) -> None:
|
|
monkeypatch.setitem(bp_module._ORIGINATORS, "war_room", _fake_originator(holder))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_war_room_originator_is_real_unarmed_no_op(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""Unlike Coroner's always-None ``_originate_coroner`` stub, War Room's
|
|
``_ORIGINATORS["war_room"]`` entry genuinely calls into
|
|
``WarRoomEngine.run_cycle`` — proven by NOT patching it here: it returns
|
|
None because the program isn't armed in this bare session, a real
|
|
arming decision (see test_war_room_engine.py for the engine's own full
|
|
arm/creds/dedup coverage), not a hardcoded stub."""
|
|
assert await bp_module._ORIGINATORS["war_room"](db_session) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_due_programs_never_opens_war_room_even_when_armed(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""EVENT programs are opened only by their own hooks, never the loop —
|
|
``run_due_programs`` must skip ``war_room`` entirely regardless of
|
|
arming. War Room's originator is REAL (unlike coroner's stub), so this
|
|
specifically proves the trigger-kind guard in ``run_due_programs``
|
|
itself — not an originator that happens to no-op."""
|
|
db_session.add(
|
|
SystemSettingTable(key="board_program.war_room.enabled", value="true")
|
|
)
|
|
await db_session.flush()
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert "war_room" not in opened
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_due_programs_never_opens_dogfood_even_when_armed(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""EVENT programs are opened only by their own hooks/run-now, never the
|
|
cron loop — ``run_due_programs`` must skip ``dogfood`` entirely
|
|
regardless of arming, mirroring ``test_run_due_programs_never_opens_
|
|
coroner_even_when_armed``. Unlike Coroner, Dogfood DOES have a real
|
|
project-scoped originator (see ``test_open_program_cycle_originates_
|
|
dogfood_for_real`` below) — this proves the cron loop's own trigger-kind
|
|
gate is what blocks it, not a missing opt-in."""
|
|
await _seed(db_session)
|
|
project = (
|
|
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
|
|
).scalar_one()
|
|
project.board_programs = ["dogfood"]
|
|
db_session.add(
|
|
SystemSettingTable(key="board_program.dogfood.enabled", value="true")
|
|
)
|
|
await db_session.flush()
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert "dogfood" not in opened
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_program_cycle_drives_war_room_via_real_originator(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""THE run-now-works-for-EVENT proof: ``open_program_cycle`` never
|
|
checks trigger kind at all, so once war_room is armed + dedup-clear it
|
|
genuinely originates through the REAL ``_ORIGINATORS["war_room"]``
|
|
entry — unlike coroner, whose run-now would 409 forever (its originator
|
|
is a stub that always returns None)."""
|
|
await _seed(db_session)
|
|
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
|
|
db_session.add(
|
|
SystemSettingTable(key="board_program.war_room.enabled", value="true")
|
|
)
|
|
new_task = await _make_exploration(db_session, source=WAR_ROOM_SOURCE)
|
|
holder: dict[str, TaskTable | None] = {"task": new_task}
|
|
_patch_war_room_originator(monkeypatch, holder)
|
|
await db_session.flush()
|
|
|
|
engine = BoardProgramEngine(db_session)
|
|
task = await engine.open_program_cycle("war_room")
|
|
assert task is not None
|
|
assert task.id == new_task.id
|
|
|
|
rows = (
|
|
(
|
|
await db_session.execute(
|
|
select(BoardProgramCycleTable).where(
|
|
BoardProgramCycleTable.program_key == "war_room"
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert len(rows) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_program_cycle_originates_dogfood_for_real(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""Unlike Coroner's never-firing stub, Dogfood registers a REAL
|
|
originator (``roboco.services.board_programs._originate_dogfood``) — a
|
|
CEO "run now" (and the release-publish hook, which calls the exact same
|
|
``open_program_cycle`` path) must actually open a cycle when armed and
|
|
an opted-in project exists."""
|
|
await _seed(db_session)
|
|
project = (
|
|
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
|
|
).scalar_one()
|
|
project.board_programs = ["dogfood"]
|
|
db_session.add(
|
|
SystemSettingTable(key="board_program.dogfood.enabled", value="true")
|
|
)
|
|
await db_session.flush()
|
|
|
|
engine = BoardProgramEngine(db_session)
|
|
task = await engine.open_program_cycle("dogfood")
|
|
assert task is not None
|
|
assert task.source == DOGFOOD_SOURCE
|
|
assert task.project_id == project.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pest Control's metric-predicate accelerator (spec §4: "weekly cron OR
|
|
# rework-rate spike") — the predicate opens a cycle off-schedule, still gated
|
|
# by enabled + scope + dedup exactly like the cron path.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# A far-future cron cadence so the cron pass never fires within these tests —
|
|
# only the metric predicate can open the cycle.
|
|
_NEVER_DUE = BoardProgram(
|
|
key="pest_control",
|
|
role="product_owner",
|
|
trigger=TriggerKind.CRON,
|
|
source="board_pest_control",
|
|
default_interval_seconds=WEEK_SECONDS * 100,
|
|
scope="project",
|
|
)
|
|
|
|
|
|
async def _seed_recently_closed_cycle(session: AsyncSession) -> None:
|
|
"""A CLOSED ledger row opened just now — makes the CRON pass genuinely
|
|
NOT due (recent + a huge interval) so a test can isolate the metric-
|
|
predicate path. Needs no linked task: ``_dedup_state`` never runs the
|
|
auto-close reconciliation on a row whose ``closed_at`` is already set."""
|
|
session.add(
|
|
BoardProgramCycleTable(
|
|
program_key="pest_control",
|
|
exploration_task_id=None,
|
|
opened_at=datetime.now(UTC),
|
|
closed_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metric_predicate_opens_cycle_off_schedule_when_it_fires(
|
|
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", _NEVER_DUE)
|
|
_arm_setting(db_session, "board_program.pest_control.enabled")
|
|
await _seed_recently_closed_cycle(db_session)
|
|
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)
|
|
)
|
|
monkeypatch.setitem(
|
|
bp_module._METRIC_PREDICATES,
|
|
"pest_control",
|
|
_fake_predicate(True),
|
|
)
|
|
await db_session.flush()
|
|
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert opened == ["pest_control"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metric_predicate_below_threshold_opens_nothing(
|
|
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", _NEVER_DUE)
|
|
_arm_setting(db_session, "board_program.pest_control.enabled")
|
|
await _seed_recently_closed_cycle(db_session)
|
|
monkeypatch.setitem(
|
|
bp_module._METRIC_PREDICATES,
|
|
"pest_control",
|
|
_fake_predicate(False),
|
|
)
|
|
await db_session.flush()
|
|
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert opened == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metric_predicate_never_consulted_when_disabled(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A disabled program's predicate must never even run (cheap, isolated —
|
|
no wasted MetricsService query on a dormant program)."""
|
|
await _seed(db_session)
|
|
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _NEVER_DUE)
|
|
called = {"n": 0}
|
|
|
|
async def _boom(_session: AsyncSession) -> bool:
|
|
called["n"] += 1
|
|
raise AssertionError("predicate must not run while disabled")
|
|
|
|
monkeypatch.setitem(bp_module._METRIC_PREDICATES, "pest_control", _boom)
|
|
await db_session.flush()
|
|
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert opened == []
|
|
assert called["n"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metric_predicate_never_evaluated_when_dedup_blocked(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An already-open cycle blocks the metric predicate from ever running —
|
|
the cheap dedup gate must run BEFORE the (8-11 query) rework-rate check,
|
|
so an open cycle costs the metrics service nothing on every tick."""
|
|
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", _NEVER_DUE)
|
|
_arm_setting(db_session, "board_program.pest_control.enabled")
|
|
open_task = await _make_exploration(db_session, source="board_pest_control")
|
|
db_session.add(
|
|
BoardProgramCycleTable(
|
|
program_key="pest_control",
|
|
exploration_task_id=open_task.id,
|
|
opened_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
called = {"n": 0}
|
|
|
|
async def _boom(_session: AsyncSession) -> bool:
|
|
called["n"] += 1
|
|
raise AssertionError("predicate must not run while dedup-blocked")
|
|
|
|
monkeypatch.setitem(bp_module._METRIC_PREDICATES, "pest_control", _boom)
|
|
await db_session.flush()
|
|
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert opened == []
|
|
assert called["n"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metric_predicate_never_evaluated_when_scope_empty(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""No project opted into pest_control — the cheap scope gate blocks the
|
|
metric predicate from ever running (no project ever opts SLUG in here,
|
|
unlike the sibling tests)."""
|
|
await _seed(db_session)
|
|
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _NEVER_DUE)
|
|
_arm_setting(db_session, "board_program.pest_control.enabled")
|
|
called = {"n": 0}
|
|
|
|
async def _boom(_session: AsyncSession) -> bool:
|
|
called["n"] += 1
|
|
raise AssertionError("predicate must not run with no project opted in")
|
|
|
|
monkeypatch.setitem(bp_module._METRIC_PREDICATES, "pest_control", _boom)
|
|
await db_session.flush()
|
|
|
|
engine = BoardProgramEngine(db_session)
|
|
opened = await engine.run_due_programs()
|
|
assert opened == []
|
|
assert called["n"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_rework_predicate_fires_above_threshold(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Exercises the REAL predicate (not a fake) against MetricsService's
|
|
rework rate — proves the threshold wiring, not just the engine seam."""
|
|
monkeypatch.setattr(cfg, "pest_rework_threshold", 0.3)
|
|
result = await bp_module._pest_control_rework_spike(db_session)
|
|
assert result is False # no completed/reworked tasks seeded -> rate 0.0
|
|
|
|
|
|
def _fake_predicate(
|
|
verdict: bool,
|
|
) -> Callable[[AsyncSession], Awaitable[bool]]:
|
|
async def _predicate(_session: AsyncSession) -> bool:
|
|
return verdict
|
|
|
|
return _predicate
|