"""#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. #177: symmetric handling for a `blocked` parent. At closure all descendants are terminal, so a still-`blocked` parent is an errant/ stale block — it must be recovered to in_progress too, else the chain wedges forever waiting for a PM to manually unblock (this run wedged exactly there). `paused` and `blocked` are mutually exclusive — each triggers only its own recovery helper. """ from __future__ import annotations from typing import cast 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] orch._auto_recover_blocked_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) cast("AsyncMock", orch._auto_resume_paused_parent).assert_awaited_once_with( client, "parent-1" ) cast("AsyncMock", orch._auto_recover_blocked_parent).assert_not_awaited() cast("AsyncMock", orch.spawn_agent).assert_awaited_once() @pytest.mark.asyncio async def test_blocked_parent_is_recovered_before_spawn() -> None: """#177: a blocked parent at closure is recovered (not the paused path).""" orch = _ready_orch() client = AsyncMock() task = {"id": "parent-2", "status": "blocked", "team": "backend"} await orch._maybe_spawn_pm_closure(client, task) cast("AsyncMock", orch._auto_recover_blocked_parent).assert_awaited_once_with( client, "parent-2" ) cast("AsyncMock", orch._auto_resume_paused_parent).assert_not_awaited() cast("AsyncMock", 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 by either recovery path.""" 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) cast("AsyncMock", orch._auto_resume_paused_parent).assert_not_awaited() cast("AsyncMock", orch._auto_recover_blocked_parent).assert_not_awaited() cast("AsyncMock", 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"} ) cast("AsyncMock", orch._auto_resume_paused_parent).assert_not_awaited() cast("AsyncMock", 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") @pytest.mark.asyncio async def test_auto_recover_blocked_patches_status_in_progress() -> None: """#177: blocked -> in_progress (same transition unblock(restore=True) performs).""" orch = _orch() client = AsyncMock() await orch._auto_recover_blocked_parent(client, "parent-7") client.patch.assert_awaited_once() call = client.patch.await_args assert call.args[0].endswith("/tasks/parent-7") assert call.kwargs["json"] == {"status": "in_progress"} @pytest.mark.asyncio async def test_auto_recover_blocked_swallows_errors() -> None: """A recovery 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_recover_blocked_parent(client, "p")