feat(git): auto-regenerate + commit codegen drift before push (#632)

A project that checks in generated artifacts (RoboCo's lifecycle renders,
verb tables) drifts whenever their source changes. The agent pre-submit gate
(make gate) omits foundation-check, so drift is invisible at the desk and only
fails on CI's drift gate — a failure with no link back to the task, which made
one live task thrash 8 revision rounds.

New per-project codegen_command (migration 078): run in the task's worktree
right before push, and any drift committed into the same push, so CI never
sees stale artifacts. Fail-open — a broken/timeout codegen command logs and
lets the push proceed (CI's drift gate is the safety net); a null command
(every project without checked-in codegen) is a pure no-op. Hooked at both
push_branch (open_pr's first push, the PR head CI grades) and push_task_branch
(later re-pushes). RoboCo sets codegen_command='make codegen' (a new Makefile
target — the write counterpart to foundation-check's read) via the panel.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-21 16:38:07 +02:00
committed by GitHub
co-authored by Renn F
parent 0527e9ebf3
commit 5f42a93b4f
12 changed files with 415 additions and 0 deletions
+9
View File
@@ -577,3 +577,12 @@ foundation-check:
# `foundation-check` is now the canonical drift gate; this alias just forwards.
.PHONY: ci-lifecycle-check
ci-lifecycle-check: foundation-check
# Write counterpart to foundation-check's read: regenerates the same checked-in
# artifacts IN PLACE (no diff/exit-code guard) so a project can point its
# `codegen_command` at this and have drift committed before a push, instead of
# only ever discovering it at CI's foundation-check hard-fail.
.PHONY: codegen
codegen:
@$(MAKE) lifecycle
@uv run python scripts/regenerate_verb_tables.py
@@ -0,0 +1,37 @@
"""Per-project codegen-drift regeneration command.
Some projects check in generated artifacts (rendered docs, generated verb/prompt
tables, ...) that drift whenever their source changes. The agent pre-submit
gate (``quality_command``, e.g. ``make gate``) never regenerates them, so drift
only ever surfaces later as CI's own foundation-check-style `git diff
--exit-code` hard-fail — a failure an agent has no way to trace back to its own
task. ``codegen_command`` (e.g. ``make codegen``) is run in the task's
workspace before every push; any resulting drift is committed so the pushed PR
head is never stale. Additive and nullable — null (the default) is a pure
no-op for projects with no generated artifacts, mirroring ``quality_command``.
Revision ID: 078_project_codegen_command
Revises: 077_github_app
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "078_project_codegen_command"
down_revision = "077_github_app"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("codegen_command", sa.String(length=500), nullable=True),
)
def downgrade() -> None:
op.drop_column("projects", "codegen_command")
@@ -148,6 +148,9 @@ function EditProjectForm({
const [qualityCommand, setQualityCommand] = useState(
project.quality_command || "",
);
const [codegenCommand, setCodegenCommand] = useState(
project.codegen_command || "",
);
const [ciWatchEnabled, setCiWatchEnabled] = useState(
project.ci_watch_enabled,
);
@@ -226,6 +229,7 @@ function EditProjectForm({
typecheck_command: typecheckCommand || undefined,
build_command: buildCommand || undefined,
quality_command: qualityCommand || undefined,
codegen_command: codegenCommand || undefined,
ci_watch_enabled: ciWatchEnabled,
ci_watch_workflow: ciWatchWorkflow || undefined,
video_engine_enabled: videoEngineEnabled,
@@ -552,6 +556,22 @@ function EditProjectForm({
in the dev&apos;s workspace at hand-off to QA.
</p>
</div>
<div className="grid gap-2">
<HelpTip label="Command that regenerates checked-in generated files, e.g. `make codegen`; run and committed before push so codegen drift never fails CI. Leave blank if the project has no generated artifacts.">
<Label htmlFor="codegen_command">Codegen Command</Label>
</HelpTip>
<Input
id="codegen_command"
value={codegenCommand}
onChange={(e) => setCodegenCommand(e.target.value)}
placeholder="make codegen"
/>
<p className="text-xs text-muted-foreground">
Regenerates checked-in generated artifacts; any drift is
committed in the task&apos;s workspace before each push.
</p>
</div>
</>
)}
+1
View File
@@ -168,6 +168,7 @@ export const projectsApi = {
typecheck_command: project.typecheck_command ?? null,
build_command: project.build_command ?? null,
quality_command: project.quality_command ?? null,
codegen_command: project.codegen_command ?? null,
ci_watch_enabled: false,
ci_watch_workflow: null,
video_engine_enabled: false,
+3
View File
@@ -1052,6 +1052,7 @@ export interface Project {
typecheck_command: string | null;
build_command: string | null;
quality_command: string | null;
codegen_command: string | null;
// Autonomous maintenance opt-in
ci_watch_enabled: boolean;
ci_watch_workflow: string | null;
@@ -1091,6 +1092,7 @@ export interface ProjectCreate {
typecheck_command?: string;
build_command?: string;
quality_command?: string;
codegen_command?: string;
}
export interface ProjectUpdate {
@@ -1114,6 +1116,7 @@ export interface ProjectUpdate {
typecheck_command?: string;
build_command?: string;
quality_command?: string;
codegen_command?: string;
// Autonomous maintenance opt-in
ci_watch_enabled?: boolean;
ci_watch_workflow?: string;
+4
View File
@@ -49,6 +49,7 @@ class ProjectResponse(BaseModel):
typecheck_command: str | None = None
build_command: str | None = None
quality_command: str | None = None
codegen_command: str | None = None
# Autonomous maintenance opt-in
ci_watch_enabled: bool = False
@@ -162,6 +163,7 @@ class ProjectCreateRequest(BaseModel):
typecheck_command: str | None = None
build_command: str | None = None
quality_command: str | None = None
codegen_command: str | None = None
class ProjectUpdateRequest(BaseModel):
@@ -201,6 +203,7 @@ class ProjectUpdateRequest(BaseModel):
typecheck_command: str | None = None
build_command: str | None = None
quality_command: str | None = None
codegen_command: str | None = None
# Autonomous maintenance opt-in
ci_watch_enabled: bool | None = None
@@ -294,6 +297,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
typecheck_command=project.typecheck_command,
build_command=project.build_command,
quality_command=project.quality_command,
codegen_command=project.codegen_command,
ci_watch_enabled=bool(project.ci_watch_enabled),
ci_watch_workflow=project.ci_watch_workflow,
video_engine_enabled=bool(project.video_engine_enabled),
+4
View File
@@ -532,6 +532,10 @@ class ProjectTable(Base):
# the agent i_am_done gate runs this in the dev's workspace instead of the
# lint/typecheck pair — e.g. "make gate".
quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Regenerates checked-in generated artifacts (e.g. "make codegen"); run and
# any drift committed in the task's workspace before every push, so CI's
# codegen-drift gate never fails on inherited drift. Null = no codegen step.
codegen_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
+11
View File
@@ -189,6 +189,15 @@ class Project(TimestampMixin):
"When set it replaces the lint/typecheck pair in the gate."
),
)
codegen_command: str | None = Field(
default=None,
description=(
"Command that regenerates checked-in generated artifacts "
"(e.g. 'make codegen'); run and any drift committed before push "
"so CI's codegen-drift gate never fails on inherited drift. "
"Null = no codegen step."
),
)
# Access Control
assigned_cell: Team = Field(..., description="Which cell owns this project")
@@ -309,6 +318,7 @@ class ProjectCreate(RobocoBase):
typecheck_command: str | None = None
build_command: str | None = None
quality_command: str | None = None
codegen_command: str | None = None
class ProjectUpdate(RobocoBase):
@@ -339,6 +349,7 @@ class ProjectUpdate(RobocoBase):
typecheck_command: str | None = None
build_command: str | None = None
quality_command: str | None = None
codegen_command: str | None = None
assigned_cell: Team | None = None
allowed_agents: list[UUID] | None = None
is_active: bool | None = None
+77
View File
@@ -1782,6 +1782,9 @@ class GitService(BaseService):
if project is None:
return 0
workspace = await self.get_workspace(project.slug, agent_id)
# Regenerate + commit any codegen drift BEFORE the push carries it —
# a no-op unless the project sets codegen_command.
await self._run_codegen_and_commit(str(task.branch_name), workspace)
# Push the task's branch BY NAME, independent of the current checkout.
# The dev's clone is shared across tasks, so by the QA-submission /
# open_pr boundary it is usually parked on a LATER task's branch; the
@@ -4242,6 +4245,77 @@ class GitService(BaseService):
workspace = await self.get_workspace(project.slug, actor_agent_id)
return await run_quality_commands(workspace, commands)
@staticmethod
def _codegen_command_for(project: Any) -> str | None:
"""The project's ``codegen_command``, or ``None`` if unset.
The ``isinstance`` check is defensive (the column is ``str | None``)
and also keeps a loosely-specced test double inert: a bare
``MagicMock`` auto-vivifies any attribute access, so without it every
unrelated GitService test would spuriously trip the codegen path.
"""
command = getattr(project, "codegen_command", None)
return command if isinstance(command, str) and command else None
async def _run_codegen_and_commit(self, branch_name: str, workspace: Path) -> None:
"""Regenerate + commit codegen drift in the branch's worktree before push.
Some projects check in generated artifacts (rendered docs, generated
verb tables, ...) that drift whenever their source changes. Left
unregenerated, drift only ever surfaces later as CI's own drift gate
(a `git diff --exit-code` hard-fail) a failure with no obvious link
back to the task that caused it. Running the project's
``codegen_command`` here means any drift lands in the SAME push that's
about to open/update the PR, so CI never sees it stale.
Fail-open by design: a broken/timing-out codegen command, or any
resolution failure (missing task/project, worktree trouble), logs a
warning and is skipped the push proceeds without a commit rather
than blocking delivery. A red CI drift-gate on the resulting PR is the
safety net, not a silent pass. A null/absent ``codegen_command`` (most
projects) is a pure no-op.
"""
try:
task = await self._task_for_branch(branch_name)
if task is None:
return
project = await self._project_for_task(task)
if project is None:
return
command = self._codegen_command_for(project)
if command is None:
return
task_id = require_uuid(task.id)
worktree = self._worktree_for_task(workspace, task_id)
await self._ensure_worktree_for_commit(workspace, worktree, branch_name)
result = await run_quality_commands(worktree, [("codegen", command)])
if not result.passed:
self.log.warning(
"codegen_command_failed",
project=getattr(project, "slug", None),
task_id=str(task_id),
output=result.output_excerpt,
)
return
status = await self._run_git(
worktree, ["status", "--porcelain"], check=False
)
if not status.stdout.strip():
return # codegen ran clean — nothing to commit
await self._run_git(worktree, ["add", "-A"])
await self._run_git(
worktree,
[
"commit",
"-m",
f"[{str(task_id)[:8]}] regenerate generated artifacts",
],
)
except Exception as exc:
self.log.warning(
"codegen_and_commit_failed", branch=branch_name, error=str(exc)
)
async def toolchain_status_for_task(
self, actor_agent_id: UUID, task: Any
) -> str | None:
@@ -4377,6 +4451,9 @@ class GitService(BaseService):
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
# Regenerate + commit any codegen drift BEFORE this first push opens
# the PR — a no-op unless the project sets codegen_command.
await self._run_codegen_and_commit(branch_name, workspace)
# Push the NAMED branch, not the workspace's current checkout. The
# clone root is shared across a dev's tasks and (F123) parked on the
# default branch while the task branch lives in a per-task worktree;
+1
View File
@@ -140,6 +140,7 @@ class ProjectService(BaseService):
typecheck_command=data.typecheck_command,
build_command=data.build_command,
quality_command=data.quality_command,
codegen_command=data.codegen_command,
created_by=created_by,
)
@@ -0,0 +1,67 @@
"""Per-project codegen-drift regeneration command (migration 078).
Migration 078 adds ``projects.codegen_command`` (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="C-Proj",
slug=f"c-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_codegen_command_column_defaults_null(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
assert project.codegen_command is None
@pytest.mark.asyncio
async def test_codegen_command_column_round_trip(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
project.codegen_command = "make codegen"
await db_session.flush()
row = (
await db_session.execute(
select(ProjectTable).where(ProjectTable.id == project.id)
)
).scalar_one()
assert row.codegen_command == "make codegen"
+181
View File
@@ -19,6 +19,7 @@ from roboco.config import settings
from roboco.exceptions import GitCommandError, GitError, MergeConflictError
from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError
from roboco.services.forge import RepoRef
from roboco.services.gateway.quality_gate import GateResult
from roboco.services.git import GitService
if TYPE_CHECKING:
@@ -163,6 +164,25 @@ async def test_push_task_branch_pushes_task_branch_by_name() -> None:
assert_branch.assert_not_awaited()
@pytest.mark.asyncio
async def test_push_task_branch_runs_codegen_before_push() -> None:
"""push_task_branch regenerates + commits codegen drift before pushing."""
task = MagicMock(branch_name="feature/backend/abc")
project = MagicMock(slug="roboco")
svc = _service()
_bind(svc, "_assert_task_owned_with_branch", AsyncMock(return_value=task))
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
codegen_mock = AsyncMock()
_bind(svc, "_run_codegen_and_commit", codegen_mock)
push_mock = AsyncMock(return_value=("feature/backend/abc", _PUSHED_COMMIT_COUNT))
_bind(svc, "push", push_mock)
await svc.push_task_branch(uuid4(), uuid4())
codegen_mock.assert_awaited_once_with("feature/backend/abc", Path("/tmp/ws"))
@pytest.mark.asyncio
async def test_push_branch_pushes_named_branch_not_current_checkout() -> None:
"""push_branch (open_pr's push side effect) pushes the NAMED branch.
@@ -192,6 +212,23 @@ async def test_push_branch_pushes_named_branch_not_current_checkout() -> None:
push_mock.assert_awaited_once_with(Path("/tmp/ws"), branch=branch_name)
@pytest.mark.asyncio
async def test_push_branch_runs_codegen_before_push() -> None:
"""push_branch (open_pr's FIRST push) regenerates + commits codegen drift
before pushing the initial PR head must never carry stale artifacts."""
branch_name = "feature/backend/abc"
svc = _service()
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
codegen_mock = AsyncMock()
_bind(svc, "_run_codegen_and_commit", codegen_mock)
push_mock = AsyncMock(return_value=(branch_name, _PUSHED_COMMIT_COUNT))
_bind(svc, "push", push_mock)
await svc.push_branch(branch_name)
codegen_mock.assert_awaited_once_with(branch_name, Path("/tmp/ws"))
@pytest.mark.asyncio
async def test_push_targets_explicit_branch_not_current_checkout() -> None:
"""push(branch=X) pushes X by ref even when the workspace is on Y."""
@@ -367,6 +404,150 @@ async def test_push_task_branch_noop_for_project_less_task() -> None:
push_mock.assert_not_awaited()
# ---------------------------------------------------------------------------
# _run_codegen_and_commit: regenerate + commit codegen drift before push
# ---------------------------------------------------------------------------
def test_codegen_command_for_returns_none_when_unset() -> None:
project = MagicMock(codegen_command=None)
assert GitService._codegen_command_for(project) is None
def test_codegen_command_for_ignores_unspecced_mock_attribute() -> None:
"""A bare MagicMock auto-vivifies codegen_command as a truthy MagicMock —
the isinstance(str) guard must treat that the same as unset, or every
loosely-specced GitService test would spuriously trip the codegen path."""
assert GitService._codegen_command_for(MagicMock()) is None
def test_codegen_command_for_returns_configured_command() -> None:
project = MagicMock(codegen_command="make codegen")
assert GitService._codegen_command_for(project) == "make codegen"
@pytest.mark.asyncio
async def test_run_codegen_and_commit_noop_when_command_unset() -> None:
"""Null codegen_command (most projects) never runs a subprocess."""
task = MagicMock(id=uuid4(), branch_name="feature/backend/abc")
project = MagicMock(codegen_command=None)
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
run_mock = AsyncMock()
with patch("roboco.services.git.run_quality_commands", run_mock):
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
run_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_codegen_and_commit_noop_when_no_task() -> None:
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(return_value=None))
run_mock = AsyncMock()
with patch("roboco.services.git.run_quality_commands", run_mock):
await svc._run_codegen_and_commit("feature/backend/missing", Path("/tmp/ws"))
run_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_codegen_and_commit_commits_drift() -> None:
"""Codegen runs clean but produces drift -> add -A + a task-prefixed commit."""
task_id = uuid4()
task = MagicMock(id=task_id, branch_name="feature/backend/abc")
project = MagicMock(codegen_command="make codegen", slug="roboco")
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
_bind(svc, "_ensure_worktree_for_commit", AsyncMock())
calls: list[list[str]] = []
async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock:
calls.append(args)
res = MagicMock()
res.returncode = 0
res.stdout = " M docs/rag/lifecycle/foo.md\n" if args[0] == "status" else ""
return res
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
gate_result = GateResult(passed=True, output="ok")
with patch(
"roboco.services.git.run_quality_commands",
AsyncMock(return_value=gate_result),
):
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
assert ["add", "-A"] in calls
commit_call = next(c for c in calls if c[0] == "commit")
assert commit_call == [
"commit",
"-m",
f"[{str(task_id)[:8]}] regenerate generated artifacts",
]
@pytest.mark.asyncio
async def test_run_codegen_and_commit_noop_when_codegen_clean() -> None:
"""Codegen runs clean with NO drift -> git status is clean, no commit."""
task = MagicMock(id=uuid4(), branch_name="feature/backend/abc")
project = MagicMock(codegen_command="make codegen", slug="roboco")
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
_bind(svc, "_ensure_worktree_for_commit", AsyncMock())
calls: list[list[str]] = []
async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock:
calls.append(args)
res = MagicMock()
res.returncode = 0
res.stdout = ""
return res
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
gate_result = GateResult(passed=True, output="ok")
with patch(
"roboco.services.git.run_quality_commands",
AsyncMock(return_value=gate_result),
):
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
assert not any(c[0] in ("add", "commit") for c in calls)
@pytest.mark.asyncio
async def test_run_codegen_and_commit_fail_open_on_command_failure() -> None:
"""A non-zero codegen exit logs a warning and skips the commit (fail-open) —
push must proceed rather than a broken codegen command blocking delivery."""
task = MagicMock(id=uuid4(), branch_name="feature/backend/abc")
project = MagicMock(codegen_command="make codegen", slug="roboco")
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
_bind(svc, "_ensure_worktree_for_commit", AsyncMock())
run_git_mock = AsyncMock()
_bind(svc, "_run_git", run_git_mock)
gate_result = GateResult(passed=False, failures=("codegen",), output="boom")
with patch(
"roboco.services.git.run_quality_commands",
AsyncMock(return_value=gate_result),
):
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
# Never even checks git status — a failed codegen command skips straight
# through without touching git.
run_git_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_codegen_and_commit_fail_open_on_exception() -> None:
"""Any unexpected exception (worktree resolution, git failure, ...) must
never raise out of this helper and break the caller's push."""
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(side_effect=RuntimeError("boom")))
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
# ---------------------------------------------------------------------------
# diff: derives parent + invokes git diff
# ---------------------------------------------------------------------------