mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(self-heal): hold an unconfirmed fix task out of dispatch until CEO approval
Adversarial review found the load-bearing invariant broken at the dispatch layer: _dispatch_pm_work skipped only PR_REVIEW_SOURCES, so a PENDING team=main_pm self_heal task (assigned_to=None, confirmed_by_human=False) was routed to Main PM and spawned BEFORE the CEO approved it — the "never start until you approve" promise didn't hold. Fix: the PM dispatcher now also skips source='self_heal' while confirmed_by_human is False (before the assigned/unassigned split, so it holds either way); the task still shows in the panel so the CEO can see and approve it. approve_and_start flips confirmed_by_human=True (the CEO's start IS the human confirmation), so it dispatches normally afterward. Other sources are unaffected. Tests: a unit test that the dispatcher holds an unconfirmed self_heal task but routes a confirmed one and ordinary tasks, plus a DB test that approve_and_start flips the gate. (The readiness gate was deliberately not used — a blocker there marks the task `blocked`; the dispatch skip leaves it cleanly PENDING.)
This commit is contained in:
@@ -11,6 +11,7 @@ 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
|
||||
@@ -208,3 +209,43 @@ async def test_loop_never_starts_or_approves(
|
||||
open_tasks = await get_task_service(db_session).list_open_self_heal_tasks()
|
||||
assert len(open_tasks) == ONE
|
||||
assert open_tasks[0].status == TaskStatus.PENDING # never advanced by the loop
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_approve_and_start_flips_the_confirmation_gate(
|
||||
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."""
|
||||
await _seed_project(db_session)
|
||||
# approve_and_start reassigns to the main-pm agent — seed it.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user