mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
co-authored by
Backend Developer 1
Backend Documenter
parent
666f261a1a
commit
89254f796c
@@ -13,6 +13,7 @@ Documentation for the Backend Cell team.
|
|||||||
- `/qa/` - QA-related docs
|
- `/qa/` - QA-related docs
|
||||||
- `/services/` - Internal service architecture & patterns
|
- `/services/` - Internal service architecture & patterns
|
||||||
- `coordination-events.md` - 5 coordination-event notification producers: reassignment, collision-sequencing, unblock, dependency-revival, stale-claim-reaped
|
- `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
|
- `/ops/` - Operational runbooks
|
||||||
- `codeql-workflows.md` - Split CodeQL workflow triggers and branch-protection notes
|
- `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
@@ -21,7 +21,9 @@ Workspace Structure:
|
|||||||
each on their own branch, without file conflicts.
|
each on their own branch, without file conflicts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, status
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
@@ -31,8 +33,6 @@ from roboco.api.deps import CurrentAgentContext, DbSession
|
|||||||
from roboco.api.schemas.git import (
|
from roboco.api.schemas.git import (
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
GitBranchCleanupRequest,
|
|
||||||
GitBranchCleanupResponse,
|
|
||||||
GitBranchListResponse,
|
GitBranchListResponse,
|
||||||
GitCheckoutRequest,
|
GitCheckoutRequest,
|
||||||
GitCheckoutResponse,
|
GitCheckoutResponse,
|
||||||
@@ -45,7 +45,6 @@ from roboco.api.schemas.git import (
|
|||||||
GitDiffResponse,
|
GitDiffResponse,
|
||||||
GitFetchRequest,
|
GitFetchRequest,
|
||||||
GitFetchResponse,
|
GitFetchResponse,
|
||||||
GitFileContentResponse,
|
|
||||||
GitLogResponse,
|
GitLogResponse,
|
||||||
GitMergePRRequest,
|
GitMergePRRequest,
|
||||||
GitMergePRResponse,
|
GitMergePRResponse,
|
||||||
@@ -57,6 +56,7 @@ from roboco.api.schemas.git import (
|
|||||||
GitRebaseResponse,
|
GitRebaseResponse,
|
||||||
GitStatusResponse,
|
GitStatusResponse,
|
||||||
)
|
)
|
||||||
|
from roboco.config import settings
|
||||||
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
|
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
|
||||||
from roboco.logging import get_logger
|
from roboco.logging import get_logger
|
||||||
from roboco.models.base import AgentRole
|
from roboco.models.base import AgentRole
|
||||||
@@ -89,43 +89,6 @@ _LOG_FORMAT_PARTS = 5
|
|||||||
# bubbling as 500 Internal Server Errors with no `detail`.
|
# bubbling as 500 Internal Server Errors with no `detail`.
|
||||||
_TranslatableError = (ServiceError, GitError)
|
_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.
|
# Roles permitted to rebase branches via the /rebase endpoint.
|
||||||
# Rebase is a history-rewriting operation that should be authorised only by
|
# Rebase is a history-rewriting operation that should be authorised only by
|
||||||
# PM-level or CEO-level callers. Developers are intentionally excluded:
|
# PM-level or CEO-level callers. Developers are intentionally excluded:
|
||||||
@@ -243,18 +206,6 @@ async def get_git_log(
|
|||||||
if not branch:
|
if not branch:
|
||||||
branch = await git_service.get_current_branch(workspace)
|
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
|
# 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
|
# this workspace yet — that's a normal race (branch created in a
|
||||||
# different agent's clone, not yet fetched here). Return empty.
|
# 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_format = "%H%x1f%h%x1f%s%x1f%an%x1f%aI"
|
||||||
log_result = await git_service._run_git(
|
log_result = await git_service._run_git(
|
||||||
workspace,
|
workspace,
|
||||||
["log", f"--format={log_format}", f"-n{limit}", head_ref],
|
["log", f"--format={log_format}", f"-n{limit}", branch],
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if log_result.returncode != 0:
|
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)
|
@router.get("/branches", response_model=GitBranchListResponse)
|
||||||
async def list_branches(
|
async def list_branches(
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
@@ -339,12 +268,8 @@ async def list_branches(
|
|||||||
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
||||||
current_branch = await git_service.get_current_branch(workspace)
|
current_branch = await git_service.get_current_branch(workspace)
|
||||||
|
|
||||||
if include_remote:
|
# Get branches
|
||||||
# Self-heal orphaned remote-tracking refs (branches deleted
|
args = ["branch", "--format=%(refname:short)|%(objectname:short)"]
|
||||||
# upstream via the forge API) before listing them.
|
|
||||||
await git_service.prune_remote_best_effort(workspace)
|
|
||||||
|
|
||||||
args = ["branch", "--format=%(refname)|%(objectname:short)"]
|
|
||||||
if include_remote:
|
if include_remote:
|
||||||
args.append("-a")
|
args.append("-a")
|
||||||
|
|
||||||
@@ -354,10 +279,16 @@ async def list_branches(
|
|||||||
|
|
||||||
branches = []
|
branches = []
|
||||||
for line in branch_result.stdout.strip().split("\n"):
|
for line in branch_result.stdout.strip().split("\n"):
|
||||||
parsed = _parse_branch_line(line)
|
if not line:
|
||||||
if parsed is None:
|
|
||||||
continue
|
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(
|
branches.append(
|
||||||
BranchInfo(
|
BranchInfo(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -382,11 +313,18 @@ async def get_git_diff(
|
|||||||
staged: bool = Query(default=False),
|
staged: bool = Query(default=False),
|
||||||
file_path: str | None = Query(default=None),
|
file_path: str | None = Query(default=None),
|
||||||
) -> GitDiffResponse:
|
) -> 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)
|
project_slug = await _resolve_project_slug(project_slug, db)
|
||||||
git_service = get_git_service(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)
|
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
||||||
|
|
||||||
args = ["diff"]
|
args = ["diff"]
|
||||||
@@ -394,14 +332,30 @@ async def get_git_diff(
|
|||||||
args.append("--staged")
|
args.append("--staged")
|
||||||
if file_path:
|
if file_path:
|
||||||
args.extend(["--", file_path])
|
args.extend(["--", file_path])
|
||||||
|
diff_res = await git_service._run_git(workspace, args)
|
||||||
diff_result = await git_service._run_git(workspace, args)
|
|
||||||
|
|
||||||
# Count files changed
|
# Count files changed
|
||||||
stat_args = ["diff", "--stat"]
|
stat_args = ["diff", "--stat"]
|
||||||
if staged:
|
if staged:
|
||||||
stat_args.append("--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:
|
except _TranslatableError as e:
|
||||||
raise _translate_error(e) from 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
|
# WRITE ENDPOINTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -788,7 +688,7 @@ async def rebase_branch(
|
|||||||
try:
|
try:
|
||||||
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
||||||
conflict, conflicted_files = await git_service.rebase(
|
conflict, conflicted_files = await git_service.rebase(
|
||||||
workspace, data.target_branch, project_slug
|
workspace, data.target_branch
|
||||||
)
|
)
|
||||||
except _TranslatableError as e:
|
except _TranslatableError as e:
|
||||||
raise _translate_error(e) from e
|
raise _translate_error(e) from e
|
||||||
@@ -798,60 +698,3 @@ async def rebase_branch(
|
|||||||
conflict=conflict,
|
conflict=conflict,
|
||||||
conflicted_files=conflicted_files,
|
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1706,6 +1706,30 @@ class Settings(BaseSettings):
|
|||||||
"repository; set this to sandbox smoke-test projects."
|
"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)
|
# Agent Guardrails (per-session budgets, loop detection, SLAs)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ still validates role + claim source-status + task_type before dispatch.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -47,21 +49,16 @@ from roboco.foundation.policy import tracing as _tr
|
|||||||
from roboco.foundation.policy.content import (
|
from roboco.foundation.policy.content import (
|
||||||
ContentValidationError,
|
ContentValidationError,
|
||||||
markers,
|
markers,
|
||||||
|
validate_findings,
|
||||||
)
|
)
|
||||||
from roboco.services.content_notes import apply_structured_note
|
from roboco.services.content_notes import apply_structured_note
|
||||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
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._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.envelope import Envelope
|
||||||
from roboco.services.gateway.evidence_builder import build_evidence_for_task
|
from roboco.services.gateway.evidence_builder import build_evidence_for_task
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
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:
|
if TYPE_CHECKING:
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -180,7 +177,37 @@ class QAMixin(_Base):
|
|||||||
t = await self.task.qa_claim(qa_agent_id, task_id)
|
t = await self.task.qa_claim(qa_agent_id, task_id)
|
||||||
await self.task.mark_evidence_inspected(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(
|
return Envelope.ok(
|
||||||
status=str(t.status),
|
status=str(t.status),
|
||||||
task_id=str(task_id),
|
task_id=str(task_id),
|
||||||
@@ -190,43 +217,109 @@ class QAMixin(_Base):
|
|||||||
).with_introspection(task=t, role=role_str)
|
).with_introspection(task=t, role=role_str)
|
||||||
|
|
||||||
async def _qa_convention_findings(
|
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]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Convention-validator findings on the task's changed files (flag-gated).
|
"""Convention-validator findings on the task's changed files (flag-gated).
|
||||||
|
|
||||||
Empty when the subsystem is off; a validator that could not run surfaces
|
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
|
a single explicit ``could_not_run`` entry rather than being dropped, so
|
||||||
QA never mistakes a silent failure for a clean diff.
|
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:
|
if not settings.conventions_enabled:
|
||||||
return []
|
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"):
|
if result.get("could_not_run"):
|
||||||
reason = result.get("reason") or "validator could not run"
|
reason = result.get("reason") or "validator could not run"
|
||||||
return [{"could_not_run": True, "reason": reason}]
|
return [{"could_not_run": True, "reason": reason}]
|
||||||
return list(result.get("findings", []))
|
return list(result.get("findings", []))
|
||||||
|
|
||||||
@staticmethod
|
async def _qa_db_reads(
|
||||||
def _qa_video_context(t: Any) -> dict[str, Any] | None:
|
self, task_id: UUID, t: Any
|
||||||
"""QA-facing artifact context for a video-authoring task.
|
) -> 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
|
They stay sequential AMONG THEMSELVES on purpose: they all read
|
||||||
``video_draft`` marker) + the latest ``request_render`` preview so QA
|
through the request-scoped ``self.task.session`` / evidence_repo, a
|
||||||
is pointed at the rendered artifact instead of the source alone.
|
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:
|
t0 = time.monotonic()
|
||||||
return None
|
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
||||||
draft = markers.get_video_draft(t) or {}
|
task_id
|
||||||
return {
|
)
|
||||||
"composition_id": draft.get("composition_id"),
|
# The ask-chain (parent → root descriptions) so QA judges INTENT
|
||||||
"render_preview": markers.get_render_preview(t),
|
# against the intake's original analysis, not only the leaf's ACs.
|
||||||
"note": (
|
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
|
||||||
"This task ships a rendered video. Call request_render to "
|
open_findings = await findings_lib.open_findings_for_task(
|
||||||
"render the PR branch state, then Read every returned frame "
|
self.task.session, t.id
|
||||||
"image — verify each acceptance criterion's scene appears "
|
)
|
||||||
"fully and legibly. Do not pass on source reading alone."
|
# 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(
|
async def _build_qa_claim_evidence(
|
||||||
self, qa_agent_id: UUID, t: Any, task_id: UUID
|
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
|
authoritative source) + journal_highlights so the QA agent has
|
||||||
the full PR context up-front and can't miss a piece.
|
the full PR context up-front and can't miss a piece.
|
||||||
|
|
||||||
files_changed comes from ``git.list_changed_files``
|
files_changed comes from the combined ``diff_and_files``
|
||||||
instead of ``work_session.files_modified``. The legacy
|
accessor instead of ``work_session.files_modified``. The legacy
|
||||||
``add_files_modified`` HTTP path that populated files_modified
|
``add_files_modified`` HTTP path that populated files_modified
|
||||||
is not called by the gateway ``commit()``, so the work_session
|
is not called by the gateway ``commit()``, so the work_session
|
||||||
list was always empty — QA saw no files even on real PRs.
|
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 = ""
|
(diff_summary, files_changed, convention_findings),
|
||||||
if t.branch_name:
|
(
|
||||||
diff_summary = await self.git.diff(branch_name=t.branch_name)
|
journal_highlights,
|
||||||
files_changed = await self.git.list_changed_files(branch_name=t.branch_name)
|
parent_context,
|
||||||
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
open_findings,
|
||||||
task_id
|
prior_findings,
|
||||||
)
|
),
|
||||||
# The ask-chain (parent → root descriptions) so QA judges INTENT
|
) = await asyncio.gather(
|
||||||
# against the intake's original analysis, not only the leaf's ACs.
|
self._qa_git_and_conventions(qa_agent_id, t),
|
||||||
# Leaf-only journals stay (include_ancestors defaults False above);
|
self._qa_db_reads(task_id, t),
|
||||||
# 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)
|
|
||||||
)
|
)
|
||||||
return build_evidence_for_task(
|
return build_evidence_for_task(
|
||||||
t,
|
t,
|
||||||
@@ -289,8 +362,6 @@ class QAMixin(_Base):
|
|||||||
revision_findings=open_findings,
|
revision_findings=open_findings,
|
||||||
prior_findings=prior_findings,
|
prior_findings=prior_findings,
|
||||||
parent_context=parent_context,
|
parent_context=parent_context,
|
||||||
collision_context=collision_context,
|
|
||||||
video_context=self._qa_video_context(t),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _verify_qa_owner(
|
async def _verify_qa_owner(
|
||||||
@@ -431,13 +502,13 @@ class QAMixin(_Base):
|
|||||||
def _qa_ac_coverage_check(
|
def _qa_ac_coverage_check(
|
||||||
cls, task: Any, ac_verdicts: list[str] | None
|
cls, task: Any, ac_verdicts: list[str] | None
|
||||||
) -> Envelope | 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
|
QA may not pass a task until it has recorded a verification for EVERY
|
||||||
never verified they actually named the right criterion — the live gap
|
acceptance criterion. A single gestalt "looks good" approval is how a
|
||||||
``_validate_criteria_verified`` closes). Kept for ``ac_verdicts``'
|
silently-unbuilt criterion slips through; requiring one verdict per
|
||||||
existing callers/tests; ``ac_verdicts`` itself still folds into the
|
criterion forces QA to check each individually. If a criterion does not
|
||||||
persisted notes when supplied.
|
hold, the QA fails the review instead of passing a partial.
|
||||||
"""
|
"""
|
||||||
criteria = list(getattr(task, "acceptance_criteria", None) or [])
|
criteria = list(getattr(task, "acceptance_criteria", None) or [])
|
||||||
if not criteria:
|
if not criteria:
|
||||||
@@ -549,181 +620,36 @@ class QAMixin(_Base):
|
|||||||
verb=verb,
|
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(
|
async def _qa_pass_final_gates(
|
||||||
self,
|
self,
|
||||||
qa_agent_id: UUID,
|
qa_agent_id: UUID,
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
t: Any,
|
t: Any,
|
||||||
role_str: str,
|
role_str: str,
|
||||||
criteria_verified: list[dict[str, Any]] | None,
|
ac_verdicts: list[str] | None,
|
||||||
) -> tuple[Envelope | None, list[tuple[str, str]]]:
|
) -> Envelope | None:
|
||||||
"""Per-AC verification + toolchain-runnability gates for pass_review.
|
"""AC-coverage + toolchain-runnability gates for pass_review.
|
||||||
|
|
||||||
Returns ``(rejection, pairs)`` — the first emitted rejection (else
|
Returns the first rejection (already emitted), else None. QA must not
|
||||||
None) and the validated ``criteria_verified`` (criterion, evidence)
|
PASS on a workspace that cannot run the suite — that is a source-read
|
||||||
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
|
|
||||||
"verification"; fail_review is unaffected.
|
"verification"; fail_review is unaffected.
|
||||||
"""
|
"""
|
||||||
pairs, bad = self._validate_criteria_verified(t, criteria_verified)
|
ac_rejection = self._qa_ac_coverage_check(t, ac_verdicts)
|
||||||
if bad is not None:
|
if ac_rejection is not None:
|
||||||
rejection = await self._emit_rejection(
|
return await self._emit_rejection(
|
||||||
bad.with_introspection(task=t, role=role_str),
|
ac_rejection.with_introspection(task=t, role=role_str),
|
||||||
agent_id=qa_agent_id,
|
agent_id=qa_agent_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
verb="pass_review",
|
verb="pass_review",
|
||||||
)
|
)
|
||||||
return rejection, []
|
|
||||||
if toolchain := await self._toolchain_broken_guard(qa_agent_id, t):
|
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),
|
toolchain.with_introspection(task=t, role=role_str),
|
||||||
agent_id=qa_agent_id,
|
agent_id=qa_agent_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
verb="pass_review",
|
verb="pass_review",
|
||||||
)
|
)
|
||||||
return rejection, []
|
return None
|
||||||
return None, pairs
|
|
||||||
|
|
||||||
async def pass_review(
|
async def pass_review(
|
||||||
self,
|
self,
|
||||||
@@ -731,7 +657,6 @@ class QAMixin(_Base):
|
|||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
notes: str,
|
notes: str,
|
||||||
ac_verdicts: list[str] | None = None,
|
ac_verdicts: list[str] | None = None,
|
||||||
criteria_verified: list[dict[str, Any]] | None = None,
|
|
||||||
) -> Envelope:
|
) -> Envelope:
|
||||||
"""QA passes the task; transitions awaiting_qa → awaiting_documentation.
|
"""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
|
The composed atomic ``qa_pass`` is then dispatched through
|
||||||
``VerbRunner.run_intent``, after which the verb body reassigns
|
``VerbRunner.run_intent``, after which the verb body reassigns
|
||||||
the documenter for handoff.
|
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")
|
rejection, t = await self._verify_qa_owner(qa_agent_id, task_id, "pass_review")
|
||||||
if rejection is not None:
|
if rejection is not None:
|
||||||
@@ -781,22 +696,18 @@ class QAMixin(_Base):
|
|||||||
soup_checks=(("notes", notes, 8),),
|
soup_checks=(("notes", notes, 8),),
|
||||||
):
|
):
|
||||||
return gate_rejection
|
return gate_rejection
|
||||||
final_rejection, criteria_pairs = await self._qa_pass_final_gates(
|
if rej := await self._qa_pass_final_gates(
|
||||||
qa_agent_id, task_id, t, role_str, criteria_verified
|
qa_agent_id, task_id, t, role_str, ac_verdicts
|
||||||
)
|
):
|
||||||
if final_rejection is not None:
|
return rej
|
||||||
return final_rejection
|
|
||||||
|
|
||||||
briefing = await self._briefing_for(qa_agent_id, task_id)
|
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(
|
spec_ctx = spec_module.Context(
|
||||||
actor_id=qa_agent_id,
|
actor_id=qa_agent_id,
|
||||||
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
|
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,
|
agent_team=str(agent.team) if agent is not None and agent.team else None,
|
||||||
original_developer_slug=_extract_original_developer(t),
|
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)
|
self._store_qa_note(t, notes, ac_verdicts, passed=True)
|
||||||
runner = self._verb_runner()
|
runner = self._verb_runner()
|
||||||
@@ -892,9 +803,16 @@ class QAMixin(_Base):
|
|||||||
)
|
)
|
||||||
if cap := findings_lib.findings_count_guard(raw):
|
if cap := findings_lib.findings_count_guard(raw):
|
||||||
return [], cap
|
return [], cap
|
||||||
validated, bad = findings_lib.validate_or_reject(raw)
|
try:
|
||||||
if bad is not None:
|
validated = validate_findings(raw)
|
||||||
return [], bad
|
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):
|
if unknown := findings_lib.unknown_finding_criteria(t, validated):
|
||||||
return [], findings_lib.criterion_mismatch_rejection(t, unknown)
|
return [], findings_lib.criterion_mismatch_rejection(t, unknown)
|
||||||
return validated, None
|
return validated, None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -238,6 +238,31 @@ class Envelope:
|
|||||||
context_briefing=context_briefing or {},
|
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
|
@classmethod
|
||||||
def from_decision(
|
def from_decision(
|
||||||
cls, decision: Any, *, briefing: dict[str, Any] | None = None
|
cls, decision: Any, *, briefing: dict[str, Any] | None = None
|
||||||
|
|||||||
+587
-1444
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from types import SimpleNamespace
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_log_no_branch_fetches_current(git_client: dict) -> None:
|
async def test_log_no_branch_fetches_current(git_client: dict) -> None:
|
||||||
log_result = MagicMock()
|
log_result = MagicMock()
|
||||||
@@ -355,7 +325,7 @@ async def test_log_service_error(git_client: dict) -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_branches_local_only(git_client: dict) -> None:
|
async def test_branches_local_only(git_client: dict) -> None:
|
||||||
branch_result = MagicMock()
|
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:
|
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||||
svc = AsyncMock()
|
svc = AsyncMock()
|
||||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||||
@@ -367,27 +337,12 @@ async def test_branches_local_only(git_client: dict) -> None:
|
|||||||
headers=_HDR,
|
headers=_HDR,
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_branches_with_remote(git_client: dict) -> None:
|
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 = MagicMock()
|
||||||
branch_result.stdout = (
|
branch_result.stdout = "main|abc123\nremotes/origin/feature/y|def456\n"
|
||||||
"refs/heads/main|abc123\n"
|
|
||||||
"refs/remotes/origin/feature/y|def456\n"
|
|
||||||
"refs/remotes/origin/HEAD|abc123\n"
|
|
||||||
)
|
|
||||||
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||||
svc = AsyncMock()
|
svc = AsyncMock()
|
||||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
||||||
@@ -400,11 +355,6 @@ async def test_branches_with_remote(git_client: dict) -> None:
|
|||||||
headers=_HDR,
|
headers=_HDR,
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
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
|
@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."""
|
"""Line 246: empty line in branch output triggers continue."""
|
||||||
branch_result = MagicMock()
|
branch_result = MagicMock()
|
||||||
# Embed an empty line between two branches.
|
# 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:
|
with patch("roboco.api.routes.git.get_git_service") as mock_get:
|
||||||
svc = AsyncMock()
|
svc = AsyncMock()
|
||||||
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
|
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
|
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
|
# 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
|
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)
|
# Re-export to keep import alive (TC reorders imports)
|
||||||
_ = SimpleNamespace
|
_ = SimpleNamespace
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from roboco.config import settings
|
||||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
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
|
task_svc.qa_claim.return_value = t_claimed
|
||||||
work_svc = AsyncMock()
|
work_svc = AsyncMock()
|
||||||
git_svc = AsyncMock()
|
git_svc = AsyncMock()
|
||||||
git_svc.diff.return_value = "+++ diff content"
|
git_svc.diff_and_files.return_value = ("+++ diff content", ["README.md"])
|
||||||
git_svc.list_changed_files.return_value = ["README.md"]
|
|
||||||
deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc)
|
deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc)
|
||||||
c = Choreographer(deps)
|
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.list_paused_for_agent.return_value = []
|
||||||
task_svc.qa_claim.return_value = t_claimed
|
task_svc.qa_claim.return_value = t_claimed
|
||||||
git_svc = AsyncMock()
|
git_svc = AsyncMock()
|
||||||
git_svc.diff.return_value = ""
|
git_svc.diff_and_files.return_value = ("", [])
|
||||||
deps = _make_deps(task=task_svc, git=git_svc)
|
deps = _make_deps(task=task_svc, git=git_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
@@ -169,6 +170,69 @@ async def test_claim_review_task_not_found_returns_not_found() -> None:
|
|||||||
assert body["error"] == "not_found"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_pass_review_task_not_found_returns_not_found() -> None:
|
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."""
|
"""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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_pass_review_not_assigned_returns_not_authorized() -> None:
|
async def test_pass_review_not_assigned_returns_not_authorized() -> None:
|
||||||
qa_id = uuid4()
|
qa_id = uuid4()
|
||||||
@@ -488,34 +464,6 @@ async def test_fail_review_requires_at_least_one_issue() -> None:
|
|||||||
assert "finding" in body["message"].lower()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_fail_review_not_assigned_returns_not_authorized() -> None:
|
async def test_fail_review_not_assigned_returns_not_authorized() -> None:
|
||||||
qa_id = uuid4()
|
qa_id = uuid4()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
|||||||
git = AsyncMock()
|
git = AsyncMock()
|
||||||
git.commit.return_value = {"sha": "abc12345"}
|
git.commit.return_value = {"sha": "abc12345"}
|
||||||
git.diff.return_value = ""
|
git.diff.return_value = ""
|
||||||
|
git.diff_and_files.return_value = ("", [])
|
||||||
|
|
||||||
a2a = overrides.get("a2a", AsyncMock())
|
a2a = overrides.get("a2a", AsyncMock())
|
||||||
journal = overrides.get("journal", 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 = AsyncMock()
|
||||||
task_svc.get.return_value = task_obj
|
task_svc.get.return_value = task_obj
|
||||||
git_svc = AsyncMock()
|
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()
|
workspace_svc = AsyncMock()
|
||||||
|
|
||||||
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
|
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 body["evidence"]["pr_number"] == pr_number
|
||||||
assert "diff --git" in body["evidence"]["pr_diff_summary"]
|
assert "diff --git" in body["evidence"]["pr_diff_summary"]
|
||||||
workspace_svc.fetch_branch_for_inspection.assert_awaited_once()
|
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
|
@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"]
|
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
|
# notify: invalid priority and explicit-ownership rejections
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -141,7 +141,13 @@ async def test_validator_timeout_fails_closed_and_reaps(
|
|||||||
return fake_proc
|
return fake_proc
|
||||||
|
|
||||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
|
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()
|
svc = _service()
|
||||||
result = await svc._run_conventions_validator(tmp_path, ["a.py"])
|
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"]
|
||||||
Reference in New Issue
Block a user