2026-06-17 21:55:33 +02:00
|
|
|
"""The PM dispatcher holds an unconfirmed self-heal task OUT of dispatch.
|
|
|
|
|
|
|
|
|
|
The load-bearing invariant: a source='self_heal' task the loop opened must NOT
|
|
|
|
|
be routed / claimed / spawned while confirmed_by_human is False — it sits inert
|
|
|
|
|
until the CEO Approve-&-Starts it (which flips the flag). Once confirmed it
|
|
|
|
|
routes like any other task. Mirrors how PR-review tasks are skipped.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any, cast
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 19:10:21 +02:00
|
|
|
def _task(
|
|
|
|
|
tid: str, source: str, confirmed: bool, assigned_to: str | None = None
|
|
|
|
|
) -> dict[str, Any]:
|
2026-06-17 21:55:33 +02:00
|
|
|
return {
|
|
|
|
|
"id": tid,
|
|
|
|
|
"source": source,
|
|
|
|
|
"confirmed_by_human": confirmed,
|
2026-06-20 19:10:21 +02:00
|
|
|
"assigned_to": assigned_to,
|
2026-06-17 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_unconfirmed_self_heal_task_is_held_out_of_dispatch() -> None:
|
|
|
|
|
tasks = [
|
|
|
|
|
_task("A", "self_heal", False), # held — must NOT route until approved
|
|
|
|
|
_task("B", "self_heal", True), # CEO-approved → routes
|
|
|
|
|
_task("C", "manual", False), # ordinary task → routes
|
2026-06-20 19:10:21 +02:00
|
|
|
# The loop now assigns the Main PM agent up front, so the hold must
|
|
|
|
|
# survive an assignee too — the self-heal skip sits before the
|
|
|
|
|
# assigned/unassigned split, so an assigned-but-unconfirmed task is
|
|
|
|
|
# neither routed nor handed to the assigned-PM path.
|
|
|
|
|
_task("D", "self_heal", False, assigned_to="main-pm"),
|
2026-06-17 21:55:33 +02:00
|
|
|
]
|
|
|
|
|
stub = MagicMock()
|
|
|
|
|
stub._fetch_tasks = AsyncMock(return_value=tasks)
|
|
|
|
|
stub._is_task_handled_this_tick = MagicMock(return_value=False)
|
|
|
|
|
stub._route_unassigned_pm_task = AsyncMock()
|
2026-06-20 19:10:21 +02:00
|
|
|
stub._handle_pm_assigned_task = AsyncMock()
|
2026-06-17 21:55:33 +02:00
|
|
|
|
|
|
|
|
client: Any = MagicMock()
|
|
|
|
|
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
|
|
|
|
|
|
|
|
|
|
routed = [c.args[1]["id"] for c in stub._route_unassigned_pm_task.await_args_list]
|
|
|
|
|
assert "A" not in routed # the unconfirmed self-heal task stays inert
|
|
|
|
|
assert set(routed) == {"B", "C"}
|
2026-06-20 19:10:21 +02:00
|
|
|
# The assigned-but-unconfirmed self-heal task (D) is held before the
|
|
|
|
|
# assigned-PM branch — never handed to _handle_pm_assigned_task.
|
|
|
|
|
stub._handle_pm_assigned_task.assert_not_awaited()
|