Files
roboco/tests/unit/runtime/test_dogfood_dispatch.py
T
a36a180b73 fix(orchestrator): let a failed Board Program exploration retry (#706)
`_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>
2026-07-26 15:52:33 +02:00

227 lines
7.9 KiB
Python

"""Dogfood exploration dispatch — Product-Owner-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_spackle_dispatch.py.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import DOGFOOD_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 _dogfood_task(
*,
orchestration_markers: dict[str, Any] | None = None,
project_slug: str | None = None,
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Dogfood walk",
"description": "Walk the product as a user and propose a friction audit.",
"assigned_to": "product-owner",
"source": DOGFOOD_SOURCE,
"orchestration_markers": orchestration_markers,
"project_slug": project_slug,
}
@pytest.mark.asyncio
async def test_dogfood_dispatch_spawns_only_product_owner() -> None:
"""A dogfood exploration task must spawn the Product Owner alone."""
orch = _make_orch()
task = _dogfood_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_dogfood_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "product-owner"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_dogfood_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 = _dogfood_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_dogfood_exploration(task)
await orch._dispatch_dogfood_exploration(task)
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
async def test_dogfood_dispatch_skips_once_authored() -> None:
"""Once ``propose_friction_fixes`` has stamped the friction_fixes marker,
the dispatcher must not spawn again."""
orch = _make_orch()
task = _dogfood_task(orchestration_markers={"friction_fixes": {"items": []}})
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_dogfood_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dogfood_dispatch_skips_active_po() -> None:
orch = _make_orch()
task = _dogfood_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_dogfood_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_dogfood_away_from_board_handler() -> None:
"""A board_dogfood task must ride the dedicated dispatcher, never the
two-reviewer ``_handle_board_assigned_task``."""
task = _dogfood_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="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_dogfood_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_dogfood_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_dogfood_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_dogfood_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_dogfood_prompt_names_solo_po_and_real_verbs() -> None:
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task())
assert "triage()" in prompt
assert "propose_friction_fixes(" in prompt
assert "i_am_idle()" in prompt
assert "do not" in prompt.lower()
def test_dogfood_prompt_omits_optional_sections_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task())
assert "## Prior cycles" not in prompt
def test_dogfood_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_dogfood_prompt(
_dogfood_task(),
"proposed 2, approved 1",
)
assert "## Prior cycles" in prompt
assert "proposed 2, approved 1" in prompt
def test_dogfood_prompt_names_browser_tools() -> None:
"""The ONE board-program prompt referencing playwright tools — proves
the prompt text matches the task-scoped MCP grant (see
test_mcp_playwright_gating.py's dogfood-specific tests)."""
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task())
assert "browser_navigate" in prompt
assert "browser_snapshot" in prompt
def test_dogfood_prompt_injects_panel_url_for_self_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "self_heal_project_slug", "roboco-api")
monkeypatch.setattr(settings, "panel_base_url", "https://panel.example.com")
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task(project_slug="roboco-api"))
assert "https://panel.example.com" in prompt
def test_dogfood_prompt_omits_panel_url_for_other_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "self_heal_project_slug", "roboco-api")
monkeypatch.setattr(settings, "panel_base_url", "https://panel.example.com")
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task(project_slug="some-other-repo"))
assert "https://panel.example.com" not in prompt
@pytest.mark.asyncio
async def test_dogfood_dispatch_injects_prior_context() -> None:
orch = _make_orch()
task = _dogfood_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_dogfood_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt