mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
230 lines
8.7 KiB
Python
230 lines
8.7 KiB
Python
"""Feature-spotlight exploration dispatch — Head-of-Marketing-solo, never the
|
|
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, cast
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
|
from roboco.services.task import X_FEATURE_EXPLORATION_SOURCE
|
|
|
|
|
|
def _make_orch() -> AgentOrchestrator:
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
cast("Any", orch)._pm_respawn_tracker = {}
|
|
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
|
orch._instances = {}
|
|
orch._board_dispatched = set()
|
|
return orch
|
|
|
|
|
|
def _feature_task(
|
|
*, orchestration_markers: dict[str, Any] | None = None
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": str(uuid4()),
|
|
"status": "pending",
|
|
"team": "board",
|
|
"title": "X feature-spotlight exploration",
|
|
"description": "Investigate shipped capabilities and propose a spotlight.",
|
|
"assigned_to": "head-marketing",
|
|
"source": X_FEATURE_EXPLORATION_SOURCE,
|
|
"orchestration_markers": orchestration_markers,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feature_spotlight_dispatch_spawns_only_head_marketing() -> None:
|
|
"""A feature-spotlight exploration task must spawn the Head of Marketing
|
|
alone — the Product Owner is not part of this cycle."""
|
|
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, "spawn_agent", new=AsyncMock()) as spawn,
|
|
):
|
|
await orch._dispatch_feature_spotlight_exploration(task)
|
|
|
|
spawn.assert_awaited_once()
|
|
calls = list(spawn.await_args_list)
|
|
assert calls[0].kwargs["agent_id"] == "head-marketing"
|
|
assert calls[0].kwargs["task_id"] == task["id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feature_spotlight_dispatch_is_one_shot() -> None:
|
|
"""Re-ticking a still-pending exploration must NOT respawn — board roles
|
|
have no progression verb, so a respawn would just loop."""
|
|
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, "spawn_agent", new=AsyncMock()) as spawn,
|
|
):
|
|
await orch._dispatch_feature_spotlight_exploration(task)
|
|
await orch._dispatch_feature_spotlight_exploration(task)
|
|
|
|
spawn.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feature_spotlight_dispatch_skips_active_hom() -> None:
|
|
orch = _make_orch()
|
|
task = _feature_task()
|
|
with (
|
|
patch.object(orch, "_is_agent_active", return_value=True),
|
|
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
|
):
|
|
await orch._dispatch_feature_spotlight_exploration(task)
|
|
|
|
spawn.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_pm_work_routes_feature_source_away_from_board() -> None:
|
|
"""An x_feature_exploration task must ride the dedicated feature-spotlight
|
|
dispatcher, never the two-reviewer ``_handle_board_assigned_task`` (which
|
|
would also spawn the Product Owner and fire the Approve & Start handoff —
|
|
both wrong here), nor the roadmap dispatcher, nor plain PM handling."""
|
|
task = _feature_task()
|
|
stub = MagicMock()
|
|
stub._fetch_tasks = AsyncMock(return_value=[task])
|
|
stub._is_task_handled_this_tick = MagicMock(return_value=False)
|
|
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
|
|
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
|
|
stub._dispatch_roadmap_exploration = AsyncMock()
|
|
stub._dispatch_feature_spotlight_exploration = AsyncMock()
|
|
stub._handle_board_assigned_task = AsyncMock()
|
|
stub._handle_pm_assigned_task = AsyncMock()
|
|
stub._route_unassigned_pm_task = AsyncMock()
|
|
|
|
client: Any = MagicMock()
|
|
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
|
|
|
|
stub._dispatch_feature_spotlight_exploration.assert_awaited_once()
|
|
stub._dispatch_roadmap_exploration.assert_not_awaited()
|
|
stub._handle_board_assigned_task.assert_not_awaited()
|
|
stub._handle_pm_assigned_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feature_spotlight_tasks_are_never_routed_by_dev_dispatch() -> None:
|
|
tasks = [_feature_task()]
|
|
stub = MagicMock()
|
|
stub._fetch_tasks = AsyncMock(return_value=tasks)
|
|
stub._is_task_handled_this_tick = MagicMock(return_value=False)
|
|
stub._dev_dispatch_one = AsyncMock()
|
|
|
|
client: Any = MagicMock()
|
|
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
|
|
|
|
stub._dev_dispatch_one.assert_not_awaited()
|
|
|
|
|
|
def test_feature_spotlight_prompt_names_real_verbs_and_seen_features() -> None:
|
|
"""The prompt must steer HoM to its real verbs (triage /
|
|
propose_feature_spotlight / i_am_idle) and render the seen-features marker,
|
|
with a friendly fallback when the list is empty."""
|
|
orch = _make_orch()
|
|
prompt = orch._build_feature_spotlight_prompt(
|
|
_feature_task(orchestration_markers={"x_seen_features": ["org-memory"]})
|
|
)
|
|
assert "triage()" in prompt
|
|
assert "propose_feature_spotlight(" in prompt
|
|
assert "i_am_idle()" in prompt
|
|
assert "org-memory" in prompt
|
|
|
|
empty_prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
|
assert "none yet" in empty_prompt.lower()
|
|
|
|
|
|
def test_feature_spotlight_prompt_names_the_skip_exit() -> None:
|
|
"""The HoM must know the skip=True exit exists and that it beats a weak
|
|
forced spotlight — otherwise the smart-cadence work is pointless."""
|
|
orch = _make_orch()
|
|
prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
|
assert "skip=True" in prompt
|
|
assert "skip_reason=" in prompt
|
|
|
|
|
|
def test_feature_spotlight_prompt_renders_enriched_brief_sections() -> None:
|
|
"""The x_spotlight_brief marker's seen-dates, shipped-since, and rejected
|
|
sections must reach the spawn prompt HoM actually sees."""
|
|
orch = _make_orch()
|
|
markers_dict = {
|
|
"x_seen_features": ["org-memory"],
|
|
"x_spotlight_brief": {
|
|
"seen": [{"slug": "org-memory", "seen_at": "2026-07-01T00:00:00+00:00"}],
|
|
"shipped_since": [
|
|
{
|
|
"version": "0.21.0",
|
|
"date": "2026-07-09",
|
|
"titles": ["Added", "Fixed"],
|
|
}
|
|
],
|
|
"rejected": [
|
|
{"slug": "old-one", "title": "Old Feature", "reason": "too niche"}
|
|
],
|
|
},
|
|
}
|
|
prompt = orch._build_feature_spotlight_prompt(
|
|
_feature_task(orchestration_markers=markers_dict)
|
|
)
|
|
assert "org-memory (seen 2026-07-01)" in prompt
|
|
assert "0.21.0" in prompt
|
|
assert "Added, Fixed" in prompt
|
|
assert "too niche" in prompt
|
|
|
|
|
|
def test_feature_spotlight_prompt_brief_fallbacks_when_marker_missing() -> None:
|
|
"""No x_spotlight_brief marker (a pre-brief exploration) must never break
|
|
prompt rendering — every section falls back to a friendly placeholder."""
|
|
orch = _make_orch()
|
|
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
|