feat(board): Board Program registry — Phase 1 (engine, LEARN ledger, per-project scoping, panel) (#689)

* feat(board): Board Program registry — generic trigger/dedup/originate/LEARN engine

One registry (foundation/policy/board_programs.py) + one BoardProgramEngine +
one orchestrator loop replace the bespoke roadmap/spotlight loops, behavior-
preserved: same sources, dispatch routing, one-open-cycle dedup (ledger rows
auto-close when their exploration task goes terminal, so x_feature's
complete-at-propose flow can't wedge), and live per-program interval
overrides with the tick capped at 1h.

program_armed() is the single arming chokepoint: the settings-store
board_program.<key>.enabled override when present, else the legacy flag —
routed through BoardProgramEngine, RoadmapEngine.run_cycle, and XEngine's
spotlight gate, so the panel toggle can never be a silent no-op against a
legacy boot flag.

LEARN: board_program_cycles (migration 087) accrues per-item CEO decisions
(exact attribution by exploration_task_id where the caller holds it) and
feeds the last closed cycles back into both exploration prompts. The
strategy engine's idle signal now opens a roadmap cycle (enabled+dedup
respected) instead of only nudging.

Per-project scoping (migration 088, projects.board_programs, dual polarity):
plain keys opt a project INTO project-scoped programs; "!key" opts it OUT
of an org-scoped program's outputs (default eligible — parity). Enforced at
propose_roadmap (names the excluded project) and defensively at materialize;
validation rejects unknown keys and meaningless polarity both directions.

API: GET /api/board-programs + POST /api/board-programs/{key}/run-now
(CEO-gated); settings keys for both migrated programs.

* feat(panel): Board Programs card + per-project program controls

Business page gains a Programs tab: per-program rows (role, trigger, scope,
open-cycle badge), enabled switch on the settings-store key, Run now
(disabled while a cycle is open). The edit-project dialog gains the
program controls next to the CI-watch/video toggles: participates-in
checkboxes for project-scoped programs, excluded-from checkboxes for
org-scoped outputs.

* test(board): full-gate hermeticity — mypy casts + shared-DB purge fixtures

make quality runs one pytest process over all suites against the shared
persistent DB: integration collects before unit, so the board-programs API
test's committed run-now state (settings-store overrides, an open cycle row,
its board_roadmap task) poisoned 13 downstream unit tests that pass in
isolation. The polluter now purges its own committed state in fixture
teardown, and the four consumer files get an autouse per-test purge
(board_program.% settings keys, ledger rows, open exploration tasks) so
they are hermetic regardless of collection order. Also the four
cast("UUID", ...) sites the tests-scope mypy run requires.

* feat(panel): re-home per-project program controls onto the settings page

