mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(release): CI wait polls the prod rung; escape the header tooltip apostrophe get_latest_ci_conclusion defaults to the ladder's head rung, so wait_for_ci searched slave for a release commit that lives on master and timed out after 40 minutes with the run already green. The wait now passes the prod branch explicitly. Also fixes the react/no-unescaped-entities error that turned master's Panel CI red. * fix(panel,video): dead dialog triggers behind tooltips; dotted composition ids render HelpTip nested inside a Dialog/AlertDialog trigger puts the trigger's click handler on the Tooltip root, which renders no DOM — the agents Spawn item and the KB Reindex-All / Delete-index confirms were dead. Tooltips now wrap the triggers. The video renderer accepts interior single dots in composition ids (release-0.25.0) with '..' still unrepresentable, and propose_video refuses an unrenderable id at authoring time. * fix(dispatch): restart-safe PM review turns A leaf task in awaiting_pm_review had no periodic pickup: the closure dispatcher bailed on childless tasks and skipped PR-bearing review tasks as already-promoted, assuming the submit-time PM session was still alive — an assumption every restart breaks. Proven live on the docs-sync leaf after the 0.25.0 redeploy, which also dependency-blocked its sibling dev task. Childless awaiting_pm_review tasks now flow to the PM's review turn, and the merge turn respawns its PM when none is active. * [1dae04a7] Revise release-0.25.0 composition to 40s scene-based pacing with four feature cards * [1dae04a7] docs(motion): update release-0.25.0 README section for 40s four-card revision --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com> Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech>
199 lines
7.7 KiB
Python
199 lines
7.7 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.
|
|
|
|
#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 Any, cast
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
|
|
|
|
|
def _orch() -> Any:
|
|
with patch.object(AgentOrchestrator, "__init__", return_value=None):
|
|
return AgentOrchestrator.__new__(AgentOrchestrator)
|
|
|
|
|
|
def _ready_orch() -> Any:
|
|
"""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)
|
|
orch._fetch_all_descendants = AsyncMock(
|
|
return_value=[{"id": "leaf", "status": "completed"}]
|
|
)
|
|
orch._all_descendants_terminal = MagicMock(return_value=True)
|
|
orch._already_promoted_for_closure = MagicMock(return_value=False)
|
|
orch._closure_pm_for_team = MagicMock(return_value="be-pm")
|
|
orch._is_agent_active = MagicMock(return_value=False)
|
|
orch._build_pm_closure_prompt = MagicMock(return_value="PROMPT")
|
|
orch._task_git_context = MagicMock(return_value=None)
|
|
orch.spawn_agent = AsyncMock()
|
|
orch._auto_resume_paused_parent = AsyncMock()
|
|
orch._auto_recover_blocked_parent = AsyncMock()
|
|
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)
|
|
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")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_childless_awaiting_pm_review_reaches_closure() -> None:
|
|
"""A leaf task in awaiting_pm_review (dev->qa->doc ran on the task itself)
|
|
is the PM's review turn — it must flow past the descendants gate, or a
|
|
restart-stranded review has no periodic pickup at all."""
|
|
orch = _ready_orch()
|
|
orch._fetch_all_descendants = AsyncMock(return_value=[])
|
|
orch._closure_handled_without_pm = AsyncMock(return_value=(True, None))
|
|
task = {"id": "t1", "status": "awaiting_pm_review", "team": "frontend"}
|
|
await orch._maybe_spawn_pm_closure(MagicMock(), task)
|
|
orch._closure_handled_without_pm.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_childless_in_progress_still_bails() -> None:
|
|
"""A childless claimed/in_progress task is a dev's work, not PM closure
|
|
material — the descendants gate must still bail."""
|
|
orch = _ready_orch()
|
|
orch._fetch_all_descendants = AsyncMock(return_value=[])
|
|
orch._closure_handled_without_pm = AsyncMock(return_value=(True, None))
|
|
task = {"id": "t1", "status": "in_progress", "team": "frontend"}
|
|
await orch._maybe_spawn_pm_closure(MagicMock(), task)
|
|
orch._closure_handled_without_pm.assert_not_awaited()
|
|
|
|
|
|
def test_promoted_skip_excludes_the_pm_merge_turn() -> None:
|
|
"""awaiting_pm_review IS the PM's turn — a PR-bearing task there must not
|
|
be skipped as already-promoted (the submit-time PM session may be gone)."""
|
|
promoted = AgentOrchestrator._already_promoted_for_closure
|
|
assert not promoted({"pr_number": 5, "status": "awaiting_pm_review"})
|
|
assert promoted({"pr_number": 5, "status": "awaiting_ceo_approval"})
|
|
assert promoted({"pr_number": 5, "status": "completed"})
|
|
assert not promoted({"pr_number": None, "status": "completed"})
|