Files
roboco/roboco/services/ci_watch_engine.py
T
Renn F 47bb5403b1 [R115] originate ci_watch/dep_update fix tasks as PLANNING coordination roots
The Main-PM-code-impossibility guard (commit e202ce39, Thread 4 of this
audit) made team=MAIN_PM + task_type=CODE impossible — a Main PM coordinates,
it does not write code. But the ci_watch and dep_update engines still
originated their fix tasks as task_type=TaskType.CODE assigned to main-pm,
so task_svc.create raised MAIN_PM_NO_CODE and NO fix task was ever opened
— a regression introduced by the earlier audit fix (confirmed: the engine
tests pass at e202ce39~1 and fail at HEAD).

Mirror the hardened self_heal_engine precedent (self_heal_engine.py:197)
which already uses task_type=TaskType.PLANNING for its Main-PM coordination
root with an explicit 'decompose the fix and delegate the code work to a
cell dev — the Main PM does not write the fix itself' description. Both
engines now originate PLANNING coordination roots with matching delegation
guidance in the description + acceptance criteria. confirmed_by_human
stays True for both (they ride the normal delivery flow without the CEO
gate, unlike self-heal — intentional per the architecture).

The dedupe/open-cap queries (list_open_ci_watch_tasks /
list_open_dep_update_tasks) key on source + non-terminal status + git_url,
NOT task_type, so the type change does not break dedup (still one open fix
task per repo).

