mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Feat/autonomous maintenance (#264)
* feat(ci-watch): config flags Default-off CI-watch config (mirrors self_heal_*): ci_watch_enabled, ci_watch_default_workflow (ci.yml), ci_watch_interval_seconds (1800), ci_watch_max_open_tasks (3), ci_watch_max_per_cycle (1). Registers ci_watch_enabled in the panel FEATURE_FLAGS. 4 tests. * feat(ci-watch): per-project ci_watch_enabled/workflow (migration 048) Adds projects.ci_watch_enabled (bool NOT NULL default false) + projects.ci_watch_workflow (varchar null) — the per-project opt-in for multi-repo CI-watch. ProjectTable + Pydantic Project fields + migration 048 (off 047_ws_single_active). Real upgrade->downgrade->upgrade chain verified against a throwaway Postgres; 2 ORM round-trip tests. * feat(runtime): prune dangling agent images in the background sweeper Every agent-image rebuild orphans the prior build's layers as an untagged <none> image; across deploys these pile up (the operator hit ~80). The sweeper now runs 'docker image prune -f --filter dangling=true' (dangling only — a tagged image or one backing a running container is never dangling), throttled to settings.image_prune_interval_seconds (default 6h) and gated by image_prune_enabled (default on). Best-effort: any failure is logged, never raised into the sweeper. Mirrors the transcript-retention prune. 4 tests. * feat(ci-watch): source tag + open-task dedupe query CI_WATCH_SOURCE='ci_watch' + TaskService.list_open_ci_watch_tasks(git_url=None): non-terminal ci_watch tasks (the dedupe + open-cap basis), optionally scoped to one repo by git_url — a monorepo registers several cell-projects on one git_url, so dedupe keys on the repo, not the slug. 2 real-PG tests. * feat(ci-watch): multi-project CI telemetry fan-out MultiProjectCITelemetrySource.fetch(projects) reuses the hardened per-project get_latest_ci_conclusion for each opted-in project (passing its ci_watch_workflow or the configured default). Per-project isolation: a GitHub error or absent signal yields NO sample (unknown, never read as green) and never aborts the sweep; only a real conclusion yields a sample (fail→breach, pass→non-breach). self-heal source untouched. 3 tests + self-heal regression green. * feat(ci-watch): engine — fan-out, originate, dedupe, cap CiWatchEngine.run_cycle(projects) mirrors SelfHealEngine: assess via MultiProjectCITelemetrySource, open one PENDING ci_watch fix task per red repo (team=main_pm, assigned_to=main-pm, confirmed_by_human=True so it dispatches without an Approve-&-Start — thefe029fe3lesson), never starts/approves/merges. Dedupe per git_url (monorepo → one fix task per repo) + per-cycle/rolling caps. Default-off; disabled → no-op. 5 real-PG tests (red→one task, dedupe, cap, green/none→nothing, disabled). * feat(ci-watch): orchestrator loop tick + watch-set loader _ci_watch_loop (registered in start(), cancelled in stop(), separate from the untouched self-heal loop): dormant unless ci_watch_enabled; each interval loads the watch set (ci_watch_enabled projects, collapsed one-per-repo via the existing _projects_one_per_repo) and runs CiWatchEngine.run_cycle, committing opened tasks. _run_ci_watch_cycle extracted for testing; loud warning when enabled-but-empty. confirmed_by_human=True on the originated task means it dispatches without an Approve-&-Start (no stranding, thefe029fe3lesson). 5 tests (disabled no-op, watch-set filter+one-per-repo, empty warn, engine run). * docs(ci-watch): CHANGELOG + CLAUDE.md for multi-repo CI-watch Document CI-watch (Added) in the CHANGELOG and the Self-Healing & Feature Flags section of CLAUDE.md — it generalizes self-heal to opted-in projects, reuses the hardened per-project CI lookup, never auto-merges, default-off. Adds the ci_watch_enabled flag to the feature-flags enumeration. * feat(dep-update): config flags Default-off dep-update config (mirrors self_heal_*/ci_watch_*): dep_update_enabled, dep_update_interval_seconds (604800 = weekly), dep_update_max_open_tasks (3), dep_update_max_per_cycle (1). Registers dep_update_enabled in FEATURE_FLAGS. 4 tests. * feat(dep-update): per-project dep_update_command/paths (migration 049) Adds projects.dep_update_command (varchar null) + dep_update_paths (varchar[] null) — the per-project opt-in for the dependency-update bot. ProjectTable + Pydantic Project fields + migration 049 (off 048_ci_watch_project_cols). Real upgrade->downgrade->upgrade chain verified on a throwaway Postgres; 2 ORM tests. * feat(dep-update): source tag + open-task dedupe query DEP_UPDATE_SOURCE='dep_update' + TaskService.list_open_dep_update_tasks(git_url=None): non-terminal dep_update tasks (dedupe + open-cap basis), optionally scoped to one repo by git_url (monorepo → one open dependency-update task per repo). 2 real-PG tests. * feat(dep-update): read-only lockfile-diff probe WorkspaceService.dry_upgrade_changes_lockfile(project): clones the project's read clone into a throwaway dir (--no-hardlinks, so the read clone is never mutated), runs project.dep_update_command (no shell, shlex.split), and reports whether any lockfile path (dep_update_paths or inferred uv.lock/pnpm-lock.yaml) is dirty. Fail-safe: null/failing command → False (don't originate on a broken probe), logged; throwaway always removed; never commits/pushes. 5 real-git tests. * feat(dep-update): engine — detect, originate, dedupe, cap DepUpdateEngine.run_cycle(projects) mirrors SelfHealEngine/CiWatchEngine: for each opted-in project (dep_update_command set) with updates available (the read-only probe), open one PENDING dep_update task (team=main_pm, assigned-to main-pm, confirmed_by_human=True), never starts/approves/merges. Cheap checks (command, per-git_url dedupe) before the expensive probe; per-cycle + rolling caps. Default-off; disabled → no-op. 6 real-PG tests. * feat(dep-update): weekly orchestrator loop tick _dep_update_loop (registered in start(), cancelled in stop(), separate from the self-heal + CI-watch loops): dormant unless dep_update_enabled; each interval (default weekly) loads projects with a dep_update_command (one-per-repo) and runs DepUpdateEngine.run_cycle, committing opened tasks. _run_dep_update_cycle extracted for testing; loud warning when enabled-but-no-commands. Refactored stop() to cancel background tasks via a shared _cancel_background_task loop (keeps it under xenon B as the loop count grows). 4 loop tests. Task 7 (anti-stranding dispatch guard) is satisfied by construction: no dispatcher skip targets source='dep_update', and the engine sets confirmed_by_human=True (thefe029fe3lesson), asserted in the engine tests — so the originated task dispatches via the assigned-PM path, never stranded. * docs(dep-update): CHANGELOG + CLAUDE.md for the dependency-update bot Document the dep-update bot (Added) in the CHANGELOG and the Self-Healing & Feature Flags section of CLAUDE.md — read-only lockfile-diff probe, never auto-merges, per-project opt-in via dep_update_command, default-off. Adds the dep_update_enabled flag to the feature-flags enumeration. * feat(ci-watch): route fix-task notification to the project's cell PM On opening a fix task, CiWatchEngine notifies the red project's own cell PM (resolved from project.assigned_cell via foundation AGENTS — e.g. BACKEND → be-pm), not the CEO, once per project per cycle. Best-effort: a notification failure never rolls back the origination. Adds _cell_pm_slug_for + _notify_cell_pm. 1 real-PG test (asserts to_agent='be-pm', not 'ceo'). * feat(ci-watch,dep-update): expose per-project opt-ins in the project API Add ci_watch_enabled/ci_watch_workflow + dep_update_command/dep_update_paths to ProjectUpdate, ProjectUpdateRequest, the PATCH route mapping, ProjectResponse, and project_to_response — so the panel edit-project dialog can read + set the per-project autonomy opt-ins (the columns were unreachable through the API before). Also threads the previously-dropped quality_command through the update route. 1 real-PG update round-trip test. * feat(ci-watch,dep-update): panel project-edit fields for the per-project opt-ins Adds an 'Autonomous Maintenance' section to the edit-project dialog: a CI-watch enable switch + workflow input, and a dependency-update command + lockfile-paths input (comma-separated → list). Threads the four fields through the Project / ProjectUpdate TS types and the mock-mode create fixture. The global on/off toggles already live in Settings → Feature Flags; these are the per-project opt-ins. panel tsc --noEmit + eslint green. * docs(0.12): CI-watch + dep-update bot + image-prune across user docs + RAG New docs/optional/autonomous-maintenance.md (mirrors self-heal.md) covering both engines; optional/index rows; panel settings + projects-and-products notes for the Feature Flags toggles + the edit-project Autonomous Maintenance fields; resilience note for the dangling-image prune; env-reference + RAG config-reference tables for all ROBOCO_CI_WATCH_* / ROBOCO_DEP_UPDATE_* / ROBOCO_IMAGE_PRUNE_* vars; mkdocs nav entry. reflow-check green; prompts unchanged (operator-facing, not agent-facing). * chore(release): 0.12.0 Cut [Unreleased] -> [0.12.0] (CI-watch + dep-update bot + image-prune housekeeping + the post-0.11.1 run-hardening fixes). Bumps all 8 canonical version refs to 0.12.0 (pyproject / uv.lock roboco pkg / panel package.json / __init__ / config.app_version + the README / deployment / agent-image-tag examples). * fix(pr-review): repo-scope external-PR dedupe (no duplicate review on a monorepo) external_review_task_exists keyed on (project_id, pr, head_sha), but a monorepo registers several cell-projects on one git_url and the poll already collapses to one canonical project per repo — so once a review task was re-pointed to a sibling project, the next poll (checking the canonical project) no longer saw it and opened a second review of the same PR (observed: PR #131 reviewed once on guard-core-saas-frontend, once on -backend). Dedupe now spans every project sharing the PR's repo (git_url); re-review on a new head SHA still works; a genuinely different repo with the same PR number is independent. 3 real-PG tests. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""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"
|
||||
"Investigate and fix the regression at its root so CI returns "
|
||||
"to green. This task was opened automatically by the CI-watch "
|
||||
"loop and is READY TO START NOW — no approval needed; pick it "
|
||||
"up and coordinate the fix. It still ships through the normal "
|
||||
"gates (QA, PR review, and the CEO's merge)."
|
||||
),
|
||||
acceptance_criteria=[
|
||||
f"CI on {slug}'s default branch is green again",
|
||||
"The cause of the failing run is fixed at its root, not "
|
||||
"masked or skipped",
|
||||
],
|
||||
team=Team.MAIN_PM,
|
||||
assigned_to=_foundation.AGENTS["main-pm"].uuid,
|
||||
created_by=_foundation.AGENTS["system"].uuid,
|
||||
task_type=TaskType.CODE,
|
||||
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)
|
||||
Reference in New Issue
Block a user