"""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