"""Board agents (Product Owner + Head of Marketing) review board-team tasks. - A board/coordination task is a TWO-reviewer gate: BOTH the Product Owner and the Head of Marketing must review it before it reaches the CEO. Each reviewer is dispatched ONCE — board roles have no verb to claim/plan/delegate/complete, so a respawn cannot advance the task and would just loop. - Once BOTH reviewers have finished, the orchestrator hands the review to the CEO: it flags the (still-pending) task ``board_review_complete`` so the CEO's Approve & Start button appears, and emits exactly one formal CEO notification so the handoff is an actionable signal, not buried channel chatter. """ from __future__ import annotations from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, cast from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest if TYPE_CHECKING: from collections.abc import AsyncIterator import httpx from roboco.runtime.orchestrator import AgentOrchestrator def _make_orch() -> AgentOrchestrator: orch = AgentOrchestrator.__new__(AgentOrchestrator) orch._instances = {} orch._board_dispatched = set() orch._board_review_ceo_notified = set() return orch def _board_task(assigned_to: str) -> dict[str, Any]: return { "id": str(uuid4()), "status": "pending", "team": "board", "title": "Strategic feature", "description": "A board-level task to review and shape.", "assigned_to": assigned_to, } def _patch_handoff_db(task_svc: AsyncMock) -> tuple[Any, Any]: """Patch the DB context + TaskService the handoff opens to flag the task. Returns a tuple of context managers for the caller's ``with`` block so the direct-call tests exercise the real handoff body without touching a DB. """ @asynccontextmanager async def _fake_ctx() -> AsyncIterator[AsyncMock]: yield AsyncMock() return ( patch("roboco.db.base.get_db_context", _fake_ctx), patch("roboco.services.task.TaskService", return_value=task_svc), ) @pytest.mark.asyncio async def test_both_board_agents_dispatched_for_board_task() -> None: """A board task must dispatch BOTH the PO and the Head of Marketing — the review is a two-reviewer gate, not a single-assignee claim.""" orch = _make_orch() task = _board_task("product-owner") with ( patch.object(orch, "_is_agent_active", return_value=False), patch.object(orch, "_task_git_context", return_value=None), patch.object( orch, "_maybe_handoff_board_review_to_ceo", new=AsyncMock(), ), patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, ): await orch._handle_board_assigned_task(task, "product-owner") dispatched = {call.kwargs["agent_id"] for call in spawn.await_args_list} assert dispatched == {"product-owner", "head-marketing"} for call in spawn.await_args_list: assert call.kwargs["task_id"] == task["id"] @pytest.mark.asyncio async def test_each_board_agent_spawned_only_once() -> None: """Board roles have no progression verb — a re-tick must NOT respawn.""" orch = _make_orch() task = _board_task("head-marketing") with ( patch.object(orch, "_is_agent_active", return_value=False), patch.object(orch, "_task_git_context", return_value=None), patch.object( orch, "_maybe_handoff_board_review_to_ceo", new=AsyncMock(), ), patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, ): await orch._handle_board_assigned_task(task, "head-marketing") # Second tick: still pending — must not respawn either reviewer. await orch._handle_board_assigned_task(task, "head-marketing") dispatched = [call.kwargs["agent_id"] for call in spawn.await_args_list] assert sorted(dispatched) == ["head-marketing", "product-owner"] @pytest.mark.asyncio async def test_board_handler_skips_active_reviewer_but_dispatches_other() -> None: """An already-running reviewer is skipped; the other is still dispatched.""" orch = _make_orch() task = _board_task("product-owner") def _active(slug: str) -> bool: return slug == "product-owner" with ( patch.object(orch, "_is_agent_active", side_effect=_active), patch.object(orch, "_task_git_context", return_value=None), patch.object( orch, "_maybe_handoff_board_review_to_ceo", new=AsyncMock(), ), patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, ): await orch._handle_board_assigned_task(task, "product-owner") dispatched = {call.kwargs["agent_id"] for call in spawn.await_args_list} assert dispatched == {"head-marketing"} @pytest.mark.asyncio async def test_board_handler_ignores_non_board_assignee() -> None: orch = _make_orch() task = _board_task("be-pm") with ( patch.object(orch, "_is_agent_active", return_value=False), patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, ): await orch._handle_board_assigned_task(task, "be-pm") spawn.assert_not_awaited() @pytest.mark.asyncio async def test_unassigned_board_task_dispatches_both_via_board_handler() -> None: """An UNASSIGNED board task must route through the board handler so BOTH reviewers are dispatched — not claimed + single-spawned for the PO only. The task stays unclaimed for the CEO's Approve & Start.""" orch = _make_orch() task = { "id": str(uuid4()), "status": "pending", "team": "board", "task_type": "code", "title": "Strategic feature", "description": "A board-level task to review and shape.", "assigned_to": None, } client = cast("httpx.AsyncClient", object()) with ( patch.object(orch, "_is_agent_active", return_value=False), patch.object(orch, "_task_git_context", return_value=None), patch.object( orch, "_maybe_handoff_board_review_to_ceo", new=AsyncMock(), ), patch.object( orch, "_claim_task_for_agent", new=AsyncMock(return_value=True) ) as claim, patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, ): await orch._route_unassigned_pm_task(client, task) # Board work is a two-reviewer gate, not a claim — never claimed here. claim.assert_not_awaited() dispatched = {call.kwargs["agent_id"] for call in spawn.await_args_list} assert dispatched == {"product-owner", "head-marketing"} def test_board_review_complete_requires_both_reviewers_idle() -> None: """Review is complete only when BOTH reviewers are dispatched AND idle.""" orch = _make_orch() task_id = str(uuid4()) with patch.object(orch, "_is_agent_active", return_value=False): # Neither dispatched yet. assert orch._board_review_complete(task_id) is False # Only PO dispatched. orch._board_dispatched.add(("product-owner", task_id)) assert orch._board_review_complete(task_id) is False # Both dispatched and idle. orch._board_dispatched.add(("head-marketing", task_id)) assert orch._board_review_complete(task_id) is True def test_board_review_not_complete_while_a_reviewer_active() -> None: orch = _make_orch() task_id = str(uuid4()) orch._board_dispatched.add(("product-owner", task_id)) orch._board_dispatched.add(("head-marketing", task_id)) with patch.object( orch, "_is_agent_active", side_effect=lambda s: s == "head-marketing" ): # HoM still running its review — not done yet. assert orch._board_review_complete(task_id) is False @pytest.mark.asyncio async def test_ceo_handoff_once_when_board_review_complete() -> None: """When both reviewers finish, the handoff flags the task board-reviewed and fires exactly one CEO notification.""" orch = _make_orch() task_id = str(uuid4()) orch._board_dispatched.add(("product-owner", task_id)) orch._board_dispatched.add(("head-marketing", task_id)) svc = AsyncMock() task_svc = AsyncMock() db_ctx, task_ctx = _patch_handoff_db(task_svc) with ( patch.object(orch, "_is_agent_active", return_value=False), patch("roboco.services.notification.NotificationService", return_value=svc), db_ctx, task_ctx, ): await orch._maybe_handoff_board_review_to_ceo(task_id) # Second tick: already handed off — must not re-emit. await orch._maybe_handoff_board_review_to_ceo(task_id) task_svc.mark_board_review_complete.assert_awaited_once() svc.send_board_review_complete_notification.assert_awaited_once_with( task_id=task_id ) assert task_id in orch._board_review_ceo_notified @pytest.mark.asyncio async def test_ceo_not_handed_off_while_review_incomplete() -> None: """No flag and no CEO notification until BOTH reviewers are done.""" orch = _make_orch() task_id = str(uuid4()) # Only PO has been dispatched/finished. orch._board_dispatched.add(("product-owner", task_id)) svc = AsyncMock() task_svc = AsyncMock() db_ctx, task_ctx = _patch_handoff_db(task_svc) with ( patch.object(orch, "_is_agent_active", return_value=False), patch("roboco.services.notification.NotificationService", return_value=svc), db_ctx, task_ctx, ): await orch._maybe_handoff_board_review_to_ceo(task_id) task_svc.mark_board_review_complete.assert_not_awaited() svc.send_board_review_complete_notification.assert_not_awaited() assert task_id not in orch._board_review_ceo_notified @pytest.mark.asyncio async def test_ceo_handoff_failure_allows_retry() -> None: """A handoff failure clears the one-shot guard so a later tick retries.""" orch = _make_orch() task_id = str(uuid4()) orch._board_dispatched.add(("product-owner", task_id)) orch._board_dispatched.add(("head-marketing", task_id)) svc = AsyncMock() svc.send_board_review_complete_notification.side_effect = RuntimeError("db down") task_svc = AsyncMock() db_ctx, task_ctx = _patch_handoff_db(task_svc) with ( patch.object(orch, "_is_agent_active", return_value=False), patch("roboco.services.notification.NotificationService", return_value=svc), db_ctx, task_ctx, ): await orch._maybe_handoff_board_review_to_ceo(task_id) # Guard cleared so a later, healthy tick can re-run the handoff. assert task_id not in orch._board_review_ceo_notified def test_board_review_prompt_names_both_reviewers_and_board_verbs() -> None: """The prompt must steer board agents to their real verbs (triage / note / say / i_am_idle), make the PO+HoM pair-review model explicit, and away from claim/plan/delegate they do not have.""" orch = _make_orch() prompt = orch._build_board_prompt(_board_task("product-owner")) assert "triage()" in prompt assert "note(" in prompt assert "i_am_idle()" in prompt assert "Product Owner" in prompt and "Head of Marketing" in prompt assert "do NOT" in prompt.lower() or "do not" in prompt.lower()