The two source-test fixtures (test_ci_watch_source / test_dep_update_source)
created CODE+MAIN_PM tasks directly to exercise the listing queries — same
guard violation; switched to PLANNING (the queries assert on source/status,
not task_type, so the fixture type matches the engines' corrected type).
2026-06-28 22:59:07 +02:00

190 lines
7.9 KiB
Python

"""Multi-repo CI-watch engine — dormant by default.
The fan-out generalization of the self-heal engine: instead of RoboCo's single
own repo, it watches EVERY project the operator opted into (``ci_watch_enabled``)
and, when one's CI is red on its default branch, opens one fix task into that
project's delivery lifecycle and STOPS. Like self-heal it is conservative:
* **Default OFF** (``ci_watch_enabled``) — the orchestrator loop never starts.
* **Never self-deploys** — it only OPENS a fix task; the fix still ships through
the normal gates (dev -> QA -> PR review -> the CEO's merge). The engine never
starts / approves / merges / deploys.
* **Bounded + deduped per repo** — at most one open ci_watch task per repo
(keyed on ``git_url``, so a monorepo's several cell-projects share one fix
task), plus per-cycle and rolling open-task caps.
Reuses the hardened per-project CI lookup via ``MultiProjectCITelemetrySource``;
the single-repo self-heal path is untouched.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from roboco.config import settings
from roboco.foundation import identity as _foundation
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.notification import NotificationService
from roboco.services.task import (
CI_WATCH_SOURCE,
TaskCreateRequest,
TaskService,
get_task_service,
)
from roboco.services.telemetry.source import get_multi_ci_telemetry_source
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
def _cell_pm_slug_for(team: Any) -> str | None:
"""The cell PM slug owning ``team`` (e.g. Team.BACKEND → 'be-pm'), or None."""
for row in _foundation.AGENTS.values():
if row.role == _foundation.Role.CELL_PM and row.team == team:
return row.slug
return None
class CiWatchEngine(BaseService):
"""Open a fix task for each opted-in project whose CI is red. Never merges."""
service_name = "ci_watch_engine"
def __init__(self, session: AsyncSession, source: Any | None = None) -> None:
super().__init__(session)
self._source = source or get_multi_ci_telemetry_source(session)
async def run_cycle(self, projects: list[Any]) -> list[TaskTable]:
"""Assess the watch set and open a fix task per red repo (bounded).
No-op unless ``ci_watch_enabled``. Returns the tasks it opened. Flushes;
the caller (the orchestrator loop) owns the commit. Never starts /
approves / merges / deploys.
"""
if not settings.ci_watch_enabled:
return []
samples = await self._source.fetch(projects)
breaches = [s for s in samples if s.is_breach]
if not breaches:
return []
by_slug = {str(getattr(p, "slug", "")): p for p in projects}
return await self._originate(breaches, by_slug)
async def _originate(
self, breaches: list[Any], by_slug: dict[str, Any]
) -> list[TaskTable]:
"""Open one ci_watch fix task per NEW red repo, bounded. Returns them."""
task_svc = get_task_service(self.session)
open_count = len(await task_svc.list_open_ci_watch_tasks())
created: list[TaskTable] = []
for sample in breaches:
if len(created) >= settings.ci_watch_max_per_cycle:
break
if open_count >= settings.ci_watch_max_open_tasks:
self.log.info(
"ci-watch open-task cap reached; not originating",
cap=settings.ci_watch_max_open_tasks,
)
break
project = by_slug.get(sample.repo_hint)
if not await self._should_open(task_svc, project):
continue
task = await self._open_fix_task(task_svc, project, sample)
created.append(task)
open_count += 1
self.log.info(
"ci-watch fix task opened",
task_id=str(task.id),
repo=sample.repo_hint,
)
await self._notify_cell_pm(project, sample)
return created
async def _notify_cell_pm(self, project: Any, sample: Any) -> None:
"""Notify the red project's cell PM that a fix task was opened.
Routed to the project's own cell PM (not the CEO — a delivery/client
repo's red CI is a cell concern), once per project per cycle (the engine
opens at most one task per repo per cycle). Best-effort: a notification
failure never rolls back the origination.
"""
team = getattr(project, "assigned_cell", None)
pm_slug = _cell_pm_slug_for(team) if team is not None else None
if not pm_slug:
return
try:
await NotificationService().send_ack_notification(
from_agent="system",
to_agent=pm_slug,
body=(
f"[ci-watch] CI is red on {sample.repo_hint}. A fix task was "
f"opened automatically and is ready to start.\n\n{sample.detail}"
),
)
except Exception as exc:
self.log.warning(
"ci-watch cell-PM notify failed (best-effort)",
repo=sample.repo_hint,
error=str(exc),
)
async def _should_open(self, task_svc: TaskService, project: Any) -> bool:
"""True when ``project`` resolves and has no open ci_watch task yet.
Dedupe is per ``git_url`` so a monorepo (several cell-projects, one repo)
gets a single open fix task, not one per cell-project.
"""
if project is None or getattr(project, "id", None) is None:
return False
existing = await task_svc.list_open_ci_watch_tasks(git_url=project.git_url)
return not existing
async def _open_fix_task(
self, task_svc: TaskService, project: Any, sample: Any
) -> TaskTable:
slug = str(getattr(project, "slug", "") or sample.repo_hint)
return await task_svc.create(
TaskCreateRequest(
title=f"CI-watch: fix the CI regression on {slug}",
description=(
f"This project's CI is red on its default branch.\n\n"
f"{sample.detail}\n\n"
f"Evidence: {sample.raw_ref}\n\n"
"This is a Main-PM coordination root: decompose the fix and "
"delegate the code work to a cell dev — the Main PM does not "
"write the fix itself. This task was opened automatically by "
"the CI-watch loop and is READY TO START NOW — no approval "
"needed; plan the fix and delegate it. It still ships through "
"the normal gates (QA, PR review, and the CEO's merge)."
),
acceptance_criteria=[
"The CI regression is decomposed into a code-fix subtask "
"delegated to a cell developer",
f"CI on {slug}'s default branch is green again and the fix "
"merged through the normal gates",
],
team=Team.MAIN_PM,
assigned_to=_foundation.AGENTS["main-pm"].uuid,
created_by=_foundation.AGENTS["system"].uuid,
task_type=TaskType.PLANNING,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=CI_WATCH_SOURCE,
confirmed_by_human=True,
)
)
def get_ci_watch_engine(
session: AsyncSession, source: Any | None = None
) -> CiWatchEngine:
"""Construct a CiWatchEngine bound to ``session`` (optionally a test source)."""
return CiWatchEngine(session, source=source)