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 — the fe029fe3 lesson), 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, the fe029fe3 lesson).
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 (the fe029fe3 lesson), 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:
Renzo F
2026-06-25 21:11:36 +02:00
committed by GitHub
co-authored by Renn F
parent 2c403c77a2
commit 153723406e
49 changed files with 2828 additions and 57 deletions
+12
View File
@@ -6,8 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
## [0.12.0] - 2026-06-25
### Added
- **Dependency-update bot — the company keeps its own dependencies current.** A default-off, per-project engine that periodically (weekly by default) checks whether a dependency upgrade would change a project's lockfiles and, if so, opens one "update dependencies" task into that project — which flows through the normal dev → QA → PR-review → CEO-merge pipeline and **never auto-merges**. Detection is read-only: it runs the project's configured `dep_update_command` (e.g. `uv lock --upgrade` / `pnpm update`) in a throwaway clone of a read-only copy and checks whether any lockfile path got dirty — the read clone is never mutated and nothing is committed or pushed. Fail-safe: a missing or failing command opens nothing. Bounded and deduped per repo (one open update task per git URL) with per-cycle and rolling caps. A project participates only when its `dep_update_command` is set (panel → project settings). Default-off (`ROBOCO_DEP_UPDATE_ENABLED`). Adds `projects.dep_update_command` / `dep_update_paths` (migration 049), the `WorkspaceService.dry_upgrade_changes_lockfile` probe, `DepUpdateEngine`, and a dedicated orchestrator loop.
- **Multi-repo CI-watch — the company watches every repo it owns, not just its own.** Self-heal already watched RoboCo's own CI and opened a fix task when it went red; CI-watch generalizes that to *any* project the operator opts in. Flip `ci_watch_enabled` on a project (panel → project settings) and, on each pass, RoboCo checks that project's latest CI conclusion on its default branch; if it's red it opens one fix task into that project (and notifies that project's cell PM) — which flows through the normal dev → QA → PR-review → CEO-merge pipeline and **never auto-merges**. It reuses the same hardened per-project CI lookup self-heal uses, so a missing signal is treated as "unknown" (never a false green) and one project's GitHub error never aborts the sweep. Bounded and deduped per repo (a monorepo's several cell-projects share one fix task, keyed on the git URL), with per-cycle and rolling open-task caps. Default-off (`ROBOCO_CI_WATCH_ENABLED`), and the single-repo self-heal loop is untouched. Adds `projects.ci_watch_enabled` / `ci_watch_workflow` (migration 048) and the `MultiProjectCITelemetrySource` + `CiWatchEngine` + a dedicated orchestrator loop.
- **The orchestrator now reclaims dangling Docker images on its own.** Every rebuild of an agent image orphans the prior build's layers as an untagged `<none>` image; across many deploys these pile up (the operator hit ~80). The background sweeper now runs `docker image prune` for dangling images only — throttled to roughly every six hours — so they don't accumulate. It is deliberately conservative: only *dangling* images are removed (a tagged image, or one backing a running container, is never dangling), it is best-effort (a failure is logged, never raised), and it can be turned off with `ROBOCO_IMAGE_PRUNE_ENABLED=false`.
### Fixed ### Fixed
- **An external PR on a monorepo is no longer reviewed twice.** Inbound external/internal PR review de-duplicated per `(project_id, pr, head_sha)`, but several cell-projects can map to one repo (a monorepo) — and the poll already collapses to a single canonical project per repo, so the dedupe and the poll disagreed 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. The dedupe is now scoped to the **repo** (`git_url`) rather than a single project, so the same PR on any project sharing the repo is reviewed once; re-review on a new head commit still works, and genuinely different repos that happen to share a PR number are reviewed independently.
- **Completing a task whose PR is already merged no longer loops.** A merge request against an already-merged PR returns the same `405` from GitHub as a genuine "not mergeable" conflict, so the completion path treated an already-landed PR as a conflict and tried to rebase / close-superseded / escalate it — bouncing the task between blocked and unblocked forever (the case where a prior cycle, a sibling, or the CEO had already merged it). The merge now disambiguates: if the PR reports as merged, the merge is treated as idempotent success and completion proceeds; only a PR that is genuinely unmerged raises the conflict. - **Completing a task whose PR is already merged no longer loops.** A merge request against an already-merged PR returns the same `405` from GitHub as a genuine "not mergeable" conflict, so the completion path treated an already-landed PR as a conflict and tried to rebase / close-superseded / escalate it — bouncing the task between blocked and unblocked forever (the case where a prior cycle, a sibling, or the CEO had already merged it). The merge now disambiguates: if the PR reports as merged, the merge is treated as idempotent success and completion proceeds; only a PR that is genuinely unmerged raises the conflict.
- **After an orchestrator restart, a still-running agent is no longer double-spawned.** The orchestrator's in-memory instance registry is lost on a restart while the agent containers keep running. The stale-claim reaper already had a Docker-liveness fallback for that, but the spawn gate (`_is_agent_active`) did not — so right after a restart it saw a live agent as inactive and could launch a second container onto the work the forgotten-but-running one was already doing. Startup now re-adopts surviving containers: it probes each known agent slug's container (the same `docker inspect` the reaper uses) and re-registers a minimal active instance for any that is running, before the dispatcher and reaper loops start. Inert when nothing is running, and best-effort (a probe error just leaves that slot for the reaper's own fallback to cover). - **After an orchestrator restart, a still-running agent is no longer double-spawned.** The orchestrator's in-memory instance registry is lost on a restart while the agent containers keep running. The stale-claim reaper already had a Docker-liveness fallback for that, but the spawn gate (`_is_agent_active`) did not — so right after a restart it saw a live agent as inactive and could launch a second container onto the work the forgotten-but-running one was already doing. Startup now re-adopts surviving containers: it probes each known agent slug's container (the same `docker inspect` the reaper uses) and re-registers a minimal active instance for any that is running, before the dispatcher and reaper loops start. Inert when nothing is running, and best-effort (a probe error just leaves that slot for the reaper's own fallback to cover).
+5 -1
View File
@@ -383,7 +383,11 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Self-healing CI loop (default-off).** RoboCo can watch its own repository's CI (a single named workflow) and, on a detected regression, open a fix task that is held out of dispatch until the CEO approves it (it terminates at `awaiting_ceo_approval`), then dispatch it through the normal delivery flow. It is dormant by default and armed by `ROBOCO_SELF_HEAL_ENABLED` plus a second opt-in `ROBOCO_SELF_HEAL_ORIGINATE_ENABLED`; origination is bounded by `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` so it can't flood the backlog. It never auto-merges or self-deploys (`roboco/services/self_heal_engine.py`). **Self-healing CI loop (default-off).** RoboCo can watch its own repository's CI (a single named workflow) and, on a detected regression, open a fix task that is held out of dispatch until the CEO approves it (it terminates at `awaiting_ceo_approval`), then dispatch it through the normal delivery flow. It is dormant by default and armed by `ROBOCO_SELF_HEAL_ENABLED` plus a second opt-in `ROBOCO_SELF_HEAL_ORIGINATE_ENABLED`; origination is bounded by `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` so it can't flood the backlog. It never auto-merges or self-deploys (`roboco/services/self_heal_engine.py`).
**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), gateway-health recovery (`ROBOCO_GATEWAY_HEALTH_ENABLED`), and the self-heal flags above. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default. **Multi-repo CI-watch (default-off).** The fan-out generalization of self-heal: instead of RoboCo's single own repo, it watches every project the operator opts into (`projects.ci_watch_enabled`, migration 048) and, on a red CI conclusion on that project's default branch, opens one fix task into that project's lifecycle that rides the normal delivery flow (+ PR-review gate) and never auto-merges. It reuses the exact hardened per-project `GitService.get_latest_ci_conclusion` (a missing signal is "unknown", never a false green; per-project errors are isolated and never abort the sweep), and is bounded + deduped per repo by `git_url` (a monorepo's cell-projects share one fix task) with per-cycle / rolling caps. Armed by `ROBOCO_CI_WATCH_ENABLED` (+ `_INTERVAL_SECONDS` / `_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` / `_DEFAULT_WORKFLOW`) and per-project `ci_watch_enabled` / `ci_watch_workflow`; `MultiProjectCITelemetrySource` (`roboco/services/telemetry/source.py`) + `CiWatchEngine` (`roboco/services/ci_watch_engine.py`) + a dedicated orchestrator `_ci_watch_loop`. The single-repo self-heal loop is untouched.
**Dependency-update bot (default-off).** A per-project engine mirroring the self-heal/CI-watch shape: weekly (default) it probes whether a dependency upgrade would change a project's lockfiles and, if so, opens one "update dependencies" task that rides the normal delivery flow (+ PR-review gate) and never auto-merges. Detection is read-only — `WorkspaceService.dry_upgrade_changes_lockfile` runs the project's `dep_update_command` (e.g. `uv lock --upgrade`) in a throwaway clone of the read clone and diffs the lockfile paths (`dep_update_paths`, or inferred `uv.lock`/`pnpm-lock.yaml`); the read clone is never mutated, nothing is committed/pushed, and a null/failing command originates nothing (fail-safe). A project participates only when `projects.dep_update_command` is set (migration 049); bounded + deduped per `git_url` with per-cycle/rolling caps. Armed by `ROBOCO_DEP_UPDATE_ENABLED` (+ `_INTERVAL_SECONDS` default 604800 / `_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE`); `DepUpdateEngine` (`roboco/services/dep_update_engine.py`) + a dedicated `_dep_update_loop`.
**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), gateway-health recovery (`ROBOCO_GATEWAY_HEALTH_ENABLED`), multi-repo CI-watch (`ROBOCO_CI_WATCH_ENABLED`), the dependency-update bot (`ROBOCO_DEP_UPDATE_ENABLED`), and the self-heal flags above. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default.
## Architectural Conventions Standard ## Architectural Conventions Standard
+1 -1
View File
@@ -129,7 +129,7 @@ Choose the registry and version with two env vars (defaults shown):
```bash ```bash
ROBOCO_REGISTRY=ghcr.io/rennf93 # or docker.io/renzof93 ROBOCO_REGISTRY=ghcr.io/rennf93 # or docker.io/renzof93
ROBOCO_VERSION=latest # or a pinned release, e.g. 0.11.1 ROBOCO_VERSION=latest # or a pinned release, e.g. 0.12.0
``` ```
The orchestrator spawns the matching pre-built agent images on demand — no build toolchain or source compile on your host. The orchestrator spawns the matching pre-built agent images on demand — no build toolchain or source compile on your host.
@@ -0,0 +1,46 @@
"""Per-project multi-repo CI-watch opt-in columns.
Multi-repo CI-watch generalizes the single-repo self-heal CI loop to any
project the operator opts in. A project is watched only when
``ci_watch_enabled`` is set; ``ci_watch_workflow`` scopes the CI signal to one
workflow file (null → the engine's configured default). Both are additive and
default-off, so existing projects keep today's behavior (unwatched).
Revision ID: 048_ci_watch_project_cols
Revises: 047_ws_single_active
Create Date: 2026-06-25
NOTE: revision id is 25 chars — alembic's ``alembic_version.version_num`` is
``VARCHAR(32)`` and a longer id raises at record time.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "048_ci_watch_project_cols"
down_revision = "047_ws_single_active"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column(
"ci_watch_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"projects",
sa.Column("ci_watch_workflow", sa.String(length=255), nullable=True),
)
def downgrade() -> None:
op.drop_column("projects", "ci_watch_workflow")
op.drop_column("projects", "ci_watch_enabled")
@@ -0,0 +1,41 @@
"""Per-project dependency-update bot opt-in columns.
The dependency-update bot participates for a project only when
``dep_update_command`` is set (e.g. ``uv lock --upgrade`` / ``pnpm update``);
``dep_update_paths`` are the lockfile globs the probe inspects (null → infer
``uv.lock`` / ``pnpm-lock.yaml``). Both are additive and nullable, so existing
projects keep today's behavior (not participating).
Revision ID: 049_dep_update_project_cols
Revises: 048_ci_watch_project_cols
Create Date: 2026-06-25
NOTE: revision id is 27 chars — alembic's ``alembic_version.version_num`` is
``VARCHAR(32)`` and a longer id raises at record time.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "049_dep_update_project_cols"
down_revision = "048_ci_watch_project_cols"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("dep_update_command", sa.String(length=500), nullable=True),
)
op.add_column(
"projects",
sa.Column("dep_update_paths", sa.ARRAY(sa.String()), nullable=True),
)
def downgrade() -> None:
op.drop_column("projects", "dep_update_paths")
op.drop_column("projects", "dep_update_command")
+1 -1
View File
@@ -30,7 +30,7 @@ Two variables choose what you pull (defaults shown):
```bash ```bash
ROBOCO_REGISTRY=ghcr.io/rennf93 # or docker.io/renzof93 ROBOCO_REGISTRY=ghcr.io/rennf93 # or docker.io/renzof93
ROBOCO_VERSION=latest # or a pinned release, e.g. 0.11.1 ROBOCO_VERSION=latest # or a pinned release, e.g. 0.12.0
``` ```
The orchestrator then spawns the **matching** pre-built agent images on demand (it reads `ROBOCO_AGENT_IMAGE_REGISTRY` / `ROBOCO_AGENT_IMAGE_TAG`, which the registry compose wires to the same registry and version). Pin `ROBOCO_VERSION` to a release tag in production so an upstream `latest` push can't silently change your fleet. The orchestrator then spawns the **matching** pre-built agent images on demand (it reads `ROBOCO_AGENT_IMAGE_REGISTRY` / `ROBOCO_AGENT_IMAGE_TAG`, which the registry compose wires to the same registry and version). Pin `ROBOCO_VERSION` to a release tag in production so an upstream `latest` push can't silently change your fleet.
+24
View File
@@ -212,6 +212,7 @@ These gate the env-toggled capabilities. Each is inert when off. See [Optional c
| `ROBOCO_OVERLOAD_BREAK_ENABLED` | `true` | Park a provider on a persistent overload (HTTP 529/500/503) the way a 429 is parked, instead of crash-retrying. | | `ROBOCO_OVERLOAD_BREAK_ENABLED` | `true` | Park a provider on a persistent overload (HTTP 529/500/503) the way a 429 is parked, instead of crash-retrying. |
| `ROBOCO_GATEWAY_HEALTH_ENABLED` | `true` | Probe a stale-heartbeat-but-live agent's gateway and kill + respawn it when the gateway is broken (a corrupted `/app` venv firing no verb), instead of the reaper protecting it forever. Off => spare live containers on verb-heartbeat liveness alone. | | `ROBOCO_GATEWAY_HEALTH_ENABLED` | `true` | Probe a stale-heartbeat-but-live agent's gateway and kill + respawn it when the gateway is broken (a corrupted `/app` venv firing no verb), instead of the reaper protecting it forever. Off => spare live containers on verb-heartbeat liveness alone. |
| `ROBOCO_GATEWAY_HEALTH_GRACE_SECONDS` | `180` | How long an agent gateway may probe as broken before recovery — tolerates a transient probe miss. | | `ROBOCO_GATEWAY_HEALTH_GRACE_SECONDS` | `180` | How long an agent gateway may probe as broken before recovery — tolerates a transient probe miss. |
| `ROBOCO_IMAGE_PRUNE_ENABLED` | `true` | Background sweep prunes dangling (`<none>`) Docker images left by agent-image rebuilds, throttled ~6h. Only dangling images are removed — a tagged image or one backing a running container is never touched. Not a feature flag; disable to manage image cleanup yourself. |
### Strategy engine — default **off** ### Strategy engine — default **off**
@@ -243,6 +244,29 @@ These gate the env-toggled capabilities. Each is inert when off. See [Optional c
| `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open self-heal tasks. | | `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open self-heal tasks. |
| `ROBOCO_SELF_HEAL_MAX_PER_CYCLE` | `1` | Max self-heal tasks originated in one cycle. | | `ROBOCO_SELF_HEAL_MAX_PER_CYCLE` | `1` | Max self-heal tasks originated in one cycle. |
### Multi-repo CI-watch — default **off**
The global switch arms the engine; each project opts in via `ci_watch_enabled` (+ optional `ci_watch_workflow`) in the edit-project dialog.
| Variable | Default | Purpose |
|----------|---------|---------|
| `ROBOCO_CI_WATCH_ENABLED` | `false` | Master switch for watching opted-in projects' CI. When off the engine never runs and no CI telemetry is fetched. |
| `ROBOCO_CI_WATCH_DEFAULT_WORKFLOW` | `ci.yml` | Workflow file to scope the CI signal to when a project sets no `ci_watch_workflow` of its own. |
| `ROBOCO_CI_WATCH_INTERVAL_SECONDS` | `1800` | Seconds between CI-watch passes. |
| `ROBOCO_CI_WATCH_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open CI-watch fix tasks per repo. |
| `ROBOCO_CI_WATCH_MAX_PER_CYCLE` | `1` | Max CI-watch fix tasks opened in one cycle. |
### Dependency-update bot — default **off**
The global switch arms the engine; each project opts in via `dep_update_command` (+ optional `dep_update_paths`) in the edit-project dialog. Detection is read-only — the command runs in a throwaway clone and only the lockfiles are diffed; the real repo is never mutated.
| Variable | Default | Purpose |
|----------|---------|---------|
| `ROBOCO_DEP_UPDATE_ENABLED` | `false` | Master switch for the dependency-update bot. When off nothing runs and no throwaway clone is made. |
| `ROBOCO_DEP_UPDATE_INTERVAL_SECONDS` | `604800` | Seconds between dependency-update passes (default weekly). |
| `ROBOCO_DEP_UPDATE_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open update-dependencies tasks per repo. |
| `ROBOCO_DEP_UPDATE_MAX_PER_CYCLE` | `1` | Max update-dependencies tasks opened in one cycle. |
## Next ## Next
- **[Production deploy](./deployment.md)** — compose files, host mounts, secure mode, startup. - **[Production deploy](./deployment.md)** — compose files, host mounts, secure mode, startup.
+4
View File
@@ -38,6 +38,10 @@ The crucial property: **work is queued, never dropped.** Parked tasks wait; the
!!! tip "Parked is not stuck" !!! tip "Parked is not stuck"
If a run goes quiet, check the banner before assuming something broke. A parked provider with a counting-down timer is RoboCo waiting out a rate limit on purpose. The work is held and will resume — there's nothing for you to do. If a run goes quiet, check the banner before assuming something broke. A parked provider with a counting-down timer is RoboCo waiting out a rate limit on purpose. The work is held and will resume — there's nothing for you to do.
## Disk housekeeping: dangling-image prune
Every agent-image rebuild leaves the previous build behind as a dangling (`<none>`) Docker image. Left alone they pile up and eat disk. The orchestrator's background sweep prunes them on a throttle (~6h): it removes **only** dangling images — a tagged image, or one still backing a running container, is never touched. It is gated by `ROBOCO_IMAGE_PRUNE_ENABLED`, which is **on by default**. This isn't a feature flag you opt into; it's an always-on safety net you can disable if you'd rather manage image cleanup yourself.
## Next ## Next
- These guardrails are part of the broader [agent gateway](../company/agent-gateway.md) — agents are structurally constrained, not trusted to behave. - These guardrails are part of the broader [agent gateway](../company/agent-gateway.md) — agents are structurally constrained, not trusted to behave.
+118
View File
@@ -0,0 +1,118 @@
# Autonomous maintenance
RoboCo can keep your projects healthy on their own schedule: it can watch each opted-in project's CI and open a fix task when it goes red, and it can periodically check whether a dependency upgrade would change a project's lockfiles and open an "update dependencies" task when it would. Both are per-project, both are **off by default**, and **neither ever auto-merges** — every task they open rides the normal `dev → QA → PR review → CEO merge` pipeline, exactly like any other task.
These two engines generalize the [self-healing CI loop](self-heal.md), which watches only RoboCo's own repository. Multi-repo CI-watch extends the same idea to *any* project you opt in.
## Multi-repo CI-watch
CI-watch assesses each opted-in project's CI on its default branch. On a red conclusion it opens **one** fix task into that project and notifies that project's cell PM. It never starts that task, never merges it, and never deploys.
### What it does
On each pass (`ROBOCO_CI_WATCH_INTERVAL_SECONDS`, default 1800s) the engine checks each opted-in project's latest CI conclusion on its default branch. On a red conclusion it opens one fix task into that project and notifies the project's cell PM. The pass is resilient by construction:
- A **missing CI signal** is treated as "unknown", never a false green — an absent run never masks a real failure.
- One repo's **GitHub error never aborts the sweep** — the engine moves on to the next project.
- Origination is **bounded and deduped per repo**: a monorepo's cell-projects share one fix task, and the caps below stop a flapping CI from flooding the backlog.
```mermaid
flowchart TD
A[Interval tick] --> B{For each opted-in project}
B --> C{CI conclusion on default branch}
C -->|green| B
C -->|unknown / missing| B
C -->|red| D[Open ONE fix task · notify the cell PM]
D -.never auto.-> E[never merges · never deploys]
```
!!! danger "It never merges or deploys"
A CI-watch fix task is an ordinary task. It flows through the normal delivery lifecycle — QA, the in-path PR-review gate, and your merge — exactly like work you create yourself. The engine never approves, merges, or deploys on its own.
### Bounds on origination
| Setting | Default | Meaning |
|---------|---------|---------|
| `ROBOCO_CI_WATCH_MAX_PER_CYCLE` | `1` | Most fix tasks the sweep may open in one cycle. |
| `ROBOCO_CI_WATCH_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open CI-watch fix tasks per repo; the engine originates nothing more while this many are still open. |
### Enable it
=== "Panel"
**Settings → Feature Flags** carries the global **"Multi-repo CI-watch"** toggle. Then opt each project in from the **edit-project dialog → "Autonomous Maintenance" section**: turn on `ci_watch_enabled` and optionally set `ci_watch_workflow` (the workflow file to scope the CI signal to, default `ci.yml`).
!!! note "Takes effect on the next backend restart"
The feature flag persists in the settings store and applies on the **next backend restart**. The per-project fields apply on the next sweep.
=== "Environment"
```bash
ROBOCO_CI_WATCH_ENABLED=true # global switch
# ROBOCO_CI_WATCH_INTERVAL_SECONDS=1800 # default
# ROBOCO_CI_WATCH_MAX_OPEN_TASKS=3 # default
# ROBOCO_CI_WATCH_MAX_PER_CYCLE=1 # default
```
The per-project opt-in (`ci_watch_enabled`, `ci_watch_workflow`) lives on the project, not in env — set it in the edit-project dialog.
## Dependency-update bot
The dependency-update bot periodically checks whether a dependency upgrade would change a project's lockfiles and, if so, opens **one** "update dependencies" task into that project. Detection is read-only: nothing in the real repo is ever mutated.
### What it does
On each pass (`ROBOCO_DEP_UPDATE_INTERVAL_SECONDS`, default 604800s — weekly) the bot runs the project's `dep_update_command` (e.g. `uv lock --upgrade` / `pnpm update`) in a **throwaway clone** and diffs the lockfiles. If the lockfiles would change it opens one "update dependencies" task; otherwise it opens nothing.
- Detection is **read-only**: the command runs in a throwaway clone and only the lockfiles are diffed. The real repo is never mutated — nothing is committed or pushed.
- It is **fail-safe**: a null `dep_update_command` or a command that fails opens nothing.
- Origination is **bounded and deduped per repo**, with the caps below.
```mermaid
flowchart TD
A[Interval tick] --> B{For each opted-in project}
B --> C[Run dep_update_command in a throwaway clone]
C --> D{lockfiles change?}
D -->|no / null / fails| B
D -->|yes| E[Open ONE update-dependencies task]
E -.never auto.-> F[never merges · never deploys]
```
!!! danger "It never merges or deploys"
The update-dependencies task is an ordinary task — QA, the in-path PR-review gate, and your merge all apply. The bot only ever *detects* and *opens*; it never commits, pushes, merges, or deploys.
### Bounds on origination
| Setting | Default | Meaning |
|---------|---------|---------|
| `ROBOCO_DEP_UPDATE_MAX_PER_CYCLE` | `1` | Most update-dependencies tasks the bot may open in one cycle. |
| `ROBOCO_DEP_UPDATE_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open update-dependencies tasks per repo. |
### Enable it
=== "Panel"
**Settings → Feature Flags** carries the global **"Dependency-update bot"** toggle. Then opt each project in from the **edit-project dialog → "Autonomous Maintenance" section**: set `dep_update_command` (e.g. `uv lock --upgrade` / `pnpm update`) and optionally `dep_update_paths` (comma-separated lockfile paths to watch; defaults to inferring `uv.lock` / `pnpm-lock.yaml`).
!!! note "Takes effect on the next backend restart"
The feature flag persists in the settings store and applies on the **next backend restart**. The per-project fields apply on the next sweep.
=== "Environment"
```bash
ROBOCO_DEP_UPDATE_ENABLED=true # global switch
# ROBOCO_DEP_UPDATE_INTERVAL_SECONDS=604800 # default (weekly)
# ROBOCO_DEP_UPDATE_MAX_OPEN_TASKS=3 # default
# ROBOCO_DEP_UPDATE_MAX_PER_CYCLE=1 # default
```
The per-project opt-in (`dep_update_command`, `dep_update_paths`) lives on the project, not in env — set it in the edit-project dialog.
## What changes when each is on
- With CI-watch on, a background sweep polls each opted-in project's CI on the configured interval; on a red conclusion a fix task appears in that project's backlog (bounded by the caps above) and its cell PM is notified. With the global flag off, nothing polls.
- With the dependency-update bot on, a background sweep checks each opted-in project's lockfiles on the configured interval; when an upgrade would change them, an update-dependencies task appears. With the global flag off, nothing runs and no throwaway clone is made.
## Next
→ [Self-healing CI](self-heal.md) for the single-repo loop these generalize · [Task lifecycle](../company/task-lifecycle.md) for what an opened task does once you start it · [Environment reference](../deploy/env-reference.md) for the full env list · back to [Optional subsystems](index.md).
+3 -1
View File
@@ -20,6 +20,8 @@ You toggle these from **Settings → Feature Flags** in the panel rather than ha
| [Pitch provisioning](pitch-provisioning.md) | `ROBOCO_PROVISIONING_TOKEN` (+ org) | inert until set | On pitch approval, auto-creates repos and seeds a build task. | | [Pitch provisioning](pitch-provisioning.md) | `ROBOCO_PROVISIONING_TOKEN` (+ org) | inert until set | On pitch approval, auto-creates repos and seeds a build task. |
| [External / internal PR review](pr-review.md) | `ROBOCO_EXTERNAL_PR_ENABLED` / `ROBOCO_INTERNAL_PR_ENABLED` | off | Reviews inbound external/fork PRs and untied org-repo PRs. | | [External / internal PR review](pr-review.md) | `ROBOCO_EXTERNAL_PR_ENABLED` / `ROBOCO_INTERNAL_PR_ENABLED` | off | Reviews inbound external/fork PRs and untied org-repo PRs. |
| [Self-healing CI](self-heal.md) | `ROBOCO_SELF_HEAL_ENABLED` (+ originate) | off | Watches RoboCo's own CI and, optionally, queues a CEO-gated fix task. | | [Self-healing CI](self-heal.md) | `ROBOCO_SELF_HEAL_ENABLED` (+ originate) | off | Watches RoboCo's own CI and, optionally, queues a CEO-gated fix task. |
| [Multi-repo CI-watch](autonomous-maintenance.md) | `ROBOCO_CI_WATCH_ENABLED` (+ per-project) | off | Watches each opted-in project's CI and opens one fix task when it goes red; never auto-merges. |
| [Dependency-update bot](autonomous-maintenance.md) | `ROBOCO_DEP_UPDATE_ENABLED` (+ per-project) | off | Read-only checks whether an upgrade changes a project's lockfiles and opens an update task; never auto-merges. |
!!! note "Always-on resilience" !!! note "Always-on resilience"
Provider overload parking (`ROBOCO_OVERLOAD_BREAK_ENABLED`) is **on by default**it's not something you enable, it's a safety net. See [Resilience](../models/resilience.md). Provider overload parking (`ROBOCO_OVERLOAD_BREAK_ENABLED`) and the dangling-image prune (`ROBOCO_IMAGE_PRUNE_ENABLED`) are **on by default**they're not things you enable, they're safety nets you can disable. See [Resilience](../models/resilience.md).
+1 -1
View File
@@ -8,7 +8,7 @@ A project is one git repository plus the configuration that tells the company ho
- **New** opens the create dialog — name, slug, git URL, GitHub token, assigned cell, default branch, and optional per-project gate commands. - **New** opens the create dialog — name, slug, git URL, GitHub token, assigned cell, default branch, and optional per-project gate commands.
- The list supports **search**, a **cell filter**, and a **show-inactive** toggle so retired repos stay out of the way without being deleted. - The list supports **search**, a **cell filter**, and a **show-inactive** toggle so retired repos stay out of the way without being deleted.
- **Edit** reopens the same form to rotate the token, change the gate commands, or flip the assigned cell. The edit dialog also hosts the per-project **Conventions** tab — see [Architectural conventions](../optional/conventions.md). - **Edit** reopens the same form to rotate the token, change the gate commands, or flip the assigned cell. The edit dialog also hosts the per-project **Conventions** tab — see [Architectural conventions](../optional/conventions.md) — and an **Autonomous Maintenance** section to opt the project into CI-watch (with an optional workflow file) and the dependency-update bot (its command and optional lockfile paths) — see [Autonomous maintenance](../optional/autonomous-maintenance.md).
The field-by-field detail — what each field means, the token scopes you need, the encryption guarantee, and the default-branch gotcha — lives in [Register your first project](../get-started/first-project.md). Read that page before you create a repo; this page doesn't repeat it. The field-by-field detail — what each field means, the token scopes you need, the encryption guarantee, and the default-branch gotcha — lives in [Register your first project](../get-started/first-project.md). Read that page before you create a repo; this page doesn't repeat it.
+5
View File
@@ -14,6 +14,8 @@ The Feature Flags card is the operator's master switchboard for the optional, de
| Strategy engine | Generating and maintaining strategy artifacts (drives the Command Center's Strategy Signals). | | Strategy engine | Generating and maintaining strategy artifacts (drives the Command Center's Strategy Signals). |
| Self-healing | Watching RoboCo's own CI and notifying you on a regression. | | Self-healing | Watching RoboCo's own CI and notifying you on a regression. |
| Self-heal originate | Also opening a *pending* fix task for a regression — needs self-healing on, and the task waits for your approval. | | Self-heal originate | Also opening a *pending* fix task for a regression — needs self-healing on, and the task waits for your approval. |
| Multi-repo CI-watch | Watching each opted-in project's CI and opening one fix task when it goes red. |
| Dependency-update bot | Read-only checking whether an upgrade changes a project's lockfiles, and opening an update task when it would. |
| Pitch provisioning | Auto-provisioning projects from approved [pitches](./business.md). | | Pitch provisioning | Auto-provisioning projects from approved [pitches](./business.md). |
| Toolchain match | Provisioning each agent workspace with the target project's own Python and blocking gates when its tests can't run. | | Toolchain match | Provisioning each agent workspace with the target project's own Python and blocking gates when its tests can't run. |
| Conventions | Enforcing the per-project architectural standard (`.roboco/conventions.yml`). | | Conventions | Enforcing the per-project architectural standard (`.roboco/conventions.yml`). |
@@ -22,6 +24,9 @@ The Feature Flags card is the operator's master switchboard for the optional, de
Each subsystem has a full page in the [optional subsystems section](../optional/index.md) — what it does, the exact `ROBOCO_*` env var behind it, and what turning it on changes. Each subsystem has a full page in the [optional subsystems section](../optional/index.md) — what it does, the exact `ROBOCO_*` env var behind it, and what turning it on changes.
!!! note "The Multi-repo CI-watch and Dependency-update flags are global switches"
Both are off by default and need a per-project opt-in to do anything. The global flag here arms the engine; you then opt each project in from the **edit-project dialog → "Autonomous Maintenance" section**: turn on CI-watch and (optionally) name its workflow file (`ci_watch_workflow`, default `ci.yml`), and/or set the dependency-update command (`dep_update_command`, e.g. `uv lock --upgrade` / `pnpm update`) with optional comma-separated lockfile paths (`dep_update_paths`). See [Autonomous maintenance](../optional/autonomous-maintenance.md).
!!! warning "Flags take effect on the next backend restart" !!! warning "Flags take effect on the next backend restart"
Toggling a flag persists the choice server-side, but it does **not** hot-reload — the backend reads it at startup. The toast says as much: "takes effect on next restart." A flag you've never set falls back to its environment / config default. So: flip it here, then restart the orchestrator for it to take hold. Toggling a flag persists the choice server-side, but it does **not** hot-reload — the backend reads it at startup. The toast says as much: "takes effect on next restart." A flag you've never set falls back to its environment / config default. So: flip it here, then restart the orchestrator for it to take hold.
+18
View File
@@ -100,6 +100,24 @@ RoboCo watching its own repo's CI. All default-off / dormant.
| `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open self-heal tasks | | `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open self-heal tasks |
| `ROBOCO_SELF_HEAL_MAX_PER_CYCLE` | `1` | Max fix tasks originated per cycle | | `ROBOCO_SELF_HEAL_MAX_PER_CYCLE` | `1` | Max fix tasks originated per cycle |
## Autonomous maintenance
The fan-out generalizations of self-heal — they watch any opted-in project, not just RoboCo's own. All default-off; neither ever auto-merges (every task rides the normal delivery + PR-review gate). Per-project opt-in lives on the project row (set in the panel's edit-project dialog), not in env.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_CI_WATCH_ENABLED` | `false` | Master switch for multi-repo CI-watch; off = the loop never runs. Per-project opt-in via `projects.ci_watch_enabled` |
| `ROBOCO_CI_WATCH_DEFAULT_WORKFLOW` | `ci.yml` | Workflow file the CI signal is scoped to when a project sets no `ci_watch_workflow` |
| `ROBOCO_CI_WATCH_INTERVAL_SECONDS` | `1800` | Seconds between CI-watch passes |
| `ROBOCO_CI_WATCH_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open ci_watch tasks |
| `ROBOCO_CI_WATCH_MAX_PER_CYCLE` | `1` | Max ci_watch fix tasks originated per cycle |
| `ROBOCO_DEP_UPDATE_ENABLED` | `false` | Master switch for the dependency-update bot; off = the loop never runs. Per-project opt-in via `projects.dep_update_command` |
| `ROBOCO_DEP_UPDATE_INTERVAL_SECONDS` | `604800` | Seconds between dependency-update passes (default weekly) |
| `ROBOCO_DEP_UPDATE_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open dep_update tasks |
| `ROBOCO_DEP_UPDATE_MAX_PER_CYCLE` | `1` | Max dep_update tasks originated per cycle |
| `ROBOCO_IMAGE_PRUNE_ENABLED` | `true` | Background sweep prunes dangling (`<none>`) Docker images from agent-image rebuilds (only dangling; ~6h throttle). Always-on safety net, not a feature flag |
| `ROBOCO_IMAGE_PRUNE_INTERVAL_SECONDS` | `21600` | Minimum seconds between dangling-image prune passes |
## Security ## Security
| Variable | Default | Description | | Variable | Default | Description |
+1
View File
@@ -166,6 +166,7 @@ nav:
- Pitch provisioning: optional/pitch-provisioning.md - Pitch provisioning: optional/pitch-provisioning.md
- External / internal PR review: optional/pr-review.md - External / internal PR review: optional/pr-review.md
- Self-healing CI: optional/self-heal.md - Self-healing CI: optional/self-heal.md
- Autonomous maintenance: optional/autonomous-maintenance.md
- Configure & Deploy: - Configure & Deploy:
- deploy/index.md - deploy/index.md
- Deployment: deploy/deployment.md - Deployment: deploy/deployment.md
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "roboco-panel", "name": "roboco-panel",
"version": "0.11.1", "version": "0.12.0",
"private": true, "private": true,
"packageManager": "pnpm@10.25.0", "packageManager": "pnpm@10.25.0",
"scripts": { "scripts": {
@@ -70,12 +70,23 @@ function EditProjectForm({
const [qualityCommand, setQualityCommand] = useState( const [qualityCommand, setQualityCommand] = useState(
project.quality_command || "", project.quality_command || "",
); );
const [ciWatchEnabled, setCiWatchEnabled] = useState(project.ci_watch_enabled);
const [ciWatchWorkflow, setCiWatchWorkflow] = useState(
project.ci_watch_workflow || "",
);
const [depUpdateCommand, setDepUpdateCommand] = useState(
project.dep_update_command || "",
);
const [depUpdatePaths, setDepUpdatePaths] = useState(
(project.dep_update_paths || []).join(", "),
);
// Token handling // Token handling
const [newToken, setNewToken] = useState(""); const [newToken, setNewToken] = useState("");
const [clearToken, setClearToken] = useState(false); const [clearToken, setClearToken] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [showAutonomy, setShowAutonomy] = useState(false);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -98,6 +109,15 @@ function EditProjectForm({
typecheck_command: typecheckCommand || undefined, typecheck_command: typecheckCommand || undefined,
build_command: buildCommand || undefined, build_command: buildCommand || undefined,
quality_command: qualityCommand || undefined, quality_command: qualityCommand || undefined,
ci_watch_enabled: ciWatchEnabled,
ci_watch_workflow: ciWatchWorkflow || undefined,
dep_update_command: depUpdateCommand || undefined,
dep_update_paths: depUpdatePaths.trim()
? depUpdatePaths
.split(",")
.map((p) => p.trim())
.filter(Boolean)
: undefined,
}; };
// Handle token update // Handle token update
@@ -339,6 +359,77 @@ function EditProjectForm({
</div> </div>
</> </>
)} )}
{/* Autonomous Maintenance Toggle */}
<Button
type="button"
variant="ghost"
className="justify-start px-0 text-muted-foreground"
onClick={() => setShowAutonomy(!showAutonomy)}
>
{showAutonomy ? "Hide" : "Show"} Autonomous Maintenance
</Button>
{showAutonomy && (
<>
<div className="flex items-center justify-between">
<Label htmlFor="ci_watch_enabled">
CI-watch (open a fix task when CI goes red)
</Label>
<Switch
id="ci_watch_enabled"
checked={ciWatchEnabled}
onCheckedChange={setCiWatchEnabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ci_watch_workflow">CI-watch Workflow</Label>
<Input
id="ci_watch_workflow"
value={ciWatchWorkflow}
onChange={(e) => setCiWatchWorkflow(e.target.value)}
placeholder="ci.yml"
/>
<p className="text-xs text-muted-foreground">
Workflow file to scope the CI signal to. Leave blank to use the
engine default.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="dep_update_command">
Dependency-Update Command
</Label>
<Input
id="dep_update_command"
value={depUpdateCommand}
onChange={(e) => setDepUpdateCommand(e.target.value)}
placeholder="uv lock --upgrade"
/>
<p className="text-xs text-muted-foreground">
Set to opt this project into the weekly dependency-update bot;
leave blank to opt out.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="dep_update_paths">
Dependency-Update Lockfile Paths
</Label>
<Input
id="dep_update_paths"
value={depUpdatePaths}
onChange={(e) => setDepUpdatePaths(e.target.value)}
placeholder="uv.lock, pnpm-lock.yaml"
/>
<p className="text-xs text-muted-foreground">
Comma-separated lockfile paths to watch. Leave blank to infer
uv.lock / pnpm-lock.yaml.
</p>
</div>
</>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
+4
View File
@@ -84,6 +84,10 @@ export const projectsApi = {
typecheck_command: project.typecheck_command ?? null, typecheck_command: project.typecheck_command ?? null,
build_command: project.build_command ?? null, build_command: project.build_command ?? null,
quality_command: project.quality_command ?? null, quality_command: project.quality_command ?? null,
ci_watch_enabled: false,
ci_watch_workflow: null,
dep_update_command: null,
dep_update_paths: null,
workspace_path: null, workspace_path: null,
last_synced_at: null, last_synced_at: null,
head_commit: null, head_commit: null,
+10
View File
@@ -1128,6 +1128,11 @@ export interface Project {
typecheck_command: string | null; typecheck_command: string | null;
build_command: string | null; build_command: string | null;
quality_command: string | null; quality_command: string | null;
// Autonomous maintenance opt-in
ci_watch_enabled: boolean;
ci_watch_workflow: string | null;
dep_update_command: string | null;
dep_update_paths: string[] | null;
// Runtime state // Runtime state
workspace_path: string | null; workspace_path: string | null;
last_synced_at: string | null; last_synced_at: string | null;
@@ -1170,6 +1175,11 @@ export interface ProjectUpdate {
typecheck_command?: string; typecheck_command?: string;
build_command?: string; build_command?: string;
quality_command?: string; quality_command?: string;
// Autonomous maintenance opt-in
ci_watch_enabled?: boolean;
ci_watch_workflow?: string;
dep_update_command?: string;
dep_update_paths?: string[];
} }
export interface ProjectSummary { export interface ProjectSummary {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "roboco" name = "roboco"
version = "0.11.1" version = "0.12.0"
description = "AI Agents Company - A virtual organization of AI agents functioning as a software development workforce" description = "AI Agents Company - A virtual organization of AI agents functioning as a software development workforce"
authors = [ authors = [
{name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"} {name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"}
+1 -1
View File
@@ -5,7 +5,7 @@ A virtual organization of 25 AI agents + 1 human CEO,
designed to operate as a complete software development workforce. designed to operate as a complete software development workforce.
""" """
__version__ = "0.11.1" __version__ = "0.12.0"
# Core exports # Core exports
from roboco.config import settings from roboco.config import settings
+5
View File
@@ -213,6 +213,11 @@ async def update_project(
format_command=data.format_command, format_command=data.format_command,
typecheck_command=data.typecheck_command, typecheck_command=data.typecheck_command,
build_command=data.build_command, build_command=data.build_command,
quality_command=data.quality_command,
ci_watch_enabled=data.ci_watch_enabled,
ci_watch_workflow=data.ci_watch_workflow,
dep_update_command=data.dep_update_command,
dep_update_paths=data.dep_update_paths,
is_active=data.is_active, is_active=data.is_active,
) )
+16
View File
@@ -44,6 +44,12 @@ class ProjectResponse(BaseModel):
build_command: str | None = None build_command: str | None = None
quality_command: str | None = None quality_command: str | None = None
# Autonomous maintenance opt-in
ci_watch_enabled: bool = False
ci_watch_workflow: str | None = None
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
# Runtime state # Runtime state
workspace_path: str | None = None workspace_path: str | None = None
last_synced_at: datetime | None = None last_synced_at: datetime | None = None
@@ -130,6 +136,12 @@ class ProjectUpdateRequest(BaseModel):
build_command: str | None = None build_command: str | None = None
quality_command: str | None = None quality_command: str | None = None
# Autonomous maintenance opt-in
ci_watch_enabled: bool | None = None
ci_watch_workflow: str | None = None
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
# State # State
is_active: bool | None = None is_active: bool | None = None
@@ -210,6 +222,10 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
typecheck_command=project.typecheck_command, typecheck_command=project.typecheck_command,
build_command=project.build_command, build_command=project.build_command,
quality_command=project.quality_command, quality_command=project.quality_command,
ci_watch_enabled=bool(project.ci_watch_enabled),
ci_watch_workflow=project.ci_watch_workflow,
dep_update_command=project.dep_update_command,
dep_update_paths=project.dep_update_paths,
workspace_path=project.workspace_path, workspace_path=project.workspace_path,
last_synced_at=project.last_synced_at, last_synced_at=project.last_synced_at,
head_commit=project.head_commit, head_commit=project.head_commit,
+88 -2
View File
@@ -28,7 +28,7 @@ class Settings(BaseSettings):
# ========================================================================== # ==========================================================================
# Application # Application
# ========================================================================== # ==========================================================================
app_version: str = "0.11.1" app_version: str = "0.12.0"
debug: bool = False debug: bool = False
environment: str = Field( environment: str = Field(
default="development", pattern="^(development|staging|production)$" default="development", pattern="^(development|staging|production)$"
@@ -477,6 +477,75 @@ class Settings(BaseSettings):
description="Max self-heal fix tasks the loop may originate in one cycle.", description="Max self-heal fix tasks the loop may originate in one cycle.",
) )
# Multi-repo CI-watch — generalizes the single-repo self-heal CI loop to any
# opted-in project (per-project `ci_watch_enabled` column). Default-off;
# never auto-merges (fix tasks ride the normal delivery + PR-review gate).
ci_watch_enabled: bool = Field(
default=False,
description=(
"Master switch for the multi-repo CI-watch loop. OFF by default; "
"when off the loop does not run and no CI telemetry is fetched. "
"Generalizes self-heal to every project with ci_watch_enabled set."
),
)
ci_watch_default_workflow: str = Field(
default="ci.yml",
description=(
"Default GitHub Actions workflow file to scope the CI signal to when "
"a watched project does not set its own ci_watch_workflow. Empty "
"reads the latest run across ALL workflows on the default branch, "
"which on a multi-workflow repo lets a green run mask a red CI run."
),
)
ci_watch_interval_seconds: int = Field(
default=1800,
ge=60,
description="Seconds between CI-watch telemetry assessment passes.",
)
ci_watch_max_open_tasks: int = Field(
default=3,
ge=1,
description=(
"Rolling cap on concurrently-open ci_watch tasks across all repos; "
"the loop originates nothing more while this many are still open."
),
)
ci_watch_max_per_cycle: int = Field(
default=1,
ge=1,
description="Max ci_watch fix tasks the loop may originate in one cycle.",
)
# Dependency-update bot — periodically detects available dependency updates
# per opted-in project (a read-clone lockfile-diff probe) and opens an
# "update dependencies" task. Default-off; never auto-merges (rides the
# normal delivery + PR-review gate).
dep_update_enabled: bool = Field(
default=False,
description=(
"Master switch for the dependency-update bot. OFF by default; when "
"off the loop does not run and no probe is executed. Only projects "
"with a dep_update_command set participate."
),
)
dep_update_interval_seconds: int = Field(
default=604800,
ge=300,
description="Seconds between dependency-update probe passes (default weekly).",
)
dep_update_max_open_tasks: int = Field(
default=3,
ge=1,
description=(
"Rolling cap on concurrently-open dep_update tasks across all repos."
),
)
dep_update_max_per_cycle: int = Field(
default=1,
ge=1,
description="Max dep_update tasks the loop may originate in one cycle.",
)
# ========================================================================== # ==========================================================================
# Workspaces (Multi-Agent Git) # Workspaces (Multi-Agent Git)
# ========================================================================== # ==========================================================================
@@ -548,7 +617,7 @@ class Settings(BaseSettings):
agent_image_tag: str = Field( agent_image_tag: str = Field(
default="", default="",
description=( description=(
"Tag for pre-built agent images (e.g. 'latest' or '0.11.1'). Empty " "Tag for pre-built agent images (e.g. 'latest' or '0.12.0'). Empty "
"leaves the tag implicit (':latest'); only meaningful with " "leaves the tag implicit (':latest'); only meaningful with "
"agent_image_registry set." "agent_image_registry set."
), ),
@@ -569,6 +638,23 @@ class Settings(BaseSettings):
"when present; this is the fallback used before one is set." "when present; this is the fallback used before one is set."
), ),
) )
image_prune_enabled: bool = Field(
default=True,
description=(
"Whether the orchestrator background sweep prunes dangling (<none>) "
"Docker images. Each agent-image rebuild orphans the prior build's "
"layers as an untagged image; over many deploys these pile up. "
"Only DANGLING images are removed — a tagged image or one backing a "
"running container is never dangling. Disable to keep them."
),
)
image_prune_interval_seconds: int = Field(
default=21600,
ge=300,
description=(
"Minimum seconds between dangling-image prune passes (default 6h)."
),
)
transcript_prune_enabled: bool = Field( transcript_prune_enabled: bool = Field(
default=True, default=True,
description=( description=(
+16
View File
@@ -497,6 +497,22 @@ class ProjectTable(Base):
# lint/typecheck pair — e.g. "make gate". # lint/typecheck pair — e.g. "make gate".
quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True) quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Autonomous maintenance opt-in (multi-repo CI-watch). Default-off: a
# project is watched only when ci_watch_enabled is set; ci_watch_workflow
# scopes the CI signal to one workflow file (null → the engine default).
ci_watch_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false", default=False
)
ci_watch_workflow: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Dependency-update bot opt-in. A project participates only when
# dep_update_command is set (e.g. "uv lock --upgrade"); dep_update_paths are
# the lockfile globs the probe inspects (null → infer uv.lock/pnpm-lock.yaml).
dep_update_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
dep_update_paths: Mapped[list[str] | None] = mapped_column(
ARRAY(String), nullable=True
)
# Access Control # Access Control
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False) assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
allowed_agents: Mapped[list[PyUUID] | None] = mapped_column( allowed_agents: Mapped[list[PyUUID] | None] = mapped_column(
+22
View File
@@ -101,6 +101,24 @@ class Project(TimestampMixin):
) )
head_commit: str | None = Field(default=None, description="Current HEAD commit SHA") head_commit: str | None = Field(default=None, description="Current HEAD commit SHA")
# Autonomous maintenance opt-in (multi-repo CI-watch)
ci_watch_enabled: bool = Field(
default=False, description="Watch this project's CI and auto-open fix tasks"
)
ci_watch_workflow: str | None = Field(
default=None, description="Workflow file to scope the CI-watch signal to"
)
# Dependency-update bot opt-in
dep_update_command: str | None = Field(
default=None,
description="Command to refresh lockfiles, e.g. 'uv lock --upgrade'",
)
dep_update_paths: list[str] | None = Field(
default=None,
description="Lockfile globs to inspect (null → infer uv.lock/pnpm-lock.yaml)",
)
# Metadata # Metadata
created_by: UUID = Field(..., description="PM who registered the project") created_by: UUID = Field(..., description="PM who registered the project")
is_active: bool = Field(default=True, description="Whether project is active") is_active: bool = Field(default=True, description="Whether project is active")
@@ -154,3 +172,7 @@ class ProjectUpdate(RobocoBase):
assigned_cell: Team | None = None assigned_cell: Team | None = None
allowed_agents: list[UUID] | None = None allowed_agents: list[UUID] | None = None
is_active: bool | None = None is_active: bool | None = None
ci_watch_enabled: bool | None = None
ci_watch_workflow: str | None = None
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
+187 -35
View File
@@ -714,12 +714,15 @@ class AgentOrchestrator:
self._sweeper_task: asyncio.Task | None = None self._sweeper_task: asyncio.Task | None = None
# Last time the transcript-retention prune ran (throttled in the sweep). # Last time the transcript-retention prune ran (throttled in the sweep).
self._last_transcript_prune: datetime | None = None self._last_transcript_prune: datetime | None = None
self._last_image_prune: datetime | None = None
# Rate-limit probe loop: 30-second interval, scans Redis for all # Rate-limit probe loop: 30-second interval, scans Redis for all
# rate-limited providers and resolves waiting agents on success. # rate-limited providers and resolves waiting agents on success.
self._rate_limit_probe_task: asyncio.Task | None = None self._rate_limit_probe_task: asyncio.Task | None = None
self._strategy_engine_task: asyncio.Task | None = None self._strategy_engine_task: asyncio.Task | None = None
self._external_pr_poll_task: asyncio.Task | None = None self._external_pr_poll_task: asyncio.Task | None = None
self._self_heal_task: asyncio.Task | None = None self._self_heal_task: asyncio.Task | None = None
self._ci_watch_task: asyncio.Task | None = None
self._dep_update_task: asyncio.Task | None = None
# Provider registry: maps a ModelProvider to a dedicated AgentProvider # Provider registry: maps a ModelProvider to a dedicated AgentProvider
# backend. Only providers needing a non-Claude-Code runtime are # backend. Only providers needing a non-Claude-Code runtime are
# registered (currently GROK, which speaks the OpenAI protocol). Agents # registered (currently GROK, which speaks the OpenAI protocol). Agents
@@ -830,6 +833,8 @@ class AgentOrchestrator:
self._strategy_engine_task = asyncio.create_task(self._strategy_engine_loop()) self._strategy_engine_task = asyncio.create_task(self._strategy_engine_loop())
self._external_pr_poll_task = asyncio.create_task(self._external_pr_poll_loop()) self._external_pr_poll_task = asyncio.create_task(self._external_pr_poll_loop())
self._self_heal_task = asyncio.create_task(self._self_heal_loop()) self._self_heal_task = asyncio.create_task(self._self_heal_loop())
self._ci_watch_task = asyncio.create_task(self._ci_watch_loop())
self._dep_update_task = asyncio.create_task(self._dep_update_loop())
logger.info( logger.info(
"Orchestrator started", "Orchestrator started",
@@ -837,45 +842,31 @@ class AgentOrchestrator:
internal_api_url=self._api_url, internal_api_url=self._api_url,
) )
async def _cancel_background_task(self, task: asyncio.Task | None) -> None:
"""Cancel one background loop task and await its teardown (idempotent)."""
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the orchestrator and all agents.""" """Stop the orchestrator and all agents."""
self._running = False self._running = False
# Cancel background tasks # Cancel every background loop, then stop the agents.
if self._health_task: for task in (
self._health_task.cancel() self._health_task,
with contextlib.suppress(asyncio.CancelledError): self._dispatcher_task,
await self._health_task self._sweeper_task,
self._rate_limit_probe_task,
if self._dispatcher_task: self._strategy_engine_task,
self._dispatcher_task.cancel() self._external_pr_poll_task,
with contextlib.suppress(asyncio.CancelledError): self._self_heal_task,
await self._dispatcher_task self._ci_watch_task,
self._dep_update_task,
if self._sweeper_task: ):
self._sweeper_task.cancel() await self._cancel_background_task(task)
with contextlib.suppress(asyncio.CancelledError):
await self._sweeper_task
if self._rate_limit_probe_task:
self._rate_limit_probe_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._rate_limit_probe_task
if self._strategy_engine_task:
self._strategy_engine_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._strategy_engine_task
if self._external_pr_poll_task:
self._external_pr_poll_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._external_pr_poll_task
if self._self_heal_task:
self._self_heal_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._self_heal_task
# Stop all agents # Stop all agents
for agent_id in list(self._instances.keys()): for agent_id in list(self._instances.keys()):
@@ -5009,6 +5000,10 @@ Start by:
# operator's bind-mounted ~/.claude doesn't grow without bound. # operator's bind-mounted ~/.claude doesn't grow without bound.
await self._sweep_transcript_retention() await self._sweep_transcript_retention()
# Prune dangling (<none>) Docker images left by agent-image rebuilds
# (throttled internally to ~6h) so deploys don't pile up orphaned layers.
await self._sweep_dangling_images()
# Close-on-land for landed supersedes — runs here (always-on sweeper) # Close-on-land for landed supersedes — runs here (always-on sweeper)
# rather than the default-off external-PR poll loop, so a supersede that # rather than the default-off external-PR poll loop, so a supersede that
# lands after external_pr_enabled is toggled off is still reconciled. # lands after external_pr_enabled is toggled off is still reconciled.
@@ -5038,6 +5033,49 @@ Start by:
await db.rollback() await db.rollback()
logger.warning("Supersede close-on-land sweep failed", error=str(e)) logger.warning("Supersede close-on-land sweep failed", error=str(e))
async def _sweep_dangling_images(self) -> None:
"""Prune dangling (<none>) Docker images left by agent-image rebuilds.
Each rebuild of an agent image orphans the prior build's layers as an
untagged ``<none>`` image; over many deploys these pile up (the operator
saw ~80). Pruning only DANGLING images is safe a tagged image, or one
backing a running container, is never dangling. Throttled to
``settings.image_prune_interval_seconds`` (default 6h) and gated by
``settings.image_prune_enabled`` (default on). Best-effort: any failure
is logged, never raised into the sweeper.
"""
if not settings.image_prune_enabled:
return
now = datetime.now(UTC)
last = self._last_image_prune
if (
last is not None
and (now - last).total_seconds() < settings.image_prune_interval_seconds
):
return
self._last_image_prune = now
try:
proc = await asyncio.create_subprocess_exec(
"docker",
"image",
"prune",
"-f",
"--filter",
"dangling=true",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
if proc.returncode == 0:
summary = stdout.decode().strip().splitlines()[-1:] if stdout else []
logger.info(
"pruned dangling images", reclaimed=summary[0] if summary else ""
)
else:
logger.warning("dangling-image prune returned non-zero")
except Exception as e:
logger.warning("dangling-image prune failed (best-effort)", error=str(e))
async def _sweep_transcript_retention(self) -> None: async def _sweep_transcript_retention(self) -> None:
"""Prune agent transcripts older than the retention window. """Prune agent transcripts older than the retention window.
@@ -5527,6 +5565,120 @@ Start by:
except Exception: except Exception:
logger.exception("self-heal cycle failed") logger.exception("self-heal cycle failed")
async def _ci_watch_loop(self) -> None:
"""Multi-repo CI-watch: watch every opted-in project's CI, open fix tasks.
Dormant by default returns immediately unless ``ci_watch_enabled``, so
a standard deployment adds zero behaviour. It generalizes the single-repo
self-heal loop (which is untouched) to every project with
``ci_watch_enabled`` set; like self-heal it only OPENS a fix task and
never starts / approves / merges / deploys. The per-cycle session commits
any opened task here.
"""
if not settings.ci_watch_enabled:
return
interval = settings.ci_watch_interval_seconds
while self._running:
try:
await asyncio.sleep(interval)
await self._run_ci_watch_cycle()
except asyncio.CancelledError:
break
except Exception:
logger.exception("ci-watch cycle failed")
async def _run_ci_watch_cycle(self) -> None:
"""One CI-watch pass: load the watch set, run the engine, commit.
Extracted from the loop so it is testable without the sleep. A loud
warning fires when CI-watch is armed but no project opted in (so a
misconfiguration isn't mistaken for "all green").
"""
from roboco.db import get_db_context
from roboco.services.ci_watch_engine import get_ci_watch_engine
async with get_db_context() as db:
watch_set = await self._load_ci_watch_set(db)
if not watch_set:
logger.warning(
"ci-watch enabled but no project has ci_watch_enabled — "
"nothing to watch"
)
return
await get_ci_watch_engine(db).run_cycle(watch_set)
await db.commit()
async def _load_ci_watch_set(self, db: Any) -> list[Any]:
"""Opted-in projects (``ci_watch_enabled`` + a git_url), one per repo.
Collapsing to one canonical project per repo means a monorepo's several
cell-projects are watched as a single repo, not N times.
"""
from roboco.services.project import get_project_service
projects = await get_project_service(db).list_all(active_only=True)
watched = [
p
for p in projects
if getattr(p, "ci_watch_enabled", False) and getattr(p, "git_url", None)
]
return self._projects_one_per_repo(watched)
async def _dep_update_loop(self) -> None:
"""Dependency-update bot: probe opted-in projects, open update tasks.
Dormant by default returns immediately unless ``dep_update_enabled``.
Each interval (default weekly) it loads projects with a
``dep_update_command``, collapses to one per repo, and runs
``DepUpdateEngine.run_cycle``; it only OPENS a task and never starts /
approves / merges / deploys. Separate from the self-heal and CI-watch
loops.
"""
if not settings.dep_update_enabled:
return
interval = settings.dep_update_interval_seconds
while self._running:
try:
await asyncio.sleep(interval)
await self._run_dep_update_cycle()
except asyncio.CancelledError:
break
except Exception:
logger.exception("dep-update cycle failed")
async def _run_dep_update_cycle(self) -> None:
"""One dep-update pass: load eligible projects, run the engine, commit.
Extracted from the loop so it is testable without the sleep. Warns when
the bot is armed but no project has a ``dep_update_command`` set.
"""
from roboco.db import get_db_context
from roboco.services.dep_update_engine import get_dep_update_engine
async with get_db_context() as db:
projects = await self._load_dep_update_set(db)
if not projects:
logger.warning(
"dep-update enabled but no project has a dep_update_command — "
"nothing to probe"
)
return
await get_dep_update_engine(db).run_cycle(projects)
await db.commit()
async def _load_dep_update_set(self, db: Any) -> list[Any]:
"""Projects with a ``dep_update_command`` + a git_url, one per repo."""
from roboco.services.project import get_project_service
projects = await get_project_service(db).list_all(active_only=True)
eligible = [
p
for p in projects
if str(getattr(p, "dep_update_command", None) or "").strip()
and getattr(p, "git_url", None)
]
return self._projects_one_per_repo(eligible)
@staticmethod @staticmethod
def _repo_key(git_url: str) -> str: def _repo_key(git_url: str) -> str:
"""Normalized repo identity (case/.git/trailing-slash insensitive).""" """Normalized repo identity (case/.git/trailing-slash insensitive)."""
+187
View File
@@ -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)
+133
View File
@@ -0,0 +1,133 @@
"""Dependency-update bot engine — dormant by default.
Mirrors the self-heal / CI-watch engines: for each opted-in project (one with a
``dep_update_command``), probe whether a dependency upgrade would change the
lockfiles (read-only, in a throwaway clone) and, if so, open one
"update dependencies" task into that project's lifecycle and STOP. Conservative:
* **Default OFF** (``dep_update_enabled``) the loop never starts.
* **Never self-deploys** it only OPENS a task; the upgrade still ships through
the normal gates (dev -> QA -> PR review -> the CEO's merge).
* **Bounded + deduped per repo** at most one open dep_update task per repo
(keyed on ``git_url``), plus per-cycle and rolling open-task caps.
"""
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.task import (
DEP_UPDATE_SOURCE,
TaskCreateRequest,
TaskService,
get_task_service,
)
from roboco.services.workspace import get_workspace_service
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
class DepUpdateEngine(BaseService):
"""Open an update-dependencies task per opted-in project with updates available."""
service_name = "dep_update_engine"
def __init__(self, session: AsyncSession, workspace: Any | None = None) -> None:
super().__init__(session)
self._workspace = workspace or get_workspace_service(session)
async def run_cycle(self, projects: list[Any]) -> list[TaskTable]:
"""Probe each opted-in project and open a dep-update task when due (bounded).
No-op unless ``dep_update_enabled``. Returns the tasks it opened. Flushes;
the caller (the orchestrator loop) owns the commit. Never starts /
approves / merges / deploys.
"""
if not settings.dep_update_enabled:
return []
task_svc = get_task_service(self.session)
open_count = len(await task_svc.list_open_dep_update_tasks())
created: list[TaskTable] = []
for project in projects:
if len(created) >= settings.dep_update_max_per_cycle:
break
if open_count >= settings.dep_update_max_open_tasks:
self.log.info(
"dep-update open-task cap reached; not originating",
cap=settings.dep_update_max_open_tasks,
)
break
if not await self._eligible(task_svc, project):
continue
task = await self._open_task(task_svc, project)
created.append(task)
open_count += 1
self.log.info(
"dep-update task opened",
task_id=str(task.id),
project=str(getattr(project, "slug", "")),
)
return created
async def _eligible(self, task_svc: TaskService, project: Any) -> bool:
"""True when ``project`` has a command, no open task yet, and updates.
Cheap checks first (command set, per-``git_url`` dedupe), then the
expensive read-only probe so a project that's already covered or
opted out never pays for a clone.
"""
if not str(getattr(project, "dep_update_command", None) or "").strip():
return False
if getattr(project, "id", None) is None:
return False
if await task_svc.list_open_dep_update_tasks(git_url=project.git_url):
return False
return await self._workspace.dry_upgrade_changes_lockfile(project)
async def _open_task(self, task_svc: TaskService, project: Any) -> TaskTable:
slug = str(getattr(project, "slug", "") or "")
return await task_svc.create(
TaskCreateRequest(
title=f"Update dependencies on {slug}",
description=(
"Dependency updates are available for this project.\n\n"
"Upgrade the dependencies to their latest compatible versions, "
"refresh the lockfile(s), and make sure the full gate passes "
"with no behavioural breakage. This task was opened "
"automatically by the dependency-update bot and is READY TO "
"START NOW — no approval needed. It still ships through the "
"normal gates (QA, PR review, and the CEO's merge)."
),
acceptance_criteria=[
"Dependencies are upgraded to latest compatible and the "
"lockfile(s) are refreshed",
"The full quality gate passes with no behavioural regression",
],
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=DEP_UPDATE_SOURCE,
confirmed_by_human=True,
)
)
def get_dep_update_engine(
session: AsyncSession, workspace: Any | None = None
) -> DepUpdateEngine:
"""Construct a DepUpdateEngine bound to ``session`` (optionally a test probe)."""
return DepUpdateEngine(session, workspace=workspace)
+2
View File
@@ -56,6 +56,8 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("rag_auto_update_enabled", "RAG auto-update"), ("rag_auto_update_enabled", "RAG auto-update"),
("transcript_prune_enabled", "Transcript pruning"), ("transcript_prune_enabled", "Transcript pruning"),
("gateway_health_enabled", "Gateway-health recovery"), ("gateway_health_enabled", "Gateway-health recovery"),
("ci_watch_enabled", "Multi-repo CI-watch"),
("dep_update_enabled", "Dependency-update bot"),
) )
_FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS) _FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS)
+88 -11
View File
@@ -421,6 +421,17 @@ PR_REVIEW_SOURCES = ("external_pr", "internal_pr")
# Approve-&-Starts it; the loop itself never starts/approves/merges it. # Approve-&-Starts it; the loop itself never starts/approves/merges it.
SELF_HEAL_SOURCE = "self_heal" SELF_HEAL_SOURCE = "self_heal"
# Source tag for a multi-repo CI-watch fix task: opened when an OPTED-IN
# project's CI regresses on its default branch. Like self_heal it rides the
# normal delivery lifecycle (+ PR-review gate) and is never auto-merged; unlike
# self_heal it can target any watched project, not just RoboCo's own repo.
CI_WATCH_SOURCE = "ci_watch"
# Source tag for a dependency-update task: opened by the dep-update bot when an
# opted-in project has dependency updates available. Rides the normal delivery
# lifecycle (+ PR-review gate) and is never auto-merged.
DEP_UPDATE_SOURCE = "dep_update"
def extract_self_heal_fingerprint(task: Any) -> str | None: def extract_self_heal_fingerprint(task: Any) -> str | None:
"""The self-heal dedupe fingerprint from a task's markers, or None. """The self-heal dedupe fingerprint from a task's markers, or None.
@@ -867,22 +878,45 @@ class TaskService(BaseService):
async def external_review_task_exists( async def external_review_task_exists(
self, project_id: UUID, pr_number: int, head_sha: str | None = None self, project_id: UUID, pr_number: int, head_sha: str | None = None
) -> bool: ) -> bool:
"""True if this (project, external PR) at this head commit is already reviewed. """True if this REPO's external PR at this head commit is already reviewed.
De-dupe key for inbound external-PR ingestion. Re-review is driven by the De-dupe key for inbound external-PR ingestion. The scope is the **repo**
PR's head commit: a review task records the SHA it covered as an (``git_url``), not a single project: a monorepo registers several
``external_pr_head=<sha>`` marker in ``quick_context``. So: cell-projects on one repo (the poll already collapses to one canonical
project per repo), and a review task may be re-pointed to a sibling
project so a project-scoped check would open a second review per
cell-project / after a re-point. Re-review is driven by the PR's head
commit, recorded as an ``external_pr_head=<sha>`` marker. So:
- no review task for this PR yet -> False (ingest the first review); - no review task for this PR on any project in this repo -> False;
- a task already covers THIS ``head_sha`` -> True (skip nothing changed); - a task already covers THIS ``head_sha`` -> True (nothing changed);
- a legacy/markerless task exists, or ``head_sha`` is unknown -> True - a legacy/markerless task exists, or ``head_sha`` is unknown -> True;
(can't prove it changed, so don't re-review / don't spam); - tasks exist but all cover OTHER SHAs -> False (new commits re-review).
- tasks exist but all cover OTHER SHAs -> False (the PR got new commits
open a fresh review for the change).
""" """
# Resolve every project sharing this project's repo (git_url) so the
# dedupe spans the whole monorepo, not just the one project. An unknown
# project_id falls back to itself (can't widen).
git_url = (
await self.session.execute(
select(ProjectTable.git_url).where(ProjectTable.id == project_id)
)
).scalar_one_or_none()
if git_url:
sibling_ids = (
(
await self.session.execute(
select(ProjectTable.id).where(ProjectTable.git_url == git_url)
)
)
.scalars()
.all()
)
scope_ids = list(sibling_ids) or [project_id]
else:
scope_ids = [project_id]
result = await self.session.execute( result = await self.session.execute(
select(TaskTable.orchestration_markers).where( select(TaskTable.orchestration_markers).where(
TaskTable.project_id == project_id, TaskTable.project_id.in_(scope_ids),
TaskTable.source.in_(PR_REVIEW_SOURCES), TaskTable.source.in_(PR_REVIEW_SOURCES),
TaskTable.pr_number == pr_number, TaskTable.pr_number == pr_number,
) )
@@ -999,6 +1033,49 @@ class TaskService(BaseService):
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def list_open_ci_watch_tasks(
self, git_url: str | None = None
) -> list[TaskTable]:
"""Non-terminal ci_watch fix 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 CI-watch dedupe must key on the
repo, not the project slug otherwise a red monorepo would open one fix
task per cell-project. While an open task exists for a repo the loop must
not originate a second; the rolling open-task cap counts these.
"""
stmt = select(TaskTable).where(
TaskTable.source == CI_WATCH_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
if git_url is not None:
stmt = stmt.join(
ProjectTable, TaskTable.project_id == ProjectTable.id
).where(ProjectTable.git_url == git_url)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def list_open_dep_update_tasks(
self, git_url: str | None = None
) -> list[TaskTable]:
"""Non-terminal dep_update tasks — the dedupe + open-cap basis.
Optionally scoped to one repo by ``git_url`` so a monorepo (several
cell-projects, one git_url) gets at most one open dependency-update task,
not one per cell-project. While an open task exists for a repo the bot
must not originate a second; the rolling open-task cap counts these.
"""
stmt = select(TaskTable).where(
TaskTable.source == DEP_UPDATE_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
if git_url is not None:
stmt = stmt.join(
ProjectTable, TaskTable.project_id == ProjectTable.id
).where(ProjectTable.git_url == git_url)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def list_external_pr_reviews_awaiting_decision(self) -> list[TaskTable]: async def list_external_pr_reviews_awaiting_decision(self) -> list[TaskTable]:
"""Completed external-PR reviews still awaiting the CEO's decision. """Completed external-PR reviews still awaiting the CEO's decision.
+84
View File
@@ -123,3 +123,87 @@ class GitHubCITelemetrySource:
def get_ci_telemetry_source(session: AsyncSession) -> GitHubCITelemetrySource: def get_ci_telemetry_source(session: AsyncSession) -> GitHubCITelemetrySource:
"""Construct the GitHub-CI telemetry source bound to ``session``.""" """Construct the GitHub-CI telemetry source bound to ``session``."""
return GitHubCITelemetrySource(session) return GitHubCITelemetrySource(session)
class MultiProjectCITelemetrySource:
"""CI health for EVERY opted-in project (multi-repo CI-watch).
The fan-out generalization of ``GitHubCITelemetrySource``: instead of
RoboCo's single own repo, it reads the latest completed CI run for each
project the operator opted into (``ci_watch_enabled``), reusing the exact
hardened per-project ``GitService.get_latest_ci_conclusion`` (do NOT
reimplement it carries the default-branch ``master`` fix, head-sha run
selection, and bounded retry).
Per-project isolation: one project's GitHub error or missing signal never
aborts the sweep and is never read as a passing build that project simply
contributes no sample, so the engine opens no task for it (a None signal is
"unknown", not "green"). Only a real conclusion yields a sample: failing
breaching (value 1.0), passing non-breaching (0.0).
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def fetch(self, projects: list[object]) -> list[TelemetrySample]:
git = GitService(self.session)
default_workflow = settings.ci_watch_default_workflow.strip()
samples: list[TelemetrySample] = []
for project in projects:
slug = str(getattr(project, "slug", "") or "").strip()
if not slug:
continue
workflow = (
str(getattr(project, "ci_watch_workflow", None) or default_workflow)
).strip() or None
sample = await self._sample_for(git, slug, workflow)
if sample is not None:
samples.append(sample)
return samples
async def _sample_for(
self, git: GitService, slug: str, workflow: str | None
) -> TelemetrySample | None:
"""One project's sample, or None on an unreadable/absent signal."""
try:
ci = await git.get_latest_ci_conclusion(slug, workflow=workflow)
except Exception as e: # per-project isolation — never abort the sweep
logger.warning(
"ci-watch: telemetry fetch failed for project",
project_slug=slug,
error=str(e),
)
return None
if ci is None:
# No signal is not "green" — emit nothing so the engine opens no
# task (and never masks a failure as a pass). Loud so it's
# diagnosable, mirroring the self-heal source.
logger.warning(
"ci-watch: no CI signal for project — skipping",
project_slug=slug,
workflow=workflow,
)
return None
conclusion = (ci.get("conclusion") or "").lower()
failed = conclusion in FAILURE_CONCLUSIONS
run_name = ci.get("run_name") or ""
detail = f"CI on {slug}@{ci.get('branch')} concluded '{conclusion}'"
if run_name:
detail += f" ({run_name})"
return TelemetrySample(
signal_name=f"ci_conclusion:{slug}",
value=1.0 if failed else 0.0,
threshold=1.0,
window="latest_completed_run",
repo_hint=slug,
observed_at=str(ci.get("completed_at") or ""),
raw_ref=str(ci.get("run_url") or ""),
detail=detail,
)
def get_multi_ci_telemetry_source(
session: AsyncSession,
) -> MultiProjectCITelemetrySource:
"""Construct the multi-project CI-watch telemetry source bound to ``session``."""
return MultiProjectCITelemetrySource(session)
+98
View File
@@ -24,8 +24,10 @@ import json
import math import math
import os import os
import re import re
import shlex
import shutil import shutil
import subprocess import subprocess
import tempfile
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
@@ -42,6 +44,10 @@ from roboco.services.toolchain import resolve_target_python
logger = get_logger(__name__) logger = get_logger(__name__)
# Lockfile paths the dep-update probe inspects when a project sets no explicit
# dep_update_paths — the two RoboCo's stack uses.
_DEP_LOCK_DEFAULTS = ("uv.lock", "pnpm-lock.yaml")
# A healthy loose ref file holds either an object id (sha1 = 40 hex, sha256 = 64 # A healthy loose ref file holds either an object id (sha1 = 40 hex, sha256 = 64
# hex) or a symbolic ref ("ref: refs/..."). Anything else is debris — used to # hex) or a symbolic ref ("ref: refs/..."). Anything else is debris — used to
# detect broken loose refs left by interrupted recovery before a fetch. # detect broken loose refs left by interrupted recovery before a fetch.
@@ -1267,6 +1273,98 @@ class WorkspaceService:
) )
return True return True
async def dry_upgrade_changes_lockfile(self, project: Any) -> bool:
"""Read-only probe: would a dependency upgrade change this repo's lockfiles?
Clones the project's read clone into a throwaway dir, runs
``project.dep_update_command`` there, and reports whether any lockfile
path is dirty. The read clone is never mutated and nothing is committed
or pushed. Returns False (don't originate) on a null command or any
probe/command error fail-safe and logs loudly. The throwaway is
always removed.
"""
command = str(getattr(project, "dep_update_command", None) or "").strip()
if not command:
return False
slug = str(getattr(project, "slug", "") or "")
try:
read_clone = await self.ensure_read_clone(slug)
except WorkspaceError as exc:
logger.warning(
"dep-update probe: read clone unavailable",
project=slug,
error=str(exc),
)
return False
lock_paths = list(
getattr(project, "dep_update_paths", None) or _DEP_LOCK_DEFAULTS
)
tmp = Path(tempfile.mkdtemp(prefix="dep-probe-"))
try:
return await asyncio.to_thread(
self._probe_lockfile_change, read_clone, tmp, command, lock_paths
)
except Exception as exc:
logger.warning(
"dep-update probe failed; not originating",
project=slug,
error=str(exc),
)
return False
finally:
shutil.rmtree(tmp, ignore_errors=True)
@staticmethod
def _probe_lockfile_change(
read_clone: Path, tmp: Path, command: str, lock_paths: list[str]
) -> bool:
"""Sync core of the dep-update probe (run in a thread). True if dirty.
Isolated local clone (``--no-hardlinks``) so the read clone is never
touched; runs the upgrade with no shell (``shlex.split``); a non-zero
upgrade yields False (fail-safe, don't originate on a broken probe).
"""
clone_dir = tmp / "repo"
timeout = settings.workspace_dep_install_timeout_seconds
subprocess.run(
[
"git",
"clone",
"--local",
"--no-hardlinks",
str(read_clone),
str(clone_dir),
],
capture_output=True,
text=True,
timeout=timeout,
check=True,
)
upgrade = subprocess.run(
shlex.split(command),
cwd=str(clone_dir),
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if upgrade.returncode != 0:
logger.warning(
"dep-update probe: upgrade command non-zero; not originating",
command=command,
stderr=upgrade.stderr.strip()[:2000],
)
return False
status = subprocess.run(
["git", "status", "--porcelain", "--", *lock_paths],
cwd=str(clone_dir),
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
return bool(status.stdout.strip())
async def workspace_exists( async def workspace_exists(
self, self,
project_slug: str, project_slug: str,
@@ -0,0 +1,159 @@
"""CiWatchEngine — originate a fix task per red opted-in repo, bounded + deduped.
Mirrors the self-heal engine: opens a PENDING ci_watch task per red project,
never merges/approves; dedupes per repo (git_url) so a still-red repo with an
open task gets none; honours per-cycle + rolling caps; a None-signal project
(no sample) yields no task.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.config import settings
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.ci_watch_engine import get_ci_watch_engine
from roboco.services.task import CI_WATCH_SOURCE, get_task_service
from roboco.services.telemetry.source import TelemetrySample
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
class _FakeSource:
def __init__(self, samples: list[TelemetrySample]) -> None:
self._samples = samples
async def fetch(self, _projects: list[object]) -> list[TelemetrySample]:
return list(self._samples)
def _breach(slug: str, *, failed: bool = True) -> TelemetrySample:
return TelemetrySample(
signal_name=f"ci_conclusion:{slug}",
value=1.0 if failed else 0.0,
threshold=1.0,
window="latest_completed_run",
repo_hint=slug,
observed_at="2026-06-25T00:00:00Z",
raw_ref=f"https://github.com/x/{slug}/actions/runs/1",
detail=f"CI on {slug}@master concluded 'failure'",
)
async def _get_or_create_agent(
db: AsyncSession, agent_id: object, role: AgentRole, slug: str
) -> None:
if await db.get(AgentTable, agent_id) is None:
db.add(
AgentTable(
id=agent_id,
name=slug,
slug=f"{slug}-{uuid4().hex[:8]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
async def _seed_project(db: AsyncSession, slug: str, git_url: str) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name=slug,
slug=slug,
git_url=git_url,
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
ci_watch_enabled=True,
)
db.add(project)
await db.flush()
return project
@pytest.fixture(autouse=True)
async def _enabled(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_max_per_cycle", 5)
monkeypatch.setattr(settings, "ci_watch_max_open_tasks", 5)
await _get_or_create_agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
await _get_or_create_agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
@pytest.mark.asyncio
async def test_red_project_opens_one_fix_task(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session, "red-a", "https://github.com/x/a.git")
engine = get_ci_watch_engine(db_session, source=_FakeSource([_breach("red-a")]))
created = await engine.run_cycle([proj])
assert len(created) == 1
task = created[0]
assert task.project_id == proj.id
assert task.source == CI_WATCH_SOURCE
assert task.confirmed_by_human is True
assert task.status == TaskStatus.PENDING # opened, never merged/approved
@pytest.mark.asyncio
async def test_still_red_with_open_task_opens_nothing(
db_session: AsyncSession,
) -> None:
proj = await _seed_project(db_session, "red-b", "https://github.com/x/b.git")
src = _FakeSource([_breach("red-b")])
engine = get_ci_watch_engine(db_session, source=src)
first = await engine.run_cycle([proj])
assert len(first) == 1
# Second cycle, same repo still red → deduped (one open task per git_url)
second = await engine.run_cycle([proj])
assert second == []
@pytest.mark.asyncio
async def test_per_cycle_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "ci_watch_max_per_cycle", 1)
p1 = await _seed_project(db_session, "red-c", "https://github.com/x/c.git")
p2 = await _seed_project(db_session, "red-d", "https://github.com/x/d.git")
src = _FakeSource([_breach("red-c"), _breach("red-d")])
created = await get_ci_watch_engine(db_session, source=src).run_cycle([p1, p2])
assert len(created) == 1 # capped at one per cycle
@pytest.mark.asyncio
async def test_green_or_no_signal_opens_nothing(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session, "quiet", "https://github.com/x/q.git")
# No sample at all (None signal) — engine must not originate.
none_engine = get_ci_watch_engine(db_session, source=_FakeSource([]))
assert await none_engine.run_cycle([proj]) == []
# A green (non-breaching) sample — also no task.
green_engine = get_ci_watch_engine(
db_session, source=_FakeSource([_breach("quiet", failed=False)])
)
assert await green_engine.run_cycle([proj]) == []
assert await get_task_service(db_session).list_open_ci_watch_tasks() == []
@pytest.mark.asyncio
async def test_disabled_is_noop(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "ci_watch_enabled", False)
proj = await _seed_project(db_session, "red-e", "https://github.com/x/e.git")
src = _FakeSource([_breach("red-e")])
assert await get_ci_watch_engine(db_session, source=src).run_cycle([proj]) == []
@@ -0,0 +1,104 @@
"""CI-watch routes its fix-task notification to the project's cell PM.
Not the CEO (a delivery/client repo's red CI is a cell concern) and once per
project per cycle (the engine opens at most one task per repo per cycle).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.services.ci_watch_engine import get_ci_watch_engine
from roboco.services.telemetry.source import TelemetrySample
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
class _FakeSource:
def __init__(self, samples: list[TelemetrySample]) -> None:
self._samples = samples
async def fetch(self, _projects: list[object]) -> list[TelemetrySample]:
return list(self._samples)
def _breach(slug: str) -> TelemetrySample:
return TelemetrySample(
signal_name=f"ci_conclusion:{slug}",
value=1.0,
threshold=1.0,
window="latest_completed_run",
repo_hint=slug,
observed_at="2026-06-25T00:00:00Z",
raw_ref=f"https://github.com/x/{slug}/actions/runs/1",
detail=f"CI on {slug}@master concluded 'failure'",
)
async def _agent(db: AsyncSession, agent_id: Any, role: AgentRole, slug: str) -> None:
if await db.get(AgentTable, agent_id) is None:
db.add(
AgentTable(
id=agent_id,
name=slug,
slug=f"{slug}-{uuid4().hex[:8]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
@pytest.fixture(autouse=True)
async def _setup(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_max_per_cycle", 5)
monkeypatch.setattr(settings, "ci_watch_max_open_tasks", 5)
await _agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
await _agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
@pytest.mark.asyncio
async def test_notifies_backend_cell_pm_once(db_session: AsyncSession) -> None:
proj = ProjectTable(
id=uuid4(),
name="red",
slug="red",
git_url="https://github.com/x/a.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
ci_watch_enabled=True,
)
db_session.add(proj)
await db_session.flush()
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
engine = get_ci_watch_engine(db_session, source=_FakeSource([_breach("red")]))
with patch(
"roboco.services.ci_watch_engine.NotificationService", return_value=notifier
):
created = await engine.run_cycle([proj])
assert len(created) == 1
notifier.send_ack_notification.assert_awaited_once()
kwargs = notifier.send_ack_notification.await_args.kwargs
assert kwargs["to_agent"] == "be-pm" # the BACKEND cell PM, not "ceo"
assert "red" in kwargs["body"]
@@ -0,0 +1,136 @@
"""CI_WATCH_SOURCE + list_open_ci_watch_tasks — the dedupe / open-cap basis.
Open ci_watch tasks count toward the cap and block a duplicate; terminal ones
and other-source tasks do not. The git_url scoping keys dedupe on the repo (a
monorepo registers several cell-projects on one git_url), so a watched repo
gets at most one open fix task even across its cell-projects.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.models.task import TaskCreateRequest
from roboco.services.task import CI_WATCH_SOURCE, get_task_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
_TWO = 2
async def _get_or_create_agent(
db: AsyncSession, agent_id: object, role: AgentRole, slug: str
) -> None:
if await db.get(AgentTable, agent_id) is None:
db.add(
AgentTable(
id=agent_id,
name=slug,
slug=f"{slug}-{uuid4().hex[:8]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
async def _seed_project(db: AsyncSession, git_url: str) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:8]}",
git_url=git_url,
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
db.add(project)
await db.flush()
return project
async def _make_ci_watch_task(
db: AsyncSession,
project: ProjectTable,
*,
source: str = CI_WATCH_SOURCE,
terminal: bool = False,
) -> None:
svc = get_task_service(db)
task = await svc.create(
TaskCreateRequest(
title="CI-watch fix",
description="Fix the CI regression on this project's default branch.",
acceptance_criteria=["CI is green again"],
team=Team.MAIN_PM,
assigned_to=MAIN_PM_UUID,
created_by=SYSTEM_UUID,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project.id,
status=TaskStatus.PENDING,
source=source,
confirmed_by_human=True,
)
)
if terminal:
task.status = TaskStatus.COMPLETED
await db.flush()
@pytest.fixture(autouse=True)
async def _agents(db_session: AsyncSession) -> None:
await _get_or_create_agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
await _get_or_create_agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
@pytest.mark.asyncio
async def test_lists_only_open_ci_watch_tasks(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session, "https://github.com/x/a.git")
await _make_ci_watch_task(db_session, proj) # open ci_watch
await _make_ci_watch_task(db_session, proj, terminal=True) # terminal ci_watch
await _make_ci_watch_task(db_session, proj, source="manual") # other source
open_tasks = await get_task_service(db_session).list_open_ci_watch_tasks()
assert len(open_tasks) == 1
assert open_tasks[0].source == CI_WATCH_SOURCE
assert open_tasks[0].status != TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_git_url_scoping_returns_only_that_repo(
db_session: AsyncSession,
) -> None:
proj_a = await _seed_project(db_session, "https://github.com/x/a.git")
proj_b = await _seed_project(db_session, "https://github.com/x/b.git")
await _make_ci_watch_task(db_session, proj_a)
await _make_ci_watch_task(db_session, proj_b)
svc = get_task_service(db_session)
assert len(await svc.list_open_ci_watch_tasks()) == _TWO
scoped = await svc.list_open_ci_watch_tasks(git_url="https://github.com/x/a.git")
assert len(scoped) == 1
assert scoped[0].project_id == proj_a.id
@@ -0,0 +1,145 @@
"""DepUpdateEngine — open an update-deps task per opted-in project with updates.
Opens a PENDING dep_update task (never merges/approves) when the probe reports
updates; skips projects with no command, no updates, or an already-open task for
the same repo (git_url dedupe); honours per-cycle + rolling caps; dormant when
disabled.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import pytest
from roboco.config import settings
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.dep_update_engine import get_dep_update_engine
from roboco.services.task import DEP_UPDATE_SOURCE
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
class _FakeWorkspace:
def __init__(self, updates: bool = True) -> None:
self._updates = updates
async def dry_upgrade_changes_lockfile(self, _project: Any) -> bool:
return self._updates
async def _get_or_create_agent(
db: AsyncSession, agent_id: object, role: AgentRole, slug: str
) -> None:
if await db.get(AgentTable, agent_id) is None:
db.add(
AgentTable(
id=agent_id,
name=slug,
slug=f"{slug}-{uuid4().hex[:8]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
async def _seed_project(
db: AsyncSession, slug: str, git_url: str, *, command: str | None = "uv lock -U"
) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name=slug,
slug=slug,
git_url=git_url,
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
dep_update_command=command,
)
db.add(project)
await db.flush()
return project
@pytest.fixture(autouse=True)
async def _enabled(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "dep_update_enabled", True)
monkeypatch.setattr(settings, "dep_update_max_per_cycle", 5)
monkeypatch.setattr(settings, "dep_update_max_open_tasks", 5)
await _get_or_create_agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
await _get_or_create_agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
@pytest.mark.asyncio
async def test_updates_available_opens_one_task(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session, "dep-a", "https://github.com/x/a.git")
engine = get_dep_update_engine(db_session, workspace=_FakeWorkspace(updates=True))
created = await engine.run_cycle([proj])
assert len(created) == 1
task = created[0]
assert task.project_id == proj.id
assert task.source == DEP_UPDATE_SOURCE
assert task.confirmed_by_human is True
assert task.status == TaskStatus.PENDING
@pytest.mark.asyncio
async def test_no_command_skipped(db_session: AsyncSession) -> None:
proj = await _seed_project(
db_session, "dep-b", "https://github.com/x/b.git", command=None
)
engine = get_dep_update_engine(db_session, workspace=_FakeWorkspace(updates=True))
assert await engine.run_cycle([proj]) == []
@pytest.mark.asyncio
async def test_no_updates_skipped(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session, "dep-c", "https://github.com/x/c.git")
engine = get_dep_update_engine(db_session, workspace=_FakeWorkspace(updates=False))
assert await engine.run_cycle([proj]) == []
@pytest.mark.asyncio
async def test_dedupe_same_repo(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session, "dep-d", "https://github.com/x/d.git")
engine = get_dep_update_engine(db_session, workspace=_FakeWorkspace(updates=True))
first = await engine.run_cycle([proj])
assert len(first) == 1
second = await engine.run_cycle([proj]) # still updatable, but already open
assert second == []
@pytest.mark.asyncio
async def test_per_cycle_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "dep_update_max_per_cycle", 1)
p1 = await _seed_project(db_session, "dep-e", "https://github.com/x/e.git")
p2 = await _seed_project(db_session, "dep-f", "https://github.com/x/f.git")
engine = get_dep_update_engine(db_session, workspace=_FakeWorkspace(updates=True))
created = await engine.run_cycle([p1, p2])
assert len(created) == 1
@pytest.mark.asyncio
async def test_disabled_is_noop(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "dep_update_enabled", False)
proj = await _seed_project(db_session, "dep-g", "https://github.com/x/g.git")
engine = get_dep_update_engine(db_session, workspace=_FakeWorkspace(updates=True))
assert await engine.run_cycle([proj]) == []
@@ -0,0 +1,96 @@
"""dry_upgrade_changes_lockfile — the read-only lockfile-diff probe.
Runs the project's dep_update_command in an isolated clone of the read clone and
reports whether a lockfile path got dirty without ever mutating the read clone
or committing/pushing. Fail-safe: a null/failing command returns False.
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.services.workspace import WorkspaceService
if TYPE_CHECKING:
from pathlib import Path
def _git(cwd: Path, *args: str) -> None:
subprocess.run(
["git", *args], cwd=str(cwd), capture_output=True, text=True, check=True
)
def _make_read_clone(tmp_path: Path) -> Path:
repo = tmp_path / "readclone"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "t")
(repo / "uv.lock").write_text("version = 1\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "init")
return repo
def _svc(read_clone: Path) -> WorkspaceService:
svc = WorkspaceService.__new__(WorkspaceService)
svc.ensure_read_clone = AsyncMock(return_value=read_clone) # type: ignore[method-assign]
return svc
def _project(command: str | None, paths: list[str] | None = None) -> MagicMock:
return MagicMock(slug="p", dep_update_command=command, dep_update_paths=paths)
@pytest.mark.asyncio
async def test_dirtying_a_lockfile_returns_true(tmp_path: Path) -> None:
read_clone = _make_read_clone(tmp_path)
svc = _svc(read_clone)
cmd = "python3 -c \"open('uv.lock','a').write('x')\""
assert await svc.dry_upgrade_changes_lockfile(_project(cmd)) is True
# The read clone itself is never mutated by the probe.
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=str(read_clone),
capture_output=True,
text=True,
check=True,
)
assert status.stdout.strip() == ""
@pytest.mark.asyncio
async def test_noop_command_returns_false(tmp_path: Path) -> None:
svc = _svc(_make_read_clone(tmp_path))
assert (
await svc.dry_upgrade_changes_lockfile(_project('python3 -c "pass"')) is False
)
@pytest.mark.asyncio
async def test_null_command_returns_false(tmp_path: Path) -> None:
svc = _svc(_make_read_clone(tmp_path))
assert await svc.dry_upgrade_changes_lockfile(_project(None)) is False
@pytest.mark.asyncio
async def test_failing_command_returns_false(tmp_path: Path) -> None:
svc = _svc(_make_read_clone(tmp_path))
cmd = 'python3 -c "import sys; sys.exit(1)"'
assert await svc.dry_upgrade_changes_lockfile(_project(cmd)) is False
@pytest.mark.asyncio
async def test_explicit_dep_update_paths_scope(tmp_path: Path) -> None:
read_clone = _make_read_clone(tmp_path)
svc = _svc(read_clone)
# Command dirties uv.lock, but we only watch a different lockfile → False.
cmd = "python3 -c \"open('uv.lock','a').write('x')\""
project = _project(cmd, paths=["pnpm-lock.yaml"])
assert await svc.dry_upgrade_changes_lockfile(project) is False
@@ -0,0 +1,132 @@
"""DEP_UPDATE_SOURCE + list_open_dep_update_tasks — the dedupe / open-cap basis.
Open dep_update tasks count toward the cap and block a duplicate; terminal ones
and other-source tasks do not. The git_url scoping keys dedupe on the repo so a
monorepo gets at most one open dependency-update task across its cell-projects.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.models.task import TaskCreateRequest
from roboco.services.task import DEP_UPDATE_SOURCE, get_task_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
_TWO = 2
async def _get_or_create_agent(
db: AsyncSession, agent_id: object, role: AgentRole, slug: str
) -> None:
if await db.get(AgentTable, agent_id) is None:
db.add(
AgentTable(
id=agent_id,
name=slug,
slug=f"{slug}-{uuid4().hex[:8]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
async def _seed_project(db: AsyncSession, git_url: str) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:8]}",
git_url=git_url,
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
db.add(project)
await db.flush()
return project
async def _make_task(
db: AsyncSession,
project: ProjectTable,
*,
source: str = DEP_UPDATE_SOURCE,
terminal: bool = False,
) -> None:
task = await get_task_service(db).create(
TaskCreateRequest(
title="Update dependencies",
description="Upgrade dependencies to latest compatible; gate must pass.",
acceptance_criteria=["lockfiles refreshed", "gate green"],
team=Team.MAIN_PM,
assigned_to=MAIN_PM_UUID,
created_by=SYSTEM_UUID,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project.id,
status=TaskStatus.PENDING,
source=source,
confirmed_by_human=True,
)
)
if terminal:
task.status = TaskStatus.COMPLETED
await db.flush()
@pytest.fixture(autouse=True)
async def _agents(db_session: AsyncSession) -> None:
await _get_or_create_agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
await _get_or_create_agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
@pytest.mark.asyncio
async def test_lists_only_open_dep_update_tasks(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session, "https://github.com/x/a.git")
await _make_task(db_session, proj)
await _make_task(db_session, proj, terminal=True)
await _make_task(db_session, proj, source="manual")
open_tasks = await get_task_service(db_session).list_open_dep_update_tasks()
assert len(open_tasks) == 1
assert open_tasks[0].source == DEP_UPDATE_SOURCE
assert open_tasks[0].status != TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_git_url_scoping(db_session: AsyncSession) -> None:
proj_a = await _seed_project(db_session, "https://github.com/x/a.git")
proj_b = await _seed_project(db_session, "https://github.com/x/b.git")
await _make_task(db_session, proj_a)
await _make_task(db_session, proj_b)
svc = get_task_service(db_session)
assert len(await svc.list_open_dep_update_tasks()) == _TWO
scoped = await svc.list_open_dep_update_tasks(git_url="https://github.com/x/a.git")
assert len(scoped) == 1
assert scoped[0].project_id == proj_a.id
@@ -0,0 +1,112 @@
"""External-PR review dedupe is repo-scoped (git_url), not project-scoped.
A monorepo registers several cell-projects on one repo. Ingesting the same PR
for a sibling project or re-pointing an existing review task to a sibling
must NOT open a second review (the duplicate the operator hit: PR #131 reviewed
once per cell-project after a re-point). Re-review on a new head SHA still works.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.services.task import get_task_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
_REPO = "https://github.com/rennf93/guard-core-app"
_OTHER_REPO = "https://github.com/rennf93/other-app"
def _pr(head_sha: str, number: int = 131) -> dict[str, Any]:
return {
"number": number,
"url": f"{_REPO}/pull/{number}",
"title": "build(deps): bump the dependencies",
"head_sha": head_sha,
}
async def _seed(db: AsyncSession, slug: str, git_url: str) -> ProjectTable:
if await db.get(AgentTable, SYSTEM_UUID) is None:
db.add(
AgentTable(
id=SYSTEM_UUID,
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
project = ProjectTable(
id=uuid4(),
name=slug,
slug=slug,
git_url=git_url,
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
db.add(project)
await db.flush()
return project
@pytest.mark.asyncio
async def test_sibling_project_same_pr_is_deduped(db_session: AsyncSession) -> None:
fe = await _seed(db_session, "gca-frontend", _REPO)
be = await _seed(db_session, "gca-backend", _REPO)
svc = get_task_service(db_session)
first = await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
)
assert first is not None # first review opens
# Same PR + same head, sibling project on the SAME repo → no second review.
dup = await svc.ingest_external_pr(
project_id=be.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.BACKEND
)
assert dup is None
@pytest.mark.asyncio
async def test_exists_is_repo_scoped(db_session: AsyncSession) -> None:
fe = await _seed(db_session, "gca-frontend", _REPO)
be = await _seed(db_session, "gca-backend", _REPO)
svc = get_task_service(db_session)
await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
)
# The sibling project sees the existing review (the fix); a new head SHA does not.
assert await svc.external_review_task_exists(be.id, 131, "abc123") is True
assert await svc.external_review_task_exists(be.id, 131, "newsha") is False
@pytest.mark.asyncio
async def test_different_repo_not_deduped(db_session: AsyncSession) -> None:
fe = await _seed(db_session, "gca-frontend", _REPO)
other = await _seed(db_session, "other", _OTHER_REPO)
svc = get_task_service(db_session)
await svc.ingest_external_pr(
project_id=fe.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.FRONTEND
)
# A genuinely different repo with the same PR number is reviewed independently.
created = await svc.ingest_external_pr(
project_id=other.id, pr=_pr("abc123"), created_by=SYSTEM_UUID, team=Team.BACKEND
)
assert created is not None
@@ -0,0 +1,72 @@
"""Project update accepts the autonomous-maintenance opt-in fields.
The CI-watch + dep-update per-project columns must be settable through the
normal ProjectUpdate path (what the panel edit-project dialog calls), or the
operator can't opt a project in.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.project import ProjectUpdate
from roboco.services.project import get_project_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
return project
@pytest.mark.asyncio
async def test_update_sets_autonomy_opt_ins(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
svc = get_project_service(db_session)
await svc.update(
project.id,
ProjectUpdate(
ci_watch_enabled=True,
ci_watch_workflow="ci.yml",
dep_update_command="uv lock --upgrade",
dep_update_paths=["uv.lock"],
),
)
reloaded = await svc.get(project.id)
assert reloaded is not None
assert reloaded.ci_watch_enabled is True
assert reloaded.ci_watch_workflow == "ci.yml"
assert reloaded.dep_update_command == "uv lock --upgrade"
assert reloaded.dep_update_paths == ["uv.lock"]
@@ -0,0 +1,72 @@
"""Multi-repo CI-watch per-project opt-in columns (migration 048).
Migration 048 adds ``projects.ci_watch_enabled`` (bool, NOT NULL default false
an unopted project is unwatched) and ``projects.ci_watch_workflow`` (varchar
null scope the CI signal to one workflow file). The real upgrade/downgrade
chain is verified separately against a throwaway Postgres; these assertions
guard the resulting schema shape and a value round-trip.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="B-Proj",
slug=f"b-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
return project
@pytest.mark.asyncio
async def test_ci_watch_columns_default_off(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
assert project.ci_watch_enabled is False
assert project.ci_watch_workflow is None
@pytest.mark.asyncio
async def test_ci_watch_columns_round_trip(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
project.ci_watch_enabled = True
project.ci_watch_workflow = "ci.yml"
await db_session.flush()
row = (
await db_session.execute(
select(ProjectTable).where(ProjectTable.id == project.id)
)
).scalar_one()
assert row.ci_watch_enabled is True
assert row.ci_watch_workflow == "ci.yml"
@@ -0,0 +1,71 @@
"""Dependency-update bot per-project opt-in columns (migration 049).
Migration 049 adds ``projects.dep_update_command`` (varchar null) and
``projects.dep_update_paths`` (varchar[] null). The real upgrade/downgrade chain
is verified separately against a throwaway Postgres; these assertions guard the
resulting schema shape and a value round-trip.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="B-Proj",
slug=f"b-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
return project
@pytest.mark.asyncio
async def test_dep_update_columns_default_null(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
assert project.dep_update_command is None
assert project.dep_update_paths is None
@pytest.mark.asyncio
async def test_dep_update_columns_round_trip(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
project.dep_update_command = "uv lock --upgrade"
project.dep_update_paths = ["uv.lock", "pnpm-lock.yaml"]
await db_session.flush()
row = (
await db_session.execute(
select(ProjectTable).where(ProjectTable.id == project.id)
)
).scalar_one()
assert row.dep_update_command == "uv lock --upgrade"
assert row.dep_update_paths == ["uv.lock", "pnpm-lock.yaml"]
+35
View File
@@ -0,0 +1,35 @@
"""Multi-repo CI-watch is gated by default-off config flags (mirrors self-heal)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
_DEFAULT_INTERVAL = 1800
_DEFAULT_MAX_OPEN = 3
_DEFAULT_MAX_PER_CYCLE = 1
def test_ci_watch_disabled_by_default() -> None:
s = Settings()
assert s.ci_watch_enabled is False
assert s.ci_watch_interval_seconds == _DEFAULT_INTERVAL
assert s.ci_watch_max_open_tasks == _DEFAULT_MAX_OPEN
assert s.ci_watch_max_per_cycle == _DEFAULT_MAX_PER_CYCLE
assert s.ci_watch_default_workflow == "ci.yml"
def test_ci_watch_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_CI_WATCH_ENABLED": "true"}):
assert Settings().ci_watch_enabled is True
def test_ci_watch_flag_registered_in_feature_flags() -> None:
assert "ci_watch_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_ci_watch_flag_validates_as_bool() -> None:
validate_setting("ci_watch_enabled", "true")
+34
View File
@@ -0,0 +1,34 @@
"""The dependency-update bot is gated by default-off config flags."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
_DEFAULT_INTERVAL = 604800
_DEFAULT_MAX_OPEN = 3
_DEFAULT_MAX_PER_CYCLE = 1
def test_dep_update_disabled_by_default() -> None:
s = Settings()
assert s.dep_update_enabled is False
assert s.dep_update_interval_seconds == _DEFAULT_INTERVAL
assert s.dep_update_max_open_tasks == _DEFAULT_MAX_OPEN
assert s.dep_update_max_per_cycle == _DEFAULT_MAX_PER_CYCLE
def test_dep_update_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_DEP_UPDATE_ENABLED": "true"}):
assert Settings().dep_update_enabled is True
def test_dep_update_flag_registered_in_feature_flags() -> None:
assert "dep_update_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_dep_update_flag_validates_as_bool() -> None:
validate_setting("dep_update_enabled", "true")
+87
View File
@@ -0,0 +1,87 @@
"""The orchestrator CI-watch loop: dormant when off, runs the engine when on.
Dormant unless ``ci_watch_enabled``; loads the watch set (opted-in projects, one
per repo), warns when enabled-but-empty, and runs CiWatchEngine.run_cycle each
interval. Separate from the single-repo self-heal loop.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
def _orch() -> AgentOrchestrator:
return AgentOrchestrator.__new__(AgentOrchestrator)
@pytest.mark.asyncio
async def test_loop_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "ci_watch_enabled", False)
orch = _orch()
cycle = AsyncMock()
orch._run_ci_watch_cycle = cycle # type: ignore[method-assign]
await orch._ci_watch_loop() # must return immediately, no infinite loop
cycle.assert_not_awaited()
@pytest.mark.asyncio
async def test_load_watch_set_filters_enabled_one_per_repo() -> None:
orch = _orch()
on_a = MagicMock(slug="be", git_url="https://x/a.git", ci_watch_enabled=True)
on_a2 = MagicMock(slug="fe", git_url="https://x/a.git", ci_watch_enabled=True)
off = MagicMock(slug="c", git_url="https://x/c.git", ci_watch_enabled=False)
svc = MagicMock()
svc.list_all = AsyncMock(return_value=[on_a, on_a2, off])
with patch("roboco.services.project.get_project_service", return_value=svc):
watch = await orch._load_ci_watch_set(MagicMock())
assert len(watch) == 1 # opt-out excluded; same-repo cell-projects collapsed
assert watch[0].git_url == "https://x/a.git"
def _db_ctx(db: Any):
@asynccontextmanager
async def _ctx() -> Any:
yield db
return _ctx
@pytest.mark.asyncio
async def test_cycle_warns_and_skips_engine_when_empty() -> None:
orch = _orch()
orch._load_ci_watch_set = AsyncMock(return_value=[]) # type: ignore[method-assign]
get_eng = MagicMock()
with (
patch("roboco.db.get_db_context", _db_ctx(MagicMock())),
patch("roboco.services.ci_watch_engine.get_ci_watch_engine", get_eng),
):
await orch._run_ci_watch_cycle()
get_eng.assert_not_called() # empty watch set → no engine run
@pytest.mark.asyncio
async def test_cycle_runs_engine_when_watch_set_present() -> None:
orch = _orch()
watch = [MagicMock()]
orch._load_ci_watch_set = AsyncMock(return_value=watch) # type: ignore[method-assign]
db = MagicMock()
db.commit = AsyncMock()
engine = MagicMock()
engine.run_cycle = AsyncMock(return_value=[])
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch(
"roboco.services.ci_watch_engine.get_ci_watch_engine",
return_value=engine,
) as get_eng,
):
await orch._run_ci_watch_cycle()
get_eng.assert_called_once()
engine.run_cycle.assert_awaited_once_with(watch)
db.commit.assert_awaited_once()
@@ -0,0 +1,87 @@
"""The orchestrator dep-update loop: dormant when off, runs the engine when on.
Dormant unless ``dep_update_enabled``; loads the eligible set (projects with a
dep_update_command, one per repo), warns when enabled-but-empty, and runs
DepUpdateEngine.run_cycle each interval. Separate from self-heal/CI-watch loops.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
def _orch() -> AgentOrchestrator:
return AgentOrchestrator.__new__(AgentOrchestrator)
@pytest.mark.asyncio
async def test_loop_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "dep_update_enabled", False)
orch = _orch()
cycle = AsyncMock()
orch._run_dep_update_cycle = cycle # type: ignore[method-assign]
await orch._dep_update_loop()
cycle.assert_not_awaited()
@pytest.mark.asyncio
async def test_load_set_filters_command_one_per_repo() -> None:
orch = _orch()
on_a = MagicMock(slug="be", git_url="https://x/a.git", dep_update_command="uv -U")
on_a2 = MagicMock(slug="fe", git_url="https://x/a.git", dep_update_command="uv -U")
off = MagicMock(slug="c", git_url="https://x/c.git", dep_update_command=None)
svc = MagicMock()
svc.list_all = AsyncMock(return_value=[on_a, on_a2, off])
with patch("roboco.services.project.get_project_service", return_value=svc):
eligible = await orch._load_dep_update_set(MagicMock())
assert len(eligible) == 1 # no-command excluded; same-repo collapsed
assert eligible[0].git_url == "https://x/a.git"
def _db_ctx(db: Any):
@asynccontextmanager
async def _ctx() -> Any:
yield db
return _ctx
@pytest.mark.asyncio
async def test_cycle_warns_and_skips_engine_when_empty() -> None:
orch = _orch()
orch._load_dep_update_set = AsyncMock(return_value=[]) # type: ignore[method-assign]
get_eng = MagicMock()
with (
patch("roboco.db.get_db_context", _db_ctx(MagicMock())),
patch("roboco.services.dep_update_engine.get_dep_update_engine", get_eng),
):
await orch._run_dep_update_cycle()
get_eng.assert_not_called()
@pytest.mark.asyncio
async def test_cycle_runs_engine_when_eligible_present() -> None:
orch = _orch()
eligible = [MagicMock()]
orch._load_dep_update_set = AsyncMock(return_value=eligible) # type: ignore[method-assign]
db = MagicMock()
db.commit = AsyncMock()
engine = MagicMock()
engine.run_cycle = AsyncMock(return_value=[])
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch(
"roboco.services.dep_update_engine.get_dep_update_engine",
return_value=engine,
) as get_eng,
):
await orch._run_dep_update_cycle()
get_eng.assert_called_once()
engine.run_cycle.assert_awaited_once_with(eligible)
db.commit.assert_awaited_once()
@@ -0,0 +1,85 @@
"""The orchestrator sweeper prunes dangling Docker images (gated + throttled).
Each agent-image rebuild orphans the prior build's layers as an untagged
``<none>`` image; over many deploys these pile up. The sweeper reclaims them
with ``docker image prune -f --filter dangling=true`` (dangling only never a
tagged image or one backing a running container), gated by
``image_prune_enabled`` and throttled to ``image_prune_interval_seconds``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
def _orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator) # bypass __init__
orch._last_image_prune = None
return orch
def _fake_proc(returncode: int = 0) -> MagicMock:
proc = MagicMock()
proc.returncode = returncode
proc.communicate = AsyncMock(return_value=(b"Total reclaimed space: 1.2GB", b""))
return proc
@pytest.mark.asyncio
async def test_prunes_dangling_when_enabled_and_due(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "image_prune_enabled", True)
orch = _orch()
spawn = AsyncMock(return_value=_fake_proc())
with patch("roboco.runtime.orchestrator.asyncio.create_subprocess_exec", spawn):
await orch._sweep_dangling_images()
spawn.assert_awaited_once()
args: tuple[Any, ...] = spawn.await_args.args
assert args[:6] == (
"docker",
"image",
"prune",
"-f",
"--filter",
"dangling=true",
)
assert orch._last_image_prune is not None
@pytest.mark.asyncio
async def test_no_prune_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "image_prune_enabled", False)
orch = _orch()
spawn = AsyncMock()
with patch("roboco.runtime.orchestrator.asyncio.create_subprocess_exec", spawn):
await orch._sweep_dangling_images()
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_throttled_when_recently_pruned(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "image_prune_enabled", True)
orch = _orch()
orch._last_image_prune = datetime.now(UTC) # pruned moments ago
spawn = AsyncMock()
with patch("roboco.runtime.orchestrator.asyncio.create_subprocess_exec", spawn):
await orch._sweep_dangling_images()
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_best_effort_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "image_prune_enabled", True)
orch = _orch()
spawn = AsyncMock(side_effect=RuntimeError("no docker"))
with patch("roboco.runtime.orchestrator.asyncio.create_subprocess_exec", spawn):
await orch._sweep_dangling_images() # must not raise
@@ -0,0 +1,86 @@
"""MultiProjectCITelemetrySource fans out the hardened per-project CI lookup.
One red project yields a breaching sample; a green one a non-breaching sample; a
None signal or a per-project error yields NO sample (unknown, never "green") and
never aborts the sweep. Each project's ci_watch_workflow (or the configured
default) is passed through to the reused lookup.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.services.telemetry.source import MultiProjectCITelemetrySource
def _project(slug: str, workflow: str | None = None) -> MagicMock:
return MagicMock(slug=slug, ci_watch_workflow=workflow)
def _ci(conclusion: str) -> dict[str, Any]:
return {
"conclusion": conclusion,
"branch": "master",
"run_url": f"https://github.com/x/{conclusion}/actions/runs/1",
"completed_at": "2026-06-25T00:00:00Z",
"run_name": "CI",
}
@pytest.mark.asyncio
async def test_fanout_red_green_and_none() -> None:
projects = [_project("red"), _project("green"), _project("nosig")]
async def conclusion(slug: str, **_kwargs: Any) -> Any:
return {"red": _ci("failure"), "green": _ci("success"), "nosig": None}[slug]
git = MagicMock()
git.get_latest_ci_conclusion = AsyncMock(side_effect=conclusion)
with patch("roboco.services.telemetry.source.GitService", return_value=git):
samples = await MultiProjectCITelemetrySource(MagicMock()).fetch(projects)
by_repo = {s.repo_hint: s for s in samples}
assert by_repo["red"].is_breach is True
assert by_repo["green"].is_breach is False
assert "nosig" not in by_repo # None signal → no sample (unknown, not green)
@pytest.mark.asyncio
async def test_per_project_error_isolated() -> None:
projects = [_project("boom"), _project("ok")]
async def conclusion(slug: str, **_kwargs: Any) -> Any:
if slug == "boom":
raise RuntimeError("github down")
return _ci("failure")
git = MagicMock()
git.get_latest_ci_conclusion = AsyncMock(side_effect=conclusion)
with patch("roboco.services.telemetry.source.GitService", return_value=git):
samples = await MultiProjectCITelemetrySource(MagicMock()).fetch(projects)
by_repo = {s.repo_hint: s for s in samples}
assert "boom" not in by_repo # error → no sample, never aborts the sweep
assert by_repo["ok"].is_breach is True # others still returned
@pytest.mark.asyncio
async def test_per_project_workflow_passthrough(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "ci_watch_default_workflow", "ci.yml")
projects = [_project("custom", workflow="release.yml"), _project("default")]
git = MagicMock()
git.get_latest_ci_conclusion = AsyncMock(return_value=_ci("success"))
with patch("roboco.services.telemetry.source.GitService", return_value=git):
await MultiProjectCITelemetrySource(MagicMock()).fetch(projects)
workflows = {
c.args[0]: c.kwargs["workflow"]
for c in git.get_latest_ci_conclusion.await_args_list
}
assert workflows["custom"] == "release.yml"
assert workflows["default"] == "ci.yml"
Generated
+1 -1
View File
@@ -2404,7 +2404,7 @@ wheels = [
[[package]] [[package]]
name = "roboco" name = "roboco"
version = "0.11.1" version = "0.12.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },