feat(gateway): run a fast quality gate at i_am_done, before QA

The developer's i_am_done submit now runs the project's fast quality gate
(lint + typecheck) in the developer's workspace and blocks the transition to
awaiting_qa if it's red, returning the failing output as the remediate hint —
so a red gate is caught at the dev's desk instead of in QA review or CI. The
slow test suite intentionally stays on CI. The gate is fail-open on
infrastructure errors (missing workspace/toolchain never blocks a submit) and a
no-op for projects that configure no lint/typecheck commands. Developer prompt
updated.
This commit is contained in:
Renn F
2026-06-14 04:54:05 +02:00
parent 2db11c7833
commit 481c5fe17b
5 changed files with 298 additions and 2 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ You write code; you do not coordinate. If you find yourself thinking "let me als
| `commit(message)` | Makes the git commit, auto-prefixes `[task-id]`, records a progress entry. This is the ONLY way to commit — the gateway covers the actual git operation. | Task in `in_progress`; on your branch. |
| `open_pr(task_id)` | Push your branch and open a PR. Run after your last commit, before `i_am_done`. `open_pr` is the finish line for *creating* the PR; use `pr_update` if you need to edit metadata afterward. | Task assigned to you; at least one commit; no PR yet. |
| `pr_update(task_id, title?, body?, reviewers?)` | Update an existing PR's title, body, or reviewer list. Use after `open_pr` if you need to correct title/body or assign a reviewer. At least one field must be set. **Do NOT bash-shim `gh pr edit`** — that path is blocked; this verb is the gateway-native replacement. | Task has `pr_number`; you are the assignee (or your PM). |
| `i_am_done(task_id, notes)` | Submit for QA. Auto-runs in_progress→verifying→awaiting_qa. Requires PR already open — run `open_pr` first. | At least one commit; PR open; progress entry; journal `reflect`; every acceptance criterion addressed. |
| `i_am_done(task_id, notes)` | Submit for QA. Auto-runs in_progress→verifying→awaiting_qa. Requires PR already open — run `open_pr` first. Also runs your project's **fast quality gate (lint + typecheck) in your workspace and blocks the submit if it's red** — the failing output comes back in `remediate`; fix it, commit, and call again. | At least one commit; PR open; progress entry; journal `reflect`; every acceptance criterion addressed; lint + typecheck green. |
| `i_am_blocked(task_id, reason, blocker_type?, what_needed?)` | Records the blocker, escalates to your PM, idles you. `blocker_type``external` (waiting on a 3rd-party API/service), `internal` (a teammate or process), `question` (need clarification), `dependency` (waiting on another task). `what_needed` is a one-sentence concrete unblock request. Both fields are pre-gateway parity — PMs triage by class. | Task is yours and active. |
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
| `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. |
@@ -101,7 +101,7 @@ The gateway enforces some of these; the rest are convention but failing one of t
1. ✅ At least one `commit()` on this branch (gateway-enforced).
2. ✅ Every acceptance criterion is met by actual code or test, not just intention. Re-read them via `evidence(task_id)`.
3. ✅ Tests/lint/typecheck pass locally — run them via `Bash`. If your project has `make quality` (or equivalent), run it; QA will run it too and fail you if it's red.
3. ✅ Tests/lint/typecheck pass locally — run them via `Bash`. If your project has `make quality` (or equivalent), run it. **`i_am_done` runs the fast gate (lint + typecheck) in your workspace and rejects the submit if it's red** — so run it yourself first and submit green on the first try; QA and CI run the full gate (incl. tests) too.
4.`git diff` (call `evidence(task_id)` to inspect) shows nothing stray — no `print()` debugging, no commented-out code, no unrelated edits.
5.`note(scope='reflect', task_id=...)` walks through every criterion (gateway-enforced as `journal:reflect`).
6.`open_pr(task_id)` has been called and the response returned a PR number (gateway-enforced via `pr_number` set).
@@ -1503,12 +1503,40 @@ class Choreographer:
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._ensure_branch_pushed(ctx):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._check_quality_gate(ctx):
return await self._reject_i_am_done(ctx, rejection)
# Pre-gateway parity: persist per-criterion
# status now that all gates have passed. The write runs AFTER the
# verdict so it cannot change i_am_done's rejection behavior.
await self._write_criteria_status(ctx.agent_id, ctx.task_id, ctx.task)
return None
async def _check_quality_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Run the project's fast quality gate (lint + typecheck) in the dev's
workspace before the task reaches QA, so a red gate is caught at the
dev's desk instead of in QA review or CI. The full test suite stays on
CI. Fail-open: a gate-infrastructure error (missing workspace or
toolchain) is logged and never blocks the submit; only an actual check
failure blocks.
"""
try:
result = await self.git.run_pre_submit_quality_gate(ctx.agent_id, ctx.task)
except Exception as exc:
logger.warning(
"quality_gate_skipped", task_id=str(ctx.task_id), error=str(exc)
)
return None
if result.passed:
return None
return Envelope.invalid_state(
message=f"quality gate failed before QA — {result.summary}",
remediate=(
"Fix these in your workspace, commit, and call i_am_done again "
"— QA reviews working code, not a red gate:\n\n" + result.output_excerpt
),
context_briefing=ctx.briefing,
)
async def _ensure_branch_pushed(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Push the task branch to origin before it reaches awaiting_qa.
+90
View File
@@ -0,0 +1,90 @@
"""Run a project's fast quality gate in a developer's workspace.
Invoked by the developer's ``i_am_done`` submit so a red gate is caught at the
dev's desk instead of surfacing in QA review or CI. The gate runs the project's
configured *non-mutating* fast checks (lint, typecheck); the slow test suite
intentionally stays on CI. The gate is fail-open on infrastructure errors (a
missing workspace or absent toolchain never blocks a submit) and fail-closed on
an actual check failure.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
# A single check can be slow (mypy on a large tree); cap it generously but
# never hang the submit verb forever.
_GATE_TIMEOUT_SECONDS = 600
# Cap the remediate excerpt so a huge lint dump doesn't bloat the envelope.
_OUTPUT_EXCERPT_CHARS = 2000
@dataclass(frozen=True)
class GateResult:
"""Outcome of a pre-submit quality gate run."""
passed: bool
skipped: bool = False
failures: tuple[str, ...] = ()
output: str = ""
@property
def summary(self) -> str:
if self.skipped:
return "no quality commands configured (skipped)"
if self.passed:
return "all checks passed"
return f"failed: {', '.join(self.failures)}"
@property
def output_excerpt(self) -> str:
"""The tail of the combined output (where errors usually are)."""
return self.output[-_OUTPUT_EXCERPT_CHARS:]
async def run_quality_commands(
workspace: Path, commands: list[tuple[str, str]]
) -> GateResult:
"""Run each ``(name, command)`` in ``workspace`` and aggregate the result.
Every command runs (we don't stop at the first failure) so the developer
sees all gate failures in one shot. A non-zero exit is a failure; its
combined stdout+stderr is captured for the remediate hint.
"""
if not commands:
return GateResult(passed=True, skipped=True)
failures: list[str] = []
chunks: list[str] = []
for name, command in commands:
return_code, out = await _run_one(workspace, command)
chunks.append(f"$ {command}\n{out.strip()}")
if return_code != 0:
failures.append(name)
return GateResult(
passed=not failures,
failures=tuple(failures),
output="\n\n".join(chunks),
)
async def _run_one(workspace: Path, command: str) -> tuple[int, str]:
"""Run one operator-configured command string in the workspace shell."""
proc = await asyncio.create_subprocess_shell(
command,
cwd=str(workspace),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=_GATE_TIMEOUT_SECONDS
)
except TimeoutError:
proc.kill()
return 124, f"command timed out after {_GATE_TIMEOUT_SECONDS}s"
return proc.returncode or 0, stdout.decode("utf-8", errors="replace")
+34
View File
@@ -48,6 +48,7 @@ from roboco.services.base import (
UnauthorizedError,
ValidationError,
)
from roboco.services.gateway.quality_gate import GateResult, run_quality_commands
from roboco.services.project import get_project_service
from roboco.services.task import TaskService, get_task_service
from roboco.services.work_session import get_work_session_service
@@ -1980,6 +1981,39 @@ class GitService(BaseService):
return None
return await project_service.get(project_ids[0])
@staticmethod
def _fast_gate_commands(project: Any) -> list[tuple[str, str]]:
"""The project's non-mutating fast-gate commands (lint + typecheck).
Format and the test suite are intentionally excluded: format mutates
files, and the slow test run stays on CI.
"""
candidates = (
("lint", getattr(project, "lint_command", None)),
("typecheck", getattr(project, "typecheck_command", None)),
)
return [(name, cmd) for name, cmd in candidates if cmd]
async def run_pre_submit_quality_gate(
self, actor_agent_id: UUID, task: Any
) -> GateResult:
"""Run the project's fast quality gate in the developer's workspace.
Resolves the task's project and the developer's workspace clone, then
runs the configured lint + typecheck commands there. Returns a skipped
pass when the project configures no fast-gate commands (so projects that
opt out are never blocked). Raises only on workspace-resolution failure;
the caller treats any such failure as fail-open.
"""
project = await self._project_for_task(task)
if project is None:
return GateResult(passed=True, skipped=True)
commands = self._fast_gate_commands(project)
if not commands:
return GateResult(passed=True, skipped=True)
workspace = await self.get_workspace(project.slug, actor_agent_id)
return await run_quality_commands(workspace, commands)
async def _project_slug_for_branch(self, branch_name: str) -> str | None:
"""Resolve project slug via the task that owns the branch."""
task = await self._task_for_branch(branch_name)
+144
View File
@@ -0,0 +1,144 @@
"""Pre-submit quality gate: lint + typecheck run in the dev's workspace on
i_am_done, blocking a red submit before it reaches QA. Full tests stay on CI.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.choreographer._impl import _IAmDoneContext
from roboco.services.gateway.quality_gate import GateResult, run_quality_commands
from roboco.services.git import GitService
# --- the runner (real subprocess) -------------------------------------------
@pytest.mark.asyncio
async def test_no_commands_is_a_skipped_pass(tmp_path) -> None:
result = await run_quality_commands(tmp_path, [])
assert result.passed is True
assert result.skipped is True
@pytest.mark.asyncio
async def test_all_commands_pass(tmp_path) -> None:
result = await run_quality_commands(
tmp_path, [("lint", "echo lint-ok"), ("typecheck", "true")]
)
assert result.passed is True
assert result.failures == ()
@pytest.mark.asyncio
async def test_a_failing_command_blocks_and_is_named(tmp_path) -> None:
result = await run_quality_commands(
tmp_path, [("lint", "echo problem-here; exit 1"), ("typecheck", "true")]
)
assert result.passed is False
assert "lint" in result.failures
assert "problem-here" in result.output
@pytest.mark.asyncio
async def test_every_command_runs_even_after_a_failure(tmp_path) -> None:
result = await run_quality_commands(
tmp_path, [("lint", "echo AAA; exit 1"), ("typecheck", "echo BBB; exit 2")]
)
assert result.passed is False
assert set(result.failures) == {"lint", "typecheck"}
assert "AAA" in result.output
assert "BBB" in result.output
def test_gate_result_summary_and_excerpt() -> None:
assert GateResult(passed=True).summary == "all checks passed"
assert GateResult(passed=True, skipped=True).summary.startswith("no quality")
failed = GateResult(passed=False, failures=("lint",), output="x" * 5000)
assert "lint" in failed.summary
# The excerpt is the truncated tail, shorter than the full output.
assert 0 < len(failed.output_excerpt) < len(failed.output)
# --- GitService command selection -------------------------------------------
def test_fast_gate_commands_picks_lint_and_typecheck() -> None:
project = SimpleNamespace(
lint_command="uv run ruff check .",
typecheck_command="uv run mypy roboco/",
format_command="uv run ruff format .", # excluded (mutating)
test_command="uv run pytest", # excluded (slow; CI only)
)
commands = GitService._fast_gate_commands(project)
assert commands == [
("lint", "uv run ruff check ."),
("typecheck", "uv run mypy roboco/"),
]
def test_fast_gate_commands_empty_when_unconfigured() -> None:
project = SimpleNamespace(lint_command=None, typecheck_command=None)
assert GitService._fast_gate_commands(project) == []
# --- choreographer glue -----------------------------------------------------
def _choreo(mock_git: MagicMock) -> Choreographer:
return Choreographer(
ChoreographerDeps(
task=MagicMock(),
work_session=MagicMock(),
git=mock_git,
a2a=MagicMock(),
journal=MagicMock(),
audit=MagicMock(),
evidence_repo=MagicMock(),
)
)
def _ctx() -> _IAmDoneContext:
return _IAmDoneContext(
agent_id=uuid4(),
task_id=uuid4(),
task=MagicMock(),
role_str="developer",
briefing={},
notes="self-verification summary",
)
@pytest.mark.asyncio
async def test_gate_pass_does_not_block() -> None:
git = MagicMock()
git.run_pre_submit_quality_gate = AsyncMock(return_value=GateResult(passed=True))
assert await _choreo(git)._check_quality_gate(_ctx()) is None
@pytest.mark.asyncio
async def test_gate_failure_blocks_with_output_in_remediate() -> None:
git = MagicMock()
git.run_pre_submit_quality_gate = AsyncMock(
return_value=GateResult(
passed=False, failures=("lint",), output="roboco/x.py:1 E501 line too long"
)
)
env = await _choreo(git)._check_quality_gate(_ctx())
assert env is not None
assert env.error == "invalid_state"
assert "E501" in (env.remediate or "")
@pytest.mark.asyncio
async def test_gate_is_fail_open_on_infrastructure_error() -> None:
"""A missing workspace / toolchain must never block a submit."""
git = MagicMock()
git.run_pre_submit_quality_gate = AsyncMock(
side_effect=RuntimeError("workspace not found")
)
assert await _choreo(git)._check_quality_gate(_ctx()) is None