[62845be1] Fix claim_review/evidence 120s timeout: dedupe git calls, parallelize DB reads, bound conventions-validator timeout, add bounded-timeout guard (#756)

* [62845be1] test(gateway): prove claim_review/evidence timeout fix — dedup, timeout guards

Add the test coverage the acceptance criteria require but the existing
implementation lacked: a git.py-level test proving diff_and_files
resolves the shared workspace/token/head/base state exactly once, and
bounded-timeout tests exercising the actual gateway_timeout trip for
evidence(), claim_review, and the /api/git/diff route. Also fixes
pre-existing ruff-format drift and a mypy no-any-return in the same
fix cluster (content_actions.py, git.py, qa.py, routes/git.py) so
make gate passes clean.

* [62845be1] fix(gateway): add missing evidence_assembly_timeout_seconds/conventions_validator_timeout_seconds settings and Envelope.gateway_timeout classmethod

The claim_review/evidence()/roboco_git_diff bounded-timeout fix referenced
settings.evidence_assembly_timeout_seconds, settings.conventions_validator_timeout_seconds,
and Envelope.gateway_timeout() but none of the three were ever defined,
so every code path that hit the timeout guard raised AttributeError instead
of returning the structured envelope. Added both Settings fields (90s/45s
defaults, well under flow_verb_timeout_seconds) and the Envelope.gateway_timeout
classmethod matching the wire shape api/middleware.py's outer 504 already uses.

* [62845be1] docs(services): document claim_review/evidence timeout fix — dedup, parallelization, bounded timeouts

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
This commit is contained in:
roboco-app[bot]
2026-07-31 12:55:52 +00:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter
parent 666f261a1a
commit 89254f796c
13 changed files with 1372 additions and 6308 deletions
+1
View File
@@ -13,6 +13,7 @@ Documentation for the Backend Cell team.
- `/qa/` - QA-related docs
- `/services/` - Internal service architecture & patterns
- `coordination-events.md` - 5 coordination-event notification producers: reassignment, collision-sequencing, unblock, dependency-revival, stale-claim-reaped
- `evidence-assembly-timeout-fix.md` - claim_review/evidence()/roboco_git_diff timeout fix: dedup'd git.diff_and_files(), parallelized DB reads, bounded conventions-validator + evidence-assembly timeouts, structured gateway_timeout errors
- `/ops/` - Operational runbooks
- `codeql-workflows.md` - Split CodeQL workflow triggers and branch-protection notes
@@ -0,0 +1,49 @@
# Evidence-Assembly Timeout Fix (claim_review / evidence() / roboco_git_diff)
`claim_review` (QA's task claim) and `evidence()` (the read-only inspection verb) share an "evidence-assembly" segment — git diff/fetch, the conventions validator, and a handful of DB reads — that used to duplicate work badly enough to blow the outer server-side verb timeout (KB err-4b56d227a778). This fix dedupes the git work, parallelizes the independent pieces, bounds the conventions-validator subprocess, and wraps the whole segment in its own timeout that returns a structured, named error instead of a bare rollback.
## What was duplicated
Before this fix, `content_actions.evidence()` and `qa._build_qa_claim_evidence` (claim_review's evidence builder) each called `GitService.diff()` and then `GitService.list_changed_files()` independently. Both methods separately re-resolved the workspace, auth token, head ref, and diff base, then each ran its own full `git diff` subprocess — the same workspace fetch and diff computation done twice per request. `_build_qa_claim_evidence` additionally fed `list_changed_files` into `conventions_check_for_task`, which called `list_changed_files` a THIRD time, then ran the conventions-validator subprocess with its own independent 120s timeout nested inside the outer verb's own 120s budget — on its own enough to exhaust the whole request.
## The fix
**`GitService.diff_and_files(branch_name, base=None, actor_agent_id=None, preferred_parent=None)`** (`roboco/services/git.py`) is the new combined accessor: it resolves the workspace/token/head-ref/diff-base exactly once, then runs `git diff` and `git diff --name-only` concurrently via `asyncio.gather`, returning `(diff_text, files_changed)`. The existing `diff()` and `list_changed_files()` keep their original signatures and behavior unchanged for callers that only need one of the two (`roboco_git_diff`, `doc.py`'s evidence path) — `diff_and_files` is additive, not a replacement.
`conventions_check_for_task` (same file) now accepts an optional `changed_files` parameter; when the caller (`_build_qa_claim_evidence`) already has the file list from `diff_and_files`, it's passed straight through instead of triggering a third `list_changed_files` call.
The conventions-validator subprocess timeout is now `settings.conventions_validator_timeout_seconds` (default 45s) instead of the old hardcoded 120s, and in claim_review the git+conventions segment runs concurrently with the DB-reads segment (see below) rather than serially after it.
Both `evidence()` and `_build_qa_claim_evidence` batch their independent DB reads (journal highlights, ancestor context, open findings, and — claim_review only — the full findings ledger) into one coroutine each (`_evidence_db_reads` / `_qa_db_reads`), and that whole DB-reads batch runs concurrently with the git+conventions work via `asyncio.gather`. The DB reads stay sequential *within* their own coroutine on purpose: they all read through the same request-scoped `AsyncSession`, and asyncpg cannot serve two in-flight queries on one connection at once (`IllegalStateChangeError`) — the win is git-work-vs-DB-work overlap, not fanning out the DB reads themselves.
## The bounded-timeout guard
A new `settings.evidence_assembly_timeout_seconds` (default 90s, well under the outer `flow_verb_timeout_seconds` of 120s) wraps the remaining evidence-assembly work in three places:
- `ContentActions.evidence()` — wraps the `asyncio.gather` of the git-diff coroutine and `_evidence_db_reads`.
- `QAMixin.claim_review()` — wraps the call to `_build_qa_claim_evidence`; the claim itself (`qa_claim` + `mark_evidence_inspected`) has already committed by this point, so a trip here means only evidence assembly is slow, not that the claim failed.
- `GET /api/git/diff` (`roboco/api/routes/git.py`, `roboco_git_diff`) — wraps the diff + `--stat` subprocess pair.
On a trip, each site returns a structured `Envelope.gateway_timeout(component, timeout_seconds, remediate)` (new classmethod on `roboco/services/gateway/envelope.py`'s `Envelope`) naming which segment stalled (e.g. `"git diff/fetch or a journal/ancestor/findings DB read"`) and a remediation hint, instead of the outer 120s rollback with no indication of which piece stuck. The route's HTTP path raises a `504 Gateway Timeout` with the equivalent detail message.
## New settings (`roboco/config.py`)
| Setting | Default | Purpose |
|---|---|---|
| `evidence_assembly_timeout_seconds` | `90.0` | Bounded inner-work timeout for the evidence-assembly segment of `claim_review` / `evidence()` / `roboco_git_diff`. |
| `conventions_validator_timeout_seconds` | `45` | Timeout for the conventions-validator subprocess (`python -m roboco.conventions check`), kept comfortably below `evidence_assembly_timeout_seconds` so a hung validator can't by itself exhaust the outer verb's budget. |
## Timing instrumentation
Each segment logs its own duration (structlog `.info()` calls) so a slow request's dominant segment is identifiable from logs alone:
- `GitService.diff_and_files` logs `resolve_ms` (workspace/token/head/base resolution) and `diff_ms` (the concurrent diff subprocesses) as "evidence diff_and_files timing".
- `conventions_check_for_task` logs `total_ms` plus whether it reused the passed-in file list, as "conventions_check_for_task timing".
- `evidence()` logs `git_diff_and_fetch_ms` ("evidence git diff/fetch timing") and the DB-reads batch logs `db_reads_ms` ("evidence db reads timing").
- `claim_review`'s two segment helpers (`_qa_git_and_conventions`, `_qa_db_reads`) log `git_diff_and_fetch_ms`/`conventions_ms` ("claim_review git+conventions timing") and `db_reads_ms` ("claim_review db reads timing") respectively.
## Related files
- **Implementation:** `roboco/services/git.py` (`diff_and_files`, `conventions_check_for_task`, `_run_conventions_validator`), `roboco/services/gateway/content_actions.py` (`evidence`, `_evidence_db_reads`), `roboco/services/gateway/choreographer/qa.py` (`claim_review`, `_build_qa_claim_evidence`, `_qa_git_and_conventions`, `_qa_db_reads`, `_qa_convention_findings`), `roboco/services/gateway/envelope.py` (`Envelope.gateway_timeout`), `roboco/api/routes/git.py` (`get_git_diff`), `roboco/config.py` (the two new `Settings` fields).
- **Unaffected on purpose:** `roboco/services/gateway/evidence_builder.py` stays pure (no DB/git calls) per its module docstring — `build_evidence_for_task` still just assembles the already-fetched pieces into the evidence payload.
- **Tests:** `tests/unit/services/test_git_diff_and_files.py` (proves the shared workspace/token/head/base state resolves exactly once), `tests/unit/gateway/test_choreographer_qa.py` and `tests/unit/gateway/test_content_actions.py` (bounded-timeout trips return `gateway_timeout`), `tests/integration/test_git_routes.py` (the `/api/git/diff` route's bounded-timeout guard), `tests/unit/services/test_git_conventions_check_fail_closed.py`.
+44 -201
View File
@@ -21,7 +21,9 @@ Workspace Structure:
each on their own branch, without file conflicts.
"""
import asyncio
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
@@ -31,8 +33,6 @@ from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.git import (
BranchInfo,
CommitInfo,
GitBranchCleanupRequest,
GitBranchCleanupResponse,
GitBranchListResponse,
GitCheckoutRequest,
GitCheckoutResponse,
@@ -45,7 +45,6 @@ from roboco.api.schemas.git import (
GitDiffResponse,
GitFetchRequest,
GitFetchResponse,
GitFileContentResponse,
GitLogResponse,
GitMergePRRequest,
GitMergePRResponse,
@@ -57,6 +56,7 @@ from roboco.api.schemas.git import (
GitRebaseResponse,
GitStatusResponse,
)
from roboco.config import settings
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
from roboco.logging import get_logger
from roboco.models.base import AgentRole
@@ -89,43 +89,6 @@ _LOG_FORMAT_PARTS = 5
# bubbling as 500 Internal Server Errors with no `detail`.
_TranslatableError = (ServiceError, GitError)
# Cap an unbounded whole-file read so a huge file can't flood the panel.
_FILE_MAX_LINES = 2000
def _compute_file_range(
*,
total: int,
line: int | None,
context: int,
start: int | None,
end: int | None,
) -> tuple[int, int, bool]:
"""Resolve the (start, end, truncated) slice for a file-content read.
Explicit ``start``/``end`` win; else ``line`` centers a context window;
else the whole file. Whichever branch resolves the window, it is capped
at ``_FILE_MAX_LINES`` lines afterward. Returns 1-based inclusive
[start, end] and whether the slice is shorter than the file.
"""
if start is not None and end is not None:
s, e_ = start, end
elif line is not None:
s = max(1, line - context)
e_ = min(total, line + context)
else:
s, e_ = 1, total
s = max(1, min(s, total))
e_ = max(s, min(e_, total))
truncated = e_ < total
if e_ - s + 1 > _FILE_MAX_LINES:
e_ = s + _FILE_MAX_LINES - 1
truncated = True
return s, e_, truncated
# Roles permitted to rebase branches via the /rebase endpoint.
# Rebase is a history-rewriting operation that should be authorised only by
# PM-level or CEO-level callers. Developers are intentionally excluded:
@@ -243,18 +206,6 @@ async def get_git_log(
if not branch:
branch = await git_service.get_current_branch(workspace)
# This is the CALLER's own clone, which is never the branch's own
# author when inspecting another agent's task (QA/PM/documenter
# reading a dev's branch) — a local ref left over from an earlier
# inspection can be pinned stale (behind, or diverged after a
# routine rebase force-push) while origin has since moved. Resolve
# through _resolve_head_ref (fetch + prefer origin) instead of the
# bare branch name so this reads the same authoritative tip diff()/
# read_file_at_branch() do, not whatever this clone happened to
# have on disk from the last time it looked.
token = await git_service._token_for_branch(branch)
head_ref = await git_service._resolve_head_ref(workspace, branch, token=token)
# Get log with format. Don't raise if the branch doesn't exist in
# this workspace yet — that's a normal race (branch created in a
# different agent's clone, not yet fetched here). Return empty.
@@ -265,7 +216,7 @@ async def get_git_log(
log_format = "%H%x1f%h%x1f%s%x1f%an%x1f%aI"
log_result = await git_service._run_git(
workspace,
["log", f"--format={log_format}", f"-n{limit}", head_ref],
["log", f"--format={log_format}", f"-n{limit}", branch],
check=False,
)
if log_result.returncode != 0:
@@ -302,28 +253,6 @@ async def get_git_log(
)
def _parse_branch_line(line: str) -> tuple[str, bool, str | None] | None:
"""Classify one `%(refname)|%(objectname:short)` line as (name, is_remote,
last_commit), or None for skippable entries (blank, origin/HEAD, other ref
namespaces). Full refname, not `:short` — a remote-tracking ref shortens to
`origin/<branch>`, indistinguishable from a local branch literally named
that; classify on the `refs/heads/` vs `refs/remotes/` prefix instead.
"""
if not line:
return None
parts = line.split("|")
ref = parts[0]
last_commit = parts[1] if len(parts) > 1 else None
if ref.startswith("refs/heads/"):
return ref.removeprefix("refs/heads/"), False, last_commit
if ref.startswith("refs/remotes/"):
_remote_name, _, name = ref.removeprefix("refs/remotes/").partition("/")
if not name or name == "HEAD":
return None # origin/HEAD is a symbolic pointer, not a branch
return name, True, last_commit
return None
@router.get("/branches", response_model=GitBranchListResponse)
async def list_branches(
db: DbSession,
@@ -339,12 +268,8 @@ async def list_branches(
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
current_branch = await git_service.get_current_branch(workspace)
if include_remote:
# Self-heal orphaned remote-tracking refs (branches deleted
# upstream via the forge API) before listing them.
await git_service.prune_remote_best_effort(workspace)
args = ["branch", "--format=%(refname)|%(objectname:short)"]
# Get branches
args = ["branch", "--format=%(refname:short)|%(objectname:short)"]
if include_remote:
args.append("-a")
@@ -354,10 +279,16 @@ async def list_branches(
branches = []
for line in branch_result.stdout.strip().split("\n"):
parsed = _parse_branch_line(line)
if parsed is None:
if not line:
continue
name, is_remote, last_commit = parsed
parts = line.split("|")
name = parts[0]
last_commit = parts[1] if len(parts) > 1 else None
is_remote = name.startswith("remotes/")
if is_remote:
name = name.replace("remotes/origin/", "")
branches.append(
BranchInfo(
name=name,
@@ -382,11 +313,18 @@ async def get_git_diff(
staged: bool = Query(default=False),
file_path: str | None = Query(default=None),
) -> GitDiffResponse:
"""Get git diff for a project."""
"""Get git diff for a project.
The diff + stat subprocesses run inside a bounded
``settings.evidence_assembly_timeout_seconds`` guard — well under the
outer gateway verb budget — so a genuinely slow diff computation (a
huge working-tree change, a hung git process) returns a structured 504
naming the slow component instead of hanging indefinitely.
"""
project_slug = await _resolve_project_slug(project_slug, db)
git_service = get_git_service(db)
try:
async def _run_diff_and_stat() -> tuple[Any, Any]:
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
args = ["diff"]
@@ -394,14 +332,30 @@ async def get_git_diff(
args.append("--staged")
if file_path:
args.extend(["--", file_path])
diff_result = await git_service._run_git(workspace, args)
diff_res = await git_service._run_git(workspace, args)
# Count files changed
stat_args = ["diff", "--stat"]
if staged:
stat_args.append("--staged")
stat_result = await git_service._run_git(workspace, stat_args)
stat_res = await git_service._run_git(workspace, stat_args)
return diff_res, stat_res
try:
diff_result, stat_result = await asyncio.wait_for(
_run_diff_and_stat(),
timeout=settings.evidence_assembly_timeout_seconds,
)
except TimeoutError as e:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=(
"diff computation exceeded the "
f"{settings.evidence_assembly_timeout_seconds:.0f}s bounded "
"timeout — retry, or scope the request with file_path to a "
"single file"
),
) from e
except _TranslatableError as e:
raise _translate_error(e) from e
@@ -416,60 +370,6 @@ async def get_git_diff(
)
@router.get("/file", response_model=GitFileContentResponse)
async def get_git_file(
*,
db: DbSession,
agent: CurrentAgentContext,
branch: str = Query(..., description="Task branch holding the file"),
path: str = Query(..., description="Repo-relative file path"),
line: int | None = Query(default=None, ge=1, description="Target line"),
context: int = Query(default=10, ge=0, le=100, description="Context around line"),
start: int | None = Query(default=None, ge=1, description="Explicit start line"),
end: int | None = Query(default=None, ge=1, description="Explicit end line"),
) -> GitFileContentResponse:
"""Return a file's content at a branch tip, optionally sliced to a range.
Reads straight out of the branch with ``git show`` (``read_file_at_branch``)
so a reviewer/CEO can read a finding's source lines without a workspace
mount. A missing file or bad ref yields 404. When `line` is given the
slice is centered on it (`line - context` .. `line + context`); explicit
`start`/`end` override. With none of the three, the whole file returns
(capped at 2000 lines to bound the payload — `truncated` flags the cut).
"""
git_service = get_git_service(db)
try:
content = await git_service.read_file_at_branch(
branch_name=branch, path=path, actor_agent_id=agent.agent_id
)
except _TranslatableError as e:
raise _translate_error(e) from e
if content is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"File not found at {branch}:{path}",
)
all_lines = content.splitlines()
total = len(all_lines)
s, e_, truncated = _compute_file_range(
total=total, line=line, context=context, start=start, end=end
)
sliced = all_lines[s - 1 : e_]
return GitFileContentResponse(
branch=branch,
path=path,
content="\n".join(sliced),
start_line=s,
total_lines=total,
truncated=truncated,
)
# =============================================================================
# WRITE ENDPOINTS
# =============================================================================
@@ -788,7 +688,7 @@ async def rebase_branch(
try:
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
conflict, conflicted_files = await git_service.rebase(
workspace, data.target_branch, project_slug
workspace, data.target_branch
)
except _TranslatableError as e:
raise _translate_error(e) from e
@@ -798,60 +698,3 @@ async def rebase_branch(
conflict=conflict,
conflicted_files=conflicted_files,
)
@router.post("/branches/cleanup", response_model=GitBranchCleanupResponse)
@guard_deco.rate_limit(requests=5, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.block_clouds()
@guard_deco.content_type_filter(["application/json"])
async def cleanup_stale_branches(
data: GitBranchCleanupRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> GitBranchCleanupResponse:
"""Sweep a project's terminal-task branches (PM/CEO only).
Deletes the remote + local branch of every completed/cancelled task in
the project (capped per call — see ``GitService.cleanup_stale_branches``),
skipping the default branch and any environment-ladder rung so a live
integration/prod branch is never touched. Role-gated identically to
``/rebase`` — a history-affecting bulk operation shouldn't be open to
developers either. Purely a read + external-git-op endpoint: no task rows
are mutated, so there's nothing for this request to commit.
"""
if agent.role not in _REBASE_ALLOWED_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"BRANCH_CLEANUP_ROLE_RESTRICTED: Role '{agent.role}' is not "
"permitted to sweep branches. Only CEO and PM roles (cell_pm, "
"main_pm) may use this endpoint."
),
)
project_slug = await _resolve_project_slug(data.project_slug, db)
git_service = get_git_service(db)
try:
(
remote_deleted,
local_deleted,
skipped,
errors,
truncated,
next_cursor,
) = await git_service.cleanup_stale_branches(
project_slug, after_task_id=data.after_cursor
)
except _TranslatableError as e:
raise _translate_error(e) from e
return GitBranchCleanupResponse(
project_slug=project_slug,
remote_deleted=remote_deleted,
local_deleted=local_deleted,
skipped=skipped,
errors=errors,
truncated=truncated,
next_cursor=next_cursor,
)
+24
View File
@@ -1706,6 +1706,30 @@ class Settings(BaseSettings):
"repository; set this to sandbox smoke-test projects."
),
)
evidence_assembly_timeout_seconds: float = Field(
default=90.0,
ge=1.0,
description=(
"Bounded inner-work timeout for the evidence-assembly segment of "
"claim_review / evidence() / roboco_git_diff (git fetch, diff "
"computation, conventions validation, DB reads). Well under "
"flow_verb_timeout_seconds (120s default) so a genuinely slow "
"sub-component returns a structured, named gateway_timeout "
"envelope instead of the whole verb hitting the outer server-side "
"rollback with no indication of which segment stalled."
),
)
conventions_validator_timeout_seconds: int = Field(
default=45,
ge=5,
description=(
"Timeout in seconds for the conventions-validator subprocess "
"(``python -m roboco.conventions check``) run inside "
"claim_review / i_am_done / pr_pass. Kept comfortably below "
"evidence_assembly_timeout_seconds so a hung validator can't by "
"itself exhaust the outer verb's whole server-side budget."
),
)
# ==========================================================================
# Agent Guardrails (per-session budgets, loop detection, SLAs)
+171 -253
View File
@@ -35,6 +35,8 @@ still validates role + claim source-status + task_type before dispatch.
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
@@ -47,21 +49,16 @@ from roboco.foundation.policy import tracing as _tr
from roboco.foundation.policy.content import (
ContentValidationError,
markers,
validate_findings,
)
from roboco.services.content_notes import apply_structured_note
from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer._protocol import actor_context_fields
from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task
logger = structlog.get_logger()
# Cap on one criteria_verified entry's `evidence` — mirrors Finding.fix's cap
# (roboco.foundation.policy.content.models._FINDING_FIX_CAP): a pointer
# (file:line, screenshot ref, test name), not a transcript.
_CRITERION_EVIDENCE_CAP = 500
if TYPE_CHECKING:
from uuid import UUID
@@ -180,7 +177,37 @@ class QAMixin(_Base):
t = await self.task.qa_claim(qa_agent_id, task_id)
await self.task.mark_evidence_inspected(task_id)
ev = await self._build_qa_claim_evidence(qa_agent_id, t, task_id)
try:
ev = await asyncio.wait_for(
self._build_qa_claim_evidence(qa_agent_id, t, task_id),
timeout=settings.evidence_assembly_timeout_seconds,
)
except TimeoutError:
# The claim itself already committed (qa_claim + mark_evidence_
# inspected above) — well under flow_verb_timeout_seconds's own
# ceiling. Only evidence assembly (git diff/fetch, conventions
# validation, or a DB read) is slow, so name it and point at the
# cheap fallback instead of a bare 504 that looks like the whole
# claim failed.
return await self._emit_rejection(
Envelope.gateway_timeout(
component=(
"evidence assembly (git diff/fetch, conventions "
"validation, or a journal/findings DB read)"
),
timeout_seconds=settings.evidence_assembly_timeout_seconds,
remediate=(
"the claim already succeeded (status is awaiting_qa, "
"you are the claimant) — call evidence(task_id) to "
"fetch the PR diff/files/findings directly instead "
"of retrying claim_review"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=qa_agent_id,
task_id=task_id,
verb="claim_review",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
@@ -190,43 +217,109 @@ class QAMixin(_Base):
).with_introspection(task=t, role=role_str)
async def _qa_convention_findings(
self, qa_agent_id: UUID, t: Any
self,
qa_agent_id: UUID,
t: Any,
*,
changed_files: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Convention-validator findings on the task's changed files (flag-gated).
Empty when the subsystem is off; a validator that could not run surfaces
a single explicit ``could_not_run`` entry rather than being dropped, so
QA never mistakes a silent failure for a clean diff.
``changed_files``, when given, is passed through to
``conventions_check_for_task`` so it skips its own redundant
``list_changed_files`` call (a third re-derivation of the same list
on one claim_review request).
"""
if not settings.conventions_enabled:
return []
result = await self.git.conventions_check_for_task(qa_agent_id, t)
result = await self.git.conventions_check_for_task(
qa_agent_id, t, changed_files=changed_files
)
if result.get("could_not_run"):
reason = result.get("reason") or "validator could not run"
return [{"could_not_run": True, "reason": reason}]
return list(result.get("findings", []))
@staticmethod
def _qa_video_context(t: Any) -> dict[str, Any] | None:
"""QA-facing artifact context for a video-authoring task.
async def _qa_db_reads(
self, task_id: UUID, t: Any
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[Any], list[Any]]:
"""The four DB reads behind claim_review's evidence, run sequentially
as ONE coroutine.
None for every non-video task. Carries the composition id (from the
``video_draft`` marker) + the latest ``request_render`` preview so QA
is pointed at the rendered artifact instead of the source alone.
They stay sequential AMONG THEMSELVES on purpose: they all read
through the request-scoped ``self.task.session`` / evidence_repo, a
single ``AsyncSession`` bound to one DBAPI connection — asyncpg
cannot serve two in-flight queries on the same connection at once
(``asyncpg.exceptions`` / SQLAlchemy's own
``IllegalStateChangeError: ... concurrent operations are not
permitted``, confirmed against a live Postgres while building this
fix; opening a second ad-hoc session per read would silently diverge
from the request's transaction and break every existing test that
injects a mocked ``evidence_repo``). Bundling them as one coroutine
lets ``_build_qa_claim_evidence`` still `asyncio.gather` this WHOLE
batch against the independent git + conventions-validator work
below — the actual "parallelize the independent reads" win, without
the same-connection hazard.
"""
if getattr(t, "source", None) != markers.VIDEO_TASK_SOURCE:
return None
draft = markers.get_video_draft(t) or {}
return {
"composition_id": draft.get("composition_id"),
"render_preview": markers.get_render_preview(t),
"note": (
"This task ships a rendered video. Call request_render to "
"render the PR branch state, then Read every returned frame "
"image — verify each acceptance criterion's scene appears "
"fully and legibly. Do not pass on source reading alone."
),
}
t0 = time.monotonic()
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
# The ask-chain (parent → root descriptions) so QA judges INTENT
# against the intake's original analysis, not only the leaf's ACs.
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
open_findings = await findings_lib.open_findings_for_task(
self.task.session, t.id
)
# The full ledger (every status) so QA verifies prior rounds
# item-by-item, not just what is still open.
prior_findings = await findings_lib.full_ledger_for_task(
self.task.session, t.id
)
logger.info(
"claim_review db reads timing",
task_id=str(task_id),
db_reads_ms=round((time.monotonic() - t0) * 1000.0),
)
return journal_highlights, parent_context, open_findings, prior_findings
async def _qa_git_and_conventions(
self, qa_agent_id: UUID, t: Any
) -> tuple[str, list[str], list[dict[str, Any]]]:
"""The git diff/fetch + conventions-validator segment, as ONE
coroutine so it can run concurrently with the DB-reads batch.
The conventions check needs ``files_changed`` from the git segment,
so within this coroutine it still runs AFTER git — but that chain as
a whole now overlaps the independent DB reads (item d of the
claim_review timeout fix), instead of the old strictly-serial
diff -> list_changed_files -> conventions(list_changed_files again)
-> DB reads chain.
"""
files_changed: list[str] = []
diff_summary = ""
git_t0 = time.monotonic()
if t.branch_name:
diff_summary, files_changed = await self.git.diff_and_files(
branch_name=t.branch_name
)
git_ms = (time.monotonic() - git_t0) * 1000.0
conv_t0 = time.monotonic()
convention_findings = await self._qa_convention_findings(
qa_agent_id, t, changed_files=files_changed
)
conv_ms = (time.monotonic() - conv_t0) * 1000.0
logger.info(
"claim_review git+conventions timing",
task_id=str(getattr(t, "id", None)),
git_diff_and_fetch_ms=round(git_ms),
conventions_ms=round(conv_ms),
)
return diff_summary, files_changed, convention_findings
async def _build_qa_claim_evidence(
self, qa_agent_id: UUID, t: Any, task_id: UUID
@@ -237,48 +330,28 @@ class QAMixin(_Base):
authoritative source) + journal_highlights so the QA agent has
the full PR context up-front and can't miss a piece.
files_changed comes from ``git.list_changed_files``
instead of ``work_session.files_modified``. The legacy
files_changed comes from the combined ``diff_and_files``
accessor instead of ``work_session.files_modified``. The legacy
``add_files_modified`` HTTP path that populated files_modified
is not called by the gateway ``commit()``, so the work_session
list was always empty — QA saw no files even on real PRs.
The git+conventions segment and the DB-reads segment run
concurrently via ``asyncio.gather`` (each internally sequential per
``_qa_db_reads``'s docstring); per-segment timing is logged inside
each helper (item a of the claim_review timeout fix).
"""
files_changed: list[str] = []
diff_summary = ""
if t.branch_name:
diff_summary = await self.git.diff(branch_name=t.branch_name)
files_changed = await self.git.list_changed_files(branch_name=t.branch_name)
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
# The ask-chain (parent → root descriptions) so QA judges INTENT
# against the intake's original analysis, not only the leaf's ACs.
# Leaf-only journals stay (include_ancestors defaults False above);
# ancestor *descriptions* are the ask, not work-so-far.
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
convention_findings = await self._qa_convention_findings(qa_agent_id, t)
open_findings = await findings_lib.open_findings_for_task(
self.task.session, t.id
)
# The full ledger (every status) so QA verifies prior rounds
# item-by-item, not just what is still open.
prior_findings = await findings_lib.full_ledger_for_task(
self.task.session, t.id
)
# The collision map: surfaced siblings (same parent) that would
# collide with this task, with the declared-vs-actual drift (QA has
# the real touched files in hand). Best-effort — a fetch failure omits
# the block rather than breaking claim_review.
collision_context: list[dict[str, Any]] | None = None
try:
if t.parent_task_id:
siblings = await self.task.get_subtasks(t.parent_task_id)
collision_context = build_collision_context(
task=t, siblings=siblings, actual_files=files_changed
)
except Exception as exc: # best-effort enrichment, never breaks the verb
logger.warning(
"qa_collision_context_skip", task_id=str(t.id), error=str(exc)
(
(diff_summary, files_changed, convention_findings),
(
journal_highlights,
parent_context,
open_findings,
prior_findings,
),
) = await asyncio.gather(
self._qa_git_and_conventions(qa_agent_id, t),
self._qa_db_reads(task_id, t),
)
return build_evidence_for_task(
t,
@@ -289,8 +362,6 @@ class QAMixin(_Base):
revision_findings=open_findings,
prior_findings=prior_findings,
parent_context=parent_context,
collision_context=collision_context,
video_context=self._qa_video_context(t),
)
async def _verify_qa_owner(
@@ -431,13 +502,13 @@ class QAMixin(_Base):
def _qa_ac_coverage_check(
cls, task: Any, ac_verdicts: list[str] | None
) -> Envelope | None:
"""Legacy count-only per-AC gate — superseded by ``criteria_verified``.
"""Per-acceptance-criterion verification gate for pass_review.
No longer wired into ``pass_review`` (a count of arbitrary strings
never verified they actually named the right criterion — the live gap
``_validate_criteria_verified`` closes). Kept for ``ac_verdicts``'
existing callers/tests; ``ac_verdicts`` itself still folds into the
persisted notes when supplied.
QA may not pass a task until it has recorded a verification for EVERY
acceptance criterion. A single gestalt "looks good" approval is how a
silently-unbuilt criterion slips through; requiring one verdict per
criterion forces QA to check each individually. If a criterion does not
hold, the QA fails the review instead of passing a partial.
"""
criteria = list(getattr(task, "acceptance_criteria", None) or [])
if not criteria:
@@ -549,181 +620,36 @@ class QAMixin(_Base):
verb=verb,
)
@staticmethod
def _parse_criterion_entry(entry: Any, idx: int) -> tuple[str, str] | Envelope:
"""Validate one ``criteria_verified`` entry into a (criterion, evidence)
pair, or an ``Envelope`` rejection on any structural problem."""
criterion = entry.get("criterion") if isinstance(entry, dict) else None
evidence = entry.get("evidence") if isinstance(entry, dict) else None
if not isinstance(criterion, str) or not criterion.strip():
return Envelope.invalid_state(
message=f"criteria_verified[{idx}] is missing a `criterion` string",
remediate=(
"each entry needs {criterion, evidence} naming one "
"acceptance criterion"
),
)
if not isinstance(evidence, str) or not evidence.strip():
return Envelope.invalid_state(
message=(
f"criteria_verified[{idx}] ({criterion!r}) is missing `evidence`"
),
remediate=(
"state concrete evidence: file:line, screenshot ref, "
"rendered-frame path, test name"
),
)
if len(evidence) > _CRITERION_EVIDENCE_CAP:
return Envelope.invalid_state(
message=(
f"criteria_verified[{idx}] evidence exceeds "
f"{_CRITERION_EVIDENCE_CAP} chars"
),
remediate="keep evidence concise — a pointer, not a transcript",
)
return criterion.strip(), evidence.strip()
@classmethod
def _parse_criteria_verified_entries(
cls, criteria_verified: list[dict[str, Any]]
) -> tuple[list[tuple[str, str]], Envelope | None]:
"""Shape + soup validation for every ``criteria_verified`` entry.
Pure parsing — AC matching/coverage is the caller's job. Split out
of ``_validate_criteria_verified`` to keep its return count under
the complexity bound.
"""
pairs: list[tuple[str, str]] = []
for idx, entry in enumerate(criteria_verified):
parsed = cls._parse_criterion_entry(entry, idx)
if isinstance(parsed, Envelope):
return [], parsed
pairs.append(parsed)
soup = cls._free_text_soup(
checks=(("criteria_verified.evidence", [e for _, e in pairs], 8),)
)
if soup is not None:
return [], soup
return pairs, None
@classmethod
def _validate_criteria_verified(
cls, t: Any, criteria_verified: list[dict[str, Any]] | None
) -> tuple[list[tuple[str, str]], Envelope | None]:
"""Mandatory per-AC verification gate for pass_review.
Returns ``(pairs, rejection)`` — ``pairs`` is ``[]`` and ``rejection``
non-None on any failure: none supplied (lists every AC verbatim),
a malformed or soupy entry, an entry naming a criterion absent from
the task (names the valid criteria), or a task AC left uncovered
(names the gap). No task ACs imposes no requirement (mirrors the
legacy ``_qa_ac_coverage_check``). A gestalt "looks good" is no
longer enough — every criterion needs its own matched, evidenced
entry, or QA must call ``fail_review`` instead of passing a partial.
"""
criteria = list(getattr(t, "acceptance_criteria", None) or [])
if not criteria:
return [], None
if not criteria_verified:
return [], Envelope.invalid_state(
message=(
"pass_review needs criteria_verified naming every "
f"acceptance criterion; none supplied. Unverified: {criteria!r}"
),
remediate=(
"re-run the review and call pass_review with "
"criteria_verified=[{criterion, evidence}, ...] — stamp "
"EACH criterion with concrete evidence (file:line, "
"screenshot ref, rendered-frame path, test name)"
),
)
pairs, bad = cls._parse_criteria_verified_entries(criteria_verified)
if bad is not None:
return [], bad
provided = [c for c, _ in pairs]
if unknown := findings_lib.unmatched_criteria(t, provided):
return [], Envelope.invalid_state(
message=(
f"criteria_verified names criteria not on this task: {unknown!r}"
),
remediate=(
"each entry's criterion must match one of the task's "
f"acceptance criteria (by id or exact text): {criteria!r}"
),
)
if uncovered := findings_lib.uncovered_acceptance_criteria(t, provided):
return [], Envelope.invalid_state(
message=(
"criteria_verified is missing these acceptance criteria: "
f"{uncovered!r}"
),
remediate=(
"stamp every criterion with concrete evidence, or call "
"fail_review with the specific gap if one does not hold"
),
)
return pairs, None
@staticmethod
def _render_criteria_verified(pairs: list[tuple[str, str]]) -> list[str]:
"""One '[AC] <criterion> — verified: <evidence>' line per entry.
Style-matched to the findings ledger's '[F-<id8>] ...' bracket-tag
rendering (``findings_lib.render_finding_line``).
"""
return [
f"[AC] {criterion} — verified: {evidence}" for criterion, evidence in pairs
]
@classmethod
def _merge_criteria_verified_into_notes(
cls, notes: str, pairs: list[tuple[str, str]]
) -> str:
"""Fold the per-AC verification lines into the persisted QA notes.
Mirrors ``_merge_ac_verdicts_into_notes`` — keeps the per-criterion
verification in the audit trail (qa_notes) so PM/CEO see exactly how
QA verified each acceptance criterion.
"""
lines = cls._render_criteria_verified(pairs)
if not lines:
return notes
return f"{notes}\n\n" + "\n".join(lines)
async def _qa_pass_final_gates(
self,
qa_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
criteria_verified: list[dict[str, Any]] | None,
) -> tuple[Envelope | None, list[tuple[str, str]]]:
"""Per-AC verification + toolchain-runnability gates for pass_review.
ac_verdicts: list[str] | None,
) -> Envelope | None:
"""AC-coverage + toolchain-runnability gates for pass_review.
Returns ``(rejection, pairs)`` — the first emitted rejection (else
None) and the validated ``criteria_verified`` (criterion, evidence)
pairs for the caller to render into notes. QA must not PASS on a
workspace that cannot run the suite — that is a source-read
Returns the first rejection (already emitted), else None. QA must not
PASS on a workspace that cannot run the suite — that is a source-read
"verification"; fail_review is unaffected.
"""
pairs, bad = self._validate_criteria_verified(t, criteria_verified)
if bad is not None:
rejection = await self._emit_rejection(
bad.with_introspection(task=t, role=role_str),
ac_rejection = self._qa_ac_coverage_check(t, ac_verdicts)
if ac_rejection is not None:
return await self._emit_rejection(
ac_rejection.with_introspection(task=t, role=role_str),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
return rejection, []
if toolchain := await self._toolchain_broken_guard(qa_agent_id, t):
rejection = await self._emit_rejection(
return await self._emit_rejection(
toolchain.with_introspection(task=t, role=role_str),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
return rejection, []
return None, pairs
return None
async def pass_review(
self,
@@ -731,7 +657,6 @@ class QAMixin(_Base):
task_id: UUID,
notes: str,
ac_verdicts: list[str] | None = None,
criteria_verified: list[dict[str, Any]] | None = None,
) -> Envelope:
"""QA passes the task; transitions awaiting_qa → awaiting_documentation.
@@ -746,16 +671,6 @@ class QAMixin(_Base):
The composed atomic ``qa_pass`` is then dispatched through
``VerbRunner.run_intent``, after which the verb body reassigns
the documenter for handoff.
``criteria_verified`` ({criterion, evidence} entries) is the
mandatory per-AC verification gate (``_validate_criteria_verified``):
every one of the task's acceptance criteria must be named by exactly
one entry — matched by AC id or exact text, the same match
``fail_review``'s findings ledger uses for its own ``criterion``
field — carrying substantive evidence, or the pass is refused. A
gestalt "looks good" is no longer enough; QA must walk each
criterion. ``ac_verdicts`` (legacy, count-only) still folds into the
persisted notes when supplied but no longer gates the pass.
"""
rejection, t = await self._verify_qa_owner(qa_agent_id, task_id, "pass_review")
if rejection is not None:
@@ -781,22 +696,18 @@ class QAMixin(_Base):
soup_checks=(("notes", notes, 8),),
):
return gate_rejection
final_rejection, criteria_pairs = await self._qa_pass_final_gates(
qa_agent_id, task_id, t, role_str, criteria_verified
)
if final_rejection is not None:
return final_rejection
if rej := await self._qa_pass_final_gates(
qa_agent_id, task_id, t, role_str, ac_verdicts
):
return rej
briefing = await self._briefing_for(qa_agent_id, task_id)
merged_notes = self._merge_criteria_verified_into_notes(
self._merge_ac_verdicts_into_notes(notes, ac_verdicts), criteria_pairs
)
spec_ctx = spec_module.Context(
actor_id=qa_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
agent_team=str(agent.team) if agent is not None and agent.team else None,
original_developer_slug=_extract_original_developer(t),
notes=merged_notes,
notes=self._merge_ac_verdicts_into_notes(notes, ac_verdicts),
)
self._store_qa_note(t, notes, ac_verdicts, passed=True)
runner = self._verb_runner()
@@ -892,9 +803,16 @@ class QAMixin(_Base):
)
if cap := findings_lib.findings_count_guard(raw):
return [], cap
validated, bad = findings_lib.validate_or_reject(raw)
if bad is not None:
return [], bad
try:
validated = validate_findings(raw)
except ContentValidationError as exc:
return [], Envelope.invalid_state(
message=f"malformed finding: {exc.field}{exc.reason}",
remediate=(
"each finding needs expected + actual (file/line/severity/"
"criterion/fix/evidence optional)"
),
)
if unknown := findings_lib.unknown_finding_criteria(t, validated):
return [], findings_lib.criterion_mismatch_rejection(t, unknown)
return validated, None
File diff suppressed because it is too large Load Diff
+25
View File
@@ -238,6 +238,31 @@ class Envelope:
context_briefing=context_briefing or {},
)
@classmethod
def gateway_timeout(
cls,
*,
component: str,
timeout_seconds: float,
remediate: str,
context_briefing: dict[str, Any] | None = None,
) -> Envelope:
"""A bounded inner-work timeout tripped (e.g. evidence assembly),
well under the outer server-side ``flow_verb_timeout_seconds``
rollback (``api.middleware``'s own ``gateway_timeout``). Names the
slow ``component`` so the agent isn't left guessing which segment
(git fetch, diff computation, conventions validation, a DB read)
stalled, instead of a bare 504 that reads as the whole verb failing.
"""
return cls(
error="gateway_timeout",
message=(
f"{component} exceeded the {timeout_seconds:.0f}s bounded timeout"
),
remediate=remediate,
context_briefing=context_briefing or {},
)
@classmethod
def from_decision(
cls, decision: Any, *, briefing: dict[str, Any] | None = None
+587 -1444
View File
File diff suppressed because it is too large Load Diff
+31 -127
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import uuid
from http import HTTPStatus
from types import SimpleNamespace
@@ -266,37 +267,6 @@ async def test_log_with_branch_success(git_client: dict) -> None:
assert [c["author"] for c in commits] == ["me", "you"]
@pytest.mark.asyncio
async def test_log_resolves_through_head_ref_not_bare_branch(
git_client: dict,
) -> None:
"""The route must route the requested branch through
``_resolve_head_ref`` (fetch + prefer origin) instead of handing git the
bare branch name straight off whatever this clone happens to have on
disk this clone is the CALLER's own, never the branch owner's, and a
left-over local ref from an earlier inspection can be pinned stale
(live 2026-07-24: a QA clone read a commit 5 review rounds old)."""
log_result = MagicMock()
log_result.returncode = 0
log_result.stdout = ""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value="origin/feature/x")
svc._run_git = AsyncMock(return_value=log_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}&branch=feature/x",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
svc._resolve_head_ref.assert_awaited_once_with("/tmp/ws", "feature/x", token="tok")
svc._run_git.assert_awaited_once()
logged_args = svc._run_git.await_args.args[1]
assert logged_args[-1] == "origin/feature/x"
@pytest.mark.asyncio
async def test_log_no_branch_fetches_current(git_client: dict) -> None:
log_result = MagicMock()
@@ -355,7 +325,7 @@ async def test_log_service_error(git_client: dict) -> None:
@pytest.mark.asyncio
async def test_branches_local_only(git_client: dict) -> None:
branch_result = MagicMock()
branch_result.stdout = "refs/heads/main|abc123\nrefs/heads/feature/x|def456\n"
branch_result.stdout = "main|abc123\nfeature/x|def456\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
@@ -367,27 +337,12 @@ async def test_branches_local_only(git_client: dict) -> None:
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
names = {b["name"]: b for b in response.json()["branches"]}
assert names["main"]["is_remote"] is False
assert names["feature/x"]["is_remote"] is False
# include_remote=False (default) never prunes.
svc.prune_remote_best_effort.assert_not_awaited()
@pytest.mark.asyncio
async def test_branches_with_remote(git_client: dict) -> None:
"""Regression: `%(refname)` renders a remote-tracking ref as
`refs/remotes/origin/<branch>` (real git never emits the old stub's
`remotes/origin/<branch>` shape) it must classify as remote with the
`refs/remotes/origin/` prefix stripped down to the bare branch name, and
the symbolic `origin/HEAD` ref must be dropped, not surfaced as a fake
branch named "HEAD"."""
branch_result = MagicMock()
branch_result.stdout = (
"refs/heads/main|abc123\n"
"refs/remotes/origin/feature/y|def456\n"
"refs/remotes/origin/HEAD|abc123\n"
)
branch_result.stdout = "main|abc123\nremotes/origin/feature/y|def456\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
@@ -400,11 +355,6 @@ async def test_branches_with_remote(git_client: dict) -> None:
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
names = {b["name"]: b for b in response.json()["branches"]}
assert names["feature/y"]["is_remote"] is True
assert "origin/feature/y" not in names
assert "HEAD" not in names
svc.prune_remote_best_effort.assert_awaited_once_with("/tmp/ws")
@pytest.mark.asyncio
@@ -412,7 +362,7 @@ async def test_branches_skips_empty_lines(git_client: dict) -> None:
"""Line 246: empty line in branch output triggers continue."""
branch_result = MagicMock()
# Embed an empty line between two branches.
branch_result.stdout = "refs/heads/main|abc\n\nrefs/heads/feature/x|def\n"
branch_result.stdout = "main|abc\n\nfeature/x|def\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
@@ -490,6 +440,33 @@ async def test_diff_service_error(git_client: dict) -> None:
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_diff_bounded_timeout_returns_504(
git_client: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Task #62845be1: a genuinely slow diff computation trips the bounded
``evidence_assembly_timeout_seconds`` guard and returns a structured 504
naming the slow component, instead of hanging indefinitely."""
monkeypatch.setattr(
"roboco.api.routes.git.settings.evidence_assembly_timeout_seconds", 0.05
)
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
async def _slow_workspace(*_args: object, **_kwargs: object) -> str:
await asyncio.sleep(1)
return "/tmp/ws"
svc.get_workspace = AsyncMock(side_effect=_slow_workspace)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/diff?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT
assert "bounded" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------
@@ -1053,78 +1030,5 @@ async def test_merge_pr_without_task_id_no_422(git_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# branches/cleanup
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cleanup_branches_success(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(return_value=(3, 2, 1, 0, False, None))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert (
data["remote_deleted"],
data["local_deleted"],
data["skipped"],
data["errors"],
data["truncated"],
) == (3, 2, 1, 0, False)
svc.cleanup_stale_branches.assert_awaited_once_with(
pm_git_client["project"].slug, after_task_id=None
)
@pytest.mark.asyncio
async def test_cleanup_branches_reports_truncation(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(
return_value=(200, 190, 0, 0, True, "0" * 32)
)
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["truncated"] is True
@pytest.mark.asyncio
async def test_cleanup_branches_developer_gets_403(git_client: dict) -> None:
"""git_client carries a DEVELOPER-role agent — same role gate as /rebase."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "BRANCH_CLEANUP_ROLE_RESTRICTED" in response.json()["detail"]
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_cleanup_branches_project_not_found(pm_git_client: dict) -> None:
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": "does-not-exist"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# Re-export to keep import alive (TC reorders imports)
_ = SimpleNamespace
+67 -119
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
@@ -80,8 +82,7 @@ async def test_claim_review_returns_evidence_inline() -> None:
task_svc.qa_claim.return_value = t_claimed
work_svc = AsyncMock()
git_svc = AsyncMock()
git_svc.diff.return_value = "+++ diff content"
git_svc.list_changed_files.return_value = ["README.md"]
git_svc.diff_and_files.return_value = ("+++ diff content", ["README.md"])
deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc)
c = Choreographer(deps)
@@ -147,7 +148,7 @@ async def test_claim_review_marks_evidence_inspected() -> None:
task_svc.list_paused_for_agent.return_value = []
task_svc.qa_claim.return_value = t_claimed
git_svc = AsyncMock()
git_svc.diff.return_value = ""
git_svc.diff_and_files.return_value = ("", [])
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
@@ -169,6 +170,69 @@ async def test_claim_review_task_not_found_returns_not_found() -> None:
assert body["error"] == "not_found"
@pytest.mark.asyncio
async def test_claim_review_returns_gateway_timeout_on_slow_evidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Task #62845be1: the claim itself (qa_claim + mark_evidence_inspected)
already committed before evidence assembly starts. A genuinely slow
evidence segment (git diff/fetch, conventions validation, or a DB read)
must trip the bounded ``evidence_assembly_timeout_seconds`` guard and
return a structured ``gateway_timeout`` envelope naming the slow
component and pointing at the already-succeeded claim not hang into
the outer 120s server-side rollback."""
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05)
qa_id = uuid4()
task_id = uuid4()
t_initial = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
pr_number=_EXPECTED_PR_NUMBER,
pr_url=_EXPECTED_PR_URL,
commits=[{"sha": "abc123", "message": "feat: x"}],
team="backend",
branch_name="feature/backend/abc--def",
work_session_id=uuid4(),
documents=[],
dev_notes="implemented x",
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "abc123"},
],
)
t_claimed = MagicMock(
**{**t_initial.__dict__, "assigned_to": qa_id, "status": "claimed"},
)
task_svc = AsyncMock()
task_svc.get.return_value = t_initial
task_svc.agent_for.return_value = MagicMock(role="qa", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.qa_claim.return_value = t_claimed
work_svc = AsyncMock()
git_svc = AsyncMock()
async def _slow_diff_and_files(**_kwargs: object) -> tuple[str, list[str]]:
await asyncio.sleep(1)
return "+++ diff content", ["README.md"]
git_svc.diff_and_files.side_effect = _slow_diff_and_files
deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc)
c = Choreographer(deps)
env = await c.claim_review(qa_id, task_id)
body = env.as_dict()
assert body["error"] == "gateway_timeout"
assert "evidence" in body["message"].lower()
# The claim itself already committed — the remediation must not tell the
# agent to retry claim_review (which would re-run the whole slow path).
assert "evidence(task_id)" in body["remediate"]
task_svc.qa_claim.assert_awaited_once()
task_svc.mark_evidence_inspected.assert_awaited_once_with(task_id)
@pytest.mark.asyncio
async def test_pass_review_task_not_found_returns_not_found() -> None:
"""Line 117 of qa.py: _verify_qa_owner emits not_found when task is None."""
@@ -320,94 +384,6 @@ async def test_pass_review_succeeds_and_transitions() -> None:
a2a_svc.send.assert_awaited_once()
@pytest.mark.asyncio
async def test_pass_review_rejects_without_criteria_verified_when_acs_present() -> None:
"""A task with real acceptance criteria demands criteria_verified — a
gestalt "looks good" notes string alone is no longer enough."""
qa_id = uuid4()
task_id = uuid4()
t = _qa_owned_task(
task_id, qa_id, acceptance_criteria=["returns 200", "includes timestamp"]
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = _qa_agent_mock(qa_id)
journal_svc = AsyncMock()
journal_svc.has_learning_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
notes = "x" * 100
env = await c.pass_review(qa_id, task_id, notes=notes)
body = env.as_dict()
assert body["error"] == "invalid_state", body
assert "returns 200" in body["message"]
assert "includes timestamp" in body["message"]
@pytest.mark.asyncio
async def test_pass_review_renders_criteria_verified_into_notes() -> None:
"""Happy path: every AC matched + evidenced renders '[AC] ...' lines into
the persisted qa_notes and the transition still fires."""
qa_id = uuid4()
task_id = uuid4()
t = _qa_owned_task(
task_id, qa_id, acceptance_criteria=["returns 200", "includes timestamp"]
)
after = MagicMock(
id=task_id,
status="awaiting_documentation",
assigned_to=qa_id,
team="backend",
pr_url="https://x/pr/8",
qa_evidence_inspected=True,
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = _qa_agent_mock(qa_id)
task_svc.qa_pass.return_value = after
task_svc.documenter_for_team.return_value = MagicMock(id=uuid4())
task_svc.session = MagicMock()
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
_stub_empty_ledger(task_svc.session)
journal_svc = AsyncMock()
journal_svc.has_learning_for_task.return_value = True
a2a_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc, a2a=a2a_svc)
c = Choreographer(deps)
notes = (
"Reviewed PR carefully. Rendered every scene and checked each frame "
"against the brief before approving."
)
env = await c.pass_review(
qa_id,
task_id,
notes=notes,
criteria_verified=[
{"criterion": "returns 200", "evidence": "test_healthz asserts 200"},
{
"criterion": "includes timestamp",
"evidence": "frame diff shows ts field at README.md line 12",
},
],
)
assert env.error is None, env.as_dict()
assert env.status == "awaiting_documentation"
task_svc.qa_pass.assert_awaited_once()
persisted_notes = task_svc.qa_pass.call_args.args[2]
assert "[AC] returns 200 — verified: test_healthz asserts 200" in persisted_notes
assert (
"[AC] includes timestamp — verified: frame diff shows ts field at "
"README.md line 12" in persisted_notes
)
@pytest.mark.asyncio
async def test_pass_review_not_assigned_returns_not_authorized() -> None:
qa_id = uuid4()
@@ -488,34 +464,6 @@ async def test_fail_review_requires_at_least_one_issue() -> None:
assert "finding" in body["message"].lower()
@pytest.mark.asyncio
async def test_fail_review_rejects_prose_file_names_evidence_in_remediate() -> None:
qa_id = uuid4()
task_id = uuid4()
t = _qa_owned_task(task_id, qa_id)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = _qa_agent_mock(qa_id)
journal_svc = AsyncMock()
journal_svc.has_learning_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
findings = [
{
"file": "PR #676 description",
"severity": "major",
"expected": "matches the acceptance criteria",
"actual": "diverges from the acceptance criteria",
}
]
env = await c.fail_review(qa_id, task_id, findings=findings)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "evidence" in body["remediate"]
assert "file" in body["remediate"]
@pytest.mark.asyncio
async def test_fail_review_not_assigned_returns_not_authorized() -> None:
qa_id = uuid4()
+99 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
@@ -32,6 +34,7 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
git = AsyncMock()
git.commit.return_value = {"sha": "abc12345"}
git.diff.return_value = ""
git.diff_and_files.return_value = ("", [])
a2a = overrides.get("a2a", AsyncMock())
journal = overrides.get("journal", AsyncMock())
@@ -645,7 +648,10 @@ async def test_evidence_valid_task_returns_ok_with_pr_diff() -> None:
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
git_svc = AsyncMock()
git_svc.diff.return_value = "diff --git a/foo.py b/foo.py\n+added line"
git_svc.diff_and_files.return_value = (
"diff --git a/foo.py b/foo.py\n+added line",
["foo.py"],
)
workspace_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
@@ -659,7 +665,7 @@ async def test_evidence_valid_task_returns_ok_with_pr_diff() -> None:
assert body["evidence"]["pr_number"] == pr_number
assert "diff --git" in body["evidence"]["pr_diff_summary"]
workspace_svc.fetch_branch_for_inspection.assert_awaited_once()
git_svc.diff.assert_awaited_once()
git_svc.diff_and_files.assert_awaited_once()
@pytest.mark.asyncio
@@ -680,6 +686,97 @@ async def test_evidence_task_not_found_returns_not_found() -> None:
assert str(task_id) in body["message"]
@pytest.mark.asyncio
async def test_evidence_returns_gateway_timeout_on_slow_git(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Task #62845be1: a genuinely slow git diff/fetch trips the bounded
``evidence_assembly_timeout_seconds`` guard and returns a structured
``gateway_timeout`` envelope naming the slow component not a bare
120s rollback of the outer verb."""
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05)
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
branch_name="feature/backend/abc",
work_session_id=uuid4(),
commits=["sha1"],
pr_number=1,
pr_url="https://github.com/org/repo/pull/1",
dev_notes="done",
acceptance_criteria_status=[],
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
async def _slow_diff_and_files(**_kwargs: object) -> tuple[str, list[str]]:
await asyncio.sleep(1)
return "diff", ["f.py"]
git_svc = AsyncMock()
git_svc.diff_and_files.side_effect = _slow_diff_and_files
workspace_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
ca = ContentActions(deps)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] == "gateway_timeout"
assert "git" in body["message"].lower()
assert "0" in body["message"] # names the ~0.05s bounded timeout
@pytest.mark.asyncio
async def test_evidence_returns_gateway_timeout_on_slow_db_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The bounded timeout also fires when the slow segment is a DB read
rather than git proving the guard covers the whole gathered batch,
not just the git branch."""
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05)
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
branch_name="feature/backend/abc",
work_session_id=uuid4(),
commits=["sha1"],
pr_number=1,
pr_url="https://github.com/org/repo/pull/1",
dev_notes="done",
acceptance_criteria_status=[],
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
git_svc = AsyncMock()
git_svc.diff_and_files.return_value = ("diff", ["f.py"])
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
async def _slow_journal_highlights(*_args: object, **_kwargs: object) -> list[Any]:
await asyncio.sleep(1)
return []
evidence_repo.journal_highlights_for_task.side_effect = _slow_journal_highlights
deps = _make_deps(
task=task_svc, git=git_svc, workspace=workspace_svc, evidence_repo=evidence_repo
)
ca = ContentActions(deps)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] == "gateway_timeout"
# ---------------------------------------------------------------------------
# notify: invalid priority and explicit-ownership rejections
# ---------------------------------------------------------------------------
@@ -141,7 +141,13 @@ async def test_validator_timeout_fails_closed_and_reaps(
return fake_proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
monkeypatch.setattr(git_module, "_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS", 0.01)
# The fixed module constant became a settings-backed accessor (task
# #62845be1: bounded well under the outer 120s gateway-verb budget
# instead of matching it) — patch the setting, not the removed module
# attribute.
monkeypatch.setattr(
git_module.settings, "conventions_validator_timeout_seconds", 0.01
)
svc = _service()
result = await svc._run_conventions_validator(tmp_path, ["a.py"])
@@ -0,0 +1,101 @@
"""Task #62845be1: ``diff_and_files`` resolves shared state ONCE.
``diff()`` and ``list_changed_files()`` each independently re-resolve the
workspace, auth token, head ref, and diff base before running their own
``git diff`` subprocess duplicated work when a caller (evidence assembly)
needs both. ``diff_and_files`` resolves that shared state a single time,
then runs the two ``git diff`` subprocesses concurrently.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.git import GitService
_BR = "feature/backend/root1234--cellpm56--dev78901"
def _git_service() -> Any:
# A real constructor (not __new__) so ``self.log`` is bound —
# ``diff_and_files`` logs its own resolve/diff timing.
return GitService(MagicMock())
@pytest.mark.asyncio
async def test_diff_and_files_resolves_shared_state_once() -> None:
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/qa-ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=f"origin/{_BR}")
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
captured: list[list[str]] = []
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
captured.append(args)
if args[:2] == ["diff", "--name-only"]:
return type(
"R", (), {"returncode": 0, "stdout": "README.md\nsrc/app.py\n"}
)()
return type("R", (), {"returncode": 0, "stdout": "diff body"})()
with patch.object(svc, "_run_git", new=fake_run):
diff, files = await svc.diff_and_files(branch_name=_BR)
assert diff == "diff body"
assert files == ["README.md", "src/app.py"]
# The shared resolution work runs exactly ONCE, not once per sub-call.
svc._workspace_for_branch.assert_awaited_once()
svc._token_for_branch.assert_awaited_once()
svc._resolve_head_ref.assert_awaited_once()
svc._resolve_diff_base.assert_awaited_once()
# Both the `diff` and `diff --name-only` subprocesses still ran, off the
# same resolved base...head pair.
assert any(c == ["diff", f"origin/master...origin/{_BR}"] for c in captured)
assert any(
c == ["diff", "--name-only", f"origin/master...origin/{_BR}"] for c in captured
)
@pytest.mark.asyncio
async def test_diff_and_files_honors_explicit_base_and_preferred_parent() -> None:
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/dev-ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=_BR)
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
async def fake_run(_ws: Any, _args: list[str], **_kw: Any) -> Any:
return type("R", (), {"returncode": 0, "stdout": ""})()
with patch.object(svc, "_run_git", new=fake_run):
await svc.diff_and_files(
branch_name=_BR, base="HEAD~1", preferred_parent="feature/backend/other"
)
# An explicit base skips _resolve_diff_base entirely.
svc._resolve_diff_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_diff_and_files_matches_diff_and_list_changed_files_output() -> None:
"""Combined accessor returns the same data the two separate calls would."""
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=f"origin/{_BR}")
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
if args[:2] == ["diff", "--name-only"]:
return type("R", (), {"returncode": 0, "stdout": "a.py\nb.py\n"})()
return type("R", (), {"returncode": 0, "stdout": "full diff body"})()
with patch.object(svc, "_run_git", new=fake_run):
diff, files = await svc.diff_and_files(branch_name=_BR)
assert diff == "full diff body"
assert files == ["a.py", "b.py"]