mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F059] self-heal: hold fix tasks for CEO Approve-&-Start (restore dispatch gate)
The module docstring promised self-heal fix tasks 'wait for the CEO's Approve-&-Start', but _originate created them confirmed_by_human=True and the orchestrator dispatched them at once — a self-heal fix that re-broke CI would trigger another cycle, open another auto-dispatched fix, and loop with no CEO gate on dispatch. Restore the documented gate: * _originate opens the task confirmed_by_human=False (held for the CEO). * The orchestrator holds a self-heal task out of both the PM and dev dispatch paths until confirmed_by_human flips True. * approve_and_start (the CEO's start gate) sets confirmed_by_human=True so the held task finally dispatches (idempotent for board/intake tasks already True). * list_pending_for_agent scopes the give_me_work hold to self-heal (source != self_heal OR confirmed_by_human) so an already-alive PM can't grab it pre-approval — while ordinary delegated subtasks (confirmed_by_human=False by default, where the delegation IS the authorization) still dispatch. The 'never self-deploys' guarantee (no merge) is unchanged.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
"""Self-heal fix tasks dispatch autonomously through the PM dispatcher.
|
||||
"""Self-heal fix tasks dispatch through the PM dispatcher only after the CEO
|
||||
approves them (F059).
|
||||
|
||||
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).
|
||||
The loop opens a ``source='self_heal'`` task assigned to the Main PM agent but
|
||||
HELD (``confirmed_by_human=False``) for the CEO's Approve-&-Start. The dispatcher
|
||||
holds an unconfirmed self-heal task out of the assigned-PM path; once the CEO's
|
||||
``approve_and_start`` flips ``confirmed_by_human`` True, it routes through the
|
||||
assigned-PM path like any other PM task. Unassigned tasks route normally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,17 +15,32 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
from roboco.services.task import SELF_HEAL_SOURCE
|
||||
|
||||
|
||||
def _task(tid: str, source: str, assigned_to: str | None = None) -> dict[str, Any]:
|
||||
return {"id": tid, "source": source, "assigned_to": assigned_to}
|
||||
def _task(
|
||||
tid: str,
|
||||
source: str,
|
||||
*,
|
||||
assigned_to: str | None = None,
|
||||
confirmed: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
task: dict[str, Any] = {"id": tid, "source": source, "assigned_to": assigned_to}
|
||||
if confirmed is not None:
|
||||
task["confirmed_by_human"] = confirmed
|
||||
return task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_heal_task_dispatches_through_the_assigned_pm_path() -> None:
|
||||
async def test_ceo_approved_self_heal_task_dispatches_through_assigned_pm_path() -> (
|
||||
None
|
||||
):
|
||||
"""A self-heal task the CEO has approved (confirmed_by_human=True) is handed
|
||||
to the assigned-PM path — the CEO's gate has lifted."""
|
||||
tasks = [
|
||||
_task("A", "self_heal", assigned_to="main-pm"), # assigned → assigned-PM path
|
||||
_task("B", "self_heal"), # unassigned self-heal → routing
|
||||
_task(
|
||||
"A", SELF_HEAL_SOURCE, assigned_to="main-pm", confirmed=True
|
||||
), # CEO-approved → assigned-PM path
|
||||
_task("C", "manual"), # ordinary unassigned → routing
|
||||
]
|
||||
stub = MagicMock()
|
||||
@@ -37,10 +55,40 @@ async def test_self_heal_task_dispatches_through_the_assigned_pm_path() -> None:
|
||||
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"}
|
||||
assert routed == ["C"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_held_self_heal_task_is_not_dispatched() -> None:
|
||||
"""A self-heal task the CEO has NOT yet approved (confirmed_by_human=False)
|
||||
is held — neither the assigned-PM path nor routing touches it."""
|
||||
tasks = [
|
||||
_task(
|
||||
"A", SELF_HEAL_SOURCE, assigned_to="main-pm", confirmed=False
|
||||
), # held → skip
|
||||
_task("B", SELF_HEAL_SOURCE, confirmed=False), # held + unassigned → skip
|
||||
_task("C", "manual"), # ordinary unassigned → routing still happens
|
||||
]
|
||||
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)
|
||||
|
||||
stub._handle_pm_assigned_task.assert_not_awaited()
|
||||
# Only the non-self-heal unassigned task routes; the held self-heal one does not.
|
||||
routed = [c.args[1]["id"] for c in stub._route_unassigned_pm_task.await_args_list]
|
||||
assert routed == ["C"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
|
||||
Reference in New Issue
Block a user