feat(env-branches): per-project ordered environment ladder (replaces default_branch) (#534)

* [env-bran] EnvSyncEngine: orchestrator-side prod→dev cascade (default-off)

- EnvSyncEngine mirrors CiWatchEngine: cascade ladder_pairs top-down via
  GitHub merges API; clean→auto-push lower rung, conflict→one sync PR +
  tracked MAIN_PM task + stop. Never pushes prod (lower rung is never prod
  by construction).
- GitService.sync_env_branch (merges API) + open_sync_pr (idempotent) +
  _env_merge_status/_post_sync_pr helpers (constants for 201/204/409).
- TaskService.ENV_SYNC_SOURCE + list_open_env_sync_tasks (per-repo dedup).
- config env_sync_enabled/_interval_seconds(1800)/_max_open_tasks(3)/_max_per_cycle(1).
- Orchestrator 4-touch registration + _load_env_sync_set (ladder+token opt-in).
- Feature-flags card + settings FEATURE_FLAGS entry for ROBOCO_ENV_SYNC_ENABLED.

* [env-bran] Panel: environment ladder editor + types + validation

- EnvironmentRung type + environments on Project/ProjectCreate/ProjectUpdate.
- EnvironmentLadderEditor (plain useState, add/remove/up-down reorder, head/
  prod labels) reused by create + edit project dialogs.
- validateLadder (non-empty name+branch, no duplicate branches) shared,
  toast.error on submit; empty editor => null => inherits default_branch shim.
- default_branch input kept with override-hint; API client passthrough.
- 6 unit tests for validateLadder.

* [env-bran] Tests + gate green: env ladder, EnvSyncEngine, promotion chain

- tests/unit/models/test_env_branches.py: shim, head/prod, ladder_pairs,
  promotion_chain, normalize (20 tests)
- tests/integration/services/test_env_sync_engine.py: cascade clean/conflict/
  missing_ref/tokenless/degenerate/caps/dedup/disabled (9 tests, DB)
- tests/integration/test_migration_env_branches.py: 073 defaults null + round-trip
- tests/unit/services/test_release_executor*.py: add env_chain=[] to
  _ReleaseContext constructions (promotion_chain field is now required)
- tests/unit/runtime/test_orchestrator_shutdown_drain.py: register _env_sync_task
  in the stop()-drain fixture (new named background loop)
- roboco/services/git.py: revert _project_head_branch rename back to
  _project_default_branch (modify-in-place per plan); the rename in the
  consumers commit broke ~15 unit-test mocks that bind the original name
- roboco/services/env_sync_engine.py + models/env_branches.py: ruff format
- roboco/api/schemas/project.py: trailing-newline format

Backend gate green (13013 passed / 439 skipped), mypy clean, ruff clean.
Panel gate green (typecheck/lint/522 tests).

* [env-bran] fix: add env_chain to _ReleaseContext in e2e smoke (CI red)

The release-executor promotion_chain change made _ReleaseContext.env_chain
required. I fixed the three unit/release test files but missed the
construction in tests/e2e_smoke/test_background_engines.py:98 — my local
gate ran 'mypy roboco/' (excludes tests/) and I skipped 'make e2e-smoke',
so CI's mypy-on-tests + the e2e runtime job caught it instead of me.

Verified locally with the CI-equivalent gates:
  uv run mypy roboco/ tests/   -> 1170 files, clean
  ROBOCO_E2E_SMOKE=1 uv run pytest tests/e2e_smoke -> 50 passed, 1 skipped

* [env-bran] fix: extract _ensure_prod_fetched to clear xenon rank C (CI red)

_production_assess grew past xenon --max-absolute B (rank C) when the
env-branches prod-tip fetch added an if/try/except branch. Extracted the
fetch-with-fallback into _ensure_prod_fetched (degan+fetch paths), moved
_run_git to the module-level import. Local make quality green (all gates
incl xenon/vulture/deptry/import-linter/foundation-check).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:43:37 +02:00
committed by GitHub
co-authored by Renn F
parent b7f2d84c77
commit d80dfb8bbe
25 changed files with 1399 additions and 24 deletions
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { validateLadder } from "@/components/projects/ladder-validation";
import type { EnvironmentRung } from "@/types";
const rungs = (rows: [string, string][]): EnvironmentRung[] =>
rows.map(([name, branch]) => ({ name, branch }));
describe("validateLadder", () => {
it("accepts null (inherits default_branch via the shim)", () => {
expect(validateLadder(null)).toBeNull();
});
it("accepts an empty ladder", () => {
expect(validateLadder([])).toBeNull();
});
it("accepts a clean ordered ladder", () => {
expect(
validateLadder(rungs([["head", "dev"], ["prod", "master"]])),
).toBeNull();
});
it("rejects a rung missing a name", () => {
expect(validateLadder(rungs([["", "dev"]]))).toMatch(/name and a branch/);
});
it("rejects a rung missing a branch", () => {
expect(validateLadder(rungs([["head", " "]]))).toMatch(/name and a branch/);
});
it("rejects duplicate branches", () => {
expect(
validateLadder(rungs([["head", "dev"], ["prod", "dev"]])),
).toMatch(/Duplicate branch "dev"/);
});
});
@@ -24,6 +24,8 @@ import {
import { Plus, Key } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectCreate } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -48,6 +50,7 @@ export function CreateProjectDialog() {
git_token: "",
assigned_cell: Team.BACKEND,
default_branch: "main",
environments: null,
});
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -74,6 +77,12 @@ export function CreateProjectDialog() {
return;
}
const envError = validateLadder(formData.environments ?? null);
if (envError) {
toast.error(envError);
return;
}
try {
await createProject.mutateAsync({
name: formData.name,
@@ -82,6 +91,7 @@ export function CreateProjectDialog() {
assigned_cell: formData.assigned_cell,
git_token: formData.git_token || undefined,
default_branch: formData.default_branch || "main",
environments: formData.environments ?? undefined,
test_command: formData.test_command || undefined,
lint_command: formData.lint_command || undefined,
format_command: formData.format_command || undefined,
@@ -98,6 +108,7 @@ export function CreateProjectDialog() {
git_token: "",
assigned_cell: Team.BACKEND,
default_branch: "main",
environments: null,
});
setShowAdvanced(false);
} catch (error) {
@@ -222,8 +233,17 @@ export function CreateProjectDialog() {
}
placeholder="main"
/>
<p className="text-xs text-muted-foreground">
Fallback head+prod branch when no environment ladder is set below.
</p>
</div>
{/* Environment ladder */}
<EnvironmentLadderEditor
rungs={formData.environments ?? null}
onChange={(rungs) => setFormData({ ...formData, environments: rungs })}
/>
{/* Advanced Options Toggle */}
<Button
type="button"
@@ -27,6 +27,8 @@ import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -82,6 +84,7 @@ function EditProjectForm({
const [gitUrl, setGitUrl] = useState(project.git_url);
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
const [environments, setEnvironments] = useState(project.environments ?? null);
const [isActive, setIsActive] = useState(project.is_active);
const [testCommand, setTestCommand] = useState(project.test_command || "");
const [lintCommand, setLintCommand] = useState(project.lint_command || "");
@@ -152,12 +155,19 @@ function EditProjectForm({
return;
}
const envError = validateLadder(environments);
if (envError) {
toast.error(envError);
return;
}
// Build update payload
const updates: ProjectUpdate = {
name,
git_url: gitUrl,
assigned_cell: assignedCell,
default_branch: defaultBranch || "main",
environments,
is_active: isActive,
test_command: testCommand || undefined,
lint_command: lintCommand || undefined,
@@ -336,8 +346,17 @@ function EditProjectForm({
onChange={(e) => setDefaultBranch(e.target.value)}
placeholder="main"
/>
<p className="text-xs text-muted-foreground">
Fallback head+prod branch when no environment ladder is set below.
</p>
</div>
{/* Environment ladder */}
<EnvironmentLadderEditor
rungs={environments}
onChange={setEnvironments}
/>
{/* Active Status */}
<div className="flex items-center justify-between">
<Label htmlFor="is_active">Active</Label>
@@ -0,0 +1,155 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react";
import type { EnvironmentRung } from "@/types";
interface EnvironmentLadderEditorProps {
// null/empty => degenerate 1-rung ladder synthesized from default_branch.
rungs: EnvironmentRung[] | null;
onChange: (rungs: EnvironmentRung[] | null) => void;
}
// An empty editor (no rungs) means "inherit default_branch" via the backend
// shim, so we keep a plain array internally and emit null when it empties.
function toRungs(value: EnvironmentRung[] | null): EnvironmentRung[] {
return value ?? [];
}
export function EnvironmentLadderEditor({
rungs,
onChange,
}: EnvironmentLadderEditorProps) {
const items = toRungs(rungs);
const emit = (next: EnvironmentRung[]) => {
onChange(next.length ? next : null);
};
const handleAdd = () => {
emit([...items, { name: "", branch: "" }]);
};
const handleRemove = (index: number) => {
emit(items.filter((_, i) => i !== index));
};
const handleUpdate = (index: number, field: keyof EnvironmentRung, value: string) => {
const next = items.map((r, i) => (i === index ? { ...r, [field]: value } : r));
emit(next);
};
const handleMove = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= items.length) return;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
emit(next);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Environment ladder</Label>
<span className="text-xs text-muted-foreground">
{items.length} rung{items.length !== 1 ? "s" : ""}
</span>
</div>
{items.length > 0 && (
<div className="space-y-2 border rounded-lg p-3 bg-muted/30">
{items.map((rung, index) => {
const isFirst = index === 0;
const isLast = index === items.length - 1;
return (
<div key={index} className="flex items-center gap-2">
<div className="flex flex-col">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={isFirst}
onClick={() => handleMove(index, -1)}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Move up (toward head)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={isLast}
onClick={() => handleMove(index, 1)}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Move down (toward prod)</TooltipContent>
</Tooltip>
</div>
<span className="text-[10px] uppercase text-muted-foreground w-10 text-center">
{isFirst ? "head" : isLast ? "prod" : `rung ${index + 1}`}
</span>
<Input
value={rung.name}
onChange={(e) => handleUpdate(index, "name", e.target.value)}
placeholder="Name (e.g. dev, qa, stag)"
className="flex-1 h-8"
/>
<Input
value={rung.branch}
onChange={(e) => handleUpdate(index, "branch", e.target.value)}
placeholder="Branch (e.g. dev, master)"
className="flex-1 h-8"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove this rung</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd}>
<Plus className="h-4 w-4 mr-1" />
Add rung
</Button>
<p className="text-xs text-muted-foreground">
Ordered topbottom: the first rung is <strong>head</strong> (where dev PRs
land) and the last is <strong>prod</strong> (the release target). Leave
empty to inherit <em>default branch</em> for both e.g.{" "}
<code>dev qa stag prod</code>, or just <code>prod</code> for a
single-branch project. When set, this overrides <em>default branch</em>{" "}
for the PR target and the release target.
</p>
</div>
);
}
@@ -0,0 +1,19 @@
import type { EnvironmentRung } from "@/types";
// Validate a ladder before submit. Returns an error string or null when valid.
// null/empty is valid (inherits default_branch via the backend shim).
export function validateLadder(rungs: EnvironmentRung[] | null): string | null {
if (!rungs || rungs.length === 0) return null;
const branches: string[] = [];
for (const rung of rungs) {
if (!rung.name.trim() || !rung.branch.trim()) {
return "Every environment rung needs both a name and a branch.";
}
const branch = rung.branch.trim();
if (branches.includes(branch)) {
return `Duplicate branch "${branch}" — each rung must target a unique branch.`;
}
branches.push(branch);
}
return null;
}
@@ -63,6 +63,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Watch every opted-in project's CI and open a fix task when its default branch goes red (per-project opt-in; never auto-merges).",
dep_update_enabled:
"Periodically probe opted-in projects for dependency updates and open an update task when a lockfile would change (per-project opt-in; never auto-merges).",
env_sync_enabled:
"Cascade each project's declared environment ladder prod→dev via GitHub's merges API so dev never falls behind prod; a conflicted rung opens a sync PR for you to merge (per-project opt-in; never pushes prod).",
release_manager_enabled:
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
org_memory_enabled:
+1
View File
@@ -125,6 +125,7 @@ export const projectsApi = {
slug: project.slug,
git_url: project.git_url,
default_branch: project.default_branch ?? "main",
environments: project.environments ?? null,
protected_branches: project.protected_branches ?? ["main", "master"],
assigned_cell: project.assigned_cell,
has_git_token: !!project.git_token, // Mock token status
+13
View File
@@ -1022,12 +1022,21 @@ export enum WorkSessionStatus {
ABANDONED = "abandoned",
}
// One rung of a project's ordered environment ladder.
export interface EnvironmentRung {
name: string;
branch: string;
}
export interface Project {
id: string;
name: string;
slug: string;
git_url: string;
default_branch: string;
// Ordered environment ladder (first=head/PR-target, last=prod/release-target).
// Null/empty => degenerate 1-rung ladder synthesized from default_branch.
environments: EnvironmentRung[] | null;
protected_branches: string[];
assigned_cell: Team;
// Git authentication (token never exposed, only boolean indicator)
@@ -1063,6 +1072,8 @@ export interface ProjectCreate {
slug: string;
git_url: string;
default_branch?: string;
// Ordered environment ladder; null/empty inherits default_branch (shim).
environments?: EnvironmentRung[] | null;
protected_branches?: string[];
assigned_cell: Team;
// Git authentication (stored encrypted, never returned)
@@ -1079,6 +1090,8 @@ export interface ProjectUpdate {
name?: string;
git_url?: string;
default_branch?: string;
// Ordered environment ladder; null clears (reverts to default_branch shim).
environments?: EnvironmentRung[] | null;
protected_branches?: string[];
assigned_cell?: Team;
// Git authentication (empty string clears, undefined leaves unchanged)
-1
View File
@@ -295,4 +295,3 @@ def project_to_summary(
ci_watch_enabled=bool(project.ci_watch_enabled),
task_counts=task_counts,
)
+31
View File
@@ -818,6 +818,37 @@ class Settings(BaseSettings):
description="Max dep_update tasks the loop may originate in one cycle.",
)
# Env-sync engine — cascades each opted-in project's env ladder prod→…→head
# (a clean merge auto-pushes to the lower rung; a conflict opens ONE sync PR)
# so dev never falls behind prod. Default-off; never pushes to prod (the
# cascade's lower/target rung is never prod by construction).
env_sync_enabled: bool = Field(
default=False,
description=(
"Master switch for the env-sync cascade loop. OFF by default; when "
"off the loop does not run. Only projects with a declared env "
"ladder (environments set) AND a git token participate."
),
)
env_sync_interval_seconds: int = Field(
default=1800,
ge=60,
description="Seconds between env-sync cascade passes.",
)
env_sync_max_open_tasks: int = Field(
default=3,
ge=1,
description=(
"Rolling cap on concurrently-open env_sync conflict tasks across "
"all repos; the loop originates nothing more while this many are open."
),
)
env_sync_max_per_cycle: int = Field(
default=1,
ge=1,
description="Max projects the env-sync loop may cascade in one cycle.",
)
# Gated release manager — at a logical point (accumulated unreleased changes
# past a threshold + green gate) the Secretary runs a deterministic readiness
# sweep and PROPOSES a release for the CEO to approve/reject. Default-off;
+13
View File
@@ -87,6 +87,19 @@ def ladder_pairs(project: object) -> list[tuple[EnvRung, EnvRung]]:
return [(rungs[i], rungs[i - 1]) for i in range(len(rungs) - 1, 0, -1)]
def promotion_chain(project: object) -> list[str]:
"""Branches to merge into the prod checkout head->...->just-below-prod on a
CEO-gated release (the full-chain promotion).
Every rung except the last (prod), excluding any rung sharing the prod
branch — so a degenerate (head==prod) ladder yields ``[]`` (no-op). Order
is head-first: ``[dev, qa, stag]`` for ``[dev, qa, stag, master]``.
"""
rungs = effective_environments(project)
prod = prod_branch(project)
return [r.branch for r in rungs[:-1] if r.branch != prod]
def normalize_environments(
value: list[dict[str, str]] | list[EnvRung] | None,
) -> list[dict[str, str]] | None:
+69
View File
@@ -1109,6 +1109,7 @@ class AgentOrchestrator:
self._self_heal_task: asyncio.Task | None = None
self._ci_watch_task: asyncio.Task | None = None
self._dep_update_task: asyncio.Task | None = None
self._env_sync_task: asyncio.Task | None = None
self._release_manager_task: asyncio.Task | None = None
self._x_mentions_task: asyncio.Task | None = None
self._roadmap_engine_task: asyncio.Task | None = None
@@ -1205,6 +1206,7 @@ class AgentOrchestrator:
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())
self._env_sync_task = asyncio.create_task(self._env_sync_loop())
self._release_manager_task = asyncio.create_task(self._release_manager_loop())
self._x_mentions_task = asyncio.create_task(self._x_mentions_poll_loop())
self._roadmap_engine_task = asyncio.create_task(self._roadmap_engine_loop())
@@ -1321,6 +1323,7 @@ class AgentOrchestrator:
self._self_heal_task,
self._ci_watch_task,
self._dep_update_task,
self._env_sync_task,
self._release_manager_task,
self._x_mentions_task,
self._roadmap_engine_task,
@@ -8137,6 +8140,72 @@ Start by:
await get_dep_update_engine(db).run_cycle(projects)
await db.commit()
async def _env_sync_loop(self) -> None:
"""Env-sync: cascade prod→…→head so dev never falls behind prod.
Dormant by default returns immediately unless ``env_sync_enabled``,
so a standard deployment adds zero behaviour. Each interval it loads the
opted-in projects (a declared env ladder + a git token), cascades the
ladder top-down via GitHub's merges API, and opens ONE sync PR + tracked
task per conflicted repo. Never pushes prod (the cascade's target is a
non-prod rung by construction), so "only the CEO merges master" holds.
"""
if not settings.env_sync_enabled:
return
interval = settings.env_sync_interval_seconds
self._record_loop_heartbeat("env_sync", interval)
while self._running:
try:
await asyncio.sleep(interval)
await self._run_env_sync_cycle()
self._record_loop_heartbeat("env_sync", interval)
except asyncio.CancelledError:
break
except Exception:
logger.exception("env-sync cycle failed")
async def _run_env_sync_cycle(self) -> None:
"""One env-sync pass: load the opted-in set, run the engine, commit.
Extracted from the loop so it is testable without the sleep. Warns when
env-sync is armed but no project has a declared env ladder so a
misconfiguration isn't mistaken for "everything is in sync".
"""
from roboco.db import get_db_context
from roboco.services.env_sync_engine import get_env_sync_engine
async with get_db_context() as db:
projects = await self._load_env_sync_set(db)
if not projects:
logger.warning(
"env-sync enabled but no project has a declared environment "
"ladder + git token — nothing to cascade"
)
return
await get_env_sync_engine(db).run_cycle(projects)
await db.commit()
async def _load_env_sync_set(self, db: Any) -> list[Any]:
"""Projects opted into env-sync: a declared env ladder (more than one
rung, so there are pairs to cascade) + a git_url, one per repo.
A degenerate ladder (head==prod, one rung) has no pairs to cascade, so it
is excluded here there is nothing to sync. Collapse to one canonical
project per repo: the engine's per-``git_url`` open-task dedup means a
monorepo's several cell-projects share one cascade anyway.
"""
from roboco.models.env_branches import ladder_pairs
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 getattr(p, "git_url", None) and ladder_pairs(p)
]
return self._projects_one_per_key(
eligible,
key_fn=lambda p: (self._repo_key(str(getattr(p, "git_url", "") or "")),),
)
async def _release_manager_loop(self) -> None:
"""Gated release manager: at a logical point, propose a CEO-gated release.
+202
View File
@@ -0,0 +1,202 @@
"""Env-sync engine — cascade prod→…→head so dev never falls behind prod.
Dormant by default (``env_sync_enabled``). For each opted-in project (a declared
env ladder + a git token) it cascades the upper rungs into the lower ones top-down
via GitHub's merges API: a clean merge auto-pushes to the lower rung, and a
conflict opens ONE sync PR (upperlower) and stops that project's cascade for the
cycle. Like the other background engines it is conservative:
* **Default OFF** (``env_sync_enabled``) the orchestrator loop never starts.
* **Never pushes prod** the cascade's lower/target rung is never prod by
construction (``ladder_pairs`` yields ``(upper, lower)`` with lower the
non-prod rungs), so "only the CEO merges master" holds.
* **Bounded + deduped per repo** at most one open env_sync task per repo (a
conflict stops the cascade at that rung), plus per-cycle and rolling 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.models.env_branches import ladder_pairs
from roboco.services.base import BaseService
from roboco.services.git import get_git_service
from roboco.services.task import (
ENV_SYNC_SOURCE,
TaskCreateRequest,
TaskService,
get_task_service,
)
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
class EnvSyncEngine(BaseService):
"""Cascade the env ladder prod→…→head so dev never falls behind prod."""
service_name = "env_sync_engine"
def __init__(self, session: AsyncSession) -> None:
super().__init__(session)
async def run_cycle(self, projects: list[Any]) -> list[TaskTable]:
"""Cascade each opted-in project's ladder; return conflict-PR tasks opened.
No-op unless ``env_sync_enabled``. Per-cycle + rolling caps; one open
env_sync task per repo (a conflict pauses the cascade at that rung).
Never pushes prod. Flushes; the caller (the orchestrator loop) owns the
commit.
"""
if not settings.env_sync_enabled:
return []
task_svc = get_task_service(self.session)
open_count = len(await task_svc.list_open_env_sync_tasks())
created: list[TaskTable] = []
for project in projects:
if len(created) >= settings.env_sync_max_per_cycle:
break
if open_count >= settings.env_sync_max_open_tasks:
self.log.info(
"env-sync open-task cap reached; not cascading",
cap=settings.env_sync_max_open_tasks,
)
break
if not await self._should_sync(task_svc, project):
continue
task = await self._cascade_project(project)
if task is not None:
created.append(task)
open_count += 1
return created
async def _should_sync(self, task_svc: TaskService, project: Any) -> bool:
"""True when ``project`` is opted in and not already being synced.
Opt-in = a declared env ladder (so there are pairs to cascade) + a git
token (the merges API needs a PAT). A degenerate ladder (head==prod)
has no pairs skip. Dedup is per repo: a project with an open env_sync
task has its cascade paused at a conflicted rung, so skip it until the
sync PR resolves.
"""
if project is None or getattr(project, "id", None) is None:
return False
# ProjectTable exposes ``git_token_encrypted`` (the column), not the
# ``has_git_token`` API-response boolean — the merges API needs a PAT.
if not getattr(project, "git_token_encrypted", None):
return False
if not ladder_pairs(project):
return False
existing = await task_svc.list_open_env_sync_tasks(
git_url=getattr(project, "git_url", None)
)
return not existing
async def _cascade_project(self, project: Any) -> TaskTable | None:
"""Cascade one project top→down; on a conflict open one sync PR + task.
Clean / already-ancestor steps continue down the ladder. A missing ref
(misconfigured ladder or API error) skips the project without opening a
PR. A conflict opens a sync PR and a tracked task, then stops never
cascade a dirty merge downward.
"""
slug = str(getattr(project, "slug", "") or "")
git = get_git_service(self.session)
for upper, lower in ladder_pairs(project):
pair = f"{upper.branch}{lower.branch}"
result = await git.sync_env_branch(slug, lower.branch, upper.branch)
status = result.get("status")
if status in ("merged", "already_ancestor"):
self.log.info("env-sync step", project=slug, pair=pair, status=status)
continue
if status == "missing_ref":
self.log.warning(
"env-sync step missing ref; skipping project",
project=slug,
pair=pair,
)
return None
# conflict: open a sync PR + tracked task, stop the cascade.
pr = await git.open_sync_pr(
slug,
upper.branch,
lower.branch,
body=(
f"The env-sync cascade could not merge `{upper.branch}` "
f"into `{lower.branch}` cleanly (non-fast-forward). Resolve "
f"the conflict and merge this PR so the cascade can resume.\n\n"
f"Opened automatically by the env-sync loop."
),
)
if pr is None:
self.log.warning(
"env-sync conflict but sync PR open failed; skipping project",
project=slug,
pair=pair,
)
return None
task = await self._open_sync_task(project, upper.branch, lower.branch, pr)
self.log.info(
"env-sync conflict; sync PR opened",
project=slug,
pair=pair,
pr=pr["number"],
)
return task
return None
async def _open_sync_task(
self,
project: Any,
upper_branch: str,
lower_branch: str,
pr: dict[str, Any],
) -> TaskTable:
"""Open ONE PENDING, dispatchable coordination task tracking the sync PR."""
task_svc = get_task_service(self.session)
slug = str(getattr(project, "slug", "") or "")
return await task_svc.create(
TaskCreateRequest(
title=(
f"env-sync: resolve {upper_branch}{lower_branch} conflict "
f"on {slug} (PR #{pr['number']})"
),
description=(
f"The env-sync cascade could not merge `{upper_branch}` into "
f"`{lower_branch}` cleanly (conflict). A sync PR was opened:\n"
f"{pr['url']}\n\n"
"Resolve the conflict and merge the PR so the cascade can "
"resume. This is a Main-PM coordination root: decompose the "
"resolution and delegate the code work to a cell dev — the "
"Main PM does not resolve it directly. Opened automatically by "
"the env-sync loop; it still ships through the normal gates "
"(QA, PR review, and the CEO's merge)."
),
acceptance_criteria=[
f"The sync PR {pr['url']} is merged (conflict resolved)",
f"`{lower_branch}` is not behind `{upper_branch}`",
],
team=Team.MAIN_PM,
assigned_to=_foundation.AGENTS["main-pm"].uuid,
created_by=_foundation.AGENTS["system"].uuid,
task_type=TaskType.PLANNING,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=ENV_SYNC_SOURCE,
confirmed_by_human=True,
)
)
def get_env_sync_engine(session: AsyncSession) -> EnvSyncEngine:
"""Construct an EnvSyncEngine bound to ``session``."""
return EnvSyncEngine(session)
+159 -7
View File
@@ -298,6 +298,10 @@ _REV_LIST_PARTS = 2
# GitHub REST API status codes
_GH_UNPROCESSABLE = 422
# merges-API success codes: 201 = merge commit created + pushed; 204 = nothing
# to merge (head already an ancestor of base).
_HTTP_CREATED = 201
_HTTP_NO_CONTENT = 204
# 404 means the PR (or repo) does not exist; surfaced as a typed GitError
# by `update_pr_for_task` so the gateway can convert it into a specific
# invalid_state envelope rather than the generic refusal message.
@@ -1122,9 +1126,9 @@ class GitService(BaseService):
parent = await task_service.get(UUID(str(task.parent_task_id)))
if parent and parent.branch_name:
return str(parent.branch_name)
return await self._project_head_branch(project_slug)
return await self._project_default_branch(project_slug)
async def _project_head_branch(self, project_slug: str) -> str:
async def _project_default_branch(self, project_slug: str) -> str:
"""Return the project's head environment branch (ladder index 0).
This is where dev/cell/leaf PRs target the dev trunk. Falls back to
@@ -1196,7 +1200,7 @@ class GitService(BaseService):
base_branch = await self._resolve_base_branch(
task_id, request.parent_branch, request.project_slug, task_service
)
default_branch = await self._project_head_branch(request.project_slug)
default_branch = await self._project_default_branch(request.project_slug)
# Token for any remote-touching git command below (fetch, ls-remote,
# pull, push). Injected into a single `http.extraheader` config for
@@ -2422,7 +2426,7 @@ class GitService(BaseService):
Returns: (pr_number, pr_url, title, source_branch, target_branch)
"""
source_branch = await self._pr_head_branch(workspace, request)
default_branch = await self._project_head_branch(request.project_slug)
default_branch = await self._project_default_branch(request.project_slug)
git_token = await self._get_project_token_or_raise(request.project_slug)
target_branch, pr_title, pr_body = await self._resolve_new_pr_context(
workspace, request, source_branch, default_branch, git_token
@@ -2463,6 +2467,154 @@ class GitService(BaseService):
target_branch,
)
async def sync_env_branch(
self, project_slug: str, target_branch: str, source_branch: str
) -> dict[str, Any]:
"""Merge ``source_branch`` (an upper env rung) into ``target_branch`` (the
lower rung) server-side via GitHub's merges API — one step of the
prodhead cascade. The merge commit lands on ``target_branch`` (the
clean-cascade auto-push). The cascade's target is never prod by
construction (``ladder_pairs``), so prod is never pushed here.
Returns ``{"status": ...}``:
* ``already_ancestor`` target already contains source (HTTP 204).
* ``merged`` merge commit created + pushed to target (HTTP 201; ``sha``).
* ``conflict`` non-fast-forward / merge conflict (HTTP 409); no commit.
* ``missing_ref`` no token / unparseable remote / a branch absent (422).
Never raises into the engine loop. Does NOT open a PR on conflict
the caller decides that.
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return {"status": "missing_ref"}
git_token = await self._token_for_project(project_slug)
if not git_token:
return {"status": "missing_ref"}
try:
owner, repo = self._parse_git_url(project.git_url)
except GitError:
return {"status": "missing_ref"}
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.post(
f"{_api_base()}/repos/{owner}/{repo}/merges",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"base": target_branch,
"head": source_branch,
"commit_message": f"sync: {source_branch}{target_branch}",
},
)
except httpx.HTTPError as exc:
self.log.warning(
"env-sync merges API error", project=project_slug, error=str(exc)
)
return {"status": "missing_ref"}
return self._env_merge_status(resp, project_slug)
def _env_merge_status(
self, resp: httpx.Response, project_slug: str
) -> dict[str, Any]:
"""Map a GitHub merges-API response to an env-sync status dict.
``merged`` carries the new merge ``sha``; ``conflict`` (409) leaves the
target untouched; any other code (incl. 422 missing-ref / no-merge) is
``missing_ref`` so the engine skips without opening a PR.
"""
if resp.status_code == _HTTP_CREATED:
return {"status": "merged", "sha": resp.json().get("sha")}
if resp.status_code == _HTTP_NO_CONTENT:
return {"status": "already_ancestor"}
if resp.status_code == _HTTP_CONFLICT:
return {"status": "conflict"}
self.log.warning(
"env-sync merges API unexpected status",
project=project_slug,
status=resp.status_code,
body=resp.text[:200],
)
return {"status": "missing_ref"}
async def open_sync_pr(
self, project_slug: str, source_branch: str, target_branch: str, body: str
) -> dict[str, Any] | None:
"""Open (or reuse) a sync PR ``source_branch → target_branch``.
Idempotent: reuses an already-open PR for the same headbase. Returns
``{"number", "url"}`` or None on a missing token / unparseable remote /
GitHub error never raises into the engine loop.
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return None
git_token = await self._token_for_project(project_slug)
if not git_token:
return None
try:
owner_repo = self._parse_git_url(project.git_url)
except GitError:
return None
return await self._post_sync_pr(
owner_repo, git_token, (source_branch, target_branch), body, project_slug
)
async def _post_sync_pr(
self,
owner_repo: tuple[str, str],
git_token: str,
branches: tuple[str, str],
body: str,
project_slug: str,
) -> dict[str, Any] | None:
"""Reuse an open sync PR or create a new one for ``source→target``.
Never raises into the engine loop: a missing existing PR, a rejected
create, or a transport error all return None.
"""
owner, repo = owner_repo
source_branch, target_branch = branches
existing = await self._find_existing_pr(
owner, repo, source_branch, target_branch, git_token
)
if existing is not None:
return {
"number": int(existing["number"]),
"url": str(existing.get("html_url", "")),
}
try:
resp = await self._post_pr(
owner,
repo,
git_token,
{
"title": f"sync: {source_branch}{target_branch}",
"body": body,
"head": source_branch,
"base": target_branch,
},
)
except GitError as exc:
self.log.warning(
"env-sync PR create failed", project=project_slug, error=str(exc)
)
return None
if not resp.is_success:
self.log.warning(
"env-sync PR create rejected",
project=project_slug,
status=resp.status_code,
body=resp.text[:200],
)
return None
data = resp.json()
return {"number": int(data["number"]), "url": str(data.get("html_url", ""))}
async def _resolve_new_pr_context(
self,
workspace: Path,
@@ -3514,7 +3666,7 @@ class GitService(BaseService):
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
target_branch = await self._project_head_branch(project_slug)
target_branch = await self._project_default_branch(project_slug)
# Default branch always exists on origin, so the plain sync is correct
# here. The best-effort variant guards the agent-facing pr_merge path,
# whose target can be an integration branch deleted from origin.
@@ -3930,7 +4082,7 @@ class GitService(BaseService):
layering is preserved. Fall back to the default branch only if the
create push itself fails.
"""
default_branch = await self._project_head_branch(project_slug)
default_branch = await self._project_default_branch(project_slug)
if base_branch == default_branch:
return base_branch
ls = await self._run_git(
@@ -4296,7 +4448,7 @@ class GitService(BaseService):
# repo's default branch — a root→master PR is merged solely by the CEO
# via approve-&-merge (merge_pr_for_task, CEO-gated from
# awaiting_ceo_approval). Agents open the master PR and escalate.
default_branch = await self._project_head_branch(project.slug)
default_branch = await self._project_default_branch(project.slug)
if target == default_branch:
raise UnauthorizedError(
action="pr_merge",
+3 -5
View File
@@ -527,7 +527,7 @@ def _resolve_release_ci_workflow() -> str:
async def get_release_executor(session: AsyncSession) -> ReleaseExecutor:
"""Build a ReleaseExecutor with a production ops over a fresh writable clone."""
from roboco.config import settings
from roboco.models.env_branches import effective_environments, prod_branch
from roboco.models.env_branches import prod_branch, promotion_chain
from roboco.services.project import get_project_service
slug = (settings.self_heal_project_slug or "roboco-api").strip()
@@ -541,10 +541,8 @@ async def get_release_executor(session: AsyncSession) -> ReleaseExecutor:
# of the dev retarget. default_branch stays as the legacy/shim source.
default_branch = prod_branch(project)
# Full-chain promotion: rung branches head→…→just-below-prod to merge into
# the prod checkout before bumping. Skips the prod rung itself and any rung
# sharing the prod branch (degenerate head==prod → empty → no-op).
rungs = effective_environments(project)
env_chain = [r.branch for r in rungs[:-1] if r.branch != default_branch]
# the prod checkout before bumping. Degenerate (head==prod) → [] → no-op.
env_chain = promotion_chain(project)
# PAT rides a per-call ``-c http.extraheader=Authorization: Basic …`` config
# (mirrors workspace.py :642 / :1285) so it never lands in the clone/push
# argv — ``/proc/<pid>/cmdline`` would otherwise expose a URL-embedded token.
+25 -11
View File
@@ -37,6 +37,7 @@ from roboco.services.notification import NotificationService
from roboco.services.project import get_project_service
from roboco.services.release_readiness import (
ReleaseReadinessReport,
_run_git,
assess,
gather_snapshot,
report_to_dict,
@@ -213,7 +214,6 @@ class ReleaseManagerEngine(BaseService):
"""
from roboco.models.env_branches import head_branch, prod_branch
from roboco.services.git import get_git_service
from roboco.services.release_readiness import _run_git
from roboco.services.workspace import get_workspace_service
slug = _roboco_slug()
@@ -250,15 +250,9 @@ class ReleaseManagerEngine(BaseService):
# already present. Best-effort: a fetch failure degrades to last_tag..HEAD
# (gather_snapshot falls back when prod_tip is None) — never aborts.
prod_name = prod_branch(project)
have_prod = prod_name != head_branch(project)
if have_prod:
try:
await asyncio.to_thread(
_run_git, Path(root), ["fetch", "origin", prod_name]
)
except Exception as exc:
self.log.warning("release-manager: prod fetch failed", error=str(exc))
have_prod = False
prod_for_snapshot = await self._ensure_prod_fetched(
root, prod_name, prod_name != head_branch(project)
)
today = datetime.now(UTC).strftime("%Y-%m-%d")
# gather_snapshot runs multiple sync `subprocess.run` git calls + a
# filesystem walk; offload so the shared API event loop isn't blocked
@@ -267,10 +261,30 @@ class ReleaseManagerEngine(BaseService):
gather_snapshot,
Path(root),
master_ci_conclusion=conclusion,
prod_branch=prod_name if have_prod else None,
prod_branch=prod_for_snapshot,
)
return assess(snapshot, today=today)
async def _ensure_prod_fetched(
self, root: Path, prod_name: str, needs_fetch: bool
) -> str | None:
"""Ensure ``origin/<prod>`` is fetched for the prod..head readiness diff.
Degenerate (prod==head) needs no fetch the read clone is pinned to
head==prod. Best-effort: a fetch failure returns None so gather_snapshot
falls back to last_tag..HEAD rather than aborting the assessment.
"""
if not needs_fetch:
return prod_name
try:
await asyncio.to_thread(
_run_git, Path(root), ["fetch", "origin", prod_name]
)
except Exception as exc:
self.log.warning("release-manager: prod fetch failed", error=str(exc))
return None
return prod_name
def get_release_manager_engine(
session: AsyncSession, assessor: ReleaseAssessor | None = None
+1
View File
@@ -62,6 +62,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("gateway_health_enabled", "Gateway-health recovery"),
("ci_watch_enabled", "Multi-repo CI-watch"),
("dep_update_enabled", "Dependency-update bot"),
("env_sync_enabled", "Environment-branch sync (cascade prod→dev)"),
("docs_sync_enabled", "Docs-divergence sync (release -> docs-update task)"),
("release_manager_enabled", "Gated release manager"),
("org_memory_enabled", "Organizational memory loop"),
+26
View File
@@ -603,6 +603,12 @@ DEP_UPDATE_SOURCE = "dep_update"
# auto-merges; requires the roboco-website project to be registered.
DOCS_SYNC_SOURCE = "docs_sync"
# Source tag for an env-sync conflict task: opened by the EnvSyncEngine when the
# prod→…→head cascade hits a non-fast-forward on a rung and opens a sync PR. Rides
# the normal delivery lifecycle (+ PR-review gate) and is never auto-merged; the
# task tracks the PR so the dedup cap + panel visibility work.
ENV_SYNC_SOURCE = "env_sync"
# Source tag for a gated release proposal: opened by the release-manager engine
# when accumulated unreleased changes pass the threshold + the gate is green.
# Unlike the sources above it is NEVER dispatched — it is HELD for the CEO
@@ -1575,6 +1581,26 @@ class TaskService(BaseService):
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def list_open_env_sync_tasks(
self, git_url: str | None = None
) -> list[TaskTable]:
"""Non-terminal env_sync conflict tasks — the dedupe + open-cap basis.
Optionally scoped to one repo by ``git_url`` (matched on the normalized
repo key). A conflict stops the cascade at that rung, so at most one
open env_sync task exists per repo: while it is open the repo's cascade
is paused and the engine skips it until the PR resolves.
"""
stmt = select(TaskTable).where(
TaskTable.source == ENV_SYNC_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
if git_url is not None:
stmt = stmt.join(ProjectTable, TaskTable.project_id == ProjectTable.id)
stmt = stmt.where(_repo_key_expr(ProjectTable.git_url) == repo_key(git_url))
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def list_open_docs_sync_tasks(
self, version: str | None = None
) -> list[TaskTable]:
@@ -102,6 +102,7 @@ async def test_h24_wait_for_ci_polls_through_non_success(
git_url="",
git_prefix=[],
ci_workflow=None,
env_chain=[],
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
sha = "abc123"
@@ -0,0 +1,269 @@
"""EnvSyncEngine — cascade prod->head; conflict opens a PR + task, never prod.
Mirrors the ci-watch engine test: seeds projects + agents, mocks the GitService
(the merges API + PR open are GitHub calls) so the cascade is driven by the
fake's queued statuses, and asserts the engine's contract:
* clean cascade (merged / already_ancestor) opens nothing,
* a conflict opens ONE sync PR + tracked task and stops the cascade,
* a tokenless project is skipped,
* per-cycle + rolling caps + per-repo dedup are honoured,
* the cascade target is never prod (the lower rung of every pair is non-prod).
"""
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 import env_sync_engine as env_sync_module
from roboco.services.env_sync_engine import get_env_sync_engine
from roboco.services.task import ENV_SYNC_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
# A two-rung ladder: head=dev (PR target), prod=master (release target).
_LADDER = [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
class _FakeGit:
"""Stand-in for GitService: drives the cascade from queued statuses."""
def __init__(self, statuses: list[str], *, pr_number: int = 42) -> None:
self._statuses = list(statuses)
self.sync_calls: list[tuple[str, str, str]] = []
self.pr_calls: list[tuple[str, str, str, str]] = []
self._pr_number = pr_number
async def sync_env_branch(
self, slug: str, target_branch: str, source_branch: str
) -> dict[str, Any]:
self.sync_calls.append((slug, target_branch, source_branch))
status = self._statuses.pop(0) if self._statuses else "already_ancestor"
return {"status": status}
async def open_sync_pr(
self, slug: str, source_branch: str, target_branch: str, body: str
) -> dict[str, Any] | None:
self.pr_calls.append((slug, source_branch, target_branch, body))
return {
"number": self._pr_number,
"url": f"https://github.com/x/{slug}/pull/{self._pr_number}",
}
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,
*,
environments: list[dict[str, str]] | None = _LADDER,
token: str | None = "fake-encrypted-token",
) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name=slug,
slug=slug,
git_url=git_url,
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
environments=environments,
git_token_encrypted=token,
)
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, "env_sync_enabled", True)
monkeypatch.setattr(settings, "env_sync_max_per_cycle", 5)
monkeypatch.setattr(settings, "env_sync_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")
def _patch_git(monkeypatch: pytest.MonkeyPatch, fake: _FakeGit) -> None:
monkeypatch.setattr(env_sync_module, "get_git_service", lambda _session: fake)
@pytest.mark.asyncio
async def test_clean_cascade_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(db_session, "clean-a", "https://github.com/x/a.git")
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
# prod(master) merged into head(dev) — one cascade step for the 2-rung ladder.
assert fake.sync_calls == [("clean-a", "dev", "master")]
assert fake.pr_calls == []
@pytest.mark.asyncio
async def test_already_ancestor_is_clean(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(db_session, "anc-a", "https://github.com/x/anc.git")
fake = _FakeGit(["already_ancestor"])
_patch_git(monkeypatch, fake)
assert await get_env_sync_engine(db_session).run_cycle([proj]) == []
assert fake.sync_calls == [("anc-a", "dev", "master")]
@pytest.mark.asyncio
async def test_conflict_opens_pr_and_task_then_stops(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
# 4-rung ladder so a conflict on the FIRST (topmost) pair stops the cascade
# before reaching head — proving it does not cascade a dirty merge downward.
proj = await _seed_project(
db_session,
"conf-a",
"https://github.com/x/conf.git",
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
],
)
fake = _FakeGit(["conflict", "merged"]) # only the first is consumed
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert len(created) == 1
task = created[0]
assert task.source == ENV_SYNC_SOURCE
assert task.status == TaskStatus.PENDING
assert task.project_id == proj.id
# The cascade stopped at the first conflict: only one sync_env_branch call.
assert len(fake.sync_calls) == 1
# The sync PR targets the lower (non-prod) rung of the conflicted pair.
assert len(fake.pr_calls) == 1
_slug, _source_branch, target_branch, _body = fake.pr_calls[0]
assert target_branch != "master" # never prod
@pytest.mark.asyncio
async def test_missing_ref_skips_without_pr(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(db_session, "miss-a", "https://github.com/x/miss.git")
fake = _FakeGit(["missing_ref"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
assert fake.pr_calls == []
@pytest.mark.asyncio
async def test_tokenless_project_skipped(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(
db_session, "notok", "https://github.com/x/notok.git", token=None
)
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
assert fake.sync_calls == [] # never attempted
@pytest.mark.asyncio
async def test_degenerate_ladder_skipped(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
# Single-rung ladder (head==prod) has no pairs to cascade.
proj = await _seed_project(
db_session,
"single",
"https://github.com/x/single.git",
environments=[{"name": "prod", "branch": "master"}],
)
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
assert fake.sync_calls == []
@pytest.mark.asyncio
async def test_per_cycle_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "env_sync_max_per_cycle", 1)
p1 = await _seed_project(db_session, "cap-1", "https://github.com/x/c1.git")
p2 = await _seed_project(db_session, "cap-2", "https://github.com/x/c2.git")
fake = _FakeGit(["conflict", "conflict"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([p1, p2])
assert len(created) == 1 # capped at one per cycle
@pytest.mark.asyncio
async def test_deduped_per_repo(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A repo with an open env_sync task is skipped until the PR resolves."""
proj = await _seed_project(db_session, "dedup", "https://github.com/x/d.git")
fake = _FakeGit(["conflict"])
_patch_git(monkeypatch, fake)
first = await get_env_sync_engine(db_session).run_cycle([proj])
assert len(first) == 1
# Second cycle, same repo still has the open task -> deduped (no new PR).
fake2 = _FakeGit(["conflict"])
_patch_git(monkeypatch, fake2)
second = await get_env_sync_engine(db_session).run_cycle([proj])
assert second == []
assert fake2.sync_calls == [] # cascade paused at the conflicted rung
assert len(await get_task_service(db_session).list_open_env_sync_tasks()) == 1
@pytest.mark.asyncio
async def test_disabled_is_noop(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "env_sync_enabled", False)
proj = await _seed_project(db_session, "off", "https://github.com/x/off.git")
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
assert await get_env_sync_engine(db_session).run_cycle([proj]) == []
assert fake.sync_calls == []
@@ -0,0 +1,74 @@
"""Per-project environment ladder column (migration 073).
Migration 073 adds ``projects.environments`` (JSONB 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,
mirroring ``test_migration_dep_update.py``.
"""
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="L-Proj",
slug=f"l-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_environments_defaults_null(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
assert project.environments is None
@pytest.mark.asyncio
async def test_environments_round_trip(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
project.environments = [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
await db_session.flush()
row = (
await db_session.execute(
select(ProjectTable).where(ProjectTable.id == project.id)
)
).scalar_one()
assert row.environments == [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
+230
View File
@@ -0,0 +1,230 @@
"""env_branches — shim, ladder pairs, head/prod resolution, normalization.
Pure domain helpers (pydantic-only); no DB. The read-time shim synthesizes a
degenerate single-branch ladder from ``default_branch`` when ``environments``
is null, so every consumer behaves identically until a real ladder is declared.
"""
from __future__ import annotations
import pytest
from roboco.models.env_branches import (
EnvRung,
effective_environments,
head_branch,
ladder_pairs,
normalize_environments,
prod_branch,
promotion_chain,
)
class _Proj:
"""Duck-typed project row (matches Project + ProjectTable surface)."""
def __init__(
self,
*,
default_branch: str = "master",
environments: list[dict[str, str]] | None = None,
) -> None:
self.default_branch = default_branch
self.environments = environments
# --- effective_environments shim ------------------------------------------
def test_null_environments_synthesizes_degenerate_ladder() -> None:
proj = _Proj(default_branch="slave")
rungs = effective_environments(proj)
assert [(r.name, r.branch) for r in rungs] == [("head", "slave"), ("prod", "slave")]
def test_empty_environments_synthesizes_degenerate_ladder() -> None:
proj = _Proj(default_branch="master", environments=[])
rungs = effective_environments(proj)
assert [(r.name, r.branch) for r in rungs] == [
("head", "master"),
("prod", "master"),
]
def test_missing_default_branch_falls_back_to_master() -> None:
proj = _Proj(default_branch="") # falsy default_branch
assert head_branch(proj) == "master"
assert prod_branch(proj) == "master"
def test_set_environments_returned_as_is_preserving_order() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "prod", "branch": "master"},
]
)
rungs = effective_environments(proj)
assert [r.branch for r in rungs] == ["dev", "qa", "master"]
assert all(isinstance(r, EnvRung) for r in rungs)
# --- head_branch / prod_branch --------------------------------------------
def test_head_and_prod_single_rung() -> None:
proj = _Proj(environments=[{"name": "prod", "branch": "master"}])
assert head_branch(proj) == "master"
assert prod_branch(proj) == "master"
def test_head_and_prod_two_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "slave"},
{"name": "prod", "branch": "master"},
]
)
assert head_branch(proj) == "slave"
assert prod_branch(proj) == "master"
def test_head_and_prod_four_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
]
)
assert head_branch(proj) == "dev"
assert prod_branch(proj) == "master"
# --- ladder_pairs (prod -> head cascade) -----------------------------------
def test_ladder_pairs_empty_for_single_rung() -> None:
assert (
ladder_pairs(_Proj(environments=[{"name": "prod", "branch": "master"}])) == []
)
def test_ladder_pairs_two_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
)
pairs = ladder_pairs(proj)
assert [(u.branch, lower.branch) for u, lower in pairs] == [("master", "dev")]
def test_ladder_pairs_four_rungs_top_down() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
]
)
# [(prod, stag), (stag, qa), (qa, head)] — merge upper into lower.
pairs = ladder_pairs(proj)
assert [(u.branch, lower.branch) for u, lower in pairs] == [
("master", "stag"),
("stag", "qa"),
("qa", "dev"),
]
def test_ladder_pairs_lower_rung_is_never_prod() -> None:
"""The cascade's target is never prod by construction — only CEO merges prod."""
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "prod", "branch": "master"},
]
)
prod_name = prod_branch(proj)
for _upper, lower in ladder_pairs(proj):
assert lower.branch != prod_name
# --- promotion_chain (full-chain release promotion) ----------------------
def test_promotion_chain_empty_for_degenerate_ladder() -> None:
"""head==prod => nothing to promote (no-op release promotion)."""
assert promotion_chain(_Proj(default_branch="master")) == []
assert (
promotion_chain(_Proj(environments=[{"name": "prod", "branch": "master"}]))
== []
)
def test_promotion_chain_two_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
)
assert promotion_chain(proj) == ["dev"]
def test_promotion_chain_four_rungs_head_first_excluding_prod() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
]
)
assert promotion_chain(proj) == ["dev", "qa", "stag"]
# --- normalize_environments ------------------------------------------------
def test_normalize_none_returns_none() -> None:
assert normalize_environments(None) is None
assert normalize_environments([]) is None
def test_normalize_strips_and_preserves_order() -> None:
out = normalize_environments(
[{"name": " head ", "branch": " dev "}, {"name": "prod", "branch": "master"}]
)
assert out == [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
def test_normalize_rejects_empty_name() -> None:
with pytest.raises(ValueError, match="non-empty name"):
normalize_environments([{"name": "", "branch": "dev"}])
def test_normalize_rejects_empty_branch() -> None:
with pytest.raises(ValueError, match="non-empty name"):
normalize_environments(
[{"name": "head", "branch": " "}]
) # branch trimmed to empty
def test_normalize_rejects_duplicate_branch() -> None:
with pytest.raises(ValueError, match="duplicate environment branch"):
normalize_environments(
[{"name": "head", "branch": "dev"}, {"name": "prod", "branch": "dev"}]
)
def test_normalize_accepts_envrung_models() -> None:
out = normalize_environments([EnvRung(name="head", branch="dev")])
assert out == [{"name": "head", "branch": "dev"}]
@@ -50,6 +50,7 @@ def _make_orchestrator() -> AgentOrchestrator:
"_self_heal_task",
"_ci_watch_task",
"_dep_update_task",
"_env_sync_task",
"_release_manager_task",
"_x_mentions_task",
"_roadmap_engine_task",
@@ -70,6 +70,9 @@ class _FakeOps:
# instance (not via __init__ — keeps the constructor under the arg-count
# gate) by tests that exercise the retry path.
self._existing_sha: str | None = None
# env-chain promotion failure message; set on the instance (same arg-
# count-gate reason) by the promotion-failure test.
self._promote_raises: str | None = None
self.calls: list[str] = []
self.bumped_plan: list[str] | None = None
self.bumped_version: str | None = None
@@ -79,6 +82,11 @@ class _FakeOps:
self.calls.append("check")
return self._already
async def promote_env_chain(self) -> None:
self.calls.append("promote")
if self._promote_raises is not None:
raise RuntimeError(self._promote_raises)
async def release_commit_sha(self, _version: str) -> str | None:
# Half-landed detection: a prior `chore(release): {version}` commit
# already on the branch means a publish_failed retry must NOT re-run the
@@ -127,6 +135,7 @@ async def test_green_path_publishes_once() -> None:
assert ops.calls.count("publish") == _ONE
assert ops.calls == [
"check",
"promote",
"bump",
"changelog",
"gate",
@@ -204,6 +213,22 @@ async def test_publish_failure_returns_structured_publish_failed() -> None:
assert ops.calls.count("publish") == _ONE
@pytest.mark.asyncio
async def test_promotion_failure_aborts_before_bump() -> None:
"""A RuntimeError from promote_env_chain (a merge conflict in the
head->...->prod chain) becomes a structured ``promotion_failed`` result
fail-closed: the bump/changelog/gate/commit/publish pipeline never runs."""
ops = _FakeOps()
ops._promote_raises = "env-chain promotion failed: non-fast-forward"
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "promotion_failed"
assert result.commit_sha is None
assert result.release_url is None
assert "bump" not in ops.calls
assert "commit" not in ops.calls
assert "publish" not in ops.calls
def test_release_result_carries_outcome_fields() -> None:
result = ReleaseResult(
status="published",
@@ -291,6 +316,7 @@ async def test_wait_for_ci_scoped_to_release_commit_not_branch_latest(
git_url="x",
git_prefix=[],
ci_workflow="ci.yml",
env_chain=[],
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha)
@@ -341,6 +367,7 @@ async def test_wait_for_ci_polls_through_rerun(
git_url="x",
git_prefix=[],
ci_workflow="ci.yml",
env_chain=[],
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha)
@@ -387,6 +414,7 @@ async def test_wait_for_ci_exhausts_window_on_persistent_failure(
git_url="x",
git_prefix=[],
ci_workflow="ci.yml",
env_chain=[],
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha)
@@ -515,6 +543,7 @@ async def test_release_push_argv_uses_extraheader_not_url_token(
git_url=git_url,
git_prefix=git_prefix,
ci_workflow=None,
env_chain=[],
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
sha = await ops.commit_and_push("0.13.0")
@@ -21,6 +21,7 @@ def _ctx() -> _ReleaseContext:
git_url="https://github.com/o/roboco",
git_prefix=[],
ci_workflow=None,
env_chain=[],
)