mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[a1f52d13] Rebuild evidence-assembly timeout fix on current main (#796)
* [a1f52d13] feat(gateway): re-apply evidence-assembly timeout fix on current main
Rebuilds the bounded-timeout fix for claim_review/evidence()/roboco_git_diff
that PR #765 carried as contaminated pre-Board-Programs file copies. The
rebase restored the current-main baseline; this commit re-applies ONLY the
validated timeout-fix hunks on top of that baseline:
- config.py: evidence_assembly_timeout_seconds (90.0s) and
conventions_validator_timeout_seconds (45s) Settings fields
- envelope.py: Envelope.gateway_timeout classmethod for structured
timeout errors naming the stalled component
- git.py: GitService.diff_and_files (single resolution, concurrent
diff+--name-only subprocesses), settings-backed conventions-validator
timeout, and changed_files passthrough to conventions_check_for_task
- qa.py: claim_review bounded-timeout wrapper around
_build_qa_claim_evidence, split into _qa_git_and_conventions and
_qa_db_reads concurrent segments (pass_review signature and gate
untouched)
- content_actions.py: evidence() bounded wrapper with _evidence_git_and_fetch
and _evidence_db_reads concurrent split (all other methods untouched)
- git routes: GET /diff bounded asyncio.wait_for guard with 504 on trip
The pre-existing methods (sync_env_branch, open_sync_pr, pass_review with
criteria_verified gate, all propose_*/request_render methods) are preserved
and untouched. The test files already exist on current main.
* [a1f52d13] fix(git-routes): wrap workspace resolution in bounded timeout for GET /diff
The bounded asyncio.wait_for must cover the workspace resolution step too,
not just the diff+stat subprocesses — the integration test makes
get_workspace slow (not _run_git), so the timeout guard needs to enclose
the entire resolve-and-diff path to return a structured 504 on trip.
* [a1f52d13] test: update mock setups from git.diff/list_changed_files to diff_and_files
Tests that exercise claim_review and evidence() paths need their git
mocks to return a tuple from diff_and_files instead of separate diff
and list_changed_files return values, matching the new combined accessor
introduced by the evidence-assembly timeout fix.
* [a1f52d13] test: configure diff_and_files mock in lifecycle parity test _make_deps
The spec-parity test test_claim_review_matches_spec[qa-awaiting_qa] failed
with ValueError at qa.py:330 because _make_deps set git=AsyncMock() without
configuring diff_and_files.return_value, so the await resolved to a MagicMock
instead of a (diff_summary, files_changed) 2-tuple. Adds the same
return_value = ("", []) configuration that commit 014a0d03 applied to the
other 4 test files.
* [a1f52d13] docs(evidence-assembly-timeout): correct GET /diff guard scope and _qa_git_and_conventions timing field
---------
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
This commit is contained in:
co-authored by
Backend Developer 2
Backend Documenter
parent
89254f796c
commit
ce89a04d26
@@ -22,7 +22,7 @@ A new `settings.evidence_assembly_timeout_seconds` (default 90s, well under the
|
|||||||
|
|
||||||
- `ContentActions.evidence()` — wraps the `asyncio.gather` of the git-diff coroutine and `_evidence_db_reads`.
|
- `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.
|
- `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.
|
- `GET /api/git/diff` (`roboco/api/routes/git.py`, `roboco_git_diff`) — wraps workspace resolution *plus* the diff + `--stat` subprocess pair in one bounded guard, so a slow workspace fetch trips the 504 too — not just a slow diff. (The workspace resolution is enclosed because an unbounded `get_workspace` is the step the integration test makes slow; leaving it outside the guard would let the whole request hang past the timeout anyway.)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Each segment logs its own duration (structlog `.info()` calls) so a slow request
|
|||||||
- `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".
|
- `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".
|
- `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").
|
- `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.
|
- `claim_review`'s two segment helpers (`_qa_git_and_conventions`, `_qa_db_reads`) log `git_diff_and_fetch_ms` ("claim_review git+conventions timing", covering the whole git+conventions block as one measurement) and `db_reads_ms` ("claim_review db reads timing") respectively.
|
||||||
|
|
||||||
## Related files
|
## Related files
|
||||||
|
|
||||||
|
|||||||
+219
-35
@@ -33,6 +33,8 @@ 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,6 +47,7 @@ from roboco.api.schemas.git import (
|
|||||||
GitDiffResponse,
|
GitDiffResponse,
|
||||||
GitFetchRequest,
|
GitFetchRequest,
|
||||||
GitFetchResponse,
|
GitFetchResponse,
|
||||||
|
GitFileContentResponse,
|
||||||
GitLogResponse,
|
GitLogResponse,
|
||||||
GitMergePRRequest,
|
GitMergePRRequest,
|
||||||
GitMergePRResponse,
|
GitMergePRResponse,
|
||||||
@@ -89,6 +92,43 @@ _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:
|
||||||
@@ -206,6 +246,18 @@ 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.
|
||||||
@@ -216,7 +268,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}", branch],
|
["log", f"--format={log_format}", f"-n{limit}", head_ref],
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if log_result.returncode != 0:
|
if log_result.returncode != 0:
|
||||||
@@ -253,6 +305,28 @@ 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,
|
||||||
@@ -268,8 +342,12 @@ 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)
|
||||||
|
|
||||||
# Get branches
|
if include_remote:
|
||||||
args = ["branch", "--format=%(refname:short)|%(objectname:short)"]
|
# Self-heal orphaned remote-tracking refs (branches deleted
|
||||||
|
# upstream via the forge API) before listing them.
|
||||||
|
await git_service.prune_remote_best_effort(workspace)
|
||||||
|
|
||||||
|
args = ["branch", "--format=%(refname)|%(objectname:short)"]
|
||||||
if include_remote:
|
if include_remote:
|
||||||
args.append("-a")
|
args.append("-a")
|
||||||
|
|
||||||
@@ -279,16 +357,10 @@ async def list_branches(
|
|||||||
|
|
||||||
branches = []
|
branches = []
|
||||||
for line in branch_result.stdout.strip().split("\n"):
|
for line in branch_result.stdout.strip().split("\n"):
|
||||||
if not line:
|
parsed = _parse_branch_line(line)
|
||||||
|
if parsed is None:
|
||||||
continue
|
continue
|
||||||
parts = line.split("|")
|
name, is_remote, last_commit = parsed
|
||||||
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,
|
||||||
@@ -315,16 +387,19 @@ async def get_git_diff(
|
|||||||
) -> GitDiffResponse:
|
) -> GitDiffResponse:
|
||||||
"""Get git diff for a project.
|
"""Get git diff for a project.
|
||||||
|
|
||||||
The diff + stat subprocesses run inside a bounded
|
The diff + stat subprocess pair is wrapped in a bounded
|
||||||
``settings.evidence_assembly_timeout_seconds`` guard — well under the
|
``evidence_assembly_timeout_seconds`` guard so a genuinely slow diff
|
||||||
outer gateway verb budget — so a genuinely slow diff computation (a
|
computation returns a structured 504 instead of hanging indefinitely.
|
||||||
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)
|
||||||
|
timeout = settings.evidence_assembly_timeout_seconds
|
||||||
|
|
||||||
async def _run_diff_and_stat() -> tuple[Any, Any]:
|
try:
|
||||||
|
# Wrap workspace resolution + diff + stat subprocesses in one bounded
|
||||||
|
# timeout so a slow workspace fetch or diff computation returns a
|
||||||
|
# structured 504 instead of hanging indefinitely.
|
||||||
|
async def _resolve_and_diff() -> 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"]
|
||||||
@@ -332,32 +407,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)
|
|
||||||
|
|
||||||
# Count files changed
|
|
||||||
stat_args = ["diff", "--stat"]
|
stat_args = ["diff", "--stat"]
|
||||||
if staged:
|
if staged:
|
||||||
stat_args.append("--staged")
|
stat_args.append("--staged")
|
||||||
stat_res = await git_service._run_git(workspace, stat_args)
|
|
||||||
return diff_res, stat_res
|
|
||||||
|
|
||||||
try:
|
diff_result, stat_result = await asyncio.gather(
|
||||||
diff_result, stat_result = await asyncio.wait_for(
|
git_service._run_git(workspace, args),
|
||||||
_run_diff_and_stat(),
|
git_service._run_git(workspace, stat_args),
|
||||||
timeout=settings.evidence_assembly_timeout_seconds,
|
|
||||||
)
|
)
|
||||||
except TimeoutError as e:
|
return diff_result, stat_result
|
||||||
|
|
||||||
|
diff_result, stat_result = await asyncio.wait_for(
|
||||||
|
_resolve_and_diff(), timeout=timeout
|
||||||
|
)
|
||||||
|
except _TranslatableError as e:
|
||||||
|
raise _translate_error(e) from e
|
||||||
|
except TimeoutError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
detail=(
|
detail=(
|
||||||
"diff computation exceeded the "
|
f"git diff timed out after {timeout}s — the bounded "
|
||||||
f"{settings.evidence_assembly_timeout_seconds:.0f}s bounded "
|
"evidence_assembly_timeout_seconds guard tripped"
|
||||||
"timeout — retry, or scope the request with file_path to a "
|
|
||||||
"single file"
|
|
||||||
),
|
),
|
||||||
) from e
|
) from None
|
||||||
except _TranslatableError as e:
|
|
||||||
raise _translate_error(e) from e
|
|
||||||
|
|
||||||
files_changed = stat_result.stdout.count("\n") - 1 if stat_result.stdout else 0
|
files_changed = stat_result.stdout.count("\n") - 1 if stat_result.stdout else 0
|
||||||
|
|
||||||
@@ -370,6 +443,60 @@ 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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -688,7 +815,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
|
workspace, data.target_branch, project_slug
|
||||||
)
|
)
|
||||||
except _TranslatableError as e:
|
except _TranslatableError as e:
|
||||||
raise _translate_error(e) from e
|
raise _translate_error(e) from e
|
||||||
@@ -698,3 +825,60 @@ 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,
|
||||||
|
)
|
||||||
|
|||||||
+23
-25
@@ -38,7 +38,7 @@ class Settings(BaseSettings):
|
|||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Application
|
# Application
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
app_version: str = "0.27.0"
|
app_version: str = "0.28.0"
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
environment: str = Field(
|
environment: str = Field(
|
||||||
default="development", pattern="^(development|staging|production)$"
|
default="development", pattern="^(development|staging|production)$"
|
||||||
@@ -1649,6 +1649,28 @@ class Settings(BaseSettings):
|
|||||||
"legitimate verbs are unaffected."
|
"legitimate verbs are unaffected."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
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 diff/fetch, "
|
||||||
|
"conventions validation, and the DB reads that accompany them. "
|
||||||
|
"Kept comfortably below flow_verb_timeout_seconds so a slow "
|
||||||
|
"assembly returns a structured gateway_timeout envelope naming "
|
||||||
|
"the stalled component instead of hitting the outer 120s rollback."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conventions_validator_timeout_seconds: int = Field(
|
||||||
|
default=45,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"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."
|
||||||
|
),
|
||||||
|
)
|
||||||
flow_verb_slow_timeout_seconds: int = Field(
|
flow_verb_slow_timeout_seconds: int = Field(
|
||||||
default=900,
|
default=900,
|
||||||
ge=1,
|
ge=1,
|
||||||
@@ -1706,30 +1728,6 @@ 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)
|
||||||
|
|||||||
@@ -49,16 +49,21 @@ 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
|
||||||
|
|
||||||
@@ -177,37 +182,32 @@ 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)
|
||||||
|
|
||||||
|
# Bounded-timeout wrapper around evidence assembly: the claim itself
|
||||||
|
# (qa_claim + mark_evidence_inspected) has already committed by this
|
||||||
|
# point, so a trip here means only evidence assembly is slow. The
|
||||||
|
# agent gets a structured gateway_timeout envelope pointing at
|
||||||
|
# evidence(task_id) (which re-runs the slow path but not the claim)
|
||||||
|
# instead of the outer 120s rollback with no indication of which
|
||||||
|
# piece stuck.
|
||||||
|
timeout = settings.evidence_assembly_timeout_seconds
|
||||||
try:
|
try:
|
||||||
ev = await asyncio.wait_for(
|
ev = await asyncio.wait_for(
|
||||||
self._build_qa_claim_evidence(qa_agent_id, t, task_id),
|
self._build_qa_claim_evidence(qa_agent_id, t, task_id),
|
||||||
timeout=settings.evidence_assembly_timeout_seconds,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
# The claim itself already committed (qa_claim + mark_evidence_
|
return Envelope.gateway_timeout(
|
||||||
# inspected above) — well under flow_verb_timeout_seconds's own
|
component="git diff/fetch or a journal/ancestor/findings DB read",
|
||||||
# ceiling. Only evidence assembly (git diff/fetch, conventions
|
timeout_seconds=timeout,
|
||||||
# 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=(
|
remediate=(
|
||||||
"the claim already succeeded (status is awaiting_qa, "
|
"the claim itself already committed — call "
|
||||||
"you are the claimant) — call evidence(task_id) to "
|
"evidence(task_id) to re-fetch the PR diff, "
|
||||||
"fetch the PR diff/files/findings directly instead "
|
"journal highlights, and findings without re-running "
|
||||||
"of retrying claim_review"
|
"the claim; or retry claim_review after a short wait"
|
||||||
),
|
),
|
||||||
|
task_id=str(task_id),
|
||||||
context_briefing=briefing,
|
context_briefing=briefing,
|
||||||
).with_introspection(task=t, role=role_str),
|
).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),
|
||||||
@@ -217,55 +217,155 @@ 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,
|
self, qa_agent_id: UUID, t: Any
|
||||||
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(
|
result = await self.git.conventions_check_for_task(qa_agent_id, t)
|
||||||
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
|
||||||
|
def _qa_video_context(t: Any) -> dict[str, Any] | None:
|
||||||
|
"""QA-facing artifact context for a video-authoring task.
|
||||||
|
|
||||||
|
None for every non-video task. Carries the composition id (from the
|
||||||
|
``video_draft`` marker) + the latest ``request_render`` preview so QA
|
||||||
|
is pointed at the rendered artifact instead of the source alone.
|
||||||
|
"""
|
||||||
|
if getattr(t, "source", None) != markers.VIDEO_TASK_SOURCE:
|
||||||
|
return None
|
||||||
|
draft = markers.get_video_draft(t) or {}
|
||||||
|
return {
|
||||||
|
"composition_id": draft.get("composition_id"),
|
||||||
|
"render_preview": markers.get_render_preview(t),
|
||||||
|
"note": (
|
||||||
|
"This task ships a rendered video. Call request_render to "
|
||||||
|
"render the PR branch state, then Read every returned frame "
|
||||||
|
"image — verify each acceptance criterion's scene appears "
|
||||||
|
"fully and legibly. Do not pass on source reading alone."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _build_qa_claim_evidence(
|
||||||
|
self, qa_agent_id: UUID, t: Any, task_id: UUID
|
||||||
|
) -> Any:
|
||||||
|
"""Assemble the inline evidence payload returned by claim_review.
|
||||||
|
|
||||||
|
Bundles files_changed + pr_diff_summary (both from git, the
|
||||||
|
authoritative source) + journal_highlights so the QA agent has
|
||||||
|
the full PR context up-front and can't miss a piece.
|
||||||
|
|
||||||
|
files_changed comes from ``git.diff_and_files`` (a single call
|
||||||
|
that resolves the shared workspace/token/head/base state once and
|
||||||
|
runs the diff + --name-only subprocesses concurrently) instead of
|
||||||
|
the legacy ``work_session.files_modified`` path. The git+conventions
|
||||||
|
segment and the DB-reads segment run concurrently via
|
||||||
|
``asyncio.gather`` so the git fetch overlaps the DB queries instead
|
||||||
|
of running serially.
|
||||||
|
"""
|
||||||
|
git_coro = self._qa_git_and_conventions(qa_agent_id, t)
|
||||||
|
db_coro = self._qa_db_reads(task_id, t)
|
||||||
|
(
|
||||||
|
(diff_summary, files_changed, convention_findings),
|
||||||
|
(
|
||||||
|
journal_highlights,
|
||||||
|
parent_context,
|
||||||
|
open_findings,
|
||||||
|
prior_findings,
|
||||||
|
),
|
||||||
|
) = await asyncio.gather(git_coro, db_coro)
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
t,
|
||||||
|
journal_highlights=journal_highlights,
|
||||||
|
files_changed=files_changed,
|
||||||
|
pr_diff_summary=diff_summary,
|
||||||
|
convention_findings=convention_findings,
|
||||||
|
revision_findings=open_findings,
|
||||||
|
prior_findings=prior_findings,
|
||||||
|
parent_context=parent_context,
|
||||||
|
collision_context=collision_context,
|
||||||
|
video_context=self._qa_video_context(t),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _qa_git_and_conventions(
|
||||||
|
self, qa_agent_id: UUID, t: Any
|
||||||
|
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||||
|
"""Git diff/fetch + conventions validation segment of claim_review.
|
||||||
|
|
||||||
|
Returns ``(diff_summary, files_changed, convention_findings)``.
|
||||||
|
Uses ``diff_and_files`` (single resolution, concurrent subprocesses)
|
||||||
|
and passes the file list straight into ``conventions_check_for_task``
|
||||||
|
so the changed-file list is computed once, not three times.
|
||||||
|
"""
|
||||||
|
git_start = time.monotonic()
|
||||||
|
files_changed: list[str] = []
|
||||||
|
diff_summary = ""
|
||||||
|
convention_findings: list[dict[str, Any]] = []
|
||||||
|
if t.branch_name:
|
||||||
|
diff_summary, files_changed = await self.git.diff_and_files(
|
||||||
|
branch_name=t.branch_name, actor_agent_id=qa_agent_id
|
||||||
|
)
|
||||||
|
# Pass the file list through so conventions_check_for_task
|
||||||
|
# doesn't trigger a redundant list_changed_files call.
|
||||||
|
if not settings.conventions_enabled:
|
||||||
|
convention_findings = []
|
||||||
|
else:
|
||||||
|
result = await self.git.conventions_check_for_task(
|
||||||
|
qa_agent_id,
|
||||||
|
t,
|
||||||
|
changed_files=files_changed,
|
||||||
|
)
|
||||||
|
if result.get("could_not_run"):
|
||||||
|
reason = result.get("reason") or "validator could not run"
|
||||||
|
convention_findings = [{"could_not_run": True, "reason": reason}]
|
||||||
|
else:
|
||||||
|
convention_findings = list(result.get("findings", []))
|
||||||
|
git_ms = (time.monotonic() - git_start) * 1000
|
||||||
|
logger.info(
|
||||||
|
"claim_review git+conventions timing",
|
||||||
|
git_diff_and_fetch_ms=round(git_ms, 1),
|
||||||
|
task_id=str(t.id),
|
||||||
|
)
|
||||||
|
return diff_summary, files_changed, convention_findings
|
||||||
|
|
||||||
async def _qa_db_reads(
|
async def _qa_db_reads(
|
||||||
self, task_id: UUID, t: Any
|
self, task_id: UUID, t: Any
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[Any], list[Any]]:
|
) -> tuple[Any, Any, list[Any], list[Any]]:
|
||||||
"""The four DB reads behind claim_review's evidence, run sequentially
|
"""DB-reads segment of claim_review — runs concurrently with git work.
|
||||||
as ONE coroutine.
|
|
||||||
|
|
||||||
They stay sequential AMONG THEMSELVES on purpose: they all read
|
Returns ``(journal_highlights, parent_context, open_findings,
|
||||||
through the request-scoped ``self.task.session`` / evidence_repo, a
|
prior_findings)``. Reads stay sequential *within* this coroutine on
|
||||||
single ``AsyncSession`` bound to one DBAPI connection — asyncpg
|
purpose: they all read through the same request-scoped AsyncSession,
|
||||||
cannot serve two in-flight queries on the same connection at once
|
and asyncpg cannot serve two in-flight queries on one connection at
|
||||||
(``asyncpg.exceptions`` / SQLAlchemy's own
|
once (IllegalStateChangeError) — the win is git-work-vs-DB-work
|
||||||
``IllegalStateChangeError: ... concurrent operations are not
|
overlap via ``asyncio.gather``, not fanning out the DB reads.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
t0 = time.monotonic()
|
db_start = time.monotonic()
|
||||||
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
||||||
task_id
|
task_id
|
||||||
)
|
)
|
||||||
@@ -280,90 +380,14 @@ class QAMixin(_Base):
|
|||||||
prior_findings = await findings_lib.full_ledger_for_task(
|
prior_findings = await findings_lib.full_ledger_for_task(
|
||||||
self.task.session, t.id
|
self.task.session, t.id
|
||||||
)
|
)
|
||||||
|
db_ms = (time.monotonic() - db_start) * 1000
|
||||||
logger.info(
|
logger.info(
|
||||||
"claim_review db reads timing",
|
"claim_review db reads timing",
|
||||||
task_id=str(task_id),
|
db_reads_ms=round(db_ms, 1),
|
||||||
db_reads_ms=round((time.monotonic() - t0) * 1000.0),
|
task_id=str(t.id),
|
||||||
)
|
)
|
||||||
return journal_highlights, parent_context, open_findings, prior_findings
|
return journal_highlights, parent_context, open_findings, prior_findings
|
||||||
|
|
||||||
async def _qa_git_and_conventions(
|
|
||||||
self, qa_agent_id: UUID, t: Any
|
|
||||||
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
|
||||||
"""The git diff/fetch + conventions-validator segment, as ONE
|
|
||||||
coroutine so it can run concurrently with the DB-reads batch.
|
|
||||||
|
|
||||||
The conventions check needs ``files_changed`` from the git segment,
|
|
||||||
so within this coroutine it still runs AFTER git — but that chain as
|
|
||||||
a whole now overlaps the independent DB reads (item d of the
|
|
||||||
claim_review timeout fix), instead of the old strictly-serial
|
|
||||||
diff -> list_changed_files -> conventions(list_changed_files again)
|
|
||||||
-> DB reads chain.
|
|
||||||
"""
|
|
||||||
files_changed: list[str] = []
|
|
||||||
diff_summary = ""
|
|
||||||
git_t0 = time.monotonic()
|
|
||||||
if t.branch_name:
|
|
||||||
diff_summary, files_changed = await self.git.diff_and_files(
|
|
||||||
branch_name=t.branch_name
|
|
||||||
)
|
|
||||||
git_ms = (time.monotonic() - git_t0) * 1000.0
|
|
||||||
conv_t0 = time.monotonic()
|
|
||||||
convention_findings = await self._qa_convention_findings(
|
|
||||||
qa_agent_id, t, changed_files=files_changed
|
|
||||||
)
|
|
||||||
conv_ms = (time.monotonic() - conv_t0) * 1000.0
|
|
||||||
logger.info(
|
|
||||||
"claim_review git+conventions timing",
|
|
||||||
task_id=str(getattr(t, "id", None)),
|
|
||||||
git_diff_and_fetch_ms=round(git_ms),
|
|
||||||
conventions_ms=round(conv_ms),
|
|
||||||
)
|
|
||||||
return diff_summary, files_changed, convention_findings
|
|
||||||
|
|
||||||
async def _build_qa_claim_evidence(
|
|
||||||
self, qa_agent_id: UUID, t: Any, task_id: UUID
|
|
||||||
) -> Any:
|
|
||||||
"""Assemble the inline evidence payload returned by claim_review.
|
|
||||||
|
|
||||||
Bundles files_changed + pr_diff_summary (both from git, the
|
|
||||||
authoritative source) + journal_highlights so the QA agent has
|
|
||||||
the full PR context up-front and can't miss a piece.
|
|
||||||
|
|
||||||
files_changed comes from the combined ``diff_and_files``
|
|
||||||
accessor instead of ``work_session.files_modified``. The legacy
|
|
||||||
``add_files_modified`` HTTP path that populated files_modified
|
|
||||||
is not called by the gateway ``commit()``, so the work_session
|
|
||||||
list was always empty — QA saw no files even on real PRs.
|
|
||||||
|
|
||||||
The git+conventions segment and the DB-reads segment run
|
|
||||||
concurrently via ``asyncio.gather`` (each internally sequential per
|
|
||||||
``_qa_db_reads``'s docstring); per-segment timing is logged inside
|
|
||||||
each helper (item a of the claim_review timeout fix).
|
|
||||||
"""
|
|
||||||
(
|
|
||||||
(diff_summary, files_changed, convention_findings),
|
|
||||||
(
|
|
||||||
journal_highlights,
|
|
||||||
parent_context,
|
|
||||||
open_findings,
|
|
||||||
prior_findings,
|
|
||||||
),
|
|
||||||
) = await asyncio.gather(
|
|
||||||
self._qa_git_and_conventions(qa_agent_id, t),
|
|
||||||
self._qa_db_reads(task_id, t),
|
|
||||||
)
|
|
||||||
return build_evidence_for_task(
|
|
||||||
t,
|
|
||||||
journal_highlights=journal_highlights,
|
|
||||||
files_changed=files_changed,
|
|
||||||
pr_diff_summary=diff_summary,
|
|
||||||
convention_findings=convention_findings,
|
|
||||||
revision_findings=open_findings,
|
|
||||||
prior_findings=prior_findings,
|
|
||||||
parent_context=parent_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _verify_qa_owner(
|
async def _verify_qa_owner(
|
||||||
self, qa_agent_id: UUID, task_id: UUID, verb: str
|
self, qa_agent_id: UUID, task_id: UUID, verb: str
|
||||||
) -> tuple[Envelope | None, Any]:
|
) -> tuple[Envelope | None, Any]:
|
||||||
@@ -502,13 +526,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:
|
||||||
"""Per-acceptance-criterion verification gate for pass_review.
|
"""Legacy count-only per-AC gate — superseded by ``criteria_verified``.
|
||||||
|
|
||||||
QA may not pass a task until it has recorded a verification for EVERY
|
No longer wired into ``pass_review`` (a count of arbitrary strings
|
||||||
acceptance criterion. A single gestalt "looks good" approval is how a
|
never verified they actually named the right criterion — the live gap
|
||||||
silently-unbuilt criterion slips through; requiring one verdict per
|
``_validate_criteria_verified`` closes). Kept for ``ac_verdicts``'
|
||||||
criterion forces QA to check each individually. If a criterion does not
|
existing callers/tests; ``ac_verdicts`` itself still folds into the
|
||||||
hold, the QA fails the review instead of passing a partial.
|
persisted notes when supplied.
|
||||||
"""
|
"""
|
||||||
criteria = list(getattr(task, "acceptance_criteria", None) or [])
|
criteria = list(getattr(task, "acceptance_criteria", None) or [])
|
||||||
if not criteria:
|
if not criteria:
|
||||||
@@ -620,36 +644,181 @@ 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,
|
||||||
ac_verdicts: list[str] | None,
|
criteria_verified: list[dict[str, Any]] | None,
|
||||||
) -> Envelope | None:
|
) -> tuple[Envelope | None, list[tuple[str, str]]]:
|
||||||
"""AC-coverage + toolchain-runnability gates for pass_review.
|
"""Per-AC verification + toolchain-runnability gates for pass_review.
|
||||||
|
|
||||||
Returns the first rejection (already emitted), else None. QA must not
|
Returns ``(rejection, pairs)`` — the first emitted rejection (else
|
||||||
PASS on a workspace that cannot run the suite — that is a source-read
|
None) and the validated ``criteria_verified`` (criterion, evidence)
|
||||||
|
pairs for the caller to render into notes. QA must not PASS on a
|
||||||
|
workspace that cannot run the suite — that is a source-read
|
||||||
"verification"; fail_review is unaffected.
|
"verification"; fail_review is unaffected.
|
||||||
"""
|
"""
|
||||||
ac_rejection = self._qa_ac_coverage_check(t, ac_verdicts)
|
pairs, bad = self._validate_criteria_verified(t, criteria_verified)
|
||||||
if ac_rejection is not None:
|
if bad is not None:
|
||||||
return await self._emit_rejection(
|
rejection = await self._emit_rejection(
|
||||||
ac_rejection.with_introspection(task=t, role=role_str),
|
bad.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):
|
||||||
return await self._emit_rejection(
|
rejection = 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 None
|
return rejection, []
|
||||||
|
return None, pairs
|
||||||
|
|
||||||
async def pass_review(
|
async def pass_review(
|
||||||
self,
|
self,
|
||||||
@@ -657,6 +826,7 @@ 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.
|
||||||
|
|
||||||
@@ -671,6 +841,16 @@ 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:
|
||||||
@@ -696,18 +876,22 @@ class QAMixin(_Base):
|
|||||||
soup_checks=(("notes", notes, 8),),
|
soup_checks=(("notes", notes, 8),),
|
||||||
):
|
):
|
||||||
return gate_rejection
|
return gate_rejection
|
||||||
if rej := await self._qa_pass_final_gates(
|
final_rejection, criteria_pairs = await self._qa_pass_final_gates(
|
||||||
qa_agent_id, task_id, t, role_str, ac_verdicts
|
qa_agent_id, task_id, t, role_str, criteria_verified
|
||||||
):
|
)
|
||||||
return rej
|
if final_rejection is not None:
|
||||||
|
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=self._merge_ac_verdicts_into_notes(notes, ac_verdicts),
|
notes=merged_notes,
|
||||||
)
|
)
|
||||||
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()
|
||||||
@@ -803,16 +987,9 @@ class QAMixin(_Base):
|
|||||||
)
|
)
|
||||||
if cap := findings_lib.findings_count_guard(raw):
|
if cap := findings_lib.findings_count_guard(raw):
|
||||||
return [], cap
|
return [], cap
|
||||||
try:
|
validated, bad = findings_lib.validate_or_reject(raw)
|
||||||
validated = validate_findings(raw)
|
if bad is not None:
|
||||||
except ContentValidationError as exc:
|
return [], bad
|
||||||
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
@@ -245,19 +245,27 @@ class Envelope:
|
|||||||
component: str,
|
component: str,
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
remediate: str,
|
remediate: str,
|
||||||
|
task_id: str | None = None,
|
||||||
context_briefing: dict[str, Any] | None = None,
|
context_briefing: dict[str, Any] | None = None,
|
||||||
) -> Envelope:
|
) -> Envelope:
|
||||||
"""A bounded inner-work timeout tripped (e.g. evidence assembly),
|
"""A bounded inner-work timeout tripped — the evidence-assembly segment
|
||||||
well under the outer server-side ``flow_verb_timeout_seconds``
|
(git diff/fetch, conventions validation, or a DB read) exceeded
|
||||||
rollback (``api.middleware``'s own ``gateway_timeout``). Names the
|
``evidence_assembly_timeout_seconds`` without completing.
|
||||||
slow ``component`` so the agent isn't left guessing which segment
|
|
||||||
(git fetch, diff computation, conventions validation, a DB read)
|
Distinct from the outer ``flow_verb_timeout_seconds`` rollback: that
|
||||||
stalled, instead of a bare 504 that reads as the whole verb failing.
|
one cancels the whole request transaction with no indication of which
|
||||||
|
piece stuck. This returns a structured, named error so the agent (and
|
||||||
|
the audit log) sees exactly which component stalled and can retry or
|
||||||
|
escalate accordingly. On a trip the verb's own state change (e.g.
|
||||||
|
``qa_claim``) has already committed, so the remediate points at
|
||||||
|
``evidence(task_id)`` rather than re-running the whole verb.
|
||||||
"""
|
"""
|
||||||
return cls(
|
return cls(
|
||||||
error="gateway_timeout",
|
error="gateway_timeout",
|
||||||
|
task_id=task_id,
|
||||||
message=(
|
message=(
|
||||||
f"{component} exceeded the {timeout_seconds:.0f}s bounded timeout"
|
f"evidence assembly timed out after {timeout_seconds}s — "
|
||||||
|
f"the {component} segment did not complete in time"
|
||||||
),
|
),
|
||||||
remediate=remediate,
|
remediate=remediate,
|
||||||
context_briefing=context_briefing or {},
|
context_briefing=context_briefing or {},
|
||||||
|
|||||||
+1474
-558
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,9 @@ def _make_deps(task_svc: Any = None) -> ChoreographerDeps:
|
|||||||
"audit": AsyncMock(),
|
"audit": AsyncMock(),
|
||||||
"evidence_repo": AsyncMock(),
|
"evidence_repo": AsyncMock(),
|
||||||
}
|
}
|
||||||
|
# diff_and_files returns a (diff_summary, files_changed) 2-tuple; without
|
||||||
|
# this the await resolves to a MagicMock and qa.py:330 unpacking fails.
|
||||||
|
base["git"].diff_and_files.return_value = ("", [])
|
||||||
repo = base["evidence_repo"]
|
repo = base["evidence_repo"]
|
||||||
for method in (
|
for method in (
|
||||||
"list_unread_a2a",
|
"list_unread_a2a",
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ def _over_cap_project() -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
def _make_deps(task_svc: AsyncMock, **overrides: Any) -> ChoreographerDeps:
|
def _make_deps(task_svc: AsyncMock, **overrides: Any) -> ChoreographerDeps:
|
||||||
|
git = AsyncMock()
|
||||||
|
git.diff_and_files.return_value = ("", [])
|
||||||
base: dict[str, Any] = {
|
base: dict[str, Any] = {
|
||||||
"task": task_svc,
|
"task": task_svc,
|
||||||
"work_session": AsyncMock(),
|
"work_session": AsyncMock(),
|
||||||
"git": AsyncMock(),
|
"git": git,
|
||||||
"a2a": AsyncMock(),
|
"a2a": AsyncMock(),
|
||||||
"journal": AsyncMock(),
|
"journal": AsyncMock(),
|
||||||
"audit": AsyncMock(),
|
"audit": AsyncMock(),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
|||||||
else:
|
else:
|
||||||
git = AsyncMock()
|
git = AsyncMock()
|
||||||
git.commit.return_value = {"sha": "abc12345"}
|
git.commit.return_value = {"sha": "abc12345"}
|
||||||
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())
|
||||||
@@ -270,7 +270,7 @@ async def test_evidence_blocks_when_not_assignee() -> 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 = ""
|
git_svc.diff_and_files.return_value = ("", [])
|
||||||
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)
|
||||||
ca = ContentActions(deps)
|
ca = ContentActions(deps)
|
||||||
@@ -279,7 +279,7 @@ async def test_evidence_blocks_when_not_assignee() -> None:
|
|||||||
body = env.as_dict()
|
body = env.as_dict()
|
||||||
assert body["error"] == "not_authorized"
|
assert body["error"] == "not_authorized"
|
||||||
workspace_svc.fetch_branch_for_inspection.assert_not_awaited()
|
workspace_svc.fetch_branch_for_inspection.assert_not_awaited()
|
||||||
git_svc.diff.assert_not_awaited()
|
git_svc.diff_and_files.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -410,7 +410,7 @@ async def test_evidence_unassigned_task_allows_inspection() -> 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 content"
|
git_svc.diff_and_files.return_value = ("diff content", [])
|
||||||
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)
|
||||||
ca = ContentActions(deps)
|
ca = ContentActions(deps)
|
||||||
@@ -446,8 +446,7 @@ async def test_evidence_allows_dependency_inspection() -> None:
|
|||||||
task_svc.get.return_value = target
|
task_svc.get.return_value = target
|
||||||
task_svc.list_assigned_for_agent.return_value = [callers_task]
|
task_svc.list_assigned_for_agent.return_value = [callers_task]
|
||||||
git_svc = AsyncMock()
|
git_svc = AsyncMock()
|
||||||
git_svc.diff.return_value = ""
|
git_svc.diff_and_files.return_value = ("", [])
|
||||||
git_svc.list_changed_files.return_value = []
|
|
||||||
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)
|
||||||
ca = ContentActions(deps)
|
ca = ContentActions(deps)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Bug:
|
|||||||
commit's delta, even when GitHub showed a multi-commit change set.
|
commit's delta, even when GitHub showed a multi-commit change set.
|
||||||
|
|
||||||
Fix:
|
Fix:
|
||||||
Pull files via ``git.list_changed_files(branch_name=...)`` (no base
|
Pull files via ``git.diff_and_files(branch_name=...)`` (no base
|
||||||
→ full diff vs parent branch). Pull diff with ``base=None`` so the
|
→ full diff vs parent branch). Pull diff with ``base=None`` so the
|
||||||
full PR diff comes through. Both use git as the authoritative source
|
full PR diff comes through. Both use git as the authoritative source
|
||||||
instead of the legacy ``work_session.files_modified`` field, which
|
instead of the legacy ``work_session.files_modified`` field, which
|
||||||
@@ -65,8 +65,10 @@ async def test_evidence_populates_files_changed_from_git() -> None:
|
|||||||
task_svc = AsyncMock()
|
task_svc = AsyncMock()
|
||||||
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc", "def"])
|
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc", "def"])
|
||||||
git_svc = AsyncMock()
|
git_svc = AsyncMock()
|
||||||
git_svc.diff.return_value = "diff --git a/README.md b/README.md\n+added line\n"
|
git_svc.diff_and_files.return_value = (
|
||||||
git_svc.list_changed_files.return_value = ["README.md", "docs/guide.md"]
|
"diff --git a/README.md b/README.md\n+added line\n",
|
||||||
|
["README.md", "docs/guide.md"],
|
||||||
|
)
|
||||||
workspace_svc = AsyncMock()
|
workspace_svc = AsyncMock()
|
||||||
evidence_repo = AsyncMock()
|
evidence_repo = AsyncMock()
|
||||||
evidence_repo.journal_highlights_for_task.return_value = []
|
evidence_repo.journal_highlights_for_task.return_value = []
|
||||||
@@ -80,12 +82,12 @@ async def test_evidence_populates_files_changed_from_git() -> None:
|
|||||||
assert body["error"] is None
|
assert body["error"] is None
|
||||||
assert body["evidence"]["files_changed"] == ["README.md", "docs/guide.md"]
|
assert body["evidence"]["files_changed"] == ["README.md", "docs/guide.md"]
|
||||||
assert "diff --git" in body["evidence"]["pr_diff_summary"]
|
assert "diff --git" in body["evidence"]["pr_diff_summary"]
|
||||||
git_svc.list_changed_files.assert_awaited_once()
|
git_svc.diff_and_files.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_evidence_uses_full_pr_diff_not_head_minus_one() -> None:
|
async def test_evidence_uses_full_pr_diff_not_head_minus_one() -> None:
|
||||||
"""git.diff must be called with base=None (full PR diff vs parent),
|
"""diff_and_files must be called with base=None (full PR diff vs parent),
|
||||||
not base='HEAD~1' (only the last commit)."""
|
not base='HEAD~1' (only the last commit)."""
|
||||||
agent_id = uuid4()
|
agent_id = uuid4()
|
||||||
task_id = uuid4()
|
task_id = uuid4()
|
||||||
@@ -94,8 +96,7 @@ async def test_evidence_uses_full_pr_diff_not_head_minus_one() -> None:
|
|||||||
# task.commits was non-empty, masking earlier commits' changes.
|
# task.commits was non-empty, masking earlier commits' changes.
|
||||||
task_svc.get.return_value = _task_with_pr(task_id, commits=["sha1", "sha2", "sha3"])
|
task_svc.get.return_value = _task_with_pr(task_id, commits=["sha1", "sha2", "sha3"])
|
||||||
git_svc = AsyncMock()
|
git_svc = AsyncMock()
|
||||||
git_svc.diff.return_value = "full diff"
|
git_svc.diff_and_files.return_value = ("full diff", [])
|
||||||
git_svc.list_changed_files.return_value = []
|
|
||||||
workspace_svc = AsyncMock()
|
workspace_svc = AsyncMock()
|
||||||
evidence_repo = AsyncMock()
|
evidence_repo = AsyncMock()
|
||||||
evidence_repo.journal_highlights_for_task.return_value = []
|
evidence_repo.journal_highlights_for_task.return_value = []
|
||||||
@@ -105,13 +106,13 @@ async def test_evidence_uses_full_pr_diff_not_head_minus_one() -> None:
|
|||||||
)
|
)
|
||||||
await ca.evidence(agent_id=agent_id, task_id=task_id)
|
await ca.evidence(agent_id=agent_id, task_id=task_id)
|
||||||
|
|
||||||
git_svc.diff.assert_awaited_once()
|
git_svc.diff_and_files.assert_awaited_once()
|
||||||
call_kwargs = git_svc.diff.await_args.kwargs
|
call_kwargs = git_svc.diff_and_files.await_args.kwargs
|
||||||
# Pre-fix bug: kwargs['base'] would be 'HEAD~1' for any multi-commit
|
# Pre-fix bug: kwargs['base'] would be 'HEAD~1' for any multi-commit
|
||||||
# branch. Post-fix: base is omitted (or explicitly None).
|
# branch. Post-fix: base is omitted (or explicitly None).
|
||||||
base = call_kwargs.get("base")
|
base = call_kwargs.get("base")
|
||||||
assert base in (None, ""), (
|
assert base in (None, ""), (
|
||||||
f"git.diff must use full-PR diff (base=None), got base={base!r}"
|
f"diff_and_files must use full-PR diff (base=None), got base={base!r}"
|
||||||
)
|
)
|
||||||
assert call_kwargs.get("branch_name") == "feature/backend/abc12345--def67890"
|
assert call_kwargs.get("branch_name") == "feature/backend/abc12345--def67890"
|
||||||
|
|
||||||
@@ -125,8 +126,7 @@ async def test_evidence_populates_journal_highlights() -> None:
|
|||||||
task_svc = AsyncMock()
|
task_svc = AsyncMock()
|
||||||
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc"])
|
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc"])
|
||||||
git_svc = AsyncMock()
|
git_svc = AsyncMock()
|
||||||
git_svc.diff.return_value = ""
|
git_svc.diff_and_files.return_value = ("", [])
|
||||||
git_svc.list_changed_files.return_value = []
|
|
||||||
workspace_svc = AsyncMock()
|
workspace_svc = AsyncMock()
|
||||||
evidence_repo = AsyncMock()
|
evidence_repo = AsyncMock()
|
||||||
highlights = [
|
highlights = [
|
||||||
@@ -179,5 +179,4 @@ async def test_evidence_no_branch_skips_git_calls() -> None:
|
|||||||
assert body["error"] is None
|
assert body["error"] is None
|
||||||
assert body["evidence"]["files_changed"] == []
|
assert body["evidence"]["files_changed"] == []
|
||||||
assert body["evidence"]["pr_diff_summary"] == ""
|
assert body["evidence"]["pr_diff_summary"] == ""
|
||||||
git_svc.diff.assert_not_awaited()
|
git_svc.diff_and_files.assert_not_awaited()
|
||||||
git_svc.list_changed_files.assert_not_awaited()
|
|
||||||
|
|||||||
@@ -107,8 +107,7 @@ async def test_claim_review_evidence_carries_prior_findings(
|
|||||||
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 = ("", [])
|
||||||
git_svc.list_changed_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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user