diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index d9aaa085..9b61996d 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -3270,6 +3270,37 @@ Start by: error=str(e), ) + async def _auto_resume_paused_parent( + self, client: httpx.AsyncClient, task_id: str + ) -> None: + """Resume a paused parent right before its PM is respawned for closure. + + #170: a PM auto-pauses its owned parent on i_am_idle (by design, + so the closure dispatcher knows to respawn it). Pre-gateway the + parent was resumed at respawn so the PM landed actionable; the + gateway refactor dropped that, so the respawned PM had to issue + ``resume()`` itself — which weak models (minimax) reliably fail, + wedging the whole chain (smoke-15). Restore the auto-resume: + paused -> in_progress before spawn so the PM can directly + submit_up / complete / escalate. Best-effort; a resume failure + must not block the spawn (the PM can still resume manually). + """ + try: + await client.patch( + f"{self._api_url}/tasks/{task_id}", + json={"status": "in_progress"}, + ) + logger.info( + "Auto-resumed paused parent for PM closure respawn", + task_id=task_id, + ) + except Exception as e: + logger.error( + "Failed to auto-resume paused parent", + task_id=task_id, + error=str(e), + ) + def _select_agent_for_cell(self, cell: str, role: str) -> str | None: """ Select the best available agent for a cell and role. @@ -4270,6 +4301,14 @@ Start now: evidence(task_id="{task_id}") pm_id=pm_id, ) + # #170: the parent auto-paused when its PM idled (by design). Resume + # it before respawn so the PM lands actionable (in_progress) and can + # directly submit_up / complete / escalate — pre-gateway behaviour the + # gateway refactor dropped, which wedged smoke-15 (minimax never + # issued resume() itself). + if task.get("status") == "paused": + await self._auto_resume_paused_parent(client, task_id) + prompt = self._build_pm_closure_prompt(task, descendants) await self.spawn_agent( agent_id=pm_id, diff --git a/tests/unit/runtime/test_pm_closure_auto_resume.py b/tests/unit/runtime/test_pm_closure_auto_resume.py new file mode 100644 index 00000000..11b8c196 --- /dev/null +++ b/tests/unit/runtime/test_pm_closure_auto_resume.py @@ -0,0 +1,107 @@ +"""#170: the closure dispatcher auto-resumes a paused parent before respawn. + +A PM auto-pauses its owned parent on i_am_idle (by design, so the +closure dispatcher knows to respawn it when subtasks finish). Pre-gateway +the parent was resumed at respawn so the PM landed actionable; the +gateway refactor dropped that, so the respawned PM had to issue +`resume()` itself — which minimax reliably failed, wedging smoke-15. +_maybe_spawn_pm_closure must resume a `paused` parent (and only a +paused one) immediately before spawning its PM. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from roboco.runtime.orchestrator import AgentOrchestrator + + +def _orch() -> AgentOrchestrator: + with patch.object(AgentOrchestrator, "__init__", return_value=None): + return AgentOrchestrator.__new__(AgentOrchestrator) + + +def _ready_orch() -> AgentOrchestrator: + """Orchestrator with every closure gate stubbed so _maybe_spawn_pm_closure + reaches the spawn (descendants terminal, not recently paused, not + already promoted, PM idle).""" + orch = _orch() + orch._is_recently_paused = MagicMock(return_value=False) # type: ignore[method-assign] + orch._fetch_all_descendants = AsyncMock( # type: ignore[method-assign] + return_value=[{"id": "leaf", "status": "completed"}] + ) + orch._all_descendants_terminal = MagicMock(return_value=True) # type: ignore[method-assign] + orch._already_promoted_for_closure = MagicMock(return_value=False) # type: ignore[method-assign] + orch._closure_pm_for_team = MagicMock(return_value="be-pm") # type: ignore[method-assign] + orch._is_agent_active = MagicMock(return_value=False) # type: ignore[method-assign] + orch._build_pm_closure_prompt = MagicMock(return_value="PROMPT") # type: ignore[method-assign] + orch._task_git_context = MagicMock(return_value=None) # type: ignore[method-assign] + orch.spawn_agent = AsyncMock() # type: ignore[method-assign] + orch._auto_resume_paused_parent = AsyncMock() # type: ignore[method-assign] + return orch + + +@pytest.mark.asyncio +async def test_paused_parent_is_resumed_before_spawn() -> None: + orch = _ready_orch() + client = AsyncMock() + task = {"id": "parent-1", "status": "paused", "team": "backend"} + + await orch._maybe_spawn_pm_closure(client, task) + + orch._auto_resume_paused_parent.assert_awaited_once_with(client, "parent-1") + orch.spawn_agent.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_non_paused_parent_is_not_resumed() -> None: + """awaiting_pm_review / in_progress parents must NOT be touched.""" + for st in ("awaiting_pm_review", "in_progress"): + orch = _ready_orch() + client = AsyncMock() + task = {"id": "p", "status": st, "team": "backend"} + + await orch._maybe_spawn_pm_closure(client, task) + + orch._auto_resume_paused_parent.assert_not_awaited() + orch.spawn_agent.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_resume_skipped_when_closure_gate_blocks_spawn() -> None: + """If descendants aren't terminal there is no spawn — and no resume.""" + orch = _ready_orch() + orch._all_descendants_terminal = MagicMock(return_value=False) # type: ignore[method-assign] + client = AsyncMock() + + await orch._maybe_spawn_pm_closure( + client, {"id": "p", "status": "paused", "team": "backend"} + ) + + orch._auto_resume_paused_parent.assert_not_awaited() + orch.spawn_agent.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_auto_resume_patches_status_in_progress() -> None: + orch = _orch() + client = AsyncMock() + + await orch._auto_resume_paused_parent(client, "parent-9") + + client.patch.assert_awaited_once() + call = client.patch.await_args + assert call.args[0].endswith("/tasks/parent-9") + assert call.kwargs["json"] == {"status": "in_progress"} + + +@pytest.mark.asyncio +async def test_auto_resume_swallows_errors() -> None: + """A resume failure must not block the spawn (best-effort).""" + orch = _orch() + client = AsyncMock() + client.patch = AsyncMock(side_effect=RuntimeError("api down")) + + # Must not raise. + await orch._auto_resume_paused_parent(client, "p")