Files
roboco/tests/unit/runtime/test_pm_closure_auto_resume.py
T
Renn F e94159dce8 fix(orchestrator): auto-resume paused parent before PM closure respawn (#170)
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. minimax reliably failed to (called resume on
the leaf / unblock on the paused root), wedging smoke-15 — the leaf
stayed awaiting_pm_review and the chain never completed.

Restore the pre-gateway behaviour: _maybe_spawn_pm_closure now calls
new _auto_resume_paused_parent (paused -> in_progress via the same
PATCH path _auto_block_task uses) immediately before spawning the PM,
but only when the parent is actually `paused` (awaiting_pm_review /
in_progress parents untouched). Best-effort: a resume failure is
logged and swallowed so it never blocks the spawn (the PM can still
resume manually). The parent stays assigned to the PM, so it lands on
its own in_progress task able to submit_up / complete / escalate
directly — no reliance on the weak model issuing resume().

Combined with 4090397 (exact-complete remediate), this closes the
smoke-15 PM-completion wedge end to end.
2026-05-16 07:06:17 +02:00

108 lines
4.1 KiB
Python

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