fix(self-heal): dispatch fix tasks autonomously instead of stranding them

A self-heal fix task was opened confirmed_by_human=false and held out of
dispatch until "Approve & Start" — but that button only renders for a
board-reviewed Intake task (pending + board_review_complete + team != main_pm),
never for a self-heal task (team=main_pm, no board review). So there was no way
to start it: it sat in pending forever and the Main PM never picked it up.

Self-heal is RoboCo healing itself, not an Intake draft — it shouldn't need a
manual Approve & Start. Origination now opens the fix task confirmed + assigned
to the Main PM agent, the PM dispatcher's self-heal hold is dropped, and the
now-dead approve_and_start special-case is removed. The fix still ships through
the normal gates (dev -> QA -> PR review -> the CEO's merge); the loop never
starts, merges, or deploys.
This commit is contained in:
Renn F
2026-06-22 23:37:07 +02:00
parent 5f828b3551
commit fe029fe34b
9 changed files with 77 additions and 128 deletions
@@ -0,0 +1,46 @@
"""Self-heal fix tasks dispatch autonomously through the PM dispatcher.
The loop opens a ``source='self_heal'`` task confirmed + assigned to the Main PM
agent, so the dispatcher routes it through the assigned-PM path like any other
task — there is no CEO Approve-&-Start hold (that gate is the Intake/board flow).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
def _task(tid: str, source: str, assigned_to: str | None = None) -> dict[str, Any]:
return {"id": tid, "source": source, "assigned_to": assigned_to}
@pytest.mark.asyncio
async def test_self_heal_task_dispatches_through_the_assigned_pm_path() -> None:
tasks = [
_task("A", "self_heal", assigned_to="main-pm"), # assigned → assigned-PM path
_task("B", "self_heal"), # unassigned self-heal → routing
_task("C", "manual"), # ordinary unassigned → routing
]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="main-pm")
stub._BOARD_AGENTS = frozenset()
stub._route_unassigned_pm_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
# The assigned self-heal task is handed to the assigned-PM path (spawned),
# NOT held — it dispatches without any CEO approval.
handled = [c.args[0]["id"] for c in stub._handle_pm_assigned_task.await_args_list]
assert handled == ["A"]
# Unassigned tasks (self-heal or not) route normally.
routed = [c.args[1]["id"] for c in stub._route_unassigned_pm_task.await_args_list]
assert set(routed) == {"B", "C"}
@@ -1,55 +0,0 @@
"""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
def _task(
tid: str, source: str, confirmed: bool, assigned_to: str | None = None
) -> dict[str, Any]:
return {
"id": tid,
"source": source,
"confirmed_by_human": confirmed,
"assigned_to": assigned_to,
}
@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
# 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"),
]
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()
stub._handle_pm_assigned_task = AsyncMock()
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"}
# 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()