mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
`_board_dispatched` is an in-memory, never-expiring set of (agent_slug, task_id). The solo exploration dispatchers consulted it, so an exploration whose propose verb rejected was never retried for the life of the process — Periscope, Sentinel, Scales and Barfly each spawned once on 2026-07-25, failed, and sat PENDING until the stack restarted. The guard was written for the two-reviewer board REVIEW pass, where it is correct: a reviewer has no verb to advance the task, so a respawn can only loop. An explorer is the opposite — `propose_*` is exactly such a verb, so a respawn can and should advance it. Drop it from the 15 `_dispatch_*_exploration` functions. Bounding falls to `_pm_respawn_should_gate`, which is what actually bounds a loop: DB-persisted, reset by a status change, and cooled down so a deploy that fixes the cause lets the work resume. `_dispatch_board_reviewer` keeps the set (its `_board_review_complete` reads it as a has-run signal) as does vault curation (same-process race guard behind its own durable marker). The 14 `*_dispatch_is_one_shot` tests asserted the old contract on the false rationale that board roles have no progression verb; they now assert a second tick re-attempts. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
239 lines
9.0 KiB
Python
239 lines
9.0 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_retries_until_breaker() -> None:
|
|
"""A failed exploration must be retried, not abandoned.
|
|
|
|
The explorer has a progression verb (``propose_*``), so a respawn CAN
|
|
advance the task — unlike the two-reviewer review pass this guard was
|
|
originally written for. Bounding belongs to
|
|
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
|
|
not to a never-expiring in-memory set.
|
|
"""
|
|
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)
|
|
|
|
ticks = 2
|
|
assert spawn.await_count == ticks, (
|
|
"a second tick must re-attempt a failed exploration"
|
|
)
|
|
|
|
|
|
@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
|