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]
## [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
- **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.
- **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`).
**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
+1 -1
View File
@@ -129,7 +129,7 @@ Choose the registry and version with two env vars (defaults shown):
```bash
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.
@@ -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
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.
+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_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_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**
@@ -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_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
- **[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"
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
- 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. |
| [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. |
| [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"
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.
- 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.
+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). |
| 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. |
| 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). |
| 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`). |
@@ -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.
!!! 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"
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_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
| Variable | Default | Description |
+1
View File
@@ -166,6 +166,7 @@ nav:
- Pitch provisioning: optional/pitch-provisioning.md
- External / internal PR review: optional/pr-review.md
- Self-healing CI: optional/self-heal.md
- Autonomous maintenance: optional/autonomous-maintenance.md
- Configure & Deploy:
- deploy/index.md
- Deployment: deploy/deployment.md
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "roboco-panel",
"version": "0.11.1",
"version": "0.12.0",
"private": true,
"packageManager": "pnpm@10.25.0",
"scripts": {
@@ -70,12 +70,23 @@ function EditProjectForm({
const [qualityCommand, setQualityCommand] = useState(
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
const [newToken, setNewToken] = useState("");
const [clearToken, setClearToken] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showAutonomy, setShowAutonomy] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -98,6 +109,15 @@ function EditProjectForm({
typecheck_command: typecheckCommand || undefined,
build_command: buildCommand || 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
@@ -339,6 +359,77 @@ function EditProjectForm({
</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>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
+4
View File
@@ -84,6 +84,10 @@ export const projectsApi = {
typecheck_command: project.typecheck_command ?? null,
build_command: project.build_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,
last_synced_at: null,
head_commit: null,
+10
View File
@@ -1128,6 +1128,11 @@ export interface Project {
typecheck_command: string | null;
build_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
workspace_path: string | null;
last_synced_at: string | null;
@@ -1170,6 +1175,11 @@ export interface ProjectUpdate {
typecheck_command?: string;
build_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 {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
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"
authors = [
{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.
"""
__version__ = "0.11.1"
__version__ = "0.12.0"
# Core exports
from roboco.config import settings
+5
View File
@@ -213,6 +213,11 @@ async def update_project(
format_command=data.format_command,
typecheck_command=data.typecheck_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,
)
+16
View File
@@ -44,6 +44,12 @@ class ProjectResponse(BaseModel):
build_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
workspace_path: str | None = None
last_synced_at: datetime | None = None
@@ -130,6 +136,12 @@ class ProjectUpdateRequest(BaseModel):
build_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
is_active: bool | None = None
@@ -210,6 +222,10 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
typecheck_command=project.typecheck_command,
build_command=project.build_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,
last_synced_at=project.last_synced_at,
head_commit=project.head_commit,
+88 -2
View File
@@ -28,7 +28,7 @@ class Settings(BaseSettings):
# ==========================================================================
# Application
# ==========================================================================
app_version: str = "0.11.1"
app_version: str = "0.12.0"
debug: bool = False
environment: str = Field(
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.",
)
# 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)
# ==========================================================================
@@ -548,7 +617,7 @@ class Settings(BaseSettings):
agent_image_tag: str = Field(
default="",
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 "
"agent_image_registry set."
),
@@ -569,6 +638,23 @@ class Settings(BaseSettings):
"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(
default=True,
description=(
+16
View File
@@ -497,6 +497,22 @@ class ProjectTable(Base):
# lint/typecheck pair — e.g. "make gate".
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
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
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")
# 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
created_by: UUID = Field(..., description="PM who registered the project")
is_active: bool = Field(default=True, description="Whether project is active")
@@ -154,3 +172,7 @@ class ProjectUpdate(RobocoBase):
assigned_cell: Team | None = None
allowed_agents: list[UUID] | 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
# Last time the transcript-retention prune ran (throttled in the sweep).
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-limited providers and resolves waiting agents on success.
self._rate_limit_probe_task: asyncio.Task | None = None
self._strategy_engine_task: asyncio.Task | None = None
self._external_pr_poll_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
# backend. Only providers needing a non-Claude-Code runtime are
# 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._external_pr_poll_task = asyncio.create_task(self._external_pr_poll_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(
"Orchestrator started",
@@ -837,45 +842,31 @@ class AgentOrchestrator:
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:
"""Stop the orchestrator and all agents."""
self._running = False
# Cancel background tasks
if self._health_task:
self._health_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._health_task
if self._dispatcher_task:
self._dispatcher_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._dispatcher_task
if self._sweeper_task:
self._sweeper_task.cancel()
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
# Cancel every background loop, then stop the agents.
for task in (
self._health_task,
self._dispatcher_task,
self._sweeper_task,
self._rate_limit_probe_task,
self._strategy_engine_task,
self._external_pr_poll_task,
self._self_heal_task,
self._ci_watch_task,
self._dep_update_task,
):
await self._cancel_background_task(task)
# Stop all agents
for agent_id in list(self._instances.keys()):
@@ -5009,6 +5000,10 @@ Start by:
# operator's bind-mounted ~/.claude doesn't grow without bound.
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)
# rather than the default-off external-PR poll loop, so a supersede that
# lands after external_pr_enabled is toggled off is still reconciled.
@@ -5038,6 +5033,49 @@ Start by:
await db.rollback()
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:
"""Prune agent transcripts older than the retention window.
@@ -5527,6 +5565,120 @@ Start by:
except Exception:
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
def _repo_key(git_url: str) -> str:
"""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"),
("transcript_prune_enabled", "Transcript pruning"),
("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)
+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.
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:
"""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(
self, project_id: UUID, pr_number: int, head_sha: str | None = None
) -> 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
PR's head commit: a review task records the SHA it covered as an
``external_pr_head=<sha>`` marker in ``quick_context``. So:
De-dupe key for inbound external-PR ingestion. The scope is the **repo**
(``git_url``), not a single project: a monorepo registers several
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);
- a task already covers THIS ``head_sha`` -> True (skip nothing changed);
- 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 (the PR got new commits
open a fresh review for the change).
- no review task for this PR on any project in this repo -> False;
- a task already covers THIS ``head_sha`` -> True (nothing changed);
- a legacy/markerless task exists, or ``head_sha`` is unknown -> True;
- tasks exist but all cover OTHER SHAs -> False (new commits re-review).
"""
# 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(
select(TaskTable.orchestration_markers).where(
TaskTable.project_id == project_id,
TaskTable.project_id.in_(scope_ids),
TaskTable.source.in_(PR_REVIEW_SOURCES),
TaskTable.pr_number == pr_number,
)
@@ -999,6 +1033,49 @@ class TaskService(BaseService):
)
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]:
"""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:
"""Construct the GitHub-CI telemetry source bound to ``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 os
import re
import shlex
import shutil
import subprocess
import tempfile
import time
from collections.abc import Iterator
from pathlib import Path
@@ -42,6 +44,10 @@ from roboco.services.toolchain import resolve_target_python
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
# hex) or a symbolic ref ("ref: refs/..."). Anything else is debris — used to
# detect broken loose refs left by interrupted recovery before a fetch.
@@ -1267,6 +1273,98 @@ class WorkspaceService:
)
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(
self,
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]]
name = "roboco"
version = "0.11.1"
version = "0.12.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },