mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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()
|
||||
@@ -3,15 +3,14 @@
|
||||
The loop opens a fix task only when ``self_heal_originate_enabled``, dedupes one
|
||||
open task per regression fingerprint, honors the per-cycle and rolling open-task
|
||||
caps, and creates the task PENDING + assigned to the Main PM agent +
|
||||
``confirmed_by_human=False`` so it sits inert until the CEO Approve-&-Starts it.
|
||||
Crucially it NEVER calls start / approve / merge / deploy — asserted here.
|
||||
``confirmed_by_human=True`` so it dispatches autonomously (no CEO Approve-&-Start).
|
||||
Crucially the loop NEVER calls start / approve / merge / deploy — asserted here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
@@ -22,7 +21,6 @@ from roboco.services.notification import NotificationService
|
||||
from roboco.services.self_heal_engine import SelfHealEngine
|
||||
from roboco.services.task import TaskService, get_task_service
|
||||
from roboco.services.telemetry import TelemetrySample
|
||||
from sqlalchemy import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -152,7 +150,7 @@ async def test_originate_creates_pending_main_pm_assigned_task(
|
||||
# Assigned to the Main PM agent up front (not just team=main_pm) so that, once
|
||||
# the CEO confirms it, the orchestrator dispatches it straight to that agent.
|
||||
assert task.assigned_to == MAIN_PM_UUID
|
||||
assert task.confirmed_by_human is False # still inert until Approve-&-Start
|
||||
assert task.confirmed_by_human is True # auto-confirmed → dispatches autonomously
|
||||
assert task.team == Team.MAIN_PM
|
||||
assert task.source == "self_heal"
|
||||
assert task.acceptance_criteria # non-empty (AC-guardrail)
|
||||
@@ -242,45 +240,17 @@ async def test_loop_never_starts_or_approves(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_approve_and_start_flips_the_confirmation_gate(
|
||||
async def test_originated_task_is_confirmed_for_autonomous_dispatch(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The opened task is unconfirmed (held out of dispatch); the CEO's
|
||||
approve_and_start flips confirmed_by_human=True so it can then dispatch."""
|
||||
"""The opened task is confirmed up front, so the PM dispatcher picks it up
|
||||
without any CEO Approve-&-Start (that gate is the Intake/board flow)."""
|
||||
await _seed_project(db_session)
|
||||
# approve_and_start reassigns to the main-pm agent — get-or-create by slug
|
||||
# (the full suite may already have committed a "main-pm" agent).
|
||||
existing_pm = (
|
||||
await db_session.execute(select(AgentTable).where(AgentTable.slug == "main-pm"))
|
||||
).scalar_one_or_none()
|
||||
if existing_pm is None:
|
||||
db_session.add(
|
||||
AgentTable(
|
||||
id=uuid4(),
|
||||
name="Main PM",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=Team.MAIN_PM,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="pm",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
_enable(monkeypatch)
|
||||
# approve_and_start emits a stream event; stub it out (no bus in the test).
|
||||
monkeypatch.setattr(TaskService, "_emit_task_event", AsyncMock())
|
||||
|
||||
await SelfHealEngine(
|
||||
db_session, source=_FakeSource([_breach("ci:roboco")])
|
||||
).run_cycle()
|
||||
task = (await get_task_service(db_session).list_open_self_heal_tasks())[0]
|
||||
assert task.confirmed_by_human is False # inert: held out of dispatch
|
||||
|
||||
started = await TaskService(db_session).approve_and_start(UUID(str(task.id)))
|
||||
assert started is not None
|
||||
assert started.confirmed_by_human is True # gate flipped → now dispatchable
|
||||
assert started.status == TaskStatus.PENDING # reassignment, not a transition
|
||||
assert task.confirmed_by_human is True # dispatches autonomously
|
||||
assert task.status == TaskStatus.PENDING # ready for the PM dispatcher
|
||||
assert task.assigned_to == MAIN_PM_UUID # straight to the Main PM agent
|
||||
|
||||
Reference in New Issue
Block a user