Wave C deleted the edit-project dialog these controls originally landed in;
they now live on the project settings page's budget/ops card next to the
CI-watch/video toggles — participates-in switches for project-scoped
programs, excluded-from switches for org-scoped outputs, dual-polarity
tooltips, order-independent dirty tracking. Nine makeProject test fixtures
gain the required board_programs field the rebase left behind.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-25 05:39:44 +02:00
committed by GitHub
co-authored by Renn F
parent cbddfc7cb3
commit e77c3b7a63
52 changed files with 3662 additions and 213 deletions
@@ -0,0 +1,61 @@
"""The generic Board Program orchestrator loop — replaces
``_roadmap_engine_loop`` / ``_x_feature_spotlight_loop`` (see
tests/unit/services/test_board_program_engine.py for the engine's own
trigger/dedup/LEARN coverage; this file covers the orchestrator-loop shell
only: interval computation + the sleep/tick/heartbeat wiring).
Unlike the two collapsed loops, ``_board_program_loop`` has no single static
disablement gate — each program's enablement is checked per-tick inside
``BoardProgramEngine``, DB-backed — so there is no "returns immediately when
disabled" behavior to test here; that guarantee now lives in
test_board_program_engine.py's disabled-program coverage.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
TWO_TICKS = 2
ONE_HOUR_SECONDS = 3600
def _orch() -> Any:
"""Bypass __init__ — the loop helper under test needs only the
heartbeats dict and ``_running``."""
o = AgentOrchestrator.__new__(AgentOrchestrator)
o._loop_heartbeats = {}
o._running = True
return o
def test_board_program_interval_is_shortest_registered_cadence_capped() -> None:
orch = _orch()
interval = orch._board_program_interval_seconds()
# x_feature's 1-day default is the shortest of the two registered
# programs, well above the 300s floor — capped at the 3600s ceiling.
assert interval == ONE_HOUR_SECONDS
@pytest.mark.asyncio
async def test_board_program_loop_one_tick_exception_does_not_crash_loop() -> None:
"""A raising cycle is logged and the loop keeps ticking — mirrors every
other engine loop's ``except Exception: logger.exception(...)`` shape."""
orch = _orch()
calls = {"n": 0}
async def _cycle() -> None:
calls["n"] += 1
if calls["n"] >= TWO_TICKS:
orch._running = False
raise RuntimeError("boom")
orch._run_board_program_cycle = AsyncMock(side_effect=_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._board_program_loop()
assert calls["n"] == TWO_TICKS
@@ -189,3 +189,41 @@ def test_feature_spotlight_prompt_brief_fallbacks_when_marker_missing() -> None:
prompt = orch._build_feature_spotlight_prompt(_feature_task())
assert "nothing new since the last cycle" in prompt
assert "(none)" in prompt
def test_feature_spotlight_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_feature_spotlight_prompt(_feature_task())
assert "## Prior cycles" not in prompt
def test_feature_spotlight_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_feature_spotlight_prompt(
_feature_task(), "proposed 1, approved 0; rejected: org-memory — too soon"
)
assert "## Prior cycles" in prompt
assert "proposed 1, approved 0; rejected: org-memory — too soon" in prompt
@pytest.mark.asyncio
async def test_feature_spotlight_dispatch_injects_prior_context_into_prompt() -> None:
"""The dispatcher fetches LEARN context (best-effort) and threads it into
the prompt builder — proving the wiring, not just the builder in
isolation."""
orch = _make_orch()
task = _feature_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 1, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_feature_spotlight_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 1, approved 1" in prompt
@@ -1,60 +0,0 @@
"""The feature-spotlight orchestrator loop is fully dormant unless BOTH the X
engine and the feature-spotlight sub-switch are enabled (both default off).
With either flag off, ``_x_feature_spotlight_loop`` must return immediately —
no sleep, no HTTP, no DB, no Head-of-Marketing spawn — so a standard
deployment (or one running only release posts / mention replies) behaves
exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "x_engine_enabled", False)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off -> returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_dormant_when_only_subswitch_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""x_engine_enabled on but the feature-spotlight sub-switch off: still
dormant — the engine still runs release posts/mention replies via their
own loops, unaffected."""
monkeypatch.setattr(cfg, "x_engine_enabled", True)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_dormant_when_only_engine_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The subswitch alone is not enough — x_engine_enabled must also be on."""
monkeypatch.setattr(cfg, "x_engine_enabled", False)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", True)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@@ -23,7 +23,6 @@ from roboco.runtime.orchestrator import AgentOrchestrator
_CI_WATCH_INTERVAL = 0.01
_VIDEO_RENDER_INTERVAL = 0.05
_X_MENTIONS_INTERVAL = 0.04
_ROADMAP_INTERVAL = 0.06
def _orch() -> Any:
@@ -199,23 +198,22 @@ async def test_x_mentions_loop_records_heartbeat(
@pytest.mark.asyncio
async def test_roadmap_engine_loop_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The roadmap-engine loop records under its canonical name + interval —
guards against copy-paste name drift on the heartbeat calls."""
async def test_board_program_loop_records_heartbeat() -> None:
"""The board-program loop (replaces roadmap-engine/x-feature-spotlight)
records under its canonical name + interval — guards against copy-paste
name drift on the heartbeat calls. See test_board_program_loop.py for
the rest of this loop's coverage (interval computation, tick-error
isolation)."""
orch = _orch()
monkeypatch.setattr(settings, "roadmap_engine_enabled", True)
monkeypatch.setattr(settings, "roadmap_interval_seconds", 0.06)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_roadmap_engine_cycle = AsyncMock(side_effect=_stop_after_cycle)
orch._run_board_program_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._roadmap_engine_loop()
await orch._board_program_loop()
assert "roadmap_engine" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["roadmap_engine"]
assert interval == _ROADMAP_INTERVAL
assert "board_program" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["board_program"]
assert interval == orch._board_program_interval_seconds()
@@ -153,3 +153,54 @@ def test_roadmap_prompt_names_solo_po_and_real_verbs() -> None:
assert "Head of Marketing is not" in prompt
assert "involved in this cycle" in prompt
assert "do not" in prompt.lower()
def test_roadmap_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_roadmap_prompt(_roadmap_task())
assert "## Prior cycles" not in prompt
def test_roadmap_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_roadmap_prompt(
_roadmap_task(), "proposed 5, approved 3; rejected: item-2 — too risky"
)
assert "## Prior cycles" in prompt
assert "proposed 5, approved 3; rejected: item-2 — too risky" in prompt
@pytest.mark.asyncio
async def test_roadmap_dispatch_injects_prior_context_into_prompt() -> None:
"""The dispatcher fetches LEARN context (best-effort) and threads it into
the prompt builder — proving the wiring, not just the builder in
isolation."""
orch = _make_orch()
task = _roadmap_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 2, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_roadmap_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt
@pytest.mark.asyncio
async def test_board_program_prior_context_survives_db_failure() -> None:
"""A DB hiccup fetching prior context degrades to '' — never raises, so
the caller (the dispatcher) never needs its own safety net around it."""
orch = _make_orch()
with patch(
"roboco.services.board_programs.get_board_program_engine",
side_effect=RuntimeError("db down"),
):
result = await orch._board_program_prior_context("roadmap")
assert result == ""
@@ -1,28 +0,0 @@
"""The roadmap-engine orchestrator loop is fully dormant when disabled
(default).
With ``roadmap_engine_enabled`` off, ``_roadmap_engine_loop`` must return
immediately — no sleep, no HTTP, no DB, no Product-Owner spawn — so a
standard deployment behaves exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_roadmap_engine_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off -> returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(AgentOrchestrator._roadmap_engine_loop(stub), timeout=1.0)