"""Choreographer hot verbs must touch last_heartbeat_at via task.heartbeat().""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps: task = overrides.get("task", AsyncMock()) work_session = overrides.get("work_session", AsyncMock()) git = overrides.get("git", AsyncMock()) a2a = overrides.get("a2a", AsyncMock()) journal = overrides.get("journal", AsyncMock()) audit = overrides.get("audit", AsyncMock()) evidence_repo = overrides.get("evidence_repo", AsyncMock()) for method in ( "list_unread_a2a", "list_unread_mentions", "list_pending_notifications", "task_metadata_gaps", "recent_team_activity", "blockers_in_lane", ): getattr(evidence_repo, method).return_value = [] return ChoreographerDeps( task=task, work_session=work_session, git=git, a2a=a2a, journal=journal, audit=audit, evidence_repo=evidence_repo, ) @pytest.mark.asyncio async def test_i_will_work_on_calls_heartbeat() -> None: aid = uuid4() tid = uuid4() pending = MagicMock( id=tid, status="pending", plan=None, assigned_to=None, parent_task_id=None, sequence=0, task_type="code", ) in_progress = MagicMock( id=tid, status="in_progress", plan={"text": "go"}, assigned_to=aid ) task_svc = AsyncMock() task_svc.get.return_value = pending task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = MagicMock( id=tid, status="claimed", plan=None, assigned_to=aid ) task_svc.set_plan.return_value = MagicMock( id=tid, status="claimed", plan={"text": "go"}, assigned_to=aid ) task_svc.start.return_value = in_progress deps = _make_deps(task=task_svc) c = Choreographer(deps) await c.i_will_work_on(aid, tid, plan="go") task_svc.heartbeat.assert_awaited_with(tid) @pytest.mark.asyncio async def test_i_have_committed_calls_heartbeat() -> None: aid = uuid4() tid = uuid4() t = MagicMock(id=tid, status="in_progress", assigned_to=aid, plan="x") task_svc = AsyncMock() task_svc.get_active_task_for_agent.return_value = t deps = _make_deps(task=task_svc) c = Choreographer(deps) await c.i_have_committed(aid, "did the thing") task_svc.heartbeat.assert_awaited_with(tid) @pytest.mark.asyncio async def test_i_am_done_calls_heartbeat() -> None: aid = uuid4() tid = uuid4() t = MagicMock( id=tid, status="verifying", assigned_to=aid, plan="x", self_verified=True, commits=[MagicMock()], pr_number=42, pr_url="http://example/pr/42", team="backend", work_session_id=None, ) submitted = MagicMock( id=tid, status="awaiting_qa", assigned_to=None, plan="x", team="backend", work_session_id=None, pr_url="http://example/pr/42", ) task_svc = AsyncMock() task_svc.get.return_value = t task_svc.submit_qa.return_value = submitted task_svc.qa_agent_for_team.return_value = None journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True evidence_repo = AsyncMock() for method in ( "list_unread_a2a", "list_unread_mentions", "list_pending_notifications", "task_metadata_gaps", "recent_team_activity", "blockers_in_lane", ): getattr(evidence_repo, method).return_value = [] evidence_repo.journal_highlights_for_task.return_value = [] deps = _make_deps(task=task_svc, journal=journal_svc, evidence_repo=evidence_repo) c = Choreographer(deps) # Patch tracing_gate to pass — _check_tracing_gates uses # check_requirements, which inspects the Mock's progress_updates etc. # By configuring t with all-required attrs we let the real gate pass. t.progress_updates = [MagicMock()] t.acceptance_criteria = [] await c.i_am_done(aid, tid, "done") task_svc.heartbeat.assert_awaited_with(tid) @pytest.mark.asyncio async def test_i_am_blocked_calls_heartbeat() -> None: aid = uuid4() tid = uuid4() t = MagicMock(id=tid, status="in_progress", assigned_to=aid) blocked = MagicMock(id=tid, status="blocked", assigned_to=aid) task_svc = AsyncMock() task_svc.get.return_value = t task_svc.escalate.return_value = blocked journal_svc = AsyncMock() deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) await c.i_am_blocked(aid, tid, "stuck on X") task_svc.heartbeat.assert_awaited_with(tid) @pytest.mark.asyncio async def test_pm_give_me_work_calls_heartbeat_when_returning_task() -> None: pm_id = uuid4() tid = uuid4() assigned = MagicMock(id=tid, status="pending") task_svc = AsyncMock() task_svc.list_assigned_for_agent.return_value = [assigned] deps = _make_deps(task=task_svc) c = Choreographer(deps) await c.pm_give_me_work(pm_id) task_svc.heartbeat.assert_awaited_with(tid) @pytest.mark.asyncio async def test_pm_give_me_work_does_not_heartbeat_on_idle() -> None: pm_id = uuid4() task_svc = AsyncMock() task_svc.list_assigned_for_agent.return_value = [] deps = _make_deps(task=task_svc) c = Choreographer(deps) await c.pm_give_me_work(pm_id) task_svc.heartbeat.assert_not_awaited() @pytest.mark.asyncio async def test_i_will_plan_calls_heartbeat() -> None: pm_id = uuid4() tid = uuid4() pending = MagicMock( id=tid, status="pending", plan=None, assigned_to=None, parent_task_id=None, sequence=0, task_type="planning", ) in_progress = MagicMock( id=tid, status="in_progress", plan="plan-text", assigned_to=pm_id ) task_svc = AsyncMock() task_svc.get.return_value = pending task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend") task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = MagicMock( id=tid, status="claimed", plan=None, assigned_to=pm_id ) task_svc.set_plan.return_value = MagicMock( id=tid, status="claimed", plan="plan-text", assigned_to=pm_id ) task_svc.start.return_value = in_progress deps = _make_deps(task=task_svc) c = Choreographer(deps) await c.i_will_plan(pm_id, tid, plan="plan-text") task_svc.heartbeat.assert_awaited_with(tid)