mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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).
190 lines
7.9 KiB
Python
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)
|