mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Origination created the task with team=main_pm but no assignee, so after the CEO's Approve-&-Start it fell to the unassigned-team routing, which the orchestrator picks up slowly or never. Assign the Main PM agent up front (the seeded foundation uuid) so the dispatcher routes it straight to that agent via the assigned-PM path once confirmed. The confirmed_by_human hold is unaffected — the dispatcher's self-heal skip sits before the assigned/unassigned split, so the task stays inert until the CEO approves it (now covered by a test).
287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""Self-heal task origination against a real Postgres DB.
|
|
|
|
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.
|
|
"""
|
|
|
|
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
|
|
from roboco.db.tables import AgentTable, ProjectTable
|
|
from roboco.foundation import identity as _foundation
|
|
from roboco.models.base import AgentRole, AgentStatus, TaskStatus, Team
|
|
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
|
|
|
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
|
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
|
SLUG = "roboco"
|
|
ONE = 1
|
|
|
|
|
|
class _FakeSource:
|
|
def __init__(self, samples: list[TelemetrySample]) -> None:
|
|
self._samples = samples
|
|
|
|
async def fetch(self) -> list[TelemetrySample]:
|
|
return list(self._samples)
|
|
|
|
|
|
def _breach(signal: str) -> TelemetrySample:
|
|
return TelemetrySample(
|
|
signal_name=signal,
|
|
value=1.0,
|
|
threshold=1.0,
|
|
window="latest_completed_run",
|
|
repo_hint=SLUG,
|
|
observed_at="2026-06-17T00:00:00Z",
|
|
raw_ref="https://github.com/x/roboco/actions/runs/1",
|
|
detail=f"{signal} concluded 'failure'",
|
|
)
|
|
|
|
|
|
async def _seed_project(session: AsyncSession, slug: str = SLUG) -> None:
|
|
"""Seed the system agent (FK target for created_by) + RoboCo's own project.
|
|
|
|
The system agent has a FIXED foundation uuid (origination hardcodes it as
|
|
created_by). Another test in the full suite may have already committed it
|
|
into the session-scoped DB, so get-or-create by id — a plain insert collides
|
|
on pk_agents (green in isolation, red in the full CI run).
|
|
"""
|
|
if await session.get(AgentTable, SYSTEM_UUID) is None:
|
|
session.add(
|
|
AgentTable(
|
|
id=SYSTEM_UUID,
|
|
name="System",
|
|
slug=f"system-{slug}",
|
|
role=AgentRole.SYSTEM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="system",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
await session.flush()
|
|
# The loop assigns the fix task to the Main PM agent (an FK to agents.id), so
|
|
# that row must exist — get-or-create by its fixed foundation uuid.
|
|
if await session.get(AgentTable, MAIN_PM_UUID) is None:
|
|
session.add(
|
|
AgentTable(
|
|
id=MAIN_PM_UUID,
|
|
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 session.flush()
|
|
session.add(
|
|
ProjectTable(
|
|
name="RoboCo",
|
|
slug=slug,
|
|
git_url="https://github.com/x/roboco.git",
|
|
default_branch="master",
|
|
protected_branches=["master"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=SYSTEM_UUID,
|
|
is_active=True,
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
|
|
def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None:
|
|
monkeypatch.setattr(cfg, "self_heal_enabled", True)
|
|
monkeypatch.setattr(cfg, "self_heal_originate_enabled", True)
|
|
monkeypatch.setattr(cfg, "self_heal_max_open_tasks", 5)
|
|
monkeypatch.setattr(cfg, "self_heal_max_per_cycle", 5)
|
|
for key, value in overrides.items():
|
|
monkeypatch.setattr(cfg, key, value)
|
|
# Keep notification a no-op (its own session/IO is out of scope here).
|
|
monkeypatch.setattr(NotificationService, "send_ack_notification", AsyncMock())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disabled_originate_creates_no_task(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed_project(db_session)
|
|
_enable(monkeypatch, self_heal_originate_enabled=False)
|
|
engine = SelfHealEngine(db_session, source=_FakeSource([_breach("ci:roboco")]))
|
|
obs = await engine.run_cycle()
|
|
assert len(obs) == ONE # detected + notified
|
|
assert await get_task_service(db_session).list_open_self_heal_tasks() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_originate_creates_pending_main_pm_assigned_task(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed_project(db_session)
|
|
_enable(monkeypatch)
|
|
engine = SelfHealEngine(db_session, source=_FakeSource([_breach("ci:roboco")]))
|
|
await engine.run_cycle()
|
|
|
|
open_tasks = await get_task_service(db_session).list_open_self_heal_tasks()
|
|
assert len(open_tasks) == ONE
|
|
task = open_tasks[0]
|
|
assert task.status == TaskStatus.PENDING
|
|
# 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.team == Team.MAIN_PM
|
|
assert task.source == "self_heal"
|
|
assert task.acceptance_criteria # non-empty (AC-guardrail)
|
|
assert "self_heal_fp=" in (task.quick_context or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dedupe_no_second_task_same_fingerprint(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed_project(db_session)
|
|
_enable(monkeypatch)
|
|
src = _FakeSource([_breach("ci:roboco")])
|
|
await SelfHealEngine(db_session, source=src).run_cycle()
|
|
await SelfHealEngine(db_session, source=src).run_cycle() # same fingerprint
|
|
open_tasks = await get_task_service(db_session).list_open_self_heal_tasks()
|
|
assert len(open_tasks) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_per_cycle_cap_limits_origination(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed_project(db_session)
|
|
_enable(monkeypatch, self_heal_max_per_cycle=1)
|
|
# Two distinct regressions in one cycle, cap = 1 → only one task opens.
|
|
src = _FakeSource([_breach("ci:roboco:a"), _breach("ci:roboco:b")])
|
|
await SelfHealEngine(db_session, source=src).run_cycle()
|
|
assert len(await get_task_service(db_session).list_open_self_heal_tasks()) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_task_cap_blocks_further_origination(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed_project(db_session)
|
|
_enable(monkeypatch, self_heal_max_open_tasks=1)
|
|
await SelfHealEngine(
|
|
db_session, source=_FakeSource([_breach("ci:roboco:a")])
|
|
).run_cycle()
|
|
# A different regression next cycle, but one task is already open → blocked.
|
|
await SelfHealEngine(
|
|
db_session, source=_FakeSource([_breach("ci:roboco:b")])
|
|
).run_cycle()
|
|
assert len(await get_task_service(db_session).list_open_self_heal_tasks()) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unresolved_project_is_notify_only(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed_project(db_session)
|
|
_enable(monkeypatch)
|
|
ghost = TelemetrySample(
|
|
signal_name="ci:ghost",
|
|
value=1.0,
|
|
threshold=1.0,
|
|
window="latest_completed_run",
|
|
repo_hint="not-a-registered-project",
|
|
observed_at="",
|
|
raw_ref="",
|
|
)
|
|
# No crash, no task — the regression can't be tied to a repo.
|
|
await SelfHealEngine(db_session, source=_FakeSource([ghost])).run_cycle()
|
|
assert await get_task_service(db_session).list_open_self_heal_tasks() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_never_starts_or_approves(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed_project(db_session)
|
|
_enable(monkeypatch)
|
|
approve = AsyncMock()
|
|
ceo_approve = AsyncMock()
|
|
monkeypatch.setattr(TaskService, "approve_and_start", approve)
|
|
monkeypatch.setattr(TaskService, "ceo_approve", ceo_approve)
|
|
await SelfHealEngine(
|
|
db_session, source=_FakeSource([_breach("ci:roboco")])
|
|
).run_cycle()
|
|
|
|
approve.assert_not_awaited()
|
|
ceo_approve.assert_not_awaited()
|
|
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 — 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
|