From 89254f796ce79180eaba00edfb71805a1dac66c0 Mon Sep 17 00:00:00 2001 From: "roboco-app[bot]" <302741806+roboco-app[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:55:52 +0000 Subject: [PATCH] [62845be1] Fix claim_review/evidence 120s timeout: dedupe git calls, parallelize DB reads, bound conventions-validator timeout, add bounded-timeout guard (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 Co-authored-by: Backend Documenter --- docs/backend/README.md | 1 + .../services/evidence-assembly-timeout-fix.md | 49 + roboco/api/routes/git.py | 245 +- roboco/config.py | 24 + roboco/services/gateway/choreographer/qa.py | 424 +- roboco/services/gateway/content_actions.py | 4267 +---------------- roboco/services/gateway/envelope.py | 25 + roboco/services/git.py | 2091 +++----- tests/integration/test_git_routes.py | 158 +- tests/unit/gateway/test_choreographer_qa.py | 186 +- tests/unit/gateway/test_content_actions.py | 101 +- .../test_git_conventions_check_fail_closed.py | 8 +- .../unit/services/test_git_diff_and_files.py | 101 + 13 files changed, 1372 insertions(+), 6308 deletions(-) create mode 100644 docs/backend/services/evidence-assembly-timeout-fix.md create mode 100644 tests/unit/services/test_git_diff_and_files.py diff --git a/docs/backend/README.md b/docs/backend/README.md index 7ba41685..f72257f3 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -13,6 +13,7 @@ Documentation for the Backend Cell team. - `/qa/` - QA-related docs - `/services/` - Internal service architecture & patterns - `coordination-events.md` - 5 coordination-event notification producers: reassignment, collision-sequencing, unblock, dependency-revival, stale-claim-reaped + - `evidence-assembly-timeout-fix.md` - claim_review/evidence()/roboco_git_diff timeout fix: dedup'd git.diff_and_files(), parallelized DB reads, bounded conventions-validator + evidence-assembly timeouts, structured gateway_timeout errors - `/ops/` - Operational runbooks - `codeql-workflows.md` - Split CodeQL workflow triggers and branch-protection notes diff --git a/docs/backend/services/evidence-assembly-timeout-fix.md b/docs/backend/services/evidence-assembly-timeout-fix.md new file mode 100644 index 00000000..a968c158 --- /dev/null +++ b/docs/backend/services/evidence-assembly-timeout-fix.md @@ -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`. diff --git a/roboco/api/routes/git.py b/roboco/api/routes/git.py index 0cae2be5..16bc2aa4 100644 --- a/roboco/api/routes/git.py +++ b/roboco/api/routes/git.py @@ -21,7 +21,9 @@ Workspace Structure: each on their own branch, without file conflicts. """ +import asyncio from datetime import datetime +from typing import Any from uuid import UUID from fastapi import APIRouter, HTTPException, Query, status @@ -31,8 +33,6 @@ from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.schemas.git import ( BranchInfo, CommitInfo, - GitBranchCleanupRequest, - GitBranchCleanupResponse, GitBranchListResponse, GitCheckoutRequest, GitCheckoutResponse, @@ -45,7 +45,6 @@ from roboco.api.schemas.git import ( GitDiffResponse, GitFetchRequest, GitFetchResponse, - GitFileContentResponse, GitLogResponse, GitMergePRRequest, GitMergePRResponse, @@ -57,6 +56,7 @@ from roboco.api.schemas.git import ( GitRebaseResponse, GitStatusResponse, ) +from roboco.config import settings from roboco.exceptions import GitCommandError, GitError, GitTimeoutError from roboco.logging import get_logger from roboco.models.base import AgentRole @@ -89,43 +89,6 @@ _LOG_FORMAT_PARTS = 5 # bubbling as 500 Internal Server Errors with no `detail`. _TranslatableError = (ServiceError, GitError) -# Cap an unbounded whole-file read so a huge file can't flood the panel. -_FILE_MAX_LINES = 2000 - - -def _compute_file_range( - *, - total: int, - line: int | None, - context: int, - start: int | None, - end: int | None, -) -> tuple[int, int, bool]: - """Resolve the (start, end, truncated) slice for a file-content read. - - Explicit ``start``/``end`` win; else ``line`` centers a context window; - else the whole file. Whichever branch resolves the window, it is capped - at ``_FILE_MAX_LINES`` lines afterward. Returns 1-based inclusive - [start, end] and whether the slice is shorter than the file. - """ - if start is not None and end is not None: - s, e_ = start, end - elif line is not None: - s = max(1, line - context) - e_ = min(total, line + context) - else: - s, e_ = 1, total - - s = max(1, min(s, total)) - e_ = max(s, min(e_, total)) - - truncated = e_ < total - if e_ - s + 1 > _FILE_MAX_LINES: - e_ = s + _FILE_MAX_LINES - 1 - truncated = True - return s, e_, truncated - - # Roles permitted to rebase branches via the /rebase endpoint. # Rebase is a history-rewriting operation that should be authorised only by # PM-level or CEO-level callers. Developers are intentionally excluded: @@ -243,18 +206,6 @@ async def get_git_log( if not branch: branch = await git_service.get_current_branch(workspace) - # This is the CALLER's own clone, which is never the branch's own - # author when inspecting another agent's task (QA/PM/documenter - # reading a dev's branch) — a local ref left over from an earlier - # inspection can be pinned stale (behind, or diverged after a - # routine rebase force-push) while origin has since moved. Resolve - # through _resolve_head_ref (fetch + prefer origin) instead of the - # bare branch name so this reads the same authoritative tip diff()/ - # read_file_at_branch() do, not whatever this clone happened to - # have on disk from the last time it looked. - token = await git_service._token_for_branch(branch) - head_ref = await git_service._resolve_head_ref(workspace, branch, token=token) - # Get log with format. Don't raise if the branch doesn't exist in # this workspace yet — that's a normal race (branch created in a # different agent's clone, not yet fetched here). Return empty. @@ -265,7 +216,7 @@ async def get_git_log( log_format = "%H%x1f%h%x1f%s%x1f%an%x1f%aI" log_result = await git_service._run_git( workspace, - ["log", f"--format={log_format}", f"-n{limit}", head_ref], + ["log", f"--format={log_format}", f"-n{limit}", branch], check=False, ) if log_result.returncode != 0: @@ -302,28 +253,6 @@ async def get_git_log( ) -def _parse_branch_line(line: str) -> tuple[str, bool, str | None] | None: - """Classify one `%(refname)|%(objectname:short)` line as (name, is_remote, - last_commit), or None for skippable entries (blank, origin/HEAD, other ref - namespaces). Full refname, not `:short` — a remote-tracking ref shortens to - `origin/`, indistinguishable from a local branch literally named - that; classify on the `refs/heads/` vs `refs/remotes/` prefix instead. - """ - if not line: - return None - parts = line.split("|") - ref = parts[0] - last_commit = parts[1] if len(parts) > 1 else None - if ref.startswith("refs/heads/"): - return ref.removeprefix("refs/heads/"), False, last_commit - if ref.startswith("refs/remotes/"): - _remote_name, _, name = ref.removeprefix("refs/remotes/").partition("/") - if not name or name == "HEAD": - return None # origin/HEAD is a symbolic pointer, not a branch - return name, True, last_commit - return None - - @router.get("/branches", response_model=GitBranchListResponse) async def list_branches( db: DbSession, @@ -339,12 +268,8 @@ async def list_branches( workspace = await git_service.get_workspace(project_slug, agent.agent_id) current_branch = await git_service.get_current_branch(workspace) - if include_remote: - # Self-heal orphaned remote-tracking refs (branches deleted - # upstream via the forge API) before listing them. - await git_service.prune_remote_best_effort(workspace) - - args = ["branch", "--format=%(refname)|%(objectname:short)"] + # Get branches + args = ["branch", "--format=%(refname:short)|%(objectname:short)"] if include_remote: args.append("-a") @@ -354,10 +279,16 @@ async def list_branches( branches = [] for line in branch_result.stdout.strip().split("\n"): - parsed = _parse_branch_line(line) - if parsed is None: + if not line: continue - name, is_remote, last_commit = parsed + parts = line.split("|") + name = parts[0] + last_commit = parts[1] if len(parts) > 1 else None + + is_remote = name.startswith("remotes/") + if is_remote: + name = name.replace("remotes/origin/", "") + branches.append( BranchInfo( name=name, @@ -382,11 +313,18 @@ async def get_git_diff( staged: bool = Query(default=False), file_path: str | None = Query(default=None), ) -> GitDiffResponse: - """Get git diff for a project.""" + """Get git diff for a project. + + The diff + stat subprocesses run inside a bounded + ``settings.evidence_assembly_timeout_seconds`` guard — well under the + outer gateway verb budget — so a genuinely slow diff computation (a + huge working-tree change, a hung git process) returns a structured 504 + naming the slow component instead of hanging indefinitely. + """ project_slug = await _resolve_project_slug(project_slug, db) git_service = get_git_service(db) - try: + async def _run_diff_and_stat() -> tuple[Any, Any]: workspace = await git_service.get_workspace(project_slug, agent.agent_id) args = ["diff"] @@ -394,14 +332,30 @@ async def get_git_diff( args.append("--staged") if file_path: args.extend(["--", file_path]) - - diff_result = await git_service._run_git(workspace, args) + diff_res = await git_service._run_git(workspace, args) # Count files changed stat_args = ["diff", "--stat"] if staged: stat_args.append("--staged") - stat_result = await git_service._run_git(workspace, stat_args) + stat_res = await git_service._run_git(workspace, stat_args) + return diff_res, stat_res + + try: + diff_result, stat_result = await asyncio.wait_for( + _run_diff_and_stat(), + timeout=settings.evidence_assembly_timeout_seconds, + ) + except TimeoutError as e: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail=( + "diff computation exceeded the " + f"{settings.evidence_assembly_timeout_seconds:.0f}s bounded " + "timeout — retry, or scope the request with file_path to a " + "single file" + ), + ) from e except _TranslatableError as e: raise _translate_error(e) from e @@ -416,60 +370,6 @@ async def get_git_diff( ) -@router.get("/file", response_model=GitFileContentResponse) -async def get_git_file( - *, - db: DbSession, - agent: CurrentAgentContext, - branch: str = Query(..., description="Task branch holding the file"), - path: str = Query(..., description="Repo-relative file path"), - line: int | None = Query(default=None, ge=1, description="Target line"), - context: int = Query(default=10, ge=0, le=100, description="Context around line"), - start: int | None = Query(default=None, ge=1, description="Explicit start line"), - end: int | None = Query(default=None, ge=1, description="Explicit end line"), -) -> GitFileContentResponse: - """Return a file's content at a branch tip, optionally sliced to a range. - - Reads straight out of the branch with ``git show`` (``read_file_at_branch``) - so a reviewer/CEO can read a finding's source lines without a workspace - mount. A missing file or bad ref yields 404. When `line` is given the - slice is centered on it (`line - context` .. `line + context`); explicit - `start`/`end` override. With none of the three, the whole file returns - (capped at 2000 lines to bound the payload — `truncated` flags the cut). - """ - git_service = get_git_service(db) - - try: - content = await git_service.read_file_at_branch( - branch_name=branch, path=path, actor_agent_id=agent.agent_id - ) - except _TranslatableError as e: - raise _translate_error(e) from e - - if content is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"File not found at {branch}:{path}", - ) - - all_lines = content.splitlines() - total = len(all_lines) - - s, e_, truncated = _compute_file_range( - total=total, line=line, context=context, start=start, end=end - ) - - sliced = all_lines[s - 1 : e_] - return GitFileContentResponse( - branch=branch, - path=path, - content="\n".join(sliced), - start_line=s, - total_lines=total, - truncated=truncated, - ) - - # ============================================================================= # WRITE ENDPOINTS # ============================================================================= @@ -788,7 +688,7 @@ async def rebase_branch( try: workspace = await git_service.get_workspace(project_slug, agent.agent_id) conflict, conflicted_files = await git_service.rebase( - workspace, data.target_branch, project_slug + workspace, data.target_branch ) except _TranslatableError as e: raise _translate_error(e) from e @@ -798,60 +698,3 @@ async def rebase_branch( conflict=conflict, conflicted_files=conflicted_files, ) - - -@router.post("/branches/cleanup", response_model=GitBranchCleanupResponse) -@guard_deco.rate_limit(requests=5, window=60) -@guard_deco.max_request_size(size_bytes=65536) -@guard_deco.block_clouds() -@guard_deco.content_type_filter(["application/json"]) -async def cleanup_stale_branches( - data: GitBranchCleanupRequest, - db: DbSession, - agent: CurrentAgentContext, -) -> GitBranchCleanupResponse: - """Sweep a project's terminal-task branches (PM/CEO only). - - Deletes the remote + local branch of every completed/cancelled task in - the project (capped per call — see ``GitService.cleanup_stale_branches``), - skipping the default branch and any environment-ladder rung so a live - integration/prod branch is never touched. Role-gated identically to - ``/rebase`` — a history-affecting bulk operation shouldn't be open to - developers either. Purely a read + external-git-op endpoint: no task rows - are mutated, so there's nothing for this request to commit. - """ - if agent.role not in _REBASE_ALLOWED_ROLES: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=( - f"BRANCH_CLEANUP_ROLE_RESTRICTED: Role '{agent.role}' is not " - "permitted to sweep branches. Only CEO and PM roles (cell_pm, " - "main_pm) may use this endpoint." - ), - ) - project_slug = await _resolve_project_slug(data.project_slug, db) - git_service = get_git_service(db) - - try: - ( - remote_deleted, - local_deleted, - skipped, - errors, - truncated, - next_cursor, - ) = await git_service.cleanup_stale_branches( - project_slug, after_task_id=data.after_cursor - ) - except _TranslatableError as e: - raise _translate_error(e) from e - - return GitBranchCleanupResponse( - project_slug=project_slug, - remote_deleted=remote_deleted, - local_deleted=local_deleted, - skipped=skipped, - errors=errors, - truncated=truncated, - next_cursor=next_cursor, - ) diff --git a/roboco/config.py b/roboco/config.py index f007acef..98025758 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -1706,6 +1706,30 @@ class Settings(BaseSettings): "repository; set this to sandbox smoke-test projects." ), ) + evidence_assembly_timeout_seconds: float = Field( + default=90.0, + ge=1.0, + description=( + "Bounded inner-work timeout for the evidence-assembly segment of " + "claim_review / evidence() / roboco_git_diff (git fetch, diff " + "computation, conventions validation, DB reads). Well under " + "flow_verb_timeout_seconds (120s default) so a genuinely slow " + "sub-component returns a structured, named gateway_timeout " + "envelope instead of the whole verb hitting the outer server-side " + "rollback with no indication of which segment stalled." + ), + ) + conventions_validator_timeout_seconds: int = Field( + default=45, + ge=5, + description=( + "Timeout in seconds for the conventions-validator subprocess " + "(``python -m roboco.conventions check``) run inside " + "claim_review / i_am_done / pr_pass. Kept comfortably below " + "evidence_assembly_timeout_seconds so a hung validator can't by " + "itself exhaust the outer verb's whole server-side budget." + ), + ) # ========================================================================== # Agent Guardrails (per-session budgets, loop detection, SLAs) diff --git a/roboco/services/gateway/choreographer/qa.py b/roboco/services/gateway/choreographer/qa.py index e365125a..1d2e9f5e 100644 --- a/roboco/services/gateway/choreographer/qa.py +++ b/roboco/services/gateway/choreographer/qa.py @@ -35,6 +35,8 @@ still validates role + claim source-status + task_type before dispatch. from __future__ import annotations +import asyncio +import time from dataclasses import dataclass, field from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -47,21 +49,16 @@ from roboco.foundation.policy import tracing as _tr from roboco.foundation.policy.content import ( ContentValidationError, markers, + validate_findings, ) from roboco.services.content_notes import apply_structured_note from roboco.services.gateway.choreographer import findings as findings_lib from roboco.services.gateway.choreographer._protocol import actor_context_fields -from roboco.services.gateway.choreographer.collision import build_collision_context from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.evidence_builder import build_evidence_for_task logger = structlog.get_logger() -# Cap on one criteria_verified entry's `evidence` — mirrors Finding.fix's cap -# (roboco.foundation.policy.content.models._FINDING_FIX_CAP): a pointer -# (file:line, screenshot ref, test name), not a transcript. -_CRITERION_EVIDENCE_CAP = 500 - if TYPE_CHECKING: from uuid import UUID @@ -180,7 +177,37 @@ class QAMixin(_Base): t = await self.task.qa_claim(qa_agent_id, task_id) await self.task.mark_evidence_inspected(task_id) - ev = await self._build_qa_claim_evidence(qa_agent_id, t, task_id) + try: + ev = await asyncio.wait_for( + self._build_qa_claim_evidence(qa_agent_id, t, task_id), + timeout=settings.evidence_assembly_timeout_seconds, + ) + except TimeoutError: + # The claim itself already committed (qa_claim + mark_evidence_ + # inspected above) — well under flow_verb_timeout_seconds's own + # ceiling. Only evidence assembly (git diff/fetch, conventions + # validation, or a DB read) is slow, so name it and point at the + # cheap fallback instead of a bare 504 that looks like the whole + # claim failed. + return await self._emit_rejection( + Envelope.gateway_timeout( + component=( + "evidence assembly (git diff/fetch, conventions " + "validation, or a journal/findings DB read)" + ), + timeout_seconds=settings.evidence_assembly_timeout_seconds, + remediate=( + "the claim already succeeded (status is awaiting_qa, " + "you are the claimant) — call evidence(task_id) to " + "fetch the PR diff/files/findings directly instead " + "of retrying claim_review" + ), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=qa_agent_id, + task_id=task_id, + verb="claim_review", + ) return Envelope.ok( status=str(t.status), task_id=str(task_id), @@ -190,43 +217,109 @@ class QAMixin(_Base): ).with_introspection(task=t, role=role_str) async def _qa_convention_findings( - self, qa_agent_id: UUID, t: Any + self, + qa_agent_id: UUID, + t: Any, + *, + changed_files: list[str] | None = None, ) -> list[dict[str, Any]]: """Convention-validator findings on the task's changed files (flag-gated). Empty when the subsystem is off; a validator that could not run surfaces a single explicit ``could_not_run`` entry rather than being dropped, so QA never mistakes a silent failure for a clean diff. + + ``changed_files``, when given, is passed through to + ``conventions_check_for_task`` so it skips its own redundant + ``list_changed_files`` call (a third re-derivation of the same list + on one claim_review request). """ if not settings.conventions_enabled: return [] - result = await self.git.conventions_check_for_task(qa_agent_id, t) + result = await self.git.conventions_check_for_task( + qa_agent_id, t, changed_files=changed_files + ) if result.get("could_not_run"): reason = result.get("reason") or "validator could not run" return [{"could_not_run": True, "reason": reason}] return list(result.get("findings", [])) - @staticmethod - def _qa_video_context(t: Any) -> dict[str, Any] | None: - """QA-facing artifact context for a video-authoring task. + async def _qa_db_reads( + self, task_id: UUID, t: Any + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[Any], list[Any]]: + """The four DB reads behind claim_review's evidence, run sequentially + as ONE coroutine. - None for every non-video task. Carries the composition id (from the - ``video_draft`` marker) + the latest ``request_render`` preview so QA - is pointed at the rendered artifact instead of the source alone. + They stay sequential AMONG THEMSELVES on purpose: they all read + through the request-scoped ``self.task.session`` / evidence_repo, a + single ``AsyncSession`` bound to one DBAPI connection — asyncpg + cannot serve two in-flight queries on the same connection at once + (``asyncpg.exceptions`` / SQLAlchemy's own + ``IllegalStateChangeError: ... concurrent operations are not + permitted``, confirmed against a live Postgres while building this + fix; opening a second ad-hoc session per read would silently diverge + from the request's transaction and break every existing test that + injects a mocked ``evidence_repo``). Bundling them as one coroutine + lets ``_build_qa_claim_evidence`` still `asyncio.gather` this WHOLE + batch against the independent git + conventions-validator work + below — the actual "parallelize the independent reads" win, without + the same-connection hazard. """ - if getattr(t, "source", None) != markers.VIDEO_TASK_SOURCE: - return None - draft = markers.get_video_draft(t) or {} - return { - "composition_id": draft.get("composition_id"), - "render_preview": markers.get_render_preview(t), - "note": ( - "This task ships a rendered video. Call request_render to " - "render the PR branch state, then Read every returned frame " - "image — verify each acceptance criterion's scene appears " - "fully and legibly. Do not pass on source reading alone." - ), - } + t0 = time.monotonic() + journal_highlights = await self.evidence_repo.journal_highlights_for_task( + task_id + ) + # The ask-chain (parent → root descriptions) so QA judges INTENT + # against the intake's original analysis, not only the leaf's ACs. + parent_context = await self.evidence_repo.ancestor_context_for_task(task_id) + open_findings = await findings_lib.open_findings_for_task( + self.task.session, t.id + ) + # The full ledger (every status) so QA verifies prior rounds + # item-by-item, not just what is still open. + prior_findings = await findings_lib.full_ledger_for_task( + self.task.session, t.id + ) + logger.info( + "claim_review db reads timing", + task_id=str(task_id), + db_reads_ms=round((time.monotonic() - t0) * 1000.0), + ) + return journal_highlights, parent_context, open_findings, prior_findings + + async def _qa_git_and_conventions( + self, qa_agent_id: UUID, t: Any + ) -> tuple[str, list[str], list[dict[str, Any]]]: + """The git diff/fetch + conventions-validator segment, as ONE + coroutine so it can run concurrently with the DB-reads batch. + + The conventions check needs ``files_changed`` from the git segment, + so within this coroutine it still runs AFTER git — but that chain as + a whole now overlaps the independent DB reads (item d of the + claim_review timeout fix), instead of the old strictly-serial + diff -> list_changed_files -> conventions(list_changed_files again) + -> DB reads chain. + """ + files_changed: list[str] = [] + diff_summary = "" + git_t0 = time.monotonic() + if t.branch_name: + diff_summary, files_changed = await self.git.diff_and_files( + branch_name=t.branch_name + ) + git_ms = (time.monotonic() - git_t0) * 1000.0 + conv_t0 = time.monotonic() + convention_findings = await self._qa_convention_findings( + qa_agent_id, t, changed_files=files_changed + ) + conv_ms = (time.monotonic() - conv_t0) * 1000.0 + logger.info( + "claim_review git+conventions timing", + task_id=str(getattr(t, "id", None)), + git_diff_and_fetch_ms=round(git_ms), + conventions_ms=round(conv_ms), + ) + return diff_summary, files_changed, convention_findings async def _build_qa_claim_evidence( self, qa_agent_id: UUID, t: Any, task_id: UUID @@ -237,49 +330,29 @@ class QAMixin(_Base): authoritative source) + journal_highlights so the QA agent has the full PR context up-front and can't miss a piece. - files_changed comes from ``git.list_changed_files`` - instead of ``work_session.files_modified``. The legacy + files_changed comes from the combined ``diff_and_files`` + accessor instead of ``work_session.files_modified``. The legacy ``add_files_modified`` HTTP path that populated files_modified is not called by the gateway ``commit()``, so the work_session list was always empty — QA saw no files even on real PRs. + + The git+conventions segment and the DB-reads segment run + concurrently via ``asyncio.gather`` (each internally sequential per + ``_qa_db_reads``'s docstring); per-segment timing is logged inside + each helper (item a of the claim_review timeout fix). """ - files_changed: list[str] = [] - diff_summary = "" - if t.branch_name: - diff_summary = await self.git.diff(branch_name=t.branch_name) - files_changed = await self.git.list_changed_files(branch_name=t.branch_name) - journal_highlights = await self.evidence_repo.journal_highlights_for_task( - task_id + ( + (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), ) - # The ask-chain (parent → root descriptions) so QA judges INTENT - # against the intake's original analysis, not only the leaf's ACs. - # Leaf-only journals stay (include_ancestors defaults False above); - # ancestor *descriptions* are the ask, not work-so-far. - parent_context = await self.evidence_repo.ancestor_context_for_task(task_id) - convention_findings = await self._qa_convention_findings(qa_agent_id, t) - open_findings = await findings_lib.open_findings_for_task( - self.task.session, t.id - ) - # The full ledger (every status) so QA verifies prior rounds - # item-by-item, not just what is still open. - prior_findings = await findings_lib.full_ledger_for_task( - self.task.session, t.id - ) - # The collision map: surfaced siblings (same parent) that would - # collide with this task, with the declared-vs-actual drift (QA has - # the real touched files in hand). Best-effort — a fetch failure omits - # the block rather than breaking claim_review. - collision_context: list[dict[str, Any]] | None = None - try: - if t.parent_task_id: - siblings = await self.task.get_subtasks(t.parent_task_id) - collision_context = build_collision_context( - task=t, siblings=siblings, actual_files=files_changed - ) - except Exception as exc: # best-effort enrichment, never breaks the verb - logger.warning( - "qa_collision_context_skip", task_id=str(t.id), error=str(exc) - ) return build_evidence_for_task( t, journal_highlights=journal_highlights, @@ -289,8 +362,6 @@ class QAMixin(_Base): revision_findings=open_findings, prior_findings=prior_findings, parent_context=parent_context, - collision_context=collision_context, - video_context=self._qa_video_context(t), ) async def _verify_qa_owner( @@ -431,13 +502,13 @@ class QAMixin(_Base): def _qa_ac_coverage_check( cls, task: Any, ac_verdicts: list[str] | None ) -> Envelope | None: - """Legacy count-only per-AC gate — superseded by ``criteria_verified``. + """Per-acceptance-criterion verification gate for pass_review. - No longer wired into ``pass_review`` (a count of arbitrary strings - never verified they actually named the right criterion — the live gap - ``_validate_criteria_verified`` closes). Kept for ``ac_verdicts``' - existing callers/tests; ``ac_verdicts`` itself still folds into the - persisted notes when supplied. + QA may not pass a task until it has recorded a verification for EVERY + acceptance criterion. A single gestalt "looks good" approval is how a + silently-unbuilt criterion slips through; requiring one verdict per + criterion forces QA to check each individually. If a criterion does not + hold, the QA fails the review instead of passing a partial. """ criteria = list(getattr(task, "acceptance_criteria", None) or []) if not criteria: @@ -549,181 +620,36 @@ class QAMixin(_Base): verb=verb, ) - @staticmethod - def _parse_criterion_entry(entry: Any, idx: int) -> tuple[str, str] | Envelope: - """Validate one ``criteria_verified`` entry into a (criterion, evidence) - pair, or an ``Envelope`` rejection on any structural problem.""" - criterion = entry.get("criterion") if isinstance(entry, dict) else None - evidence = entry.get("evidence") if isinstance(entry, dict) else None - if not isinstance(criterion, str) or not criterion.strip(): - return Envelope.invalid_state( - message=f"criteria_verified[{idx}] is missing a `criterion` string", - remediate=( - "each entry needs {criterion, evidence} naming one " - "acceptance criterion" - ), - ) - if not isinstance(evidence, str) or not evidence.strip(): - return Envelope.invalid_state( - message=( - f"criteria_verified[{idx}] ({criterion!r}) is missing `evidence`" - ), - remediate=( - "state concrete evidence: file:line, screenshot ref, " - "rendered-frame path, test name" - ), - ) - if len(evidence) > _CRITERION_EVIDENCE_CAP: - return Envelope.invalid_state( - message=( - f"criteria_verified[{idx}] evidence exceeds " - f"{_CRITERION_EVIDENCE_CAP} chars" - ), - remediate="keep evidence concise — a pointer, not a transcript", - ) - return criterion.strip(), evidence.strip() - - @classmethod - def _parse_criteria_verified_entries( - cls, criteria_verified: list[dict[str, Any]] - ) -> tuple[list[tuple[str, str]], Envelope | None]: - """Shape + soup validation for every ``criteria_verified`` entry. - - Pure parsing — AC matching/coverage is the caller's job. Split out - of ``_validate_criteria_verified`` to keep its return count under - the complexity bound. - """ - pairs: list[tuple[str, str]] = [] - for idx, entry in enumerate(criteria_verified): - parsed = cls._parse_criterion_entry(entry, idx) - if isinstance(parsed, Envelope): - return [], parsed - pairs.append(parsed) - soup = cls._free_text_soup( - checks=(("criteria_verified.evidence", [e for _, e in pairs], 8),) - ) - if soup is not None: - return [], soup - return pairs, None - - @classmethod - def _validate_criteria_verified( - cls, t: Any, criteria_verified: list[dict[str, Any]] | None - ) -> tuple[list[tuple[str, str]], Envelope | None]: - """Mandatory per-AC verification gate for pass_review. - - Returns ``(pairs, rejection)`` — ``pairs`` is ``[]`` and ``rejection`` - non-None on any failure: none supplied (lists every AC verbatim), - a malformed or soupy entry, an entry naming a criterion absent from - the task (names the valid criteria), or a task AC left uncovered - (names the gap). No task ACs imposes no requirement (mirrors the - legacy ``_qa_ac_coverage_check``). A gestalt "looks good" is no - longer enough — every criterion needs its own matched, evidenced - entry, or QA must call ``fail_review`` instead of passing a partial. - """ - criteria = list(getattr(t, "acceptance_criteria", None) or []) - if not criteria: - return [], None - if not criteria_verified: - return [], Envelope.invalid_state( - message=( - "pass_review needs criteria_verified naming every " - f"acceptance criterion; none supplied. Unverified: {criteria!r}" - ), - remediate=( - "re-run the review and call pass_review with " - "criteria_verified=[{criterion, evidence}, ...] — stamp " - "EACH criterion with concrete evidence (file:line, " - "screenshot ref, rendered-frame path, test name)" - ), - ) - pairs, bad = cls._parse_criteria_verified_entries(criteria_verified) - if bad is not None: - return [], bad - provided = [c for c, _ in pairs] - if unknown := findings_lib.unmatched_criteria(t, provided): - return [], Envelope.invalid_state( - message=( - f"criteria_verified names criteria not on this task: {unknown!r}" - ), - remediate=( - "each entry's criterion must match one of the task's " - f"acceptance criteria (by id or exact text): {criteria!r}" - ), - ) - if uncovered := findings_lib.uncovered_acceptance_criteria(t, provided): - return [], Envelope.invalid_state( - message=( - "criteria_verified is missing these acceptance criteria: " - f"{uncovered!r}" - ), - remediate=( - "stamp every criterion with concrete evidence, or call " - "fail_review with the specific gap if one does not hold" - ), - ) - return pairs, None - - @staticmethod - def _render_criteria_verified(pairs: list[tuple[str, str]]) -> list[str]: - """One '[AC] — verified: ' line per entry. - - Style-matched to the findings ledger's '[F-] ...' bracket-tag - rendering (``findings_lib.render_finding_line``). - """ - return [ - f"[AC] {criterion} — verified: {evidence}" for criterion, evidence in pairs - ] - - @classmethod - def _merge_criteria_verified_into_notes( - cls, notes: str, pairs: list[tuple[str, str]] - ) -> str: - """Fold the per-AC verification lines into the persisted QA notes. - - Mirrors ``_merge_ac_verdicts_into_notes`` — keeps the per-criterion - verification in the audit trail (qa_notes) so PM/CEO see exactly how - QA verified each acceptance criterion. - """ - lines = cls._render_criteria_verified(pairs) - if not lines: - return notes - return f"{notes}\n\n" + "\n".join(lines) - async def _qa_pass_final_gates( self, qa_agent_id: UUID, task_id: UUID, t: Any, role_str: str, - criteria_verified: list[dict[str, Any]] | None, - ) -> tuple[Envelope | None, list[tuple[str, str]]]: - """Per-AC verification + toolchain-runnability gates for pass_review. + ac_verdicts: list[str] | None, + ) -> Envelope | None: + """AC-coverage + toolchain-runnability gates for pass_review. - Returns ``(rejection, pairs)`` — the first emitted rejection (else - None) and the validated ``criteria_verified`` (criterion, evidence) - pairs for the caller to render into notes. QA must not PASS on a - workspace that cannot run the suite — that is a source-read + Returns the first rejection (already emitted), else None. QA must not + PASS on a workspace that cannot run the suite — that is a source-read "verification"; fail_review is unaffected. """ - pairs, bad = self._validate_criteria_verified(t, criteria_verified) - if bad is not None: - rejection = await self._emit_rejection( - bad.with_introspection(task=t, role=role_str), + ac_rejection = self._qa_ac_coverage_check(t, ac_verdicts) + if ac_rejection is not None: + return await self._emit_rejection( + ac_rejection.with_introspection(task=t, role=role_str), agent_id=qa_agent_id, task_id=task_id, verb="pass_review", ) - return rejection, [] if toolchain := await self._toolchain_broken_guard(qa_agent_id, t): - rejection = await self._emit_rejection( + return await self._emit_rejection( toolchain.with_introspection(task=t, role=role_str), agent_id=qa_agent_id, task_id=task_id, verb="pass_review", ) - return rejection, [] - return None, pairs + return None async def pass_review( self, @@ -731,7 +657,6 @@ class QAMixin(_Base): task_id: UUID, notes: str, ac_verdicts: list[str] | None = None, - criteria_verified: list[dict[str, Any]] | None = None, ) -> Envelope: """QA passes the task; transitions awaiting_qa → awaiting_documentation. @@ -746,16 +671,6 @@ class QAMixin(_Base): The composed atomic ``qa_pass`` is then dispatched through ``VerbRunner.run_intent``, after which the verb body reassigns the documenter for handoff. - - ``criteria_verified`` ({criterion, evidence} entries) is the - mandatory per-AC verification gate (``_validate_criteria_verified``): - every one of the task's acceptance criteria must be named by exactly - one entry — matched by AC id or exact text, the same match - ``fail_review``'s findings ledger uses for its own ``criterion`` - field — carrying substantive evidence, or the pass is refused. A - gestalt "looks good" is no longer enough; QA must walk each - criterion. ``ac_verdicts`` (legacy, count-only) still folds into the - persisted notes when supplied but no longer gates the pass. """ rejection, t = await self._verify_qa_owner(qa_agent_id, task_id, "pass_review") if rejection is not None: @@ -781,22 +696,18 @@ class QAMixin(_Base): soup_checks=(("notes", notes, 8),), ): return gate_rejection - final_rejection, criteria_pairs = await self._qa_pass_final_gates( - qa_agent_id, task_id, t, role_str, criteria_verified - ) - if final_rejection is not None: - return final_rejection + if rej := await self._qa_pass_final_gates( + qa_agent_id, task_id, t, role_str, ac_verdicts + ): + return rej briefing = await self._briefing_for(qa_agent_id, task_id) - merged_notes = self._merge_criteria_verified_into_notes( - self._merge_ac_verdicts_into_notes(notes, ac_verdicts), criteria_pairs - ) spec_ctx = spec_module.Context( actor_id=qa_agent_id, actor_slug=getattr(agent, "slug", None) if agent is not None else None, agent_team=str(agent.team) if agent is not None and agent.team else None, original_developer_slug=_extract_original_developer(t), - notes=merged_notes, + notes=self._merge_ac_verdicts_into_notes(notes, ac_verdicts), ) self._store_qa_note(t, notes, ac_verdicts, passed=True) runner = self._verb_runner() @@ -892,9 +803,16 @@ class QAMixin(_Base): ) if cap := findings_lib.findings_count_guard(raw): return [], cap - validated, bad = findings_lib.validate_or_reject(raw) - if bad is not None: - return [], bad + try: + validated = validate_findings(raw) + except ContentValidationError as exc: + return [], Envelope.invalid_state( + message=f"malformed finding: {exc.field} — {exc.reason}", + remediate=( + "each finding needs expected + actual (file/line/severity/" + "criterion/fix/evidence optional)" + ), + ) if unknown := findings_lib.unknown_finding_criteria(t, validated): return [], findings_lib.criterion_mismatch_rejection(t, unknown) return validated, None diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index ae472fdd..a8e782dd 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -13,15 +13,10 @@ from __future__ import annotations import asyncio import contextlib -import io import re -import shutil -import tarfile +import time from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, cast -from urllib.parse import urlparse +from typing import TYPE_CHECKING, Any, ClassVar import structlog @@ -30,9 +25,7 @@ from roboco.exceptions import GitError from roboco.foundation.policy import communications as _comms from roboco.foundation.policy.content import ContentValidationError, markers from roboco.foundation.policy.content.validators import reject_trivial -from roboco.foundation.policy.injection_guard import screen_external_text from roboco.foundation.policy.journaling import Scope as _Scope -from roboco.models.base import TaskStatus from roboco.services.content_notes import content_type_for_role from roboco.services.gateway.choreographer import findings as findings_lib from roboco.services.gateway.commit_validator import validate_commit_message @@ -41,11 +34,9 @@ from roboco.services.gateway.evidence_builder import build_evidence_for_task from roboco.services.x_client import MAX_TWEET_CHARS if TYPE_CHECKING: - from collections.abc import Callable from uuid import UUID from roboco.foundation.identity import Team - from roboco.foundation.policy.board_programs import BoardProgram logger = structlog.get_logger() @@ -103,21 +94,28 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset( r.value for r in _comms.NOTIFY_SENDER_ROLES ) -# Roles with NO agent-comms surface (CLAUDE.md): the human-only prompter and -# secretary — restricted to note + evidence, no dm/notify, they own their own -# dedicated chat pages instead. (Auditor and pr_reviewer carry dm/read_a2a -# now — the CEO can DM either and it can reply in-thread — so they're no -# longer in this set; the auditor's silence toward PEERS is enforced -# separately in agents_config.can_a2a_direct.) +# Roles with NO agent-comms surface (CLAUDE.md): auditor (silent observer), +# pr_reviewer (posts review findings on the PR itself — no dm), prompter +# and secretary (human-only, restricted to note + evidence — no dm/notify). # The spawn manifest already omits dm from these roles' tool surfaces, but # that is convention-only — this frozenset is the handler-level defence-in-depth # that refuses any call that bypassed the manifest (direct verb dispatch, test # harness, future routing change), so the no-comms invariant holds regardless of # how the call arrived. Matches the explicit role-frozenset gates on commit / -# notify / pitch / playbook. Derived from the canonical set in -# foundation.policy.communications — agents_config.can_a2a_direct's CEO -# target-side check reuses the same source. -_NO_COMMS_ROLES: frozenset[str] = frozenset(r.value for r in _comms.NO_COMMS_ROLES) +# notify / pitch / playbook. +_NO_COMMS_ROLES: frozenset[str] = frozenset( + {"auditor", "pr_reviewer", "prompter", "secretary"} +) + + +def _no_comms_remediate(role: str) -> str: + """Role-appropriate remediation for a no-comms role blocked at dm.""" + if role == "auditor": + return "record observations via note(scope='reflect') instead" + if role == "pr_reviewer": + return "post review findings on the PR itself via pr_pass/pr_fail instead" + # prompter / secretary are human-only (note + evidence). + return "use note() to record; this human-only role has no agent-comms surface" _DECISION_SECTIONS: tuple[tuple[str, str], ...] = ( @@ -310,19 +308,6 @@ class ContentActionsDeps: orchestrator: Any = None -@dataclass(frozen=True) -class _RenderSource: - """A request_render source: a directory containing ``motion/``, plus its - provenance. ``cleanup`` (set only for a QA branch-export scratch dir) is - always called by request_render's ``finally``, dev or QA alike.""" - - root: Path - head_sha: str | None - dirty: bool - kind: str # "workspace" (dev's own tree) | "branch" (QA's read-only export) - cleanup: Callable[[], None] | None = None - - _VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset(p.value for p in _comms.Priority) # The Board roles that may author a pitch (a product proposal for CEO approval). _PITCH_ROLES: frozenset[str] = frozenset({"product_owner", "head_marketing"}) @@ -332,81 +317,10 @@ _PITCH_ROLES: frozenset[str] = frozenset({"product_owner", "head_marketing"}) # spec's non-goals). _ROADMAP_ROLES: frozenset[str] = frozenset({"product_owner"}) -# Pest Control (Board Program) bug hunts are Product-Owner-only, mirroring -# _ROADMAP_ROLES. -_PEST_ROLES: frozenset[str] = frozenset({"product_owner"}) - -# Spackle (Board Program) gap-fill audits are Product-Owner-only, mirroring -# _PEST_ROLES. -_GAP_FILL_ROLES: frozenset[str] = frozenset({"product_owner"}) - -# Mirror (Board Program) messaging-fix audits are Head-of-Marketing-only — -# the mirror image of _GAP_FILL_ROLES. -_MESSAGING_FIXES_ROLES: frozenset[str] = frozenset({"head_marketing"}) - # Feature spotlights are HoM-authored — the Product Owner stays out of this # cycle (mirrors _ROADMAP_ROLES's PO-only symmetry, reversed). _FEATURE_SPOTLIGHT_ROLES: frozenset[str] = frozenset({"head_marketing"}) -# Periscope market briefs are HoM-authored, mirroring _FEATURE_SPOTLIGHT_ROLES. -_PERISCOPE_ROLES: frozenset[str] = frozenset({"head_marketing"}) - -# Megaphone editorial posts are HoM-authored, mirroring _PERISCOPE_ROLES. -_MEGAPHONE_ROLES: frozenset[str] = frozenset({"head_marketing"}) -_EDITORIAL_ANGLES: frozenset[str] = frozenset( - {"dev_log", "behind_scenes", "changelog_highlight", "other"} -) -_EDITORIAL_RATIONALE_MAX_CHARS = 300 - -# Barfly (Board Program) conversation replies are HoM-authored, mirroring -# _PERISCOPE_ROLES. -_BARFLY_ROLES: frozenset[str] = frozenset({"head_marketing"}) -_BARFLY_RATIONALE_MAX_CHARS = 300 - -# Market-brief free-text caps (spec §4 / Task 2). ``source_url`` is validated -# separately (a URL, not soup-checked prose) — see _reject_market_brief_url. -_MARKET_BRIEF_HEADLINE_MAX_CHARS = 200 -_MARKET_BRIEF_FINDING_CLAIM_MAX_CHARS = 500 -_MARKET_BRIEF_FINDING_SOURCE_URL_MAX_CHARS = 300 -_MARKET_BRIEF_FINDING_RELEVANCE_MAX_CHARS = 300 -_MARKET_BRIEF_POSITIONING_NOTE_MAX_CHARS = 500 -_MARKET_BRIEF_LIST_MAX_ITEMS = 5 # threats / opportunities cap -_MARKET_BRIEF_LIST_ITEM_MAX_CHARS = 300 - -# Coroner postmortems are Auditor-authored — the one program the Auditor -# originates content for (spec §4), distinct from its curation-only -# approve/reject_playbook verbs. -_CORONER_ROLES: frozenset[str] = frozenset({"auditor"}) -_CORONER_PROCESS_CHANGE_KINDS: frozenset[str] = frozenset( - {"playbook", "prompt_fix", "conventions_rule", "other"} -) -_CORONER_INCIDENT_SUMMARY_MAX_CHARS = 500 -_CORONER_ROOT_CAUSE_MAX_CHARS = 800 -_CORONER_PROCESS_CHANGE_DESC_MAX_CHARS = 800 - -# Sentinel (Board Program) quality reports are Auditor-authored — a bounded -# expansion mirroring _PEST_ROLES/_PERISCOPE_ROLES. -_SENTINEL_ROLES: frozenset[str] = frozenset({"auditor"}) - -# Quality-report free-text caps (spec §4). -_QUALITY_REPORT_HEADLINE_MAX_CHARS = 200 -_QUALITY_REPORT_ITEM_OBSERVATION_MAX_CHARS = 500 -_QUALITY_REPORT_ITEM_EVIDENCE_MAX_CHARS = 500 -_QUALITY_REPORT_ITEM_SUGGESTED_ACTION_MAX_CHARS = 300 -_QUALITY_REPORT_OVERALL_ASSESSMENT_MAX_CHARS = 800 -_QUALITY_REPORT_AREAS: frozenset[str] = frozenset( - {"waivers", "findings", "conventions", "budget", "docs", "other"} -) - -# Librarian (Board Program) playbook drafts are Auditor-authored, mirroring -# _SENTINEL_ROLES. Each draft is created directly via PlaybookService (the -# Coroner _draft_coroner_playbook precedent) — the Auditor does NOT also gain -# draft_playbook (see _DRAFT_PLAYBOOK_ROLES above / role_config.py). -_LIBRARIAN_ROLES: frozenset[str] = frozenset({"auditor"}) -_PLAYBOOK_DRAFT_TITLE_MAX_CHARS = 200 # matches PlaybookCreate.title's own cap -_PLAYBOOK_DRAFT_BODY_MAX_CHARS = 4000 -_PLAYBOOK_DRAFT_PATTERN_EVIDENCE_MAX_CHARS = 500 - # Text fields on a roadmap item draft, with their anti-soup minimum length. _ROADMAP_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = ( ("title", 5), @@ -416,102 +330,7 @@ _ROADMAP_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = ( ("rationale", 8), ) -# Text fields on a pest-hunt item draft. ``evidence`` is the load-bearing one -# (spec §4: "a bug hunt without evidence is noise") — required, substantive, -# and capped so a runaway dump can't blow out the marker payload. -_PEST_HUNT_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = ( - ("title", 5), - ("description", 15), - ("project_slug", 2), - ("team", 2), - ("evidence", 20), -) -_PEST_HUNT_EVIDENCE_MAX_CHARS = 2000 - -# Text fields on a gap-fill item draft. ``evidence`` is the load-bearing one -# (spec §4: evidence must name BOTH sides of the gap — e.g. the route that -# exists and the panel surface that doesn't) — required, substantive, and -# capped so a runaway dump can't blow out the marker payload. Mirrors -# _PEST_HUNT_ITEM_TEXT_FIELDS. -_GAP_FILL_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = ( - ("title", 5), - ("description", 15), - ("project_slug", 2), - ("team", 2), - ("evidence", 20), -) -_GAP_FILL_EVIDENCE_MAX_CHARS = 2000 - -# Scales (Board Program) rebalance plans are Product-Owner-only, mirroring -# _PEST_ROLES. Unlike a roadmap/pest-control item, a rebalance item never -# drafts a NEW task — it references a LIVE one (``task_ref``) that approval -# mutates (reprioritize) or cancels, so there is no team/project-slug/ -# acceptance-criteria shape to validate here, just the action + rationale. -_SCALES_ROLES: frozenset[str] = frozenset({"product_owner"}) -_SCALES_ACTIONS: frozenset[str] = frozenset({"reprioritize", "cancel"}) -_SCALES_VALID_PRIORITIES: frozenset[int] = frozenset({0, 1, 2, 3}) -_SCALES_RATIONALE_MAX_CHARS = 500 - -# Text fields on a messaging-fix item draft. ``evidence`` is the load-bearing -# one (spec §4: must name the drifted claim AND the reality it contradicts) -# — required, substantive, and capped so a runaway dump can't blow out the -# marker payload. Mirrors _GAP_FILL_ITEM_TEXT_FIELDS. -_MESSAGING_FIX_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = ( - ("title", 5), - ("description", 15), - ("project_slug", 2), - ("team", 2), - ("evidence", 20), -) -_MESSAGING_FIX_EVIDENCE_MAX_CHARS = 2000 - -# War Room (Board Program) campaigns are HoM-authored, mirroring -# _FEATURE_SPOTLIGHT_ROLES/_PERISCOPE_ROLES. -_WAR_ROOM_ROLES: frozenset[str] = frozenset({"head_marketing"}) -_CAMPAIGN_STAGE_LABELS: frozenset[str] = frozenset( - {"teaser", "launch", "follow_up", "spotlight", "other"} -) -_CAMPAIGN_NAME_MAX_CHARS = 100 -_CAMPAIGN_MIN_POSTS = 2 -_CAMPAIGN_MAX_POSTS = 6 - -# Dogfood (Board Program) friction-fix audits are Product-Owner-only, -# mirroring _GAP_FILL_ROLES. -_DOGFOOD_ROLES: frozenset[str] = frozenset({"product_owner"}) - -# Text fields on a friction-fix item draft. ``evidence`` is the load-bearing -# one (spec §4: the actual walked path — clicks/pages — plus what broke or -# felt wrong, prose, no screenshots) — required, substantive, and capped so -# a runaway dump can't blow out the marker payload. Mirrors -# _GAP_FILL_ITEM_TEXT_FIELDS. -_FRICTION_FIXES_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = ( - ("title", 5), - ("description", 15), - ("project_slug", 2), - ("team", 2), - ("evidence", 20), -) -_FRICTION_FIXES_EVIDENCE_MAX_CHARS = 2000 - -# nothing_to_propose is registry-driven, not role-frozenset-gated like every -# verb above — it resolves the caller's NAMED task, derives the program from -# that task's own source, then requires the caller's role to equal THAT -# program's declared explorer role -# (roboco.foundation.policy.board_programs.PROGRAMS), so a program registered -# later needs no edit here. -_NOTHING_TO_PROPOSE_REASON_MIN_CHARS = 15 -_NOTHING_TO_PROPOSE_REASON_MAX_CHARS = 800 - # Playbook curation RBAC: delivery roles DRAFT; only the Auditor CURATES. -# The Auditor is deliberately NOT in this set — "auditor curates but does not -# draft" is an enforced invariant (test_playbook_verbs.py). A Coroner -# postmortem's playbook-kind process change is drafted through a DIFFERENT -# path — directly via PlaybookService inside propose_postmortem, never this -# do-verb (see _draft_coroner_playbook below) — so the invariant holds even -# though the Auditor now originates playbook drafts by another route; that -# draft rides the SAME pending-playbook curation queue every delivery-role -# draft does, curated by the Auditor same as any other, never self-approved -# inline. _DRAFT_PLAYBOOK_ROLES: frozenset[str] = frozenset( {"developer", "qa", "documenter", "cell_pm", "main_pm"} ) @@ -525,8 +344,6 @@ _CURATE_VAULT_ROLES: frozenset[str] = frozenset({"auditor"}) # reuses MAX_TWEET_CHARS). No role frozenset here, unlike the sets above: # propose_video is gated on the caller's TEAM at runtime (_caller_team), not # role — Role.DEVELOPER doesn't distinguish a ux-dev from a be-dev/fe-dev. -# Kept in lockstep with video-renderer/server.js COMPOSITION_ID_RE. -_COMPOSITION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$") _VIDEO_PLATFORMS: frozenset[str] = frozenset({"x", "tiktok"}) _MAX_TIKTOK_CAPTION_CHARS = 2200 @@ -574,181 +391,6 @@ def _normalize_roadmap_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]: } -def _normalize_pest_hunt_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]: - """Coerce a validated raw pest-hunt item dict into the stored marker - shape. Mirrors ``_normalize_roadmap_item`` — ``id`` is server-assigned.""" - priority = raw.get("priority") - try: - priority = int(priority) if priority is not None else 2 - except (TypeError, ValueError): - priority = 2 - return { - "id": f"item-{idx}", - "title": str(raw["title"]).strip(), - "description": str(raw["description"]).strip(), - "acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]], - "project_slug": str(raw["project_slug"]).strip(), - "team": str(raw["team"]).strip(), - "priority": priority, - "evidence": str(raw["evidence"]).strip(), - "status": "proposed", - "reject_reason": None, - "materialized_task_id": None, - } - - -def _normalize_gap_fill_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]: - """Coerce a validated raw gap-fill item dict into the stored marker - shape. Mirrors ``_normalize_pest_hunt_item`` — ``id`` is server-assigned.""" - priority = raw.get("priority") - try: - priority = int(priority) if priority is not None else 2 - except (TypeError, ValueError): - priority = 2 - return { - "id": f"item-{idx}", - "title": str(raw["title"]).strip(), - "description": str(raw["description"]).strip(), - "acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]], - "project_slug": str(raw["project_slug"]).strip(), - "team": str(raw["team"]).strip(), - "priority": priority, - "evidence": str(raw["evidence"]).strip(), - "status": "proposed", - "reject_reason": None, - "materialized_task_id": None, - } - - -def _normalize_scales_item( - idx: int, raw: dict[str, Any], target: Any -) -> dict[str, Any]: - """Coerce a validated raw rebalance item dict + its resolved target task - into the stored marker shape. Mirrors ``_normalize_pest_hunt_item`` — - ``id`` is server-assigned. Unlike a pest-hunt item there is no draft to - normalize: ``target`` (resolved by ``TaskService.resolve_scales_task_ref`` - before this is called) supplies the id/title actually acted on.""" - action = str(raw["action"]).strip() - return { - "id": f"item-{idx}", - "task_ref": str(raw["task_ref"]).strip(), - "target_task_id": str(target.id), - "target_task_title": target.title, - "action": action, - "new_priority": raw.get("new_priority") if action == "reprioritize" else None, - "rationale": str(raw["rationale"]).strip(), - "status": "proposed", - "reject_reason": None, - "executed_detail": None, - } - - -def _normalize_messaging_fix_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]: - """Coerce a validated raw messaging-fix item dict into the stored marker - shape. Mirrors ``_normalize_gap_fill_item`` — ``id`` is server-assigned.""" - priority = raw.get("priority") - try: - priority = int(priority) if priority is not None else 2 - except (TypeError, ValueError): - priority = 2 - return { - "id": f"item-{idx}", - "title": str(raw["title"]).strip(), - "description": str(raw["description"]).strip(), - "acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]], - "project_slug": str(raw["project_slug"]).strip(), - "team": str(raw["team"]).strip(), - "priority": priority, - "evidence": str(raw["evidence"]).strip(), - "status": "proposed", - "reject_reason": None, - "materialized_task_id": None, - } - - -def _normalize_friction_fix_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]: - """Coerce a validated raw friction-fix item dict into the stored marker - shape. Mirrors ``_normalize_messaging_fix_item`` — ``id`` is server- - assigned.""" - priority = raw.get("priority") - try: - priority = int(priority) if priority is not None else 2 - except (TypeError, ValueError): - priority = 2 - return { - "id": f"item-{idx}", - "title": str(raw["title"]).strip(), - "description": str(raw["description"]).strip(), - "acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]], - "project_slug": str(raw["project_slug"]).strip(), - "team": str(raw["team"]).strip(), - "priority": priority, - "evidence": str(raw["evidence"]).strip(), - "status": "proposed", - "reject_reason": None, - "materialized_task_id": None, - } - - -def _normalize_market_brief_finding(idx: int, raw: dict[str, Any]) -> dict[str, Any]: - """Coerce a validated raw market-brief finding into the stored marker - shape. Mirrors ``_normalize_pest_hunt_item`` — ``id`` is server-assigned. - - ``status``/``reject_reason``/``materialized_task_id`` mirror the roadmap/ - pest-hunt item shape even though the exploration task itself completes - at propose time — the finding still carries its OWN per-item CEO - decision (``PeriscopeService.approve_finding``/``reject_finding``), - orthogonal to the task's own terminal status. - """ - return { - "id": f"finding-{idx}", - "claim": str(raw["claim"]).strip(), - "source_url": str(raw["source_url"]).strip(), - "relevance": str(raw["relevance"]).strip(), - "status": "proposed", - "reject_reason": None, - "materialized_task_id": None, - } - - -def _normalize_quality_report_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]: - """Coerce a validated raw quality-report item into the stored marker - shape. Mirrors ``_normalize_market_brief_finding`` — ``id`` is - server-assigned, and the same per-item ``status`` triple applies (see - ``SentinelService.approve_item``/``reject_item``).""" - return { - "id": f"item-{idx}", - "area": str(raw["area"]).strip(), - "observation": str(raw["observation"]).strip(), - "evidence": str(raw["evidence"]).strip(), - "suggested_action": str(raw["suggested_action"]).strip(), - "status": "proposed", - "reject_reason": None, - "materialized_task_id": None, - } - - -def _render_market_brief_for_screening( - headline: str, - findings: list[dict[str, Any]], - threats: list[str], - opportunities: list[str], - positioning_note: str, -) -> str: - """One line per content piece — every line is independently checked by - ``screen_external_text``, so a single injected line among otherwise-clean - web-derived content is flagged without dropping the rest of the brief.""" - lines = [f"Headline: {headline}"] - for f in findings: - lines.append(f"Finding: {f['claim']} (source: {f['source_url']})") - lines.append(f"Relevance: {f['relevance']}") - lines.extend(f"Threat: {t}" for t in threats) - lines.extend(f"Opportunity: {o}" for o in opportunities) - if positioning_note: - lines.append(f"Positioning: {positioning_note}") - return "\n".join(lines) - - class ContentActions: def __init__(self, deps: ContentActionsDeps) -> None: self._deps = deps @@ -909,7 +551,7 @@ class ContentActions: ), context_briefing={}, ) - subject = _strip_task_prefix(_strip_ai_attribution(message)).strip() + subject = _strip_task_prefix(message).strip() result = validate_commit_message( subject, min_chars=settings.commit_subject_min_chars, @@ -1541,7 +1183,6 @@ class ContentActions: remediate="fix the pitch fields and retry", context_briefing={}, ) - await self._notify_pitch(pitch) return Envelope.ok( status="proposed", task_id=str(pitch.id), @@ -1549,17 +1190,6 @@ class ContentActions: context_briefing={}, ) - async def _notify_pitch(self, pitch: Any) -> None: - """Best-effort CEO nudge the moment a pitch is proposed — without it - a pitch rots silently until the CEO happens to open the Pitches - queue. A send failure never fails ``pitch()`` itself.""" - if self._deps.notification_delivery is None: - return - try: - await self._deps.notification_delivery.notify_ceo_of_pitch(pitch=pitch) - except Exception as exc: - logger.warning("pitch telegram notify failed (best-effort)", error=str(exc)) - @classmethod def _reject_roadmap_item_fields( cls, raw: dict[str, Any], idx: int @@ -1610,13 +1240,8 @@ class ContentActions: return None @classmethod - def _reject_roadmap_item_shape(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw roadmap item dict's shape/fields; None when clean. - - Synchronous — no DB access. Split from ``_reject_roadmap_item`` (which - adds the Task-6b project-exclusion check) so the shape checks stay - classmethod-testable without a session. - """ + def _reject_roadmap_item(cls, raw: Any, idx: int) -> Envelope | None: + """Validate one raw roadmap item dict; None when clean.""" if not isinstance(raw, dict): return Envelope.invalid_state( message=f"item {idx} is not an object", @@ -1630,54 +1255,6 @@ class ContentActions: return rej return cls._reject_roadmap_item_team(raw, idx) - async def _reject_roadmap_item( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate one raw roadmap item dict; None when clean. - - Folds the shape/fields/team checks with the Task-6b project-exclusion - check into one call so the ``propose_roadmap`` loop keeps a single - return point per item (xenon/PLR0911 budget). - """ - if rej := self._reject_roadmap_item_shape(raw, idx): - return rej - return await self._reject_excluded_roadmap_project(raw, idx) - - async def _reject_excluded_roadmap_project( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Reject an item targeting a project that excluded itself from the - roadmap program (``!roadmap`` in its ``board_programs``) — the PO - learns this at propose time instead of a silent materialize-time skip. - - An unresolvable ``project_slug`` is NOT rejected here; that surfaces - downstream at approve/materialize time as it already did before Task - 6b (this check only ever narrows an otherwise-valid slug). - """ - from roboco.foundation.policy.board_programs import ( - PROGRAMS, - project_participates, - ) - from roboco.services.project import get_project_service - - slug = str(raw.get("project_slug", "")).strip() - project = await get_project_service(self.task.session).get_by_slug(slug) - if project is None: - return None - if not project_participates(PROGRAMS["roadmap"], project.board_programs): - return Envelope.invalid_state( - message=( - f"item {idx} targets project {slug!r}, which excluded " - "itself from the roadmap program" - ), - remediate=( - f"drop item {idx} or retarget it to a project not " - "excluded via '!roadmap'" - ), - context_briefing={}, - ) - return None - async def propose_roadmap( self, *, @@ -1718,7 +1295,7 @@ class ContentActions: ) normalized: list[dict[str, Any]] = [] for idx, raw in enumerate(items): - if rej := await self._reject_roadmap_item(raw, idx): + if rej := self._reject_roadmap_item(raw, idx): return rej normalized.append(_normalize_roadmap_item(idx, raw)) @@ -1747,7 +1324,6 @@ class ContentActions: task, {"goal": cycle_goal.strip(), "items": normalized} ) await self.task.session.flush() - await self._notify_roadmap_items(task, normalized) return Envelope.ok( status="roadmap_proposed", task_id=str(task.id), @@ -1758,1163 +1334,6 @@ class ContentActions: }, ) - async def _notify_roadmap_items( - self, task: Any, items: list[dict[str, Any]] - ) -> None: - """Best-effort push DM per proposed item — this is the moment a - roadmap item first becomes CEO-actionable (the engine's own - exploration-task origination has nothing to review yet), so the DM - fires here rather than from ``RoadmapEngine``. A send failure never - blocks ``propose_roadmap`` itself.""" - if self._deps.notification_delivery is None: - return - id8 = str(task.id)[:8] - for item in items: - try: - await self._deps.notification_delivery.notify_ceo_of_queue_item( - kind="roadmap", - id8=id8, - extra=str(item.get("id") or ""), - title=item.get("title") or "untitled", - ) - except Exception as exc: - logger.warning( - "roadmap telegram notify failed (best-effort)", error=str(exc) - ) - - @classmethod - def _reject_pest_hunt_item_text_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the plain text fields (title/description/project_slug/ - team/evidence) of one pest-hunt item dict.""" - for field, min_chars in _PEST_HUNT_ITEM_TEXT_FIELDS: - value = raw.get(field) - if not isinstance(value, str) or not value.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing '{field}'", - remediate=f"provide a substantive '{field}' for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - value, field=f"item {idx} {field}", min_chars=min_chars - ): - return rej - return None - - @staticmethod - def _reject_pest_hunt_item_evidence_and_ac( - raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the evidence char-cap and acceptance_criteria list of one - pest-hunt item dict — split from the text-fields loop above to keep - ``_reject_pest_hunt_item_fields`` under the xenon complexity budget.""" - evidence = str(raw.get("evidence", "")) - if len(evidence) > _PEST_HUNT_EVIDENCE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"item {idx} evidence is {len(evidence)} chars, over the " - f"{_PEST_HUNT_EVIDENCE_MAX_CHARS}-char cap" - ), - remediate=( - f"shorten item {idx}'s evidence to " - f"{_PEST_HUNT_EVIDENCE_MAX_CHARS} characters or fewer" - ), - context_briefing={}, - ) - ac = raw.get("acceptance_criteria") - if ( - not isinstance(ac, list) - or not ac - or not all(isinstance(c, str) and c.strip() for c in ac) - ): - return Envelope.invalid_state( - message=f"item {idx} is missing acceptance_criteria", - remediate=( - f"provide a non-empty list of acceptance criteria for item {idx}" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_pest_hunt_item_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the text + evidence-cap + acceptance-criteria fields of - one pest-hunt item dict. Mirrors ``_reject_roadmap_item_fields``.""" - if rej := cls._reject_pest_hunt_item_text_fields(raw, idx): - return rej - return cls._reject_pest_hunt_item_evidence_and_ac(raw, idx) - - @classmethod - def _reject_pest_hunt_item_shape(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw pest-hunt item dict's shape/fields; None when - clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is - identical for both item kinds.""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"item {idx} is not an object", - remediate=( - "each item must be an object with title/description/" - "acceptance_criteria/project_slug/team/priority/evidence" - ), - context_briefing={}, - ) - if rej := cls._reject_pest_hunt_item_fields(raw, idx): - return rej - return cls._reject_roadmap_item_team(raw, idx) - - async def _reject_pest_hunt_item( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate one raw pest-hunt item dict; None when clean. Mirrors - ``_reject_roadmap_item``.""" - if rej := self._reject_pest_hunt_item_shape(raw, idx): - return rej - return await self._reject_unparticipating_pest_control_project(raw, idx) - - async def _reject_unparticipating_pest_control_project( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Reject an item targeting a project that has NOT opted into the - pest_control program (``"pest_control"`` absent from its - ``board_programs``) — the positive-gate mirror of - ``_reject_excluded_roadmap_project``'s ``!roadmap`` exclusion check: - project-scoped programs are opt-in, so the polarity flips. - - An unresolvable ``project_slug`` is NOT rejected here; that surfaces - downstream at approve/materialize time, same posture as roadmap's - check. - """ - from roboco.foundation.policy.board_programs import ( - PROGRAMS, - project_participates, - ) - from roboco.services.project import get_project_service - - slug = str(raw.get("project_slug", "")).strip() - project = await get_project_service(self.task.session).get_by_slug(slug) - if project is None: - return None - if not project_participates(PROGRAMS["pest_control"], project.board_programs): - return Envelope.invalid_state( - message=( - f"item {idx} targets project {slug!r}, which has not " - "opted into the pest_control program" - ), - remediate=( - f"drop item {idx} or ask the CEO to opt {slug!r} into " - "pest_control on its project settings page" - ), - context_briefing={}, - ) - return None - - async def propose_bug_hunt( - self, - *, - agent_id: UUID, - items: list[dict[str, Any]], - ) -> Envelope: - """Product Owner authors a Pest Control bug hunt (1-N evidence-backed - item drafts, N = the registry's ``max_items_per_cycle``). - - Persists the hunt onto the caller's open exploration task (markers) - — each item starts 'proposed', awaiting the CEO's per-item approve/ - reject in the pest-control queue. One call per cycle: the exploration - task stays open (and this verb keeps refusing) until every item is - terminal. Mirrors ``propose_roadmap`` — no top-level theme goal here, - just the items. - """ - from roboco.foundation.policy.board_programs import PROGRAMS - - role = await self._caller_role(agent_id) - if role not in _PEST_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose a bug hunt; only the " - "Product Owner authors one" - ), - remediate="this verb is Product-Owner-only", - context_briefing={}, - ) - max_items = PROGRAMS["pest_control"].max_items_per_cycle - if not (1 <= len(items) <= max_items): - return Envelope.invalid_state( - message=( - f"a bug hunt needs 1-{max_items} item drafts, got {len(items)}" - ), - remediate=f"propose between 1 and {max_items} evidence-backed items", - context_briefing={}, - ) - normalized: list[dict[str, Any]] = [] - for idx, raw in enumerate(items): - if rej := await self._reject_pest_hunt_item(raw, idx): - return rej - normalized.append(_normalize_pest_hunt_item(idx, raw)) - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_pest_control_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_pest_hunt(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open pest-control exploration task assigned to you", - remediate=( - "propose_bug_hunt only runs against an active exploration " - "cycle spawned by the pest-control engine; wait for the " - "next cycle" - ), - context_briefing={}, - ) - markers.set_pest_hunt(task, {"items": normalized}) - await self.task.session.flush() - await self._notify_pest_hunt_items(task, normalized) - return Envelope.ok( - status="pest_hunt_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reviews each item in the pest-control queue", - context_briefing={"item_count": len(normalized)}, - ) - - async def _notify_pest_hunt_items( - self, task: Any, items: list[dict[str, Any]] - ) -> None: - """Best-effort push DM per proposed item — mirrors - ``_notify_roadmap_items``.""" - if self._deps.notification_delivery is None: - return - id8 = str(task.id)[:8] - for item in items: - try: - await self._deps.notification_delivery.notify_ceo_of_queue_item( - kind="pest_control", - id8=id8, - extra=str(item.get("id") or ""), - title=item.get("title") or "untitled", - ) - except Exception as exc: - logger.warning( - "pest-control telegram notify failed (best-effort)", - error=str(exc), - ) - - @staticmethod - def _reject_scales_item_action_and_priority( - raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate ``action`` + the conditional ``new_priority`` requirement - of one raw rebalance item dict — split out to keep - ``_reject_scales_item_shape`` under the xenon/PLR0911 budget.""" - action = raw.get("action") - if action not in _SCALES_ACTIONS: - return Envelope.invalid_state( - message=f"item {idx} has an invalid action {action!r}", - remediate="action must be 'reprioritize' or 'cancel'", - context_briefing={}, - ) - if action != "reprioritize": - return None - new_priority = raw.get("new_priority") - if ( - not isinstance(new_priority, int) - or isinstance(new_priority, bool) - or new_priority not in _SCALES_VALID_PRIORITIES - ): - return Envelope.invalid_state( - message=( - f"item {idx} is 'reprioritize' but new_priority is {new_priority!r}" - ), - remediate=( - "new_priority is required for a reprioritize item and must " - "be one of 0 (P0/highest) .. 3 (P3/lowest)" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_scales_item_rationale( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the required ``rationale`` field — split out of - ``_reject_scales_item_shape`` to keep its own return-statement count - under the xenon/PLR0911 budget.""" - rationale = raw.get("rationale") - if not isinstance(rationale, str) or not rationale.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing 'rationale'", - remediate=f"provide a substantive rationale for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - rationale, field=f"item {idx} rationale", min_chars=8 - ): - return rej - if len(rationale) > _SCALES_RATIONALE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"item {idx} rationale is {len(rationale)} chars, over the " - f"{_SCALES_RATIONALE_MAX_CHARS}-char cap" - ), - remediate=( - f"shorten item {idx}'s rationale to " - f"{_SCALES_RATIONALE_MAX_CHARS} characters or fewer" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_scales_item_shape(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw rebalance item dict's shape/fields; None when - clean (before ``task_ref`` resolution, which needs the DB).""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"item {idx} is not an object", - remediate=( - "each item must be an object with task_ref/action/" - "new_priority/rationale" - ), - context_briefing={}, - ) - task_ref = raw.get("task_ref") - if not isinstance(task_ref, str) or not task_ref.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing 'task_ref'", - remediate=( - f"provide the id8 or exact title of the live task item " - f"{idx} targets" - ), - context_briefing={}, - ) - if rej := cls._reject_scales_item_action_and_priority(raw, idx): - return rej - return cls._reject_scales_item_rationale(raw, idx) - - async def _reject_scales_item( - self, raw: dict[str, Any], idx: int - ) -> tuple[Envelope | None, Any]: - """Validate one raw rebalance item, then resolve its ``task_ref``. - - Returns ``(None, target_task)`` when clean, ``(rejection, None)`` - otherwise. Resolution happens here (not a separate pass) since a - ``task_ref`` only makes sense checked against a real live task. - """ - if rej := self._reject_scales_item_shape(raw, idx): - return rej, None - from roboco.services.task import get_task_service - - target = await get_task_service(self.task.session).resolve_scales_task_ref( - str(raw["task_ref"]).strip() - ) - if target is None: - return ( - Envelope.invalid_state( - message=( - f"item {idx} task_ref {raw['task_ref']!r} does not " - "resolve to a live BACKLOG/PENDING task" - ), - remediate=( - f"item {idx}'s task_ref must be the id8 or exact title " - "of a live BACKLOG/PENDING task" - ), - context_briefing={}, - ), - None, - ) - return None, target - - async def propose_rebalance( - self, - *, - agent_id: UUID, - items: list[dict[str, Any]], - ) -> Envelope: - """Product Owner authors a Scales portfolio-rebalance plan (1-N - re-priority/cancellation items against the LIVE backlog, N = the - registry's ``max_items_per_cycle``). - - Persists the plan onto the caller's open exploration task (markers) - — each item starts 'proposed', awaiting the CEO's per-item approve/ - reject in the Scales queue. One call per cycle: the exploration task - stays open (and this verb keeps refusing) until every item is - terminal. Unlike ``propose_roadmap``/``propose_bug_hunt`` an item - never drafts a NEW task — it references a LIVE one (``task_ref``, - resolved to a real BACKLOG/PENDING task here) that approval MUTATES - (reprioritize) or cancels, never creates. - """ - from roboco.foundation.policy.board_programs import PROGRAMS - - role = await self._caller_role(agent_id) - if role not in _SCALES_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose a rebalance plan; only the " - "Product Owner authors one" - ), - remediate="this verb is Product-Owner-only", - context_briefing={}, - ) - max_items = PROGRAMS["scales"].max_items_per_cycle - if not (1 <= len(items) <= max_items): - return Envelope.invalid_state( - message=( - f"a rebalance plan needs 1-{max_items} item drafts, got " - f"{len(items)}" - ), - remediate=f"propose between 1 and {max_items} items", - context_briefing={}, - ) - normalized: list[dict[str, Any]] = [] - for idx, raw in enumerate(items): - rejection, target = await self._reject_scales_item(raw, idx) - if rejection is not None: - return rejection - normalized.append(_normalize_scales_item(idx, raw, target)) - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_scales_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_rebalance_plan(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open scales exploration task assigned to you", - remediate=( - "propose_rebalance only runs against an active exploration " - "cycle spawned by the scales engine; wait for the next cycle" - ), - context_briefing={}, - ) - markers.set_rebalance_plan(task, {"items": normalized}) - await self.task.session.flush() - await self._notify_rebalance_items(task, normalized) - return Envelope.ok( - status="rebalance_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reviews each item in the Scales queue", - context_briefing={"item_count": len(normalized)}, - ) - - async def _notify_rebalance_items( - self, task: Any, items: list[dict[str, Any]] - ) -> None: - """Best-effort push DM per proposed item — mirrors - ``_notify_pest_hunt_items``.""" - if self._deps.notification_delivery is None: - return - id8 = str(task.id)[:8] - for item in items: - try: - await self._deps.notification_delivery.notify_ceo_of_queue_item( - kind="scales", - id8=id8, - extra=str(item.get("id") or ""), - title=item.get("target_task_title") or "untitled", - ) - except Exception as exc: - logger.warning( - "scales telegram notify failed (best-effort)", error=str(exc) - ) - - @classmethod - def _reject_gap_fill_item_text_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the plain text fields (title/description/project_slug/ - team/evidence) of one gap-fill item dict. Mirrors - ``_reject_pest_hunt_item_text_fields``.""" - for field, min_chars in _GAP_FILL_ITEM_TEXT_FIELDS: - value = raw.get(field) - if not isinstance(value, str) or not value.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing '{field}'", - remediate=f"provide a substantive '{field}' for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - value, field=f"item {idx} {field}", min_chars=min_chars - ): - return rej - return None - - @staticmethod - def _reject_gap_fill_item_evidence_and_ac( - raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the evidence char-cap and acceptance_criteria list of one - gap-fill item dict — split from the text-fields loop above to keep - ``_reject_gap_fill_item_fields`` under the xenon complexity budget.""" - evidence = str(raw.get("evidence", "")) - if len(evidence) > _GAP_FILL_EVIDENCE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"item {idx} evidence is {len(evidence)} chars, over the " - f"{_GAP_FILL_EVIDENCE_MAX_CHARS}-char cap" - ), - remediate=( - f"shorten item {idx}'s evidence to " - f"{_GAP_FILL_EVIDENCE_MAX_CHARS} characters or fewer" - ), - context_briefing={}, - ) - ac = raw.get("acceptance_criteria") - if ( - not isinstance(ac, list) - or not ac - or not all(isinstance(c, str) and c.strip() for c in ac) - ): - return Envelope.invalid_state( - message=f"item {idx} is missing acceptance_criteria", - remediate=( - f"provide a non-empty list of acceptance criteria for item {idx}" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_gap_fill_item_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the text + evidence-cap + acceptance-criteria fields of - one gap-fill item dict. Mirrors ``_reject_pest_hunt_item_fields``.""" - if rej := cls._reject_gap_fill_item_text_fields(raw, idx): - return rej - return cls._reject_gap_fill_item_evidence_and_ac(raw, idx) - - @classmethod - def _reject_gap_fill_item_shape(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw gap-fill item dict's shape/fields; None when - clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is - identical for every item kind.""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"item {idx} is not an object", - remediate=( - "each item must be an object with title/description/" - "acceptance_criteria/project_slug/team/priority/evidence" - ), - context_briefing={}, - ) - if rej := cls._reject_gap_fill_item_fields(raw, idx): - return rej - return cls._reject_roadmap_item_team(raw, idx) - - async def _reject_gap_fill_item( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate one raw gap-fill item dict; None when clean. Mirrors - ``_reject_pest_hunt_item``.""" - if rej := self._reject_gap_fill_item_shape(raw, idx): - return rej - return await self._reject_unparticipating_spackle_project(raw, idx) - - async def _reject_unparticipating_spackle_project( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Reject an item targeting a project that has NOT opted into the - spackle program (``"spackle"`` absent from its ``board_programs``) — - mirrors ``_reject_unparticipating_pest_control_project``. - - An unresolvable ``project_slug`` is NOT rejected here; that surfaces - downstream at approve/materialize time, same posture as pest_control's - check. - """ - from roboco.foundation.policy.board_programs import ( - PROGRAMS, - project_participates, - ) - from roboco.services.project import get_project_service - - slug = str(raw.get("project_slug", "")).strip() - project = await get_project_service(self.task.session).get_by_slug(slug) - if project is None: - return None - if not project_participates(PROGRAMS["spackle"], project.board_programs): - return Envelope.invalid_state( - message=( - f"item {idx} targets project {slug!r}, which has not " - "opted into the spackle program" - ), - remediate=( - f"drop item {idx} or ask the CEO to opt {slug!r} into " - "spackle on its project settings page" - ), - context_briefing={}, - ) - return None - - async def propose_gap_fill( - self, - *, - agent_id: UUID, - items: list[dict[str, Any]], - ) -> Envelope: - """Product Owner authors a Spackle gap-fill audit (1-N evidence-backed - item drafts, N = the registry's ``max_items_per_cycle``). - - Persists the audit onto the caller's open exploration task (markers) - — each item starts 'proposed', awaiting the CEO's per-item approve/ - reject in the spackle queue. One call per cycle: the exploration - task stays open (and this verb keeps refusing) until every item is - terminal. Mirrors ``propose_bug_hunt`` — no top-level theme goal - here, just the items. - """ - from roboco.foundation.policy.board_programs import PROGRAMS - - role = await self._caller_role(agent_id) - if role not in _GAP_FILL_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose a gap-fill audit; only " - "the Product Owner authors one" - ), - remediate="this verb is Product-Owner-only", - context_briefing={}, - ) - max_items = PROGRAMS["spackle"].max_items_per_cycle - if not (1 <= len(items) <= max_items): - return Envelope.invalid_state( - message=( - f"a gap-fill audit needs 1-{max_items} item drafts, " - f"got {len(items)}" - ), - remediate=f"propose between 1 and {max_items} evidence-backed items", - context_briefing={}, - ) - normalized: list[dict[str, Any]] = [] - for idx, raw in enumerate(items): - if rej := await self._reject_gap_fill_item(raw, idx): - return rej - normalized.append(_normalize_gap_fill_item(idx, raw)) - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_spackle_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_gap_fill(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open spackle exploration task assigned to you", - remediate=( - "propose_gap_fill only runs against an active exploration " - "cycle spawned by the spackle engine; wait for the next " - "cycle" - ), - context_briefing={}, - ) - markers.set_gap_fill(task, {"items": normalized}) - await self.task.session.flush() - await self._notify_gap_fill_items(task, normalized) - return Envelope.ok( - status="gap_fill_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reviews each item in the spackle queue", - context_briefing={"item_count": len(normalized)}, - ) - - async def _notify_gap_fill_items( - self, task: Any, items: list[dict[str, Any]] - ) -> None: - """Best-effort push DM per proposed item — mirrors - ``_notify_pest_hunt_items``.""" - if self._deps.notification_delivery is None: - return - id8 = str(task.id)[:8] - for item in items: - try: - await self._deps.notification_delivery.notify_ceo_of_queue_item( - kind="spackle", - id8=id8, - extra=str(item.get("id") or ""), - title=item.get("title") or "untitled", - ) - except Exception as exc: - logger.warning( - "spackle telegram notify failed (best-effort)", - error=str(exc), - ) - - @classmethod - def _reject_messaging_fix_item_text_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the plain text fields (title/description/project_slug/ - team/evidence) of one messaging-fix item dict. Mirrors - ``_reject_gap_fill_item_text_fields``.""" - for field, min_chars in _MESSAGING_FIX_ITEM_TEXT_FIELDS: - value = raw.get(field) - if not isinstance(value, str) or not value.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing '{field}'", - remediate=f"provide a substantive '{field}' for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - value, field=f"item {idx} {field}", min_chars=min_chars - ): - return rej - return None - - @staticmethod - def _reject_messaging_fix_item_evidence_and_ac( - raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the evidence char-cap and acceptance_criteria list of one - messaging-fix item dict — split from the text-fields loop above to - keep ``_reject_messaging_fix_item_fields`` under the xenon complexity - budget.""" - evidence = str(raw.get("evidence", "")) - if len(evidence) > _MESSAGING_FIX_EVIDENCE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"item {idx} evidence is {len(evidence)} chars, over the " - f"{_MESSAGING_FIX_EVIDENCE_MAX_CHARS}-char cap" - ), - remediate=( - f"shorten item {idx}'s evidence to " - f"{_MESSAGING_FIX_EVIDENCE_MAX_CHARS} characters or fewer" - ), - context_briefing={}, - ) - ac = raw.get("acceptance_criteria") - if ( - not isinstance(ac, list) - or not ac - or not all(isinstance(c, str) and c.strip() for c in ac) - ): - return Envelope.invalid_state( - message=f"item {idx} is missing acceptance_criteria", - remediate=( - f"provide a non-empty list of acceptance criteria for item {idx}" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_messaging_fix_item_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the text + evidence-cap + acceptance-criteria fields of - one messaging-fix item dict. Mirrors ``_reject_gap_fill_item_fields``.""" - if rej := cls._reject_messaging_fix_item_text_fields(raw, idx): - return rej - return cls._reject_messaging_fix_item_evidence_and_ac(raw, idx) - - @classmethod - def _reject_messaging_fix_item_shape(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw messaging-fix item dict's shape/fields; None when - clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is - identical for every item kind.""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"item {idx} is not an object", - remediate=( - "each item must be an object with title/description/" - "acceptance_criteria/project_slug/team/priority/evidence" - ), - context_briefing={}, - ) - if rej := cls._reject_messaging_fix_item_fields(raw, idx): - return rej - return cls._reject_roadmap_item_team(raw, idx) - - async def _reject_messaging_fix_item( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate one raw messaging-fix item dict; None when clean. Mirrors - ``_reject_gap_fill_item``.""" - if rej := self._reject_messaging_fix_item_shape(raw, idx): - return rej - return await self._reject_unparticipating_mirror_project(raw, idx) - - async def _reject_unparticipating_mirror_project( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Reject an item targeting a project that has NOT opted into the - mirror program (``"mirror"`` absent from its ``board_programs``) — - mirrors ``_reject_unparticipating_spackle_project``. - - An unresolvable ``project_slug`` is NOT rejected here; that surfaces - downstream at approve/materialize time, same posture as spackle's - check. - """ - from roboco.foundation.policy.board_programs import ( - PROGRAMS, - project_participates, - ) - from roboco.services.project import get_project_service - - slug = str(raw.get("project_slug", "")).strip() - project = await get_project_service(self.task.session).get_by_slug(slug) - if project is None: - return None - if not project_participates(PROGRAMS["mirror"], project.board_programs): - return Envelope.invalid_state( - message=( - f"item {idx} targets project {slug!r}, which has not " - "opted into the mirror program" - ), - remediate=( - f"drop item {idx} or ask the CEO to opt {slug!r} into " - "mirror on its project settings page" - ), - context_briefing={}, - ) - return None - - async def propose_messaging_fixes( - self, - *, - agent_id: UUID, - items: list[dict[str, Any]], - ) -> Envelope: - """Head of Marketing authors a Mirror positioning audit (1-N - evidence-backed item drafts, N = the registry's - ``max_items_per_cycle``). - - Persists the audit onto the caller's open exploration task (markers) - — each item starts 'proposed', awaiting the CEO's per-item approve/ - reject in the mirror queue. One call per cycle: the exploration - task stays open (and this verb keeps refusing) until every item is - terminal. Mirrors ``propose_gap_fill`` — no top-level theme goal - here, just the items. - """ - from roboco.foundation.policy.board_programs import PROGRAMS - - role = await self._caller_role(agent_id) - if role not in _MESSAGING_FIXES_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose messaging fixes; only " - "the Head of Marketing authors this audit" - ), - remediate="this verb is Head-of-Marketing-only", - context_briefing={}, - ) - max_items = PROGRAMS["mirror"].max_items_per_cycle - if not (1 <= len(items) <= max_items): - return Envelope.invalid_state( - message=( - f"a messaging-fixes audit needs 1-{max_items} item drafts, " - f"got {len(items)}" - ), - remediate=f"propose between 1 and {max_items} evidence-backed items", - context_briefing={}, - ) - normalized: list[dict[str, Any]] = [] - for idx, raw in enumerate(items): - if rej := await self._reject_messaging_fix_item(raw, idx): - return rej - normalized.append(_normalize_messaging_fix_item(idx, raw)) - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_mirror_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_messaging_fixes(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open mirror exploration task assigned to you", - remediate=( - "propose_messaging_fixes only runs against an active " - "exploration cycle spawned by the mirror engine; wait " - "for the next cycle" - ), - context_briefing={}, - ) - markers.set_messaging_fixes(task, {"items": normalized}) - await self.task.session.flush() - await self._notify_messaging_fix_items(task, normalized) - return Envelope.ok( - status="messaging_fixes_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reviews each item in the mirror queue", - context_briefing={"item_count": len(normalized)}, - ) - - async def _notify_messaging_fix_items( - self, task: Any, items: list[dict[str, Any]] - ) -> None: - """Best-effort push DM per proposed item — mirrors - ``_notify_gap_fill_items``.""" - if self._deps.notification_delivery is None: - return - id8 = str(task.id)[:8] - for item in items: - try: - await self._deps.notification_delivery.notify_ceo_of_queue_item( - kind="mirror", - id8=id8, - extra=str(item.get("id") or ""), - title=item.get("title") or "untitled", - ) - except Exception as exc: - logger.warning( - "mirror telegram notify failed (best-effort)", - error=str(exc), - ) - - @classmethod - def _reject_friction_fix_item_text_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the plain text fields (title/description/project_slug/ - team/evidence) of one friction-fix item dict. Mirrors - ``_reject_messaging_fix_item_text_fields``.""" - for field, min_chars in _FRICTION_FIXES_ITEM_TEXT_FIELDS: - value = raw.get(field) - if not isinstance(value, str) or not value.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing '{field}'", - remediate=f"provide a substantive '{field}' for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - value, field=f"item {idx} {field}", min_chars=min_chars - ): - return rej - return None - - @staticmethod - def _reject_friction_fix_item_evidence_and_ac( - raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the evidence char-cap and acceptance_criteria list of one - friction-fix item dict — split from the text-fields loop above to - keep ``_reject_friction_fix_item_fields`` under the xenon complexity - budget.""" - evidence = str(raw.get("evidence", "")) - if len(evidence) > _FRICTION_FIXES_EVIDENCE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"item {idx} evidence is {len(evidence)} chars, over the " - f"{_FRICTION_FIXES_EVIDENCE_MAX_CHARS}-char cap" - ), - remediate=( - f"shorten item {idx}'s evidence to " - f"{_FRICTION_FIXES_EVIDENCE_MAX_CHARS} characters or fewer" - ), - context_briefing={}, - ) - ac = raw.get("acceptance_criteria") - if ( - not isinstance(ac, list) - or not ac - or not all(isinstance(c, str) and c.strip() for c in ac) - ): - return Envelope.invalid_state( - message=f"item {idx} is missing acceptance_criteria", - remediate=( - f"provide a non-empty list of acceptance criteria for item {idx}" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_friction_fix_item_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the text + evidence-cap + acceptance-criteria fields of - one friction-fix item dict. Mirrors ``_reject_messaging_fix_item_fields``.""" - if rej := cls._reject_friction_fix_item_text_fields(raw, idx): - return rej - return cls._reject_friction_fix_item_evidence_and_ac(raw, idx) - - @classmethod - def _reject_friction_fix_item_shape(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw friction-fix item dict's shape/fields; None when - clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is - identical for every item kind.""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"item {idx} is not an object", - remediate=( - "each item must be an object with title/description/" - "acceptance_criteria/project_slug/team/priority/evidence" - ), - context_briefing={}, - ) - if rej := cls._reject_friction_fix_item_fields(raw, idx): - return rej - return cls._reject_roadmap_item_team(raw, idx) - - async def _reject_friction_fix_item( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate one raw friction-fix item dict; None when clean. Mirrors - ``_reject_messaging_fix_item``.""" - if rej := self._reject_friction_fix_item_shape(raw, idx): - return rej - return await self._reject_unparticipating_dogfood_project(raw, idx) - - async def _reject_unparticipating_dogfood_project( - self, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Reject an item targeting a project that has NOT opted into the - dogfood program (``"dogfood"`` absent from its ``board_programs``) — - mirrors ``_reject_unparticipating_mirror_project``. - - An unresolvable ``project_slug`` is NOT rejected here; that surfaces - downstream at approve/materialize time, same posture as mirror's - check. - """ - from roboco.foundation.policy.board_programs import ( - PROGRAMS, - project_participates, - ) - from roboco.services.project import get_project_service - - slug = str(raw.get("project_slug", "")).strip() - project = await get_project_service(self.task.session).get_by_slug(slug) - if project is None: - return None - if not project_participates(PROGRAMS["dogfood"], project.board_programs): - return Envelope.invalid_state( - message=( - f"item {idx} targets project {slug!r}, which has not " - "opted into the dogfood program" - ), - remediate=( - f"drop item {idx} or ask the CEO to opt {slug!r} into " - "dogfood on its project settings page" - ), - context_briefing={}, - ) - return None - - async def propose_friction_fixes( - self, - *, - agent_id: UUID, - items: list[dict[str, Any]], - ) -> Envelope: - """Product Owner authors a Dogfood friction audit (1-N evidence-backed - item drafts, N = the registry's ``max_items_per_cycle``). - - Persists the audit onto the caller's open exploration task (markers) - — each item starts 'proposed', awaiting the CEO's per-item approve/ - reject in the dogfood queue. One call per cycle: the exploration - task stays open (and this verb keeps refusing) until every item is - terminal. Mirrors ``propose_messaging_fixes`` — no top-level theme - goal here, just the items. - """ - from roboco.foundation.policy.board_programs import PROGRAMS - - role = await self._caller_role(agent_id) - if role not in _DOGFOOD_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose friction fixes; only " - "the Product Owner authors this audit" - ), - remediate="this verb is Product-Owner-only", - context_briefing={}, - ) - max_items = PROGRAMS["dogfood"].max_items_per_cycle - if not (1 <= len(items) <= max_items): - return Envelope.invalid_state( - message=( - f"a friction audit needs 1-{max_items} item drafts, " - f"got {len(items)}" - ), - remediate=f"propose between 1 and {max_items} evidence-backed items", - context_briefing={}, - ) - normalized: list[dict[str, Any]] = [] - for idx, raw in enumerate(items): - if rej := await self._reject_friction_fix_item(raw, idx): - return rej - normalized.append(_normalize_friction_fix_item(idx, raw)) - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_dogfood_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_friction_fixes(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open dogfood exploration task assigned to you", - remediate=( - "propose_friction_fixes only runs against an active " - "exploration cycle spawned by the dogfood engine; wait " - "for the next cycle" - ), - context_briefing={}, - ) - markers.set_friction_fixes(task, {"items": normalized}) - await self.task.session.flush() - await self._notify_friction_fix_items(task, normalized) - return Envelope.ok( - status="friction_fixes_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reviews each item in the dogfood queue", - context_briefing={"item_count": len(normalized)}, - ) - - async def _notify_friction_fix_items( - self, task: Any, items: list[dict[str, Any]] - ) -> None: - """Best-effort push DM per proposed item — mirrors - ``_notify_messaging_fix_items``.""" - if self._deps.notification_delivery is None: - return - id8 = str(task.id)[:8] - for item in items: - try: - await self._deps.notification_delivery.notify_ceo_of_queue_item( - kind="dogfood", - id8=id8, - extra=str(item.get("id") or ""), - title=item.get("title") or "untitled", - ) - except Exception as exc: - logger.warning( - "dogfood telegram notify failed (best-effort)", - error=str(exc), - ) - @classmethod def _reject_feature_spotlight_fields( cls, feature_slug: str, feature_title: str, body: str @@ -3083,1232 +1502,6 @@ class ContentActions: }, ) - @classmethod - def _reject_editorial_post_fields( - cls, angle: str, body: str, rationale: str - ) -> Envelope | None: - """Angle vocabulary + soup + 280-char validation for a Megaphone - draft's fields, collapsed into one caller-side check (keeps - propose_editorial_post's return-statement count under the - xenon/PLR0911 budget) — mirrors - ``_reject_feature_spotlight_fields``.""" - if angle not in _EDITORIAL_ANGLES: - return Envelope.invalid_state( - message=f"angle {angle!r} is not a recognized editorial angle", - remediate=( - "angle must be one of: " + ", ".join(sorted(_EDITORIAL_ANGLES)) - ), - context_briefing={}, - ) - if rej := cls._reject_soup(body, field="body", min_chars=8): - return rej - if len(body) > MAX_TWEET_CHARS: - return Envelope.invalid_state( - message=( - f"body is {len(body)} chars, over the {MAX_TWEET_CHARS}-char " - "tweet limit" - ), - remediate="shorten the post to 280 characters or fewer", - context_briefing={}, - ) - if rej := cls._reject_soup(rationale, field="rationale", min_chars=8): - return rej - if len(rationale) > _EDITORIAL_RATIONALE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"rationale is {len(rationale)} chars, over the " - f"{_EDITORIAL_RATIONALE_MAX_CHARS}-char cap" - ), - remediate="shorten the rationale", - context_briefing={}, - ) - return None - - async def propose_editorial_post( - self, - *, - agent_id: UUID, - angle: str = "", - body: str = "", - rationale: str = "", - ) -> Envelope: - """Head of Marketing authors ONE Megaphone editorial-calendar post. - - Validates role, the angle vocabulary, the 280-char tweet limit, and - the rationale, then materializes the SAME held X-queue draft - ``propose_feature_spotlight`` uses (via ``XEngine. - materialize_editorial_post``, source=x_editorial) and completes the - caller's exploration task in the same call — a Megaphone post has no - per-item CEO decision to leave the exploration open for, mirroring - the x_feature complete-at-propose asymmetry. One call per cycle. - """ - role = await self._caller_role(agent_id) - if role not in _MEGAPHONE_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose an editorial post; only " - "the Head of Marketing does" - ), - remediate="this verb is Head-of-Marketing-only", - context_briefing={}, - ) - if rej := self._reject_editorial_post_fields(angle, body, rationale): - return rej - - from roboco.services.task import get_task_service - from roboco.services.x_engine import get_x_engine - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_megaphone_cycles() - task = next((t for t in cycles if t.assigned_to == agent_id), None) - if task is None: - return Envelope.invalid_state( - message="no open megaphone exploration task assigned to you", - remediate=( - "propose_editorial_post only runs against an active " - "exploration spawned by the megaphone engine; wait for " - "the next cycle" - ), - context_briefing={}, - ) - engine = get_x_engine(self.task.session) - new_task = await engine.materialize_editorial_post( - exploration_task=task, angle=angle, body=body, rationale=rationale - ) - return Envelope.ok( - status="editorial_post_proposed", - task_id=str(new_task.id), - next="i_am_idle() — the CEO reviews the draft in the X post queue", - context_briefing={"angle": angle, "rationale": rationale}, - ) - - @classmethod - def _reject_barfly_item_shape(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw conversation-reply item dict's shape; None when - clean (before ``tweet_id`` resolution against the task's real - candidates, which needs the task in hand).""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"item {idx} is not an object", - remediate=( - "each item must be an object with tweet_id/reply_body/rationale" - ), - context_briefing={}, - ) - tweet_id = raw.get("tweet_id") - if not isinstance(tweet_id, str) or not tweet_id.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing 'tweet_id'", - remediate=( - f"item {idx}'s tweet_id must name one of the candidate " - "conversations already on this task" - ), - context_briefing={}, - ) - reply_body = raw.get("reply_body") - if not isinstance(reply_body, str) or not reply_body.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing 'reply_body'", - remediate=f"provide a substantive reply_body for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - reply_body, field=f"item {idx} reply_body", min_chars=8 - ): - return rej - if len(reply_body) > MAX_TWEET_CHARS: - return Envelope.invalid_state( - message=( - f"item {idx} reply_body is {len(reply_body)} chars, over " - f"the {MAX_TWEET_CHARS}-char tweet limit" - ), - remediate=f"shorten item {idx}'s reply_body to {MAX_TWEET_CHARS} chars", - context_briefing={}, - ) - return cls._reject_barfly_item_rationale(raw, idx) - - @classmethod - def _reject_barfly_item_rationale( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate the required ``rationale`` field — split out of - ``_reject_barfly_item_shape`` to keep its own return-statement count - under the xenon/PLR0911 budget.""" - rationale = raw.get("rationale") - if not isinstance(rationale, str) or not rationale.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing 'rationale'", - remediate=f"provide a substantive rationale for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - rationale, field=f"item {idx} rationale", min_chars=8 - ): - return rej - if len(rationale) > _BARFLY_RATIONALE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"item {idx} rationale is {len(rationale)} chars, over " - f"the {_BARFLY_RATIONALE_MAX_CHARS}-char cap" - ), - remediate=f"shorten item {idx}'s rationale", - context_briefing={}, - ) - return None - - @staticmethod - def _reject_barfly_item_candidate( - raw: dict[str, Any], idx: int, candidates_by_id: dict[str, dict[str, Any]] - ) -> Envelope | None: - """Reject a ``tweet_id`` that doesn't name one of THIS cycle's real - screened candidates — the agent must reply to what was actually - found, never invent a tweet. Split out so - ``_reject_barfly_item``'s xenon budget stays flat.""" - tweet_id = str(raw["tweet_id"]).strip() - if tweet_id in candidates_by_id: - return None - valid = ", ".join(sorted(candidates_by_id)) or "(none)" - return Envelope.invalid_state( - message=( - f"item {idx} tweet_id {tweet_id!r} does not match any " - "candidate conversation on this task" - ), - remediate=f"item {idx}'s tweet_id must be one of: {valid}", - context_briefing={}, - ) - - def _reject_barfly_caller_and_bounds( - self, role: str, items: list[dict[str, Any]], max_items: int - ) -> Envelope | None: - """Role gate + item-count bounds — split out so the main verb's own - branch count stays flat (xenon budget).""" - if role not in _BARFLY_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose conversation replies; only " - "the Head of Marketing authors them" - ), - remediate="this verb is Head-of-Marketing-only", - context_briefing={}, - ) - if not (1 <= len(items) <= max_items): - return Envelope.invalid_state( - message=( - f"conversation replies need 1-{max_items} item drafts, " - f"got {len(items)}" - ), - remediate=f"propose between 1 and {max_items} drafted replies", - context_briefing={}, - ) - return None - - @classmethod - def _reject_barfly_item_shapes(cls, items: list[dict[str, Any]]) -> Envelope | None: - """Pure, DB-free pass over every item's shape — split out so the - main verb's own branch count stays flat (xenon budget).""" - for idx, raw in enumerate(items): - if rej := cls._reject_barfly_item_shape(raw, idx): - return rej - return None - - @staticmethod - def _reject_barfly_item_candidates( - items: list[dict[str, Any]], candidates_by_id: dict[str, dict[str, Any]] - ) -> Envelope | None: - """Second pass, once the exploration task (and so its real - candidates) is in hand — split out so the main verb's own branch - count stays flat (xenon budget).""" - for idx, raw in enumerate(items): - if rej := ContentActions._reject_barfly_item_candidate( - raw, idx, candidates_by_id - ): - return rej - return None - - @staticmethod - async def _materialize_barfly_replies( - engine: Any, - task: Any, - items: list[dict[str, Any]], - candidates_by_id: dict[str, dict[str, Any]], - ) -> list[str]: - """One held draft per approved-shape item, through the shared - ``_originate_post`` chokepoint — split out so the main verb's own - branch count stays flat (xenon budget).""" - materialized_ids: list[str] = [] - for raw in items: - candidate = candidates_by_id[str(raw["tweet_id"]).strip()] - new_task = await engine.materialize_barfly_reply( - exploration_task=task, - candidate=candidate, - reply_body=str(raw["reply_body"]).strip(), - rationale=str(raw["rationale"]).strip(), - ) - materialized_ids.append(str(new_task.id)) - return materialized_ids - - async def propose_conversation_replies( - self, - *, - agent_id: UUID, - items: list[dict[str, Any]], - ) -> Envelope: - """Head of Marketing drafts 1-N replies (N = the registry's - ``max_items_per_cycle``) to screened X conversations Barfly's search - cycle already gathered onto the exploration task. - - Validation runs in two passes, mirroring every other item-verb's - pure-then-DB split: first EVERY item's shape (dict/tweet_id/ - reply_body/rationale — no DB touched), then the exploration task is - resolved, then EVERY item's ``tweet_id`` is checked against that - task's own screened candidates — an invented tweet is rejected - naming the valid ids. Unlike ``propose_gap_fill``/``propose_bug_ - hunt`` (a per-item CEO queue that keeps the exploration task open) - this mirrors ``propose_feature_spotlight``'s complete-at-propose - asymmetry MULTIPLIED across every item: each approved-shape reply - materializes its own held draft (source=x_barfly) through - ``XEngine.materialize_barfly_reply`` in this same call, then the - exploration task itself completes — the CEO decides each - materialized draft individually in the existing X post queue, not on - this task. - """ - from roboco.foundation.policy.board_programs import PROGRAMS - - role = await self._caller_role(agent_id) - max_items = PROGRAMS["barfly"].max_items_per_cycle - if rej := self._reject_barfly_caller_and_bounds(role, items, max_items): - return rej - if rej := self._reject_barfly_item_shapes(items): - return rej - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_barfly_cycles() - task = next((t for t in cycles if t.assigned_to == agent_id), None) - if task is None: - return Envelope.invalid_state( - message="no open barfly exploration task assigned to you", - remediate=( - "propose_conversation_replies only runs against an active " - "exploration cycle spawned by the barfly engine; wait for " - "the next cycle" - ), - context_briefing={}, - ) - candidates_by_id = { - str(c.get("id")): c - for c in markers.get_barfly_candidates(task) - if isinstance(c, dict) and c.get("id") - } - if rej := self._reject_barfly_item_candidates(items, candidates_by_id): - return rej - - from roboco.services.x_engine import get_x_engine - - engine = get_x_engine(self.task.session) - materialized_ids = await self._materialize_barfly_replies( - engine, task, items, candidates_by_id - ) - task.status = TaskStatus.COMPLETED - await self.task.session.flush() - return Envelope.ok( - status="conversation_replies_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reviews each reply in the X post queue", - context_briefing={ - "item_count": len(items), - "materialized_task_ids": materialized_ids, - }, - ) - - @staticmethod - def _reject_market_brief_url(url: str, idx: int) -> Envelope | None: - """An uncited market claim is noise (spec Task 2) — validate - ``source_url`` parses as a real http(s) URL rather than soup-checking - it as prose.""" - if len(url) > _MARKET_BRIEF_FINDING_SOURCE_URL_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"finding {idx} source_url is {len(url)} chars, over the " - f"{_MARKET_BRIEF_FINDING_SOURCE_URL_MAX_CHARS}-char cap" - ), - remediate=f"shorten finding {idx}'s source_url", - context_briefing={}, - ) - parsed = urlparse(url) - if parsed.scheme not in ("http", "https") or not parsed.netloc: - return Envelope.invalid_state( - message=f"finding {idx} source_url {url!r} is not a valid http(s) URL", - remediate=( - f"provide a real http(s) URL finding {idx}'s claim came from" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_market_brief_finding(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw market-brief finding dict; None when clean.""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"finding {idx} is not an object", - remediate="each finding needs claim/source_url/relevance", - context_briefing={}, - ) - if rej := cls._reject_market_brief_finding_claim(raw, idx): - return rej - if rej := cls._reject_market_brief_finding_source(raw, idx): - return rej - return cls._reject_market_brief_finding_relevance(raw, idx) - - @classmethod - def _reject_market_brief_finding_claim( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Split out of ``_reject_market_brief_finding`` to keep its - return-statement count under the xenon/PLR0911 budget.""" - claim = raw.get("claim") - if not isinstance(claim, str) or not claim.strip(): - return Envelope.invalid_state( - message=f"finding {idx} is missing 'claim'", - remediate=f"provide a substantive claim for finding {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup(claim, field=f"finding {idx} claim", min_chars=8): - return rej - if len(claim) > _MARKET_BRIEF_FINDING_CLAIM_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"finding {idx} claim is {len(claim)} chars, over the " - f"{_MARKET_BRIEF_FINDING_CLAIM_MAX_CHARS}-char cap" - ), - remediate=f"shorten finding {idx}'s claim", - context_briefing={}, - ) - return None - - @classmethod - def _reject_market_brief_finding_source( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Split out of ``_reject_market_brief_finding`` to keep its - return-statement count under the xenon/PLR0911 budget.""" - source_url = raw.get("source_url") - if not isinstance(source_url, str) or not source_url.strip(): - return Envelope.invalid_state( - message=( - f"finding {idx} is missing 'source_url' — an uncited " - "market claim is noise" - ), - remediate=( - f"provide the http(s) source URL finding {idx}'s claim came from" - ), - context_briefing={}, - ) - return cls._reject_market_brief_url(source_url, idx) - - @classmethod - def _reject_market_brief_finding_relevance( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Split out of ``_reject_market_brief_finding`` to keep its - return-statement count under the xenon/PLR0911 budget.""" - relevance = raw.get("relevance") - if not isinstance(relevance, str) or not relevance.strip(): - return Envelope.invalid_state( - message=f"finding {idx} is missing 'relevance'", - remediate=f"provide a substantive relevance for finding {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - relevance, field=f"finding {idx} relevance", min_chars=8 - ): - return rej - if len(relevance) > _MARKET_BRIEF_FINDING_RELEVANCE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"finding {idx} relevance is {len(relevance)} chars, over " - f"the {_MARKET_BRIEF_FINDING_RELEVANCE_MAX_CHARS}-char cap" - ), - remediate=f"shorten finding {idx}'s relevance", - context_briefing={}, - ) - return None - - @classmethod - def _reject_market_brief_text_list( - cls, values: Any, *, field: str - ) -> Envelope | None: - """Validate an optional ``threats``/``opportunities`` list: at most - ``_MARKET_BRIEF_LIST_MAX_ITEMS`` substantive strings, each capped at - ``_MARKET_BRIEF_LIST_ITEM_MAX_CHARS``. ``None`` (omitted) is clean.""" - if values is None: - return None - if not isinstance(values, list) or len(values) > _MARKET_BRIEF_LIST_MAX_ITEMS: - return Envelope.invalid_state( - message=( - f"{field} must be a list of at most " - f"{_MARKET_BRIEF_LIST_MAX_ITEMS} strings" - ), - remediate=f"provide at most {_MARKET_BRIEF_LIST_MAX_ITEMS} {field}", - context_briefing={}, - ) - for i, v in enumerate(values): - if rej := cls._reject_market_brief_list_item(v, field=field, idx=i): - return rej - return None - - @classmethod - def _reject_market_brief_list_item( - cls, value: Any, *, field: str, idx: int - ) -> Envelope | None: - """One ``threats``/``opportunities`` entry — split out of - ``_reject_market_brief_text_list`` to keep its own return-statement - count under the xenon/PLR0911 budget.""" - if not isinstance(value, str) or not value.strip(): - return Envelope.invalid_state( - message=f"{field}[{idx}] is empty", - remediate=f"provide substantive text for {field}[{idx}] or drop it", - context_briefing={}, - ) - if rej := cls._reject_soup(value, field=f"{field}[{idx}]", min_chars=4): - return rej - if len(value) > _MARKET_BRIEF_LIST_ITEM_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"{field}[{idx}] is {len(value)} chars, over the " - f"{_MARKET_BRIEF_LIST_ITEM_MAX_CHARS}-char cap" - ), - remediate=f"shorten {field}[{idx}]", - context_briefing={}, - ) - return None - - async def propose_market_brief( - self, - *, - agent_id: UUID, - headline: str, - findings: list[dict[str, Any]], - threats: list[str] | None = None, - opportunities: list[str] | None = None, - positioning_note: str = "", - ) -> Envelope: - """Head of Marketing files ONE Periscope weekly market-research brief - — competitors, adjacent-tool releases, positioning shifts — delivered - as a held REPORT to the CEO. - - Unlike ``propose_roadmap``/``propose_bug_hunt`` (a per-item CEO queue - that keeps the exploration task open until every item is decided), - this mirrors ``propose_feature_spotlight``'s complete-at-propose - asymmetry: a report has no per-item decision, so the exploration task - completes in this same call. The brief is screened through - ``injection_guard.screen_external_text`` before persisting — it is - web-derived content that later reaches the roadmap exploration - prompt, same untrusted-text posture as X mentions / vault notes - (screen-and-flag, never drop). - """ - role = await self._caller_role(agent_id) - if role not in _PERISCOPE_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose a market brief; only the " - "Head of Marketing authors one" - ), - remediate="this verb is Head-of-Marketing-only", - context_briefing={}, - ) - if rej := self._reject_market_brief_fields( - headline, findings, threats, opportunities, positioning_note - ): - return rej - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_periscope_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_market_brief(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open periscope exploration task assigned to you", - remediate=( - "propose_market_brief only runs against an active " - "exploration cycle spawned by the periscope engine; wait " - "for the next cycle" - ), - context_briefing={}, - ) - - await self._persist_market_brief( - task, - headline=headline, - findings=findings, - threats=threats, - opportunities=opportunities, - positioning_note=positioning_note, - ) - await self._notify_periscope_brief(task, headline.strip()) - return Envelope.ok( - status="market_brief_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reads the brief as a report in the panel", - context_briefing={ - "headline": headline.strip(), - "finding_count": len(findings), - }, - ) - - async def _persist_market_brief( - self, - task: Any, - *, - headline: str, - findings: list[dict[str, Any]], - threats: list[str] | None, - opportunities: list[str] | None, - positioning_note: str, - ) -> None: - """Normalize, screen, persist, and complete — split out of - ``propose_market_brief`` to keep its own cyclomatic complexity under - the xenon budget. Complete-at-propose: a report has no per-item CEO - decision to wait on (the x_feature asymmetry, not the roadmap/ - pest-control per-item flow) — BoardProgramEngine's dedup ledger - auto-closes the cycle row the moment it next checks this now-terminal - exploration task. - """ - normalized_findings = [ - _normalize_market_brief_finding(idx, raw) - for idx, raw in enumerate(findings) - ] - normalized_threats = [str(t).strip() for t in (threats or [])] - normalized_opportunities = [str(o).strip() for o in (opportunities or [])] - normalized_note = positioning_note.strip() - screened = screen_external_text( - _render_market_brief_for_screening( - headline, - normalized_findings, - normalized_threats, - normalized_opportunities, - normalized_note, - ), - source=f"periscope_brief:{task.id}", - ) - if screened.flagged: - logger.warning( - "periscope: injection pattern detected in market brief", - task_id=str(task.id), - hits=screened.hits, - ) - markers.set_market_brief( - task, - { - "headline": headline.strip(), - "findings": normalized_findings, - "threats": normalized_threats, - "opportunities": normalized_opportunities, - "positioning_note": normalized_note, - "injection_hits": screened.hits, - }, - ) - task.status = TaskStatus.COMPLETED - await self.task.session.flush() - - @classmethod - def _reject_market_brief_findings_list( - cls, findings: list[dict[str, Any]] - ) -> Envelope | None: - """The count cap + per-finding validation loop, split out of - ``_reject_market_brief_fields`` to keep its own return-statement - count under the xenon/PLR0911 budget.""" - from roboco.foundation.policy.board_programs import PROGRAMS - - max_findings = PROGRAMS["periscope"].max_items_per_cycle - if not (1 <= len(findings) <= max_findings): - return Envelope.invalid_state( - message=( - f"a brief needs 1-{max_findings} cited findings, got " - f"{len(findings)}" - ), - remediate=f"propose between 1 and {max_findings} cited findings", - context_briefing={}, - ) - for idx, raw in enumerate(findings): - if rej := cls._reject_market_brief_finding(raw, idx): - return rej - return None - - @classmethod - def _reject_market_brief_fields( - cls, - headline: str, - findings: list[dict[str, Any]], - threats: list[str] | None, - opportunities: list[str] | None, - positioning_note: str, - ) -> Envelope | None: - """Full field validation for ``propose_market_brief``, split out to - keep the verb's own return-statement count under the xenon/PLR0911 - budget.""" - if rej := cls._reject_soup(headline, field="headline", min_chars=8): - return rej - if len(headline) > _MARKET_BRIEF_HEADLINE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"headline is {len(headline)} chars, over the " - f"{_MARKET_BRIEF_HEADLINE_MAX_CHARS}-char cap" - ), - remediate="shorten the headline", - context_briefing={}, - ) - if rej := cls._reject_market_brief_findings_list(findings): - return rej - if rej := cls._reject_market_brief_text_list(threats, field="threats"): - return rej - if rej := cls._reject_market_brief_text_list( - opportunities, field="opportunities" - ): - return rej - return cls._reject_market_brief_positioning_note(positioning_note) - - @classmethod - def _reject_market_brief_positioning_note(cls, value: str) -> Envelope | None: - """Split out of ``_reject_market_brief_fields`` to keep its own - return-statement count under the xenon/PLR0911 budget. Optional — - empty is clean; only a non-empty value is soup/length-checked.""" - if not value or not value.strip(): - return None - if rej := cls._reject_soup(value, field="positioning_note", min_chars=8): - return rej - if len(value) > _MARKET_BRIEF_POSITIONING_NOTE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"positioning_note is {len(value)} chars, over the " - f"{_MARKET_BRIEF_POSITIONING_NOTE_MAX_CHARS}-char cap" - ), - remediate="shorten positioning_note", - context_briefing={}, - ) - return None - - async def _notify_periscope_brief(self, task: Any, headline: str) -> None: - """Best-effort CEO nudge the moment a market brief lands — ONE call - per cycle (a report, not N queue items), so unlike - ``_notify_pest_hunt_items``/``_notify_roadmap_items`` this fires once, - not per-finding.""" - if self._deps.notification_delivery is None: - return - try: - await self._deps.notification_delivery.notify_ceo_of_periscope_brief( - task=task, task_id=task.id, headline=headline - ) - except Exception as exc: - logger.warning( - "periscope telegram notify failed (best-effort)", error=str(exc) - ) - - @staticmethod - def _reject_quality_report_item_area( - raw: dict[str, Any], idx: int - ) -> Envelope | None: - area = raw.get("area") - if not isinstance(area, str) or area.strip() not in _QUALITY_REPORT_AREAS: - return Envelope.invalid_state( - message=( - f"item {idx} area {area!r} must be one of " - f"{sorted(_QUALITY_REPORT_AREAS)}" - ), - remediate=( - f"set item {idx}'s area to one of {sorted(_QUALITY_REPORT_AREAS)}" - ), - context_briefing={}, - ) - return None - - @classmethod - def _reject_quality_report_item_text_fields( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Validate observation/evidence/suggested_action of one quality- - report item dict — split from ``_reject_quality_report_item`` to - keep its own xenon complexity budget.""" - for field, max_chars in ( - ("observation", _QUALITY_REPORT_ITEM_OBSERVATION_MAX_CHARS), - ("evidence", _QUALITY_REPORT_ITEM_EVIDENCE_MAX_CHARS), - ("suggested_action", _QUALITY_REPORT_ITEM_SUGGESTED_ACTION_MAX_CHARS), - ): - value = raw.get(field) - if not isinstance(value, str) or not value.strip(): - return Envelope.invalid_state( - message=f"item {idx} is missing '{field}'", - remediate=f"provide a substantive '{field}' for item {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup(value, field=f"item {idx} {field}", min_chars=8): - return rej - if len(value) > max_chars: - return Envelope.invalid_state( - message=( - f"item {idx} {field} is {len(value)} chars, over the " - f"{max_chars}-char cap" - ), - remediate=f"shorten item {idx}'s {field}", - context_briefing={}, - ) - return None - - @classmethod - def _reject_quality_report_item(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw quality-report item dict; None when clean. - Mirrors ``_reject_market_brief_finding``.""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"item {idx} is not an object", - remediate=( - "each item needs area/observation/evidence/suggested_action" - ), - context_briefing={}, - ) - if rej := cls._reject_quality_report_item_area(raw, idx): - return rej - return cls._reject_quality_report_item_text_fields(raw, idx) - - @classmethod - def _reject_quality_report_items( - cls, items: list[dict[str, Any]] - ) -> Envelope | None: - """The count cap + per-item validation loop, split out of - ``_reject_quality_report_fields`` to keep its own return-statement - count under the xenon/PLR0911 budget.""" - from roboco.foundation.policy.board_programs import PROGRAMS - - max_items = PROGRAMS["sentinel"].max_items_per_cycle - if not (1 <= len(items) <= max_items): - return Envelope.invalid_state( - message=( - f"a quality report needs 1-{max_items} items, got {len(items)}" - ), - remediate=f"propose between 1 and {max_items} evidence-backed items", - context_briefing={}, - ) - for idx, raw in enumerate(items): - if rej := cls._reject_quality_report_item(raw, idx): - return rej - return None - - @classmethod - def _reject_quality_report_fields( - cls, headline: str, items: list[dict[str, Any]], overall_assessment: str - ) -> Envelope | None: - """Full field validation for ``propose_quality_report``, split out to - keep the verb's own return-statement count under the xenon/PLR0911 - budget.""" - if rej := cls._reject_soup(headline, field="headline", min_chars=8): - return rej - if len(headline) > _QUALITY_REPORT_HEADLINE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"headline is {len(headline)} chars, over the " - f"{_QUALITY_REPORT_HEADLINE_MAX_CHARS}-char cap" - ), - remediate="shorten the headline", - context_briefing={}, - ) - if rej := cls._reject_quality_report_items(items): - return rej - if rej := cls._reject_soup( - overall_assessment, field="overall_assessment", min_chars=8 - ): - return rej - if len(overall_assessment) > _QUALITY_REPORT_OVERALL_ASSESSMENT_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"overall_assessment is {len(overall_assessment)} chars, " - "over the " - f"{_QUALITY_REPORT_OVERALL_ASSESSMENT_MAX_CHARS}-char cap" - ), - remediate="shorten overall_assessment", - context_briefing={}, - ) - return None - - async def propose_quality_report( - self, - *, - agent_id: UUID, - headline: str, - items: list[dict[str, Any]], - overall_assessment: str, - ) -> Envelope: - """Auditor files ONE Sentinel weekly "state of quality" report — - waiver-accumulation trends, conventions-violation hotspots, budget - anomalies — delivered as a held REPORT to the CEO. - - Mirrors ``propose_market_brief``'s complete-at-propose asymmetry: a - report has no per-item CEO decision, so the exploration task - completes in this same call. Unlike ``propose_market_brief`` this is - deliberately NOT screened through - ``injection_guard.screen_external_text`` — every input here is - internal org data (the findings ledger, the conventions table, the - spend tables, the Auditor's own read of the codebase), never - untrusted web/external text, so there is nothing to screen. - """ - role = await self._caller_role(agent_id) - if role not in _SENTINEL_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose a quality report; only " - "the Auditor authors one" - ), - remediate="this verb is Auditor-only", - context_briefing={}, - ) - if rej := self._reject_quality_report_fields( - headline, items, overall_assessment - ): - return rej - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_sentinel_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_quality_report(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open sentinel exploration task assigned to you", - remediate=( - "propose_quality_report only runs against an active " - "exploration cycle spawned by the sentinel engine; wait " - "for the next cycle" - ), - context_briefing={}, - ) - - await self._persist_quality_report( - task, - headline=headline, - items=items, - overall_assessment=overall_assessment, - ) - await self._notify_quality_report(task, headline.strip()) - return Envelope.ok( - status="quality_report_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reads the report in the panel", - context_briefing={ - "headline": headline.strip(), - "item_count": len(items), - }, - ) - - async def _persist_quality_report( - self, - task: Any, - *, - headline: str, - items: list[dict[str, Any]], - overall_assessment: str, - ) -> None: - """Normalize, persist, and complete — split out of - ``propose_quality_report`` to keep its own cyclomatic complexity - under the xenon budget. Complete-at-propose: mirrors - ``_persist_market_brief``.""" - normalized_items = [ - _normalize_quality_report_item(idx, raw) for idx, raw in enumerate(items) - ] - markers.set_quality_report( - task, - { - "headline": headline.strip(), - "items": normalized_items, - "overall_assessment": overall_assessment.strip(), - }, - ) - task.status = TaskStatus.COMPLETED - await self.task.session.flush() - - async def _notify_quality_report(self, task: Any, headline: str) -> None: - """Best-effort CEO nudge the moment a quality report lands — ONE call - per cycle (a report, not N queue items), mirrors - ``_notify_periscope_brief``.""" - if self._deps.notification_delivery is None: - return - try: - await self._deps.notification_delivery.notify_ceo_of_sentinel_report( - task=task, task_id=task.id, headline=headline - ) - except Exception as exc: - logger.warning( - "sentinel telegram notify failed (best-effort)", error=str(exc) - ) - - @classmethod - def _reject_campaign_name(cls, campaign_name: str) -> Envelope | None: - if rej := cls._reject_soup(campaign_name, field="campaign_name", min_chars=3): - return rej - if len(campaign_name) > _CAMPAIGN_NAME_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"campaign_name is {len(campaign_name)} chars, over the " - f"{_CAMPAIGN_NAME_MAX_CHARS}-char cap" - ), - remediate="shorten campaign_name", - context_briefing={}, - ) - return None - - @classmethod - def _reject_campaign_post_body( - cls, raw: dict[str, Any], idx: int - ) -> Envelope | None: - """Split out of ``_reject_campaign_post`` to keep its own - return-statement count under the xenon/PLR0911 budget.""" - body = raw.get("body") - if not isinstance(body, str) or not body.strip(): - return Envelope.invalid_state( - message=f"post {idx} is missing 'body'", - remediate=f"provide the tweet text for post {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup(body, field=f"post {idx} body", min_chars=8): - return rej - if len(body) > MAX_TWEET_CHARS: - return Envelope.invalid_state( - message=( - f"post {idx} body is {len(body)} chars, over the " - f"{MAX_TWEET_CHARS}-char tweet limit" - ), - remediate=( - f"shorten post {idx} to {MAX_TWEET_CHARS} characters or fewer" - ), - context_briefing={}, - ) - return None - - @staticmethod - def _reject_campaign_post_stage(raw: dict[str, Any], idx: int) -> Envelope | None: - stage = raw.get("stage_label") - if not isinstance(stage, str) or stage.strip() not in _CAMPAIGN_STAGE_LABELS: - return Envelope.invalid_state( - message=( - f"post {idx} stage_label must be one of " - f"{sorted(_CAMPAIGN_STAGE_LABELS)}" - ), - remediate=( - f"set post {idx}'s stage_label to one of " - f"{sorted(_CAMPAIGN_STAGE_LABELS)}" - ), - context_briefing={}, - ) - return None - - @staticmethod - def _reject_campaign_post_timing( - raw: dict[str, Any], idx: int, previous: datetime | None - ) -> tuple[Envelope | None, Any]: - """Parse + validate one post's ``publish_after``: a real ISO 8601 - datetime, strictly in the future at propose time, and strictly after - the previous item's (ascending order across the campaign — spec §4's - teaser -> launch -> follow-up -> spotlight arc). Returns - ``(rejection, parsed)`` — a non-None rejection means ``parsed`` is - unusable; the caller threads the clean ``parsed`` value into the NEXT - item's ascending-order check.""" - value = raw.get("publish_after") - if not isinstance(value, str) or not value.strip(): - return ( - Envelope.invalid_state( - message=f"post {idx} is missing 'publish_after'", - remediate=( - f"provide post {idx}'s recommended publish time as an " - "ISO 8601 datetime" - ), - context_briefing={}, - ), - None, - ) - try: - parsed = datetime.fromisoformat(value.strip()) - except ValueError: - return ( - Envelope.invalid_state( - message=( - f"post {idx} publish_after {value!r} is not a valid " - "ISO 8601 datetime" - ), - remediate=( - f"provide post {idx}'s publish_after as an ISO 8601 " - "datetime, e.g. '2026-08-01T09:00:00+00:00'" - ), - context_briefing={}, - ), - None, - ) - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - if parsed <= datetime.now(UTC): - return ( - Envelope.invalid_state( - message=f"post {idx} publish_after {value!r} is not in the future", - remediate=f"post {idx}'s publish_after must be a future timestamp", - context_briefing={}, - ), - None, - ) - if previous is not None and parsed <= previous: - return ( - Envelope.invalid_state( - message=( - f"post {idx} publish_after must be strictly after post " - f"{idx - 1}'s — campaign posts run in ascending order" - ), - remediate=( - f"push post {idx}'s publish_after later than post {idx - 1}'s" - ), - context_briefing={}, - ), - None, - ) - return None, parsed - - @classmethod - def _reject_campaign_post( - cls, raw: Any, idx: int, previous: datetime | None - ) -> tuple[Envelope | None, Any]: - """Validate one raw campaign-post dict; ``(None, parsed_publish_after)`` - when clean.""" - if not isinstance(raw, dict): - return ( - Envelope.invalid_state( - message=f"post {idx} is not an object", - remediate=( - "each post must be an object with body/publish_after/" - "stage_label" - ), - context_briefing={}, - ), - None, - ) - if rej := cls._reject_campaign_post_body(raw, idx): - return rej, None - if rej := cls._reject_campaign_post_stage(raw, idx): - return rej, None - return cls._reject_campaign_post_timing(raw, idx, previous) - - async def propose_campaign( - self, - *, - agent_id: UUID, - campaign_name: str, - posts: list[dict[str, Any]], - ) -> Envelope: - """Head of Marketing authors ONE War Room campaign — an ordered set - of 2-6 held X drafts (teaser -> launch -> follow-up -> spotlight), - each carrying a recommended ``publish_after`` timestamp. - - V1 is manual-cadence (spec, 2026-07-24, pinned by the orchestrating - session): ``publish_after`` is GUIDANCE rendered in the panel queue, - never a schedule anything acts on — the CEO approves each draft at - its own moment, exactly like every other X-queue draft ("nothing - auto-posts" stays absolute; see ``WarRoomEngine``'s module docstring - for the documented auto-schedule ceiling, not built here). Call this - exactly once per cycle: it materializes every post (via - ``XEngine.materialize_campaign_post``) and completes the exploration - task in the same call — mirrors ``propose_market_brief``'s - complete-at-propose shape, batched over N posts. - """ - role = await self._caller_role(agent_id) - if role not in _WAR_ROOM_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose a campaign; only the " - "Head of Marketing authors one" - ), - remediate="this verb is Head-of-Marketing-only", - context_briefing={}, - ) - if rej := self._reject_campaign_name(campaign_name): - return rej - if not (_CAMPAIGN_MIN_POSTS <= len(posts) <= _CAMPAIGN_MAX_POSTS): - return Envelope.invalid_state( - message=( - f"a campaign needs {_CAMPAIGN_MIN_POSTS}-" - f"{_CAMPAIGN_MAX_POSTS} ordered posts, got {len(posts)}" - ), - remediate=( - f"propose between {_CAMPAIGN_MIN_POSTS} and " - f"{_CAMPAIGN_MAX_POSTS} posts" - ), - context_briefing={}, - ) - name = campaign_name.strip() - normalized: list[dict[str, Any]] = [] - previous: datetime | None = None - for idx, raw in enumerate(posts): - rejection, parsed = self._reject_campaign_post(raw, idx, previous) - if rejection is not None: - return rejection - previous = parsed - normalized.append( - { - "body": str(raw["body"]).strip(), - "campaign_name": name, - "stage_label": str(raw["stage_label"]).strip(), - "publish_after": parsed.isoformat(), - "sequence": idx + 1, - } - ) - - from roboco.services.task import get_task_service - from roboco.services.x_engine import get_x_engine - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_war_room_cycles() - task = next((t for t in cycles if t.assigned_to == agent_id), None) - if task is None: - return Envelope.invalid_state( - message="no open war-room exploration task assigned to you", - remediate=( - "propose_campaign only runs against an active exploration " - "cycle spawned by the War Room engine; wait for the next " - "cycle" - ), - context_briefing={}, - ) - engine = get_x_engine(self.task.session) - for item in normalized: - await engine.materialize_campaign_post( - exploration_task=task, - campaign_ref={ - "campaign_name": item["campaign_name"], - "stage_label": item["stage_label"], - "publish_after": item["publish_after"], - "sequence": item["sequence"], - }, - body=item["body"], - ) - task.status = TaskStatus.COMPLETED - await self.task.session.flush() - return Envelope.ok( - status="campaign_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO reviews each post in the X post queue", - context_briefing={"campaign_name": name, "post_count": len(normalized)}, - ) - @classmethod def _reject_caption( cls, value: str, *, field: str, max_chars: int @@ -4341,20 +1534,6 @@ class ContentActions: statement count under the xenon/PLR0911 budget).""" if rej := cls._reject_soup(composition_id, field="composition_id", min_chars=2): return rej - # Mirror the video-renderer sidecar's charset rule so an unrenderable - # id is refused at authoring time, not at render time days later. - if not _COMPOSITION_ID_RE.fullmatch(composition_id.strip()): - return Envelope.invalid_state( - message=( - f"composition_id {composition_id!r} is not renderable — " - "letters, digits, '_' or '-' with optional interior dots" - ), - remediate=( - "rename the composition dir to match (e.g. " - "'release-0-25-0' or 'release-0.25.0') and call " - "propose_video again with that id" - ), - ) if rej := cls._reject_caption( x_caption, field="x_caption", max_chars=MAX_TWEET_CHARS ): @@ -4452,665 +1631,6 @@ class ContentActions: }, ) - @classmethod - def _reject_postmortem_text_fields( - cls, incident_summary: str, root_cause: str - ) -> Envelope | None: - """Soup + length caps on the postmortem's two free-text narrative - fields, folded into one caller-side check (xenon/PLR0911 budget — - mirrors ``_reject_feature_spotlight_fields``).""" - if rej := cls._reject_soup( - incident_summary, field="incident_summary", min_chars=20 - ): - return rej - if len(incident_summary) > _CORONER_INCIDENT_SUMMARY_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"incident_summary is {len(incident_summary)} chars, over " - f"the {_CORONER_INCIDENT_SUMMARY_MAX_CHARS}-char limit" - ), - remediate="shorten incident_summary", - context_briefing={}, - ) - if rej := cls._reject_soup(root_cause, field="root_cause", min_chars=20): - return rej - if len(root_cause) > _CORONER_ROOT_CAUSE_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"root_cause is {len(root_cause)} chars, over the " - f"{_CORONER_ROOT_CAUSE_MAX_CHARS}-char limit" - ), - remediate="shorten root_cause", - context_briefing={}, - ) - return None - - @staticmethod - def _reject_postmortem_failed_stage(failed_stage: str) -> Envelope | None: - """``failed_stage`` must be a real lifecycle status — never a made-up - label — so it stays comparable across postmortems.""" - from roboco.models.base import TaskStatus - - valid = {s.value for s in TaskStatus} - if failed_stage not in valid: - return Envelope.invalid_state( - message=f"failed_stage {failed_stage!r} is not a real task status", - remediate=f"pass one of: {sorted(valid)}", - context_briefing={}, - ) - return None - - @classmethod - def _reject_postmortem_process_change(cls, process_change: Any) -> Envelope | None: - if not isinstance(process_change, dict): - return Envelope.invalid_state( - message="process_change must be an object", - remediate="pass process_change={'kind': ..., 'description': ...}", - context_briefing={}, - ) - kind = process_change.get("kind") - if kind not in _CORONER_PROCESS_CHANGE_KINDS: - return Envelope.invalid_state( - message=f"process_change.kind {kind!r} is invalid", - remediate=( - f"kind must be one of {sorted(_CORONER_PROCESS_CHANGE_KINDS)}" - ), - context_briefing={}, - ) - description = str(process_change.get("description", "")) - if rej := cls._reject_soup( - description, field="process_change.description", min_chars=15 - ): - return rej - if len(description) > _CORONER_PROCESS_CHANGE_DESC_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"process_change.description is {len(description)} chars, " - f"over the {_CORONER_PROCESS_CHANGE_DESC_MAX_CHARS}-char limit" - ), - remediate="shorten process_change.description", - context_briefing={}, - ) - return None - - @classmethod - def _reject_postmortem_playbook( - cls, process_change: dict[str, Any], playbook: dict[str, Any] | None - ) -> Envelope | None: - """``playbook`` is required iff ``process_change.kind == 'playbook'`` - (spec §4) — never optional-but-ignored on that kind, never demanded - on any other.""" - if process_change.get("kind") != "playbook": - return None - if not isinstance(playbook, dict): - return Envelope.invalid_state( - message="process_change.kind='playbook' requires a playbook", - remediate="pass playbook={'title': ..., 'body': ...}", - context_briefing={}, - ) - if rej := cls._reject_soup( - str(playbook.get("title", "")), field="playbook.title", min_chars=5 - ): - return rej - return cls._reject_soup( - str(playbook.get("body", "")), field="playbook.body", min_chars=20 - ) - - @classmethod - def _reject_postmortem( - cls, - incident_summary: str, - root_cause: str, - failed_stage: str, - process_change: Any, - playbook: dict[str, Any] | None, - ) -> Envelope | None: - if rej := cls._reject_postmortem_text_fields(incident_summary, root_cause): - return rej - if rej := cls._reject_postmortem_failed_stage(failed_stage): - return rej - if rej := cls._reject_postmortem_process_change(process_change): - return rej - return cls._reject_postmortem_playbook(process_change, playbook) - - async def _draft_coroner_playbook( - self, agent_id: UUID, incident_summary: str, playbook: dict[str, Any] - ) -> tuple[str | None, Envelope | None]: - """Create the process-change playbook DRAFT directly via - ``PlaybookService`` — not the ``draft_playbook`` do-verb, since this - call already runs inside the Auditor-only ``propose_postmortem`` gate. - The Auditor also carries ``draft_playbook`` on its manifest - (role_config.py) so a coroner-authored draft is indistinguishable - from any other in the pending-playbook curation queue — reviewed and - approved/rejected there same as any delivery-role draft, never - self-approved in this same call. Returns (playbook_id, None) on - success or (None, rejection_envelope) on a title conflict — checked - BEFORE any postmortem-task mutation so a conflict is a clean, - retryable rejection, not a half-completed autopsy.""" - from roboco.models.playbook import PlaybookCreate - from roboco.services.base import ConflictError - from roboco.services.playbook import get_playbook_service - - try: - drafted = await get_playbook_service(self.task.session).draft( - PlaybookCreate( - title=str(playbook["title"]).strip(), - problem=incident_summary.strip(), - procedure=str(playbook["body"]).strip(), - tags=["coroner", "postmortem"], - ), - created_by=agent_id, - ) - except ConflictError as exc: - return None, Envelope.invalid_state( - message=str(exc), - remediate="use a more distinct playbook title (slug must be unique)", - context_briefing={}, - ) - return str(drafted.id), None - - @classmethod - def _reject_playbook_draft_item(cls, raw: Any, idx: int) -> Envelope | None: - """Validate one raw playbook-draft dict; None when clean. Mirrors - ``_reject_quality_report_item_text_fields``'s loop-over-fields shape.""" - if not isinstance(raw, dict): - return Envelope.invalid_state( - message=f"draft {idx} is not an object", - remediate="each draft needs title/body/pattern_evidence", - context_briefing={}, - ) - for field, min_chars, max_chars in ( - ("title", 5, _PLAYBOOK_DRAFT_TITLE_MAX_CHARS), - ("body", 20, _PLAYBOOK_DRAFT_BODY_MAX_CHARS), - ("pattern_evidence", 15, _PLAYBOOK_DRAFT_PATTERN_EVIDENCE_MAX_CHARS), - ): - value = raw.get(field) - if not isinstance(value, str) or not value.strip(): - return Envelope.invalid_state( - message=f"draft {idx} is missing '{field}'", - remediate=f"provide a substantive '{field}' for draft {idx}", - context_briefing={}, - ) - if rej := cls._reject_soup( - value, field=f"draft {idx} {field}", min_chars=min_chars - ): - return rej - if len(value) > max_chars: - return Envelope.invalid_state( - message=( - f"draft {idx} {field} is {len(value)} chars, over the " - f"{max_chars}-char cap" - ), - remediate=f"shorten draft {idx}'s {field}", - context_briefing={}, - ) - return None - - @staticmethod - def _reject_playbook_draft_duplicate_titles( - drafts: list[dict[str, Any]], - ) -> Envelope | None: - """Case-insensitive dedup WITHIN this batch — split out so the - title-conflict message is distinct from the per-item field check.""" - seen: set[str] = set() - for idx, raw in enumerate(drafts): - title = str(raw.get("title", "")).strip().lower() - if title in seen: - return Envelope.invalid_state( - message=f"draft {idx} title duplicates another draft in this batch", - remediate="give each draft a distinct title", - context_briefing={}, - ) - seen.add(title) - return None - - @classmethod - def _reject_playbook_drafts_batch( - cls, drafts: list[dict[str, Any]] - ) -> Envelope | None: - """The count cap + per-draft validation + in-batch dedup, split out - of ``propose_playbook_drafts`` to keep its own return-statement - count under the xenon/PLR0911 budget.""" - from roboco.foundation.policy.board_programs import PROGRAMS - - max_drafts = PROGRAMS["librarian"].max_items_per_cycle - if not (1 <= len(drafts) <= max_drafts): - return Envelope.invalid_state( - message=f"propose 1-{max_drafts} playbook drafts, got {len(drafts)}", - remediate=f"propose between 1 and {max_drafts} drafts", - context_briefing={}, - ) - for idx, raw in enumerate(drafts): - if rej := cls._reject_playbook_draft_item(raw, idx): - return rej - return cls._reject_playbook_draft_duplicate_titles(drafts) - - async def _reject_playbook_drafts_existing_titles( - self, drafts: list[dict[str, Any]] - ) -> Envelope | None: - """Live, unbounded case-insensitive dedup against every non-archived - playbook already in the store — the mining prompt only shows the - most-recent 20 as a hint, so this re-checks fresh at propose time - rather than trusting what the Auditor read minutes ago.""" - from roboco.services.librarian_engine import get_librarian_engine - - existing = await get_librarian_engine( - self.task.session - ).existing_playbook_titles_lower() - for idx, raw in enumerate(drafts): - title = str(raw["title"]).strip() - if title.lower() in existing: - return Envelope.invalid_state( - message=( - f"draft {idx} title {title!r} duplicates an existing playbook" - ), - remediate=( - "give this draft a distinct title, or drop it — a playbook " - "for this pattern may already exist" - ), - context_briefing={}, - ) - return None - - async def _draft_librarian_playbooks( - self, agent_id: UUID, drafts: list[dict[str, Any]] - ) -> tuple[list[dict[str, str]], Envelope | None]: - """Create each validated draft as a real DRAFT playbook via - ``PlaybookService.draft()`` directly — the Coroner precedent - (``_draft_coroner_playbook`` above), never the ``draft_playbook`` - do-verb, so "auditor curates but does not draft" stays true at the - do-verb surface even though the Auditor originates these drafts. - - A per-item ``ConflictError`` (a genuine same-tick race — the live - pre-check in ``_reject_playbook_drafts_existing_titles`` already - closed the realistic window) aborts the rest of the batch with a - clean rejection. - ponytail: no rollback of earlier successes in this loop — any draft - already created before the conflict stays a real, independently - valid playbook riding the normal curation queue (just not - cross-referenced on this particular report); this is the same - residual race Coroner already accepts, and Librarian's own - single-open-cycle dedup makes a same-cycle collision vanishingly - rare in practice. - """ - from roboco.models.playbook import PlaybookCreate - from roboco.services.base import ConflictError - from roboco.services.playbook import get_playbook_service - - svc = get_playbook_service(self.task.session) - created: list[dict[str, str]] = [] - for idx, raw in enumerate(drafts): - try: - drafted = await svc.draft( - PlaybookCreate( - title=str(raw["title"]).strip(), - problem=str(raw["pattern_evidence"]).strip(), - procedure=str(raw["body"]).strip(), - tags=["librarian", "auto-authored"], - ), - created_by=agent_id, - ) - except ConflictError as exc: - return created, Envelope.invalid_state( - message=f"draft {idx}: {exc}", - remediate="use a more distinct title (slug must be unique)", - context_briefing={"drafted_before_conflict": len(created)}, - ) - created.append({"id": str(drafted.id), "title": drafted.title}) - return created, None - - async def propose_playbook_drafts( - self, *, agent_id: UUID, drafts: list[dict[str, Any]] - ) -> Envelope: - """Auditor mines journals/learnings for repeated patterns and drafts - 1-3 playbooks on its open Librarian cycle task, completing it in the - same call (mirrors ``propose_market_brief``/``propose_quality_report``'s - complete-at-propose asymmetry — a mining cycle has no per-item CEO - decision to wait on). - - Each draft is created via ``PlaybookService.draft()`` DIRECTLY — the - same Coroner precedent ``_draft_coroner_playbook`` established: the - Auditor does NOT also carry ``draft_playbook`` on its manifest - (``role_config.py``, ``test_playbook_verbs.py``'s "auditor curates - but does not draft" invariant), so a Librarian-authored draft reaches - the pending-playbook curation queue through this direct service - call, never the do-verb every delivery role uses. A LATER Auditor - spawn curates them — a deliberate, documented self-curation - asymmetry (see ``agents/prompts/identities/auditor.md``). - """ - role = await self._caller_role(agent_id) - if role not in _LIBRARIAN_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose playbook drafts; only the " - "Auditor mines for patterns" - ), - remediate="this verb is Auditor-only", - context_briefing={}, - ) - if rej := self._reject_playbook_drafts_batch(drafts): - return rej - - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_librarian_cycles() - task = next( - ( - t - for t in cycles - if t.assigned_to == agent_id and markers.get_playbook_drafts(t) is None - ), - None, - ) - if task is None: - return Envelope.invalid_state( - message="no open Librarian mining task assigned to you", - remediate=( - "propose_playbook_drafts only runs against an active mining " - "cycle spawned by the librarian engine; wait for the next cycle" - ), - context_briefing={}, - ) - - if rej := await self._reject_playbook_drafts_existing_titles(drafts): - return rej - - created, rej = await self._draft_librarian_playbooks(agent_id, drafts) - if rej is not None: - return rej - - markers.set_playbook_drafts(task, {"drafts": created}) - task.status = TaskStatus.COMPLETED - await self.task.session.flush() - await self._notify_librarian_drafts(task, created) - return Envelope.ok( - status="playbook_drafts_proposed", - task_id=str(task.id), - next=( - "i_am_idle() — the drafts ride the normal pending-playbook " - "curation queue" - ), - context_briefing={"draft_count": len(created)}, - ) - - async def _notify_librarian_drafts( - self, task: Any, created: list[dict[str, str]] - ) -> None: - """Best-effort CEO nudge the moment Librarian mines its drafts — - mirrors ``_notify_postmortem``.""" - if self._deps.notification_delivery is None: - return - try: - await self._deps.notification_delivery.notify_ceo_of_librarian_drafts( - task=task, - task_id=task.id, - titles=[d["title"] for d in created], - ) - except Exception as exc: - logger.warning( - "librarian telegram notify failed (best-effort)", error=str(exc) - ) - - async def propose_postmortem( - self, - *, - agent_id: UUID, - incident_summary: str, - root_cause: str, - failed_stage: str, - process_change: dict[str, Any], - playbook: dict[str, Any] | None = None, - ) -> Envelope: - """Auditor authors ONE Coroner postmortem on its open autopsy task, - completing it in the same call — no per-item CEO queue (spec §4: - a report, not a list of items the CEO decides one by one). Call - exactly once per autopsy cycle. - """ - role = await self._caller_role(agent_id) - if role not in _CORONER_ROLES: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot propose a postmortem; only the " - "Auditor authors one" - ), - remediate="this verb is Auditor-only", - context_briefing={}, - ) - if rej := self._reject_postmortem( - incident_summary, root_cause, failed_stage, process_change, playbook - ): - return rej - - from roboco.services.coroner_engine import get_coroner_engine - from roboco.services.task import get_task_service - - task_svc = get_task_service(self.task.session) - cycles = await task_svc.list_open_coroner_cycles() - task = next((t for t in cycles if t.assigned_to == agent_id), None) - if task is None: - return Envelope.invalid_state( - message="no open Coroner autopsy task assigned to you", - remediate=( - "propose_postmortem only runs against an active autopsy " - "spawned by the coroner engine; wait for the next incident" - ), - context_briefing={}, - ) - - playbook_id: str | None = None - if process_change["kind"] == "playbook": - playbook_id, rej = await self._draft_coroner_playbook( - agent_id, incident_summary, playbook or {} - ) - if rej is not None: - return rej - - engine = get_coroner_engine(self.task.session) - await engine.complete_with_postmortem( - task, - { - "incident_summary": incident_summary.strip(), - "root_cause": root_cause.strip(), - "failed_stage": failed_stage, - "process_change": { - "kind": process_change["kind"], - "description": str(process_change["description"]).strip(), - # A "playbook" kind already routed straight into the - # playbook curation queue above — nothing left for the - # CEO to decide on THIS process change, so it never - # enters the proposed/approved/rejected per-item flow - # (CoronerService.approve_process_change/ - # reject_process_change refuse it outright). - "status": ( - "not_applicable" - if process_change["kind"] == "playbook" - else "proposed" - ), - "reject_reason": None, - "materialized_task_id": None, - }, - "playbook_id": playbook_id, - }, - ) - await self._notify_postmortem(task, incident_summary, process_change["kind"]) - return Envelope.ok( - status="postmortem_proposed", - task_id=str(task.id), - next="i_am_idle() — the CEO is notified; no per-item decision needed", - context_briefing={ - "failed_stage": failed_stage, - "process_change_kind": process_change["kind"], - "playbook_id": playbook_id, - }, - ) - - async def _notify_postmortem( - self, task: Any, incident_summary: str, process_change_kind: str - ) -> None: - """Best-effort push notification the moment a postmortem lands — - mirrors ``_notify_pest_hunt_items``.""" - if self._deps.notification_delivery is None: - return - try: - await self._deps.notification_delivery.notify_ceo_of_postmortem( - task=task, - task_id=task.id, - incident_summary=incident_summary, - process_change_kind=process_change_kind, - ) - except Exception as exc: - logger.warning( - "coroner postmortem telegram notify failed (best-effort)", - error=str(exc), - ) - - async def _resolve_nothing_to_propose_task( - self, agent_id: UUID, task_id: UUID - ) -> Envelope | tuple[Any, BoardProgram]: - """Resolve + validate ``task_id`` for ``nothing_to_propose``: exists, - its ``source`` is a registered Board Program, it is assigned to the - caller, it is non-terminal, and the caller's role matches that - program's declared explorer role. Returns the first failing check's - envelope, else ``(task, program)`` — split out of the verb itself - purely to keep its own return count under the lint ceiling. - """ - from roboco.foundation.policy.board_programs import PROGRAMS - - task = await self.task.get(task_id) - if task is None: - return Envelope.not_found(message=f"task {task_id} not found") - program = next((p for p in PROGRAMS.values() if p.source == task.source), None) - if program is None: - return Envelope.invalid_state( - message=( - f"task {task_id} source {task.source!r} is not a registered " - "Board Program" - ), - remediate="this task is not a Board Program exploration cycle", - context_briefing={}, - ) - if task.assigned_to != agent_id: - return Envelope.not_authorized( - message=f"task {task_id} is not assigned to you", - remediate=( - "nothing_to_propose only completes the caller's own " - "exploration task — pass the task_id printed as " - "'TASK: ' at the top of your prompt" - ), - context_briefing={}, - ) - if task.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED): - return Envelope.invalid_state( - message=f"task {task_id} is already {task.status.value}", - remediate="this exploration cycle is already closed; nothing to do", - context_briefing={}, - ) - role = await self._caller_role(agent_id) - if role != program.role: - return Envelope.not_authorized( - message=( - f"role {role!r} cannot resolve {program.key!r}'s exploration " - f"task; only {program.role!r} does" - ), - remediate=( - f"nothing_to_propose only completes a {program.role} " - "explorer's own cycle" - ), - context_briefing={}, - ) - return task, program - - async def nothing_to_propose( - self, - *, - agent_id: UUID, - task_id: UUID, - reason: str, - ) -> Envelope: - """The explicit "this cycle found nothing worth proposing" exit for - ANY Board Program exploration task, named explicitly by ``task_id``. - - Every ``propose_*`` verb requires at least one item (or a single - substantive report), so an explorer that legitimately has nothing — - Barfly found no worthwhile X conversations, Coroner has no autopsy - subject worth a process change — had no way to complete its task; it - correctly declined and called ``i_am_idle()``, leaving the task - PENDING forever: a permanent, expensive respawn loop, and (worse) - ``BoardProgramEngine``'s one-open-cycle dedup wedges that whole - program shut, since the ledger row never closes. - - ``task_id`` is REQUIRED, not inferred: one explorer role owns several - independently-cadenced programs at once (e.g. head_marketing owns - x_feature/periscope/mirror/megaphone/war_room/barfly), each assigning - its own exploration task to the same agent — so several of one - agent's exploration tasks can be open simultaneously by design, and - guessing (e.g. "the oldest one") completes the WRONG cycle and - stamps its LEARN reason onto the wrong ledger row. The exploration - prompt always prints "TASK: ", so the caller has it in hand - (mirrors ``curate_vault``'s explicit ``task_id`` convention). See - ``_resolve_nothing_to_propose_task`` for the resolution + validation - (exists, registered program, assigned to caller, non-terminal, role - matches) — registry-driven, not a hardcoded role set, so a program - registered later needs no edit here. - - Completes the task (mirrors ``propose_conversation_replies``'/ - ``propose_postmortem``'s complete-at-propose pattern) and records the - reason onto the LEARN ledger row so the next cycle's exploration - prompt sees WHY, not just a bare "proposed 0, approved 0". - """ - if rej := self._reject_soup( - reason, - field="reason", - min_chars=_NOTHING_TO_PROPOSE_REASON_MIN_CHARS, - ): - return rej - if len(reason) > _NOTHING_TO_PROPOSE_REASON_MAX_CHARS: - return Envelope.invalid_state( - message=( - f"reason is {len(reason)} chars, over the " - f"{_NOTHING_TO_PROPOSE_REASON_MAX_CHARS}-char cap" - ), - remediate="shorten the reason", - context_briefing={}, - ) - - resolved = await self._resolve_nothing_to_propose_task(agent_id, task_id) - if isinstance(resolved, Envelope): - return resolved - task, program = resolved - - reason = reason.strip() - task.status = TaskStatus.COMPLETED - await self.task.session.flush() - - from roboco.services.board_programs import get_board_program_engine - - try: - # Isolated in its own savepoint: record_nothing_to_propose does - # its own flush() on this SAME session, and a bare try/except - # around a same-session flush is not enough — a genuine DB - # failure there leaves the session pending-rollback, so the - # completion flushed just above would be silently discarded at - # the outer commit despite this except swallowing the error. - async with self.task.session.begin_nested(): - await get_board_program_engine( - self.task.session - ).record_nothing_to_propose(program.key, cast("UUID", task.id), reason) - except Exception: - logger.warning( - "nothing_to_propose: LEARN record failed (best-effort)", - program=program.key, - task_id=str(task.id), - ) - - return Envelope.ok( - status="nothing_to_propose", - task_id=str(task.id), - next="i_am_idle()", - context_briefing={"program": program.key, "reason": reason}, - ) - async def dm( self, *, @@ -5123,10 +1643,10 @@ class ContentActions: """A2A direct message. Requires task_id (active or explicit).""" if rej := self._reject_soup(text, field="message", min_chars=2): return rej - # Spec §5.5: no-comms roles — defense-in-depth runtime guard. dm() is - # the channel through which a no-comms role could "speak"; covers the - # human-only prompter/secretary (own dedicated chat pages, no agent - # A2A surface at all). + # Spec §5.5: silent / no-comms roles — defense-in-depth runtime guard. + # Defense-in-depth: dm() is the channel through which a no-comms role + # could "speak". Covers auditor, + # pr_reviewer, and the human-only prompter / secretary. agent = await self.task.agent_for(agent_id) caller_role = str(agent.role) if agent is not None else "" if caller_role in _NO_COMMS_ROLES: @@ -5135,10 +1655,7 @@ class ContentActions: f"role '{caller_role}' is a silent / no-comms role;" " dm is not permitted" ), - remediate=( - "use note() to record; this human-only role has no" - " agent-comms surface" - ), + remediate=_no_comms_remediate(caller_role), context_briefing={}, ) @@ -5358,6 +1875,42 @@ class ContentActions: assigned = await self.task.list_assigned_for_agent(agent_id) return any(task.id in (a.dependency_ids or []) for a in assigned) + async def _evidence_db_reads( + self, task_id: UUID + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[Any]]: + """The three DB reads behind evidence(), run sequentially as ONE + coroutine. + + They stay sequential AMONG THEMSELVES on purpose: they all read + through the request-scoped ``self.evidence_repo`` / ``self.task. + session``, a single ``AsyncSession`` bound to one DBAPI connection — + asyncpg cannot serve two in-flight queries on the same connection at + once (confirmed against a live Postgres while building this fix: + SQLAlchemy raises ``IllegalStateChangeError: ... concurrent + operations are not permitted``; opening a second ad-hoc session per + read would also silently diverge from the request's transaction and + break every existing test that injects a mocked ``evidence_repo``). + Bundling them as one coroutine lets ``evidence()`` still + ``asyncio.gather`` this WHOLE batch against the independent git + diff/fetch — the actual "parallelize the independent reads" win, + without the same-connection hazard. Mirrors + ``qa.QAMixin._qa_db_reads``. + """ + t0 = time.monotonic() + journal_highlights = await self.evidence_repo.journal_highlights_for_task( + task_id, include_ancestors=True + ) + parent_context = await self.evidence_repo.ancestor_context_for_task(task_id) + open_findings = await findings_lib.open_findings_for_task( + self.task.session, task_id + ) + logger.info( + "evidence db reads timing", + task_id=str(task_id), + db_reads_ms=round((time.monotonic() - t0) * 1000.0), + ) + return journal_highlights, parent_context, open_findings + async def evidence( self, *, @@ -5374,7 +1927,12 @@ class ContentActions: ``files_changed`` and ``pr_diff_summary`` are pulled from git (against the branch's parent — the authoritative source) rather than the latest - commit's delta, so reviewers see the full multi-commit change set. + commit's delta, so reviewers see the full multi-commit change set. Both + come from the single combined ``git.diff_and_files`` call (one + workspace/base resolution instead of two) and the assembly work is + bounded by ``settings.evidence_assembly_timeout_seconds`` — well under + the outer gateway verb budget — so a genuinely slow git fetch or DB + read returns a structured, named timeout instead of a bare rollback. """ t = await self.task.get(task_id) if t is None: @@ -5394,22 +1952,45 @@ class ContentActions: await self.workspace.fetch_branch_for_inspection( agent_id=agent_id, branch_name=t.branch_name ) - diff = "" - files_changed: list[str] = [] - if t.branch_name: - diff = await self.git.diff( + timeout = settings.evidence_assembly_timeout_seconds + + async def _git_diff() -> tuple[str, list[str]]: + if not t.branch_name: + return "", [] + git_t0 = time.monotonic() + diff_text, files = await self.git.diff_and_files( branch_name=t.branch_name, actor_agent_id=agent_id ) - files_changed = await self.git.list_changed_files( - branch_name=t.branch_name, actor_agent_id=agent_id + logger.info( + "evidence git diff/fetch timing", + task_id=str(task_id), + git_diff_and_fetch_ms=round((time.monotonic() - git_t0) * 1000.0), + ) + return diff_text, files + + try: + ( + (diff, files_changed), + ( + journal_highlights, + parent_context, + open_findings, + ), + ) = await asyncio.wait_for( + asyncio.gather(_git_diff(), self._evidence_db_reads(task_id)), + timeout=timeout, + ) + except TimeoutError: + return Envelope.gateway_timeout( + component="git diff/fetch or a journal/ancestor/findings DB read", + timeout_seconds=timeout, + remediate=( + "retry evidence(task_id); a persistent timeout means the " + "underlying git fetch or DB read is genuinely slow — " + "escalate to your PM" + ), + context_briefing={}, ) - journal_highlights = await self.evidence_repo.journal_highlights_for_task( - task_id, include_ancestors=True - ) - parent_context = await self.evidence_repo.ancestor_context_for_task(task_id) - open_findings = await findings_lib.open_findings_for_task( - self.task.session, task_id - ) ev = build_evidence_for_task( t, journal_highlights=journal_highlights, @@ -5470,149 +2051,29 @@ class ContentActions: ) return requested, None - @staticmethod - def _validate_per_call_extensions( - extensions: dict[str, list[str]] | None, - opted: frozenset[str], - merged: dict[str, set[str]], - ) -> Envelope | None: - """Allowlist-validate the per-call extension map and union it into - ``merged``; return a clean invalid_state rejection or None. - - Extracted from ``_sandbox_features_scope`` so its loop's two rejection - branches don't push the caller over the complexity bound. A feature - for a non-opted service or outside the service's allowlist is rejected - naming the allowed set — the containment that keeps a ``plpython3u`` - from reaching the provisioner. - """ - from roboco.models.sandbox import SANDBOX_ENGINE_FEATURES - - for svc, feats in (extensions or {}).items(): - if svc not in opted: - return Envelope.invalid_state( - message=( - f"extensions given for {svc!r}, which this project has " - f"not opted into" - ), - remediate=( - f"this project's opted-in set is {sorted(opted)} — " - "request extensions only for those services" - ), - context_briefing={}, - ) - allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset()) - bad = sorted(set(feats or []) - allowed) - if bad: - return Envelope.invalid_state( - message=f"unallowed {svc} extension(s) {bad}", - remediate=( - f"the allowlist for {svc} is {sorted(allowed)} — " - "request a subset; plpython3u and other superuser-" - "language extensions are excluded by construction" - ), - context_briefing={}, - ) - merged.setdefault(svc, set()).update(feats or []) - return None - - @staticmethod - def _sandbox_features_scope( - project: Any, - extensions: dict[str, list[str]] | None, - opted: frozenset[str], - ) -> tuple[dict[str, list[str]], Envelope | None]: - """request_sandbox's extension guard: the per-service feature map to - activate (project standing union per-call, bounded by the opted set + - the allowlist), or a clean invalid_state rejection. - - Per-call ``extensions`` is allowlist-validated HERE (not only at the - provisioner) so a ``plpython3u`` gets a remediate naming the allowed - set, mirroring the unknown-service remediate. The project's standing - ``sandbox_extensions`` was allowlist-validated at write time, so it is - trusted and unioned in; entries for a service no longer opted into are - dropped (a venture may deactivate a service without clearing its - standing extensions). Returns only services with a non-empty feature - list — a service with no features is bare (the provisioner's default). - """ - standing = (project.sandbox_extensions if project else None) or {} - # Union per service: standing (trusted) + per-call (validated below). - merged: dict[str, set[str]] = {} - for svc, feats in standing.items(): - if svc in opted: - merged.setdefault(svc, set()).update(feats or []) - rejection = ContentActions._validate_per_call_extensions( - extensions, opted, merged - ) - if rejection is not None: - return {}, rejection - return {svc: sorted(f) for svc, f in merged.items() if f}, None - - async def _sandbox_provision_or_reject( - self, - agent_slug: str, - requested: frozenset[str], - opted: frozenset[str], - features: dict[str, list[str]] | None, - task_id: UUID, - ) -> tuple[Any, Envelope | None]: - """Run ``ensure_sandbox`` for request_sandbox; return (info, None) on - success or (None, rejection) when the orchestrator handle is missing - or provisioning raises. Extracted so ``request_sandbox``'s - orchestrator-None guard + try/except don't push it over the - complexity bound. Heartbeats the caller's task on success. - """ - if self.orchestrator is None: - return None, Envelope.invalid_state( - message="orchestrator handle unavailable — cannot provision a sandbox", - remediate=( - "retry request_sandbox shortly; the orchestrator may be restarting" - ), - context_briefing={}, - ) - from roboco.runtime.sandbox import SandboxProvisionError - - try: - info = await self.orchestrator.ensure_sandbox( - agent_slug, sorted(requested), sorted(opted), features=features or None - ) - except SandboxProvisionError as e: - return None, Envelope.invalid_state( - message=f"sandbox provisioning failed: {e}", - remediate="retry shortly; escalate to your PM if it keeps failing", - context_briefing={}, - ) - await self._touch_heartbeat(task_id) - return info, None - async def request_sandbox( self, *, agent_id: UUID, services: list[str] | None = None, - extensions: dict[str, list[str]] | None = None, ) -> Envelope: """On-demand sandbox DB/Redis/Mongo (dev + QA only, see role_config). Replaces eager per-spawn provisioning: a sandbox is created only when an agent actually asks for one, keyed off the CALLER's authenticated slug (never another agent's). ``services`` omitted means the - project's whole opted-in set. ``extensions`` (per-service - extensions/modules, e.g. ``{"postgres": ["vector"]}``) is an additive - per-call override unioned with the project's standing - ``sandbox_extensions`` and bounded by the opted set + the allowlist — - a ``plpython3u`` is rejected here with the allowed set named. + project's whole opted-in set. Guards, in order: flag off; caller has no claimed/active, project-bound task (`_sandbox_active_task`); project not opted into any sandbox service, or a requested service outside its opted set - (`_sandbox_scope`, names the allowed set); per-call extensions for a - non-opted service or outside the allowlist (`_sandbox_features_scope`, - names the allowed set); orchestrator handle unavailable (retryable). - `ensure_sandbox` always provisions the project's whole opted-in set - regardless of ``services`` (so a later call can never trigger a - mid-session teardown of a live container); the evidence payload here - is filtered back down to what THIS call asked for. Creds come back in - the evidence payload, never as injected env — see + (`_sandbox_scope`, names the allowed set); orchestrator handle + unavailable (retryable). `ensure_sandbox` always provisions the + project's whole opted-in set regardless of ``services`` (so a later + call can never trigger a mid-session teardown of a live container); + the evidence payload here is filtered back down to what THIS call + asked for. Creds come back in the evidence payload, never as + injected env — see ``docs/internal/specs/2026-07-08-sandbox-on-demand.md`` §4. """ if not settings.sandbox_db_enabled: @@ -5631,24 +2092,33 @@ class ContentActions: from roboco.services.project import get_project_service project = await get_project_service(self.task.session).get(t.project_id) - requested, rej_scope = self._sandbox_scope(project, services) - opted = frozenset(project.sandbox_services or []) if project else frozenset() - features, rej_features = self._sandbox_features_scope( - project, extensions, opted - ) - # Scope before features: an unknown-service rejection wins over a - # per-call extension rejection for the same call. - rejection = rej_scope or rej_features + requested, rejection = self._sandbox_scope(project, services) if rejection is not None: return rejection + opted = frozenset(project.sandbox_services or []) if project else frozenset() + if self.orchestrator is None: + return Envelope.invalid_state( + message="orchestrator handle unavailable — cannot provision a sandbox", + remediate=( + "retry request_sandbox shortly; the orchestrator may be restarting" + ), + context_briefing={}, + ) from roboco.agents_config import _resolve_to_slug + from roboco.runtime.sandbox import SandboxProvisionError agent_slug = _resolve_to_slug(str(agent_id)) - info, rej = await self._sandbox_provision_or_reject( - agent_slug, requested, opted, features, t.id - ) - if rej is not None: - return rej + try: + info = await self.orchestrator.ensure_sandbox( + agent_slug, sorted(requested), sorted(opted) + ) + except SandboxProvisionError as e: + return Envelope.invalid_state( + message=f"sandbox provisioning failed: {e}", + remediate="retry shortly; escalate to your PM if it keeps failing", + context_briefing={}, + ) + await self._touch_heartbeat(t.id) # ensure_sandbox provisions the project's whole opted-in set (see its # docstring); the evidence payload stays scoped to what THIS call # asked for. @@ -5664,452 +2134,6 @@ class ContentActions: context_briefing={}, ) - async def _render_active_video_task( - self, agent_id: UUID - ) -> tuple[Any, Envelope | None]: - """request_render's task guard: an active, project-bound video- - authoring task, or a clean invalid_state rejection.""" - from roboco.services.task import VIDEO_SOURCE - - t = await self.task.get_active_task_for_agent(agent_id) - if t is None or t.project_id is None or t.source != VIDEO_SOURCE: - return None, Envelope.invalid_state( - message="no active video-authoring task assigned to you", - remediate=( - "request_render is only available on a video-authoring " - "task — claim your assigned authoring task first" - ), - context_briefing={}, - ) - return t, None - - @staticmethod - def _render_resolve_composition_id( - task: Any, composition_id: str | None - ) -> tuple[str, Envelope | None]: - """Explicit ``composition_id``, else the task's ``video_draft`` - marker's, validated with the SAME charset regex ``propose_video`` - enforces so an unrenderable id is refused here, not deep inside the - sidecar call.""" - resolved = composition_id or (markers.get_video_draft(task) or {}).get( - "composition_id" - ) - if not resolved or not str(resolved).strip(): - return "", Envelope.incomplete_input( - missing=["composition_id"], - field_hints={"composition_id": "the HyperFrames composition id"}, - remediate=( - "pass composition_id explicitly, or call propose_video " - "first so it's on the task's video_draft marker" - ), - context_briefing={}, - ) - resolved = str(resolved).strip() - if not _COMPOSITION_ID_RE.fullmatch(resolved): - return "", Envelope.invalid_state( - message=f"composition_id {resolved!r} is not renderable", - remediate=( - "letters, digits, '_' or '-' with optional interior dots — " - "match the directory name under motion/compositions/" - ), - context_briefing={}, - ) - return resolved, None - - _RENDER_ORIENTATIONS: ClassVar[frozenset[str]] = frozenset({"vertical", "square"}) - _RENDER_MAX_FRAMES: ClassVar[int] = 32 - - @classmethod - def _render_validate_params( - cls, orientation: str, frame_count: int - ) -> Envelope | None: - """``orientation``/``frame_count`` bounds guard, folded into one - rejection point so ``request_render`` keeps one return per guard.""" - if orientation not in cls._RENDER_ORIENTATIONS: - return Envelope.invalid_state( - message=f"orientation {orientation!r} must be 'vertical' or 'square'", - remediate="pass orientation='vertical' or orientation='square'", - context_briefing={}, - ) - if not isinstance(frame_count, int) or not ( - 1 <= frame_count <= cls._RENDER_MAX_FRAMES - ): - bound = cls._RENDER_MAX_FRAMES - return Envelope.invalid_state( - message=f"frame_count {frame_count!r} must be an integer 1-{bound}", - remediate=f"pass frame_count between 1 and {bound}", - context_briefing={}, - ) - return None - - @staticmethod - def _render_resolve_input_props( - task: Any, input_props: dict[str, Any] | None - ) -> dict[str, Any]: - if input_props is not None: - return input_props - draft = markers.get_video_draft(task) or {} - return draft.get("input_props") or draft.get("suggested_input_props") or {} - - @staticmethod - async def _render_git_rev(workspace: Path, ref: str) -> str | None: - """Best-effort ``git rev-parse `` in ``workspace``; ``None`` on - any failure (missing ref, missing dir, no git binary). The render - preview's provenance stamp is best-effort — a git hiccup must never - block the render response itself.""" - try: - proc = await asyncio.create_subprocess_exec( - "git", - "-C", - str(workspace), - "rev-parse", - ref, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, _ = await proc.communicate() - except OSError: - return None - if proc.returncode != 0: - return None - sha = out.decode().strip() - return sha or None - - @classmethod - async def _render_git_head_and_dirty( - cls, workspace: Path - ) -> tuple[str | None, bool]: - """Best-effort ``(HEAD sha, has-uncommitted-changes)`` for a dev's own - working tree. ``(None, False)`` on any failure.""" - head = await cls._render_git_rev(workspace, "HEAD") - if head is None: - return None, False - try: - proc = await asyncio.create_subprocess_exec( - "git", - "-C", - str(workspace), - "status", - "--porcelain", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, _ = await proc.communicate() - except OSError: - return head, False - dirty = bool(out.decode().strip()) if proc.returncode == 0 else False - return head, dirty - - async def _render_dev_source( - self, agent_id: UUID, agent_slug: str, task: Any, project: Any - ) -> tuple[Any, Envelope | None]: - """A developer's own working tree — the per-task worktree when one - exists on disk, else the clone root (F123).""" - from roboco.services.workspace import WorkspaceError - - agent = await self.task.agent_for(agent_id) - if agent is None or not agent.team: - return None, Envelope.invalid_state( - message="your team could not be resolved", - remediate="ensure your agent record has a team, then retry", - context_briefing={}, - ) - try: - clone_root = self.workspace.get_clone_root_path( - project.slug, agent.team, agent_slug - ) - worktree = self.workspace.get_worktree_path( - project.slug, agent.team, agent_slug, task.id.hex[:8] - ) - except WorkspaceError as exc: - return None, Envelope.invalid_state( - message=f"could not resolve your workspace path: {exc}", - remediate="retry request_render; escalate to your PM if it persists", - context_briefing={}, - ) - root = worktree if worktree.exists() else clone_root - head_sha, dirty = await self._render_git_head_and_dirty(root) - return ( - _RenderSource(root=root, head_sha=head_sha, dirty=dirty, kind="workspace"), - None, - ) - - async def _render_qa_source( - self, task: Any, project: Any - ) -> tuple[Any, Envelope | None]: - """QA never renders from a working tree: a read-only export of the - assembled branch's ``motion/`` subtree via - ``WorkspaceService.export_branch_motion``.""" - from roboco.services.workspace import WorkspaceError - - branch = getattr(task, "branch_name", None) - if not branch: - return None, Envelope.invalid_state( - message="task has no recorded branch to export for a QA render", - remediate=( - "the assembled PR's branch must exist before requesting a render" - ), - context_briefing={}, - ) - try: - scratch = await self.workspace.export_branch_motion(project, branch) - except WorkspaceError as exc: - return None, Envelope.invalid_state( - message=f"could not export branch {branch!r} for render: {exc}", - remediate=( - "ensure the branch is pushed to origin, then retry request_render" - ), - context_briefing={}, - ) - read_clone = await self.workspace.ensure_read_clone(project.slug) - head_sha = await self._render_git_rev( - read_clone, f"refs/remotes/origin/{branch}" - ) - - def _cleanup() -> None: - shutil.rmtree(scratch, ignore_errors=True) - - return ( - _RenderSource( - root=scratch, - head_sha=head_sha, - dirty=False, - kind="branch", - cleanup=_cleanup, - ), - None, - ) - - async def _render_resolve_source( - self, agent_id: UUID, agent_slug: str, task: Any, project: Any - ) -> tuple[Any, Envelope | None]: - """Dispatch the render SOURCE by the caller's role: developer → their - own tree; QA → a read-only branch export; anyone else → refused.""" - role = await self._caller_role(agent_id) - if role == "developer": - return await self._render_dev_source(agent_id, agent_slug, task, project) - if role == "qa": - return await self._render_qa_source(task, project) - return None, Envelope.not_authorized( - message=f"role {role!r} may not request a render", - remediate="request_render is developer/QA only", - context_briefing={}, - ) - - async def _render_resolve_project_and_source( - self, agent_id: UUID, agent_slug: str, task: Any - ) -> tuple[Any, Envelope | None]: - """Resolve the task's project, then the caller's render source — - bundled into one combined-rejection helper so ``request_render``'s - own return count stays under the xenon/PLR0911 budget. Success value - is a ``(project, source)`` pair.""" - from roboco.services.project import get_project_service - - project = await get_project_service(self.task.session).get(task.project_id) - if project is None: - return None, Envelope.invalid_state( - message="task's project could not be resolved", - remediate="retry shortly; the project record may be mid-update", - context_briefing={}, - ) - source, rejection = await self._render_resolve_source( - agent_id, agent_slug, task, project - ) - if rejection is not None: - return None, rejection - return (project, source), None - - def _render_extract_frames( - self, project_slug: str, task_id: Any, orientation: str, frames_tar_gz: bytes - ) -> list[str]: - """Extract the sidecar's frames tar.gz to the container-shared - preview dir, wiping any stale render first; returns sorted absolute - frame paths. Every agent container mounts the same /data/workspaces - volume, so this path is identical inside every container regardless - of who rendered — that's what lets a dev render and a QA (or PM) - read the same frames from their own container.""" - out_dir = ( - Path(settings.workspaces_root) - / project_slug - / ".previews" - / task_id.hex[:8] - / orientation - ) - if out_dir.exists(): - shutil.rmtree(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - with tarfile.open(fileobj=io.BytesIO(frames_tar_gz)) as tar: - tar.extractall(out_dir, filter="data") - return sorted(str(p) for p in out_dir.rglob("*") if p.is_file()) - - async def _render_execute( - self, - *, - task: Any, - project: Any, - agent_slug: str, - resolved_id: str, - resolved_props: dict[str, Any], - orientation: str, - frame_count: int, - source: Any, - ) -> Envelope: - """The render call + frame extraction + marker stamp, once every - guard in ``request_render`` has passed. Always cleans up a QA - scratch-dir source (dev sources have no cleanup callback).""" - try: - comp_dir = source.root / "motion" / "compositions" / resolved_id - if not comp_dir.is_dir(): - expected = f"motion/compositions/{resolved_id}/" - return Envelope.invalid_state( - message=f"no composition found at {expected}", - remediate=( - f"build the composition under {expected} first " - "(propose_video / commit it), then retry request_render" - ), - context_briefing={}, - ) - from roboco.services.video_renderer_client import ( - VideoRendererError, - get_video_renderer, - ) - - try: - frames_tar_gz, duration = await get_video_renderer().render_frames( - str(source.root / "motion"), - composition_id=resolved_id, - input_props=resolved_props, - orientation=orientation, - frame_count=frame_count, - ) - except VideoRendererError as exc: - return Envelope.invalid_state( - message=f"render failed: {exc}", - remediate=( - "retry request_render shortly; escalate to your PM if " - "it keeps failing" - ), - context_briefing={}, - ) - frames = self._render_extract_frames( - project.slug, task.id, orientation, frames_tar_gz - ) - payload = { - "at": datetime.now(UTC).isoformat(), - "composition_id": resolved_id, - "orientation": orientation, - "frame_count": frame_count, - "duration_seconds": duration, - "frames": frames, - "head_sha": source.head_sha, - "dirty": source.dirty, - "rendered_by": agent_slug, - "source": source.kind, - } - markers.set_render_preview(task, payload) - # The post-completion render loop keys on video_draft.composition_id - # — a dev who only ever passed composition_id explicitly (never - # propose_video) must still leave it stamped, or the loop skips the - # completed task silently (proven live on task 1dae04a7). - draft = markers.get_video_draft(task) or {} - if not draft.get("composition_id"): - markers.set_video_draft(task, {**draft, "composition_id": resolved_id}) - await self.task.session.flush() - await self._touch_heartbeat(task.id) - return Envelope.ok( - status=str(task.status), - task_id=str(task.id), - next=( - "Read every frames[] path with your file tools; if any " - "scene is missing or clipped, fix the composition and " - "call request_render again." - ), - evidence={ - **payload, - "note": ( - "Read each frame image and verify every scene/feature " - "from the brief appears fully and legibly before " - "i_am_done." - ), - }, - context_briefing={}, - ) - finally: - if source.cleanup is not None: - source.cleanup() - - async def request_render( - self, - *, - agent_id: UUID, - composition_id: str | None = None, - orientation: str = "vertical", - frame_count: int = 8, - input_props: dict[str, Any] | None = None, - ) -> Envelope: - """Render a video composition to a strip of preview frames the - caller reads with file tools — verifying the RENDERED artifact, not - just the HyperFrames source, which can look plausible and still - render wrong (missing scene, clipped layout, wrong text). - - Guards, in order: video engine flag off; caller has no active, - project-bound video-authoring task (`_render_active_video_task`); - the renderer sidecar unconfigured (`video_renderer_base_url`); - `composition_id` unresolvable or failing `propose_video`'s own - charset regex, or `orientation`/`frame_count` out of range; the - caller's role-based SOURCE (`_render_resolve_source` — a developer's - own tree, or QA's read-only branch export, never a QA working tree); - the resolved source missing `motion/compositions//`; the sidecar - call itself (`VideoRendererError` -> a retryable rejection). On - success, extracts the returned frames to a container-shared - `.previews///` path and stamps `render_preview` — - the marker `i_am_done`'s RENDER_VERIFIED gate checks. - """ - if not settings.video_engine_enabled: - return Envelope.invalid_state( - message="the video engine is disabled", - remediate="ROBOCO_VIDEO_ENGINE_ENABLED is off — nothing to render", - context_briefing={}, - ) - t, rejection = await self._render_active_video_task(agent_id) - if rejection is not None: - return rejection - renderer_rej = ( - None - if settings.video_renderer_base_url.strip() - else Envelope.invalid_state( - message="the video-renderer sidecar is not configured", - remediate="ask the CEO to set ROBOCO_VIDEO_RENDERER_BASE_URL", - context_briefing={}, - ) - ) - resolved_id, rej_id = self._render_resolve_composition_id(t, composition_id) - rej_params = self._render_validate_params(orientation, frame_count) - rejection = renderer_rej or rej_id or rej_params - if rejection is not None: - return rejection - resolved_props = self._render_resolve_input_props(t, input_props) - - from roboco.agents_config import _resolve_to_slug - - agent_slug = _resolve_to_slug(str(agent_id)) - resolved, rejection = await self._render_resolve_project_and_source( - agent_id, agent_slug, t - ) - if rejection is not None: - return rejection - project, source = resolved - return await self._render_execute( - task=t, - project=project, - agent_slug=agent_slug, - resolved_id=resolved_id, - resolved_props=resolved_props, - orientation=orientation, - frame_count=frame_count, - source=source, - ) - # ========================================================================= # Wave 1 — pre-gateway parity restoration # ========================================================================= @@ -6463,22 +2487,3 @@ class ContentActions: def _strip_task_prefix(msg: str) -> str: """Strip any [task-id] prefix the agent supplied; gateway re-adds canonical.""" return _TASK_ID_PREFIX_RE.sub("", msg) - - -_AI_ATTRIBUTION_RE = re.compile( - r"co-authored-by:.*(?:anthropic\.com|claude|grok|x\.?ai)" - r"|generated with.*(?:claude|grok)", - re.IGNORECASE, -) - - -def _strip_ai_attribution(msg: str) -> str: - """Drop model self-attribution lines from a commit message. - - Company policy: agent commits carry the agent's own identity, never the - model vendor's. The settings-level ``includeCoAuthoredBy: false`` removes - the harness nudge, but the model can still hand-write the trailer — this - chokepoint covers every provider deterministically. - """ - kept = [ln for ln in msg.splitlines() if not _AI_ATTRIBUTION_RE.search(ln)] - return "\n".join(kept) diff --git a/roboco/services/gateway/envelope.py b/roboco/services/gateway/envelope.py index fa72fe1b..af7accb3 100644 --- a/roboco/services/gateway/envelope.py +++ b/roboco/services/gateway/envelope.py @@ -238,6 +238,31 @@ class Envelope: context_briefing=context_briefing or {}, ) + @classmethod + def gateway_timeout( + cls, + *, + component: str, + timeout_seconds: float, + remediate: str, + context_briefing: dict[str, Any] | None = None, + ) -> Envelope: + """A bounded inner-work timeout tripped (e.g. evidence assembly), + well under the outer server-side ``flow_verb_timeout_seconds`` + rollback (``api.middleware``'s own ``gateway_timeout``). Names the + slow ``component`` so the agent isn't left guessing which segment + (git fetch, diff computation, conventions validation, a DB read) + stalled, instead of a bare 504 that reads as the whole verb failing. + """ + return cls( + error="gateway_timeout", + message=( + f"{component} exceeded the {timeout_seconds:.0f}s bounded timeout" + ), + remediate=remediate, + context_briefing=context_briefing or {}, + ) + @classmethod def from_decision( cls, decision: Any, *, briefing: dict[str, Any] | None = None diff --git a/roboco/services/git.py b/roboco/services/git.py index e194bb4c..8d1fda10 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -15,7 +15,6 @@ import os import re import subprocess import sys -import tempfile import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass @@ -43,7 +42,7 @@ if TYPE_CHECKING: GitCreatePRRequest, GitMergePRRequest, ) - from roboco.db.tables import ProjectTable, TaskTable + from roboco.db.tables import TaskTable from roboco.config import settings from roboco.exceptions import ( GitCommandError, @@ -52,9 +51,7 @@ from roboco.exceptions import ( MergeConflictError, ) from roboco.foundation.policy import lifecycle -from roboco.foundation.policy.pr_labels import CONVENTIONS_PR_LABELS, derive_pr_labels from roboco.models.base import AgentRole, TaskStatus -from roboco.models.env_branches import effective_environments, head_branch from roboco.services.base import ( BaseService, NotFoundError, @@ -62,7 +59,6 @@ from roboco.services.base import ( UnauthorizedError, ValidationError, ) -from roboco.services.forge import ForgeRouter, GitProvider, RepoRef from roboco.services.gateway.quality_gate import GateResult, run_quality_commands from roboco.services.project import get_project_service from roboco.services.task import TaskService, get_task_service @@ -299,17 +295,8 @@ def _git_ownership_scope(args: list[str]) -> str: # Expected number of parts in various git outputs _REV_LIST_PARTS = 2 -# `git status --porcelain`: 2 status columns + 1 space precede the path -_PORCELAIN_PATH_OFFSET = 3 -# A quoted path needs at least its two surrounding quote characters -_MIN_QUOTED_TOKEN_LEN = 2 - # GitHub REST API status codes _GH_UNPROCESSABLE = 422 -# merges-API success codes: 201 = merge commit created + pushed; 204 = nothing -# to merge (head already an ancestor of base). -_HTTP_CREATED = 201 -_HTTP_NO_CONTENT = 204 # 404 means the PR (or repo) does not exist; surfaced as a typed GitError # by `update_pr_for_task` so the gateway can convert it into a specific # invalid_state envelope rather than the generic refusal message. @@ -332,9 +319,23 @@ _HTTP_METHOD_NOT_ALLOWED = 405 # commit, or (on the unscoped all-workflows endpoint) an unrelated green # workflow, masks the HEAD commit's failing run and the signal flickers. _CI_RUN_WINDOW = 20 +# Transient GitHub failures (network, 429, 5xx) are retried within the cycle so +# a single blip does not silently skip a whole self-heal pass. +_CI_FETCH_ATTEMPTS = 3 +_CI_FETCH_BACKOFF_SECONDS = 0.5 +_CI_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504}) + + # Cap a conventions-validator run so a hung subprocess (tree-sitter deadlock, -# huge repo) can't hang the i_am_done/pr_pass gate forever. -_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS = 120 +# huge repo) can't hang the i_am_done/pr_pass gate forever. Sourced from +# settings (default 45s — see `settings.conventions_validator_timeout_seconds`) +# so it stays comfortably under the outer gateway verb's 120s budget instead +# of matching it 1:1, which let a hung validator alone exhaust claim_review's +# whole server-side timeout. +def _conventions_validator_timeout() -> int: + return settings.conventions_validator_timeout_seconds + + # --- pr_pass CI-status guard ------------------------------------------------ # GitHub check-run conclusions that count as a failing check on a PR's head # commit. ``neutral``/``skipped``/``success`` (and ``None`` on a still-running @@ -348,20 +349,6 @@ _FAILING_CHECK_CONCLUSIONS = frozenset( _HTTP_NOT_FOUND = 404 -def _latest_check_runs_by_name( - check_runs: list[dict[str, Any]], -) -> dict[str, dict[str, Any]]: - """Newest (highest-id) check-run per name — GitHub check-run ids are - globally monotonic, so the highest id is the most recent attempt.""" - latest: dict[str, dict[str, Any]] = {} - for cr in check_runs: - name = str(cr.get("name") or "check") - prev = latest.get(name) - if prev is None or int(cr.get("id") or 0) > int(prev.get("id") or 0): - latest[name] = cr - return latest - - def _select_ci_head_run(runs: list[dict[str, Any]]) -> dict[str, Any]: """Pick the run reflecting the branch's current-HEAD CI conclusion. @@ -376,14 +363,24 @@ def _select_ci_head_run(runs: list[dict[str, Any]]) -> dict[str, Any]: return max(same_head, key=lambda r: int(r.get("run_attempt") or 0)) +def _api_base() -> str: + """GitHub REST base URL — honors ``settings.github_api_base_url``. + + Five call sites already read the setting (CI runs, open-PR list); the + PR create/merge/branch sites hardcoded the public host, which broke any + GitHub Enterprise or test override. One helper keeps them uniform. + """ + return settings.github_api_base_url.rstrip("/") + + @dataclass(frozen=True) class _CiRunQuery: - """Bundle of per-project inputs to a CI-run fetch (repo ref, branch, token, + """Bundle of per-project inputs to a CI-run fetch (owner/repo, branch, token, slug for logging) so ``_fetch_latest_ci_run`` stays under the arg-count gate — - repo_ref alone was already bundled for the same reason.""" + owner_repo alone was already bundled for the same reason.""" project_slug: str - repo_ref: RepoRef + owner_repo: tuple[str, str] branch: str git_token: str @@ -401,21 +398,6 @@ class GitService(BaseService): service_name: ClassVar[str] = "git" - @property - def _forge(self) -> GitProvider: - """The per-call forge router (Phase 2 of the forge-providers spec). - - A property, not an ``__init__``-set attribute: several unit tests - build a ``GitService`` via ``GitService.__new__(GitService)`` to skip - the DB-session constructor, and a plain instance attribute would be - unset on those. The router resolves the concrete transport per call - from ``RepoRef.host`` (None → GitHub; a registered gitea host → that - instance's ``GiteaProvider``), so every existing call site stays - byte-for-byte unchanged. Construction is cheap (no I/O), so - resolving fresh per access costs nothing. - """ - return ForgeRouter() - async def _run_git( self, workspace: Path, @@ -601,7 +583,7 @@ class GitService(BaseService): project_slug=project_slug, agent_id=agent_id, git_url=project.git_url, - default_branch=head_branch(project), + default_branch=project.default_branch or "master", ) else: workspace = await workspace_service.resolve_workspace( @@ -653,28 +635,6 @@ class GitService(BaseService): return None return bool(result.stdout.strip()) - async def prune_remote_best_effort(self, workspace: Path) -> None: - """Drop stale `origin/*` remote-tracking refs (ref-only, no object - transfer) so a branch deleted upstream stops showing in the viewing - clone's remote branch list. Never raises — a prune failure must not - break the caller's listing, mirroring `branch_exists_on_remote`. - """ - try: - token = await self._token_for_workspace(workspace) - await self._run_git( - workspace, - ["remote", "prune", "origin"], - check=False, - token=token, - timeout=_network_git_timeout(), - ) - except Exception as exc: - self.log.warning( - "remote prune failed; continuing with existing refs", - workspace=str(workspace), - error=str(exc), - ) - # ========================================================================= # STATUS / INFO METHODS # ========================================================================= @@ -800,21 +760,26 @@ class GitService(BaseService): # ========================================================================= @staticmethod - def _parse_git_url(url: str) -> RepoRef: - """Parse any accepted GitHub URL form into a :class:`RepoRef`. + def _parse_git_url(url: str) -> tuple[str, str]: + """Extract (owner, repo) from any accepted GitHub URL form. Handles tokened, plain-https, and SSH forms: https://x-access-token:TOKEN@github.com/owner/repo.git https://github.com/owner/repo.git git@github.com:owner/repo.git - - Delegates to the router's URL parsing, which routes a registered - gitea host to its own provider and everything GitHub-shaped to - ``GitHubProvider``. """ - return ForgeRouter().parse_repo_ref(url) + path_match = re.search( + r"github\.com[:/]+(?P[^/]+)/(?P[^/\s]+?)(?:\.git)?$", + url, + ) + if not path_match: + raise GitError( + "Could not parse GitHub owner/repo from remote URL", + {"url_host": url.rsplit("@", maxsplit=1)[-1].split("/", maxsplit=1)[0]}, + ) + return path_match.group("owner"), path_match.group("repo") - def _parse_github_remote(self, workspace: Path) -> RepoRef: + def _parse_github_remote(self, workspace: Path) -> tuple[str, str]: """Read the origin remote URL from a workspace and parse owner/repo.""" cfg = workspace / ".git" / "config" try: @@ -1168,111 +1133,14 @@ class GitService(BaseService): return await self._project_default_branch(project_slug) async def _project_default_branch(self, project_slug: str) -> str: - """Return the project's head environment branch (ladder index 0). - - This is where dev/cell/leaf PRs target — the dev trunk. Falls back to - default_branch (and then 'master') via the env-ladder shim when the - project has no declared environment ladder. - """ + """Return the project's configured default branch, or 'master'.""" project_service = get_project_service(self.session) project = await project_service.get_by_slug(project_slug) - if project is None: - return "master" - return head_branch(project) - - async def _protected_branches_for(self, project_slug: str | None) -> frozenset[str]: - """The project's own ``protected_branches``, normalized. - - Consulted by every hardcoded rebase/sync safety gate as a UNION - with its own literal set — this can only ADD branches to what's - refused, never remove one, so a missing/unresolvable project or an - emptied field degrades to exactly the prior hardcoded-only behavior. - Branch names are matched case-sensitively (git refs are); entries are - stripped of surrounding whitespace defensively. - - Fail-OPEN on a lookup error (logged): a rebase/sync refusal wrongly - blocking real work over a transient DB blip is the worse tradeoff - here — unlike deletion (see :meth:`_protected_branches_for_deletion`), - a skipped rebase doesn't get a free retry at the next sweep. - - Deliberately does NOT include environment-ladder rungs — rebase - (``rebase``) and force-push sync (``sync_task_branch``) stay scoped to - the declared field + the master/main floor; see - :meth:`_protected_branches_for_deletion` for the deletion-only - superset that adds rungs. - """ - if not project_slug: - return frozenset() - try: - project = await get_project_service(self.session).get_by_slug(project_slug) - except Exception as e: - self.log.warning( - "protected_branches lookup failed; degrading to hardcoded floor only", - project_slug=project_slug, - error=str(e), - ) - return frozenset() - if project is None or not project.protected_branches: - return frozenset() - return frozenset(b.strip() for b in project.protected_branches if b.strip()) - - async def _protected_branches_for_deletion( - self, project_slug: str | None - ) -> frozenset[str] | None: - """Deletion-only superset of :meth:`_protected_branches_for`: also - unions in the project's environment-ladder rung branches (see - :mod:`roboco.models.env_branches`). - - Consulted ONLY by ``_delete_remote_branch_best_effort`` — the shared - remote-branch-deletion chokepoint every delete path (task-branch - cleanup on cancel, the stale-branch sweep, and the merged-PR - source-branch cleanup after ``merge_pull_request``/``pr_merge``/ - ``close_pull_request``) routes through — so a ladder rung (e.g. an - env-sync PR's own source branch) can never be deleted regardless of - which caller triggered it. A null ``environments`` degenerates to a - single-rung ladder synthesized from ``default_branch`` (see - ``effective_environments``), so a renamed trunk is protected here too, - not just the hardcoded ``main``/``master`` floor. - - Returns ``None`` — distinct from an empty ``frozenset`` — when the - project LOOKUP ITSELF RAISED (a transient DB blip etc.): the caller - treats that as "skip this delete entirely" rather than degrading to - the hardcoded floor. Deletion fails CLOSED here, unlike - ``_protected_branches_for``'s fail-OPEN rebase/sync posture, because - every sibling failure mode in this chokepoint already fails closed - (a missing token or an HTTPError from the forge both skip the - delete) and the delete is best-effort anyway — a skipped one just - retries at the next sweep, whereas silently proceeding on an - unresolvable project could delete a custom-named rung (e.g. - "staging") this project actually declares, losing a - deployment-lineage branch for good. - - A project that resolves to ``None`` (the row is genuinely gone, not - a lookup failure) is NOT the fail-closed case: its ladder is - meaningless once the project itself no longer exists, so this - returns the empty set — proceed with the hardcoded floor only — - rather than refusing forever to clean up an orphaned project's - leftover branches. - """ - if not project_slug: - return frozenset() - try: - project = await get_project_service(self.session).get_by_slug(project_slug) - except Exception as e: - self.log.warning( - "branch-delete protection lookup failed; skipping delete " - "rather than risk silently deleting an unresolvable rung", - project_slug=project_slug, - error=str(e), - ) - return None - if project is None: - return frozenset() - fields = frozenset( - b.strip() for b in (project.protected_branches or []) if b.strip() + return ( + str(project.default_branch) + if project and project.default_branch + else "master" ) - rungs = frozenset(rung.branch for rung in effective_environments(project)) - return fields | rungs async def _checkout_base_with_fallback( self, @@ -1646,8 +1514,8 @@ class GitService(BaseService): task_service = get_task_service(self.session) project = await project_service.get_by_slug(project_slug) allowed: set[str] = set() - if project: - allowed.add(head_branch(project)) + if project and project.default_branch: + allowed.add(project.default_branch) # Include tasks where agent is either assignee OR claimer result = await self.session.execute( @@ -1881,11 +1749,6 @@ class GitService(BaseService): if project is None: return 0 workspace = await self.get_workspace(project.slug, agent_id) - # Regenerate + commit any codegen drift BEFORE the push carries it — - # a no-op unless the project sets codegen_command. - await self._run_codegen_and_commit( - str(task.branch_name), workspace, actor_agent_id=agent_id - ) # Push the task's branch BY NAME, independent of the current checkout. # The dev's clone is shared across tasks, so by the QA-submission / # open_pr boundary it is usually parked on a LATER task's branch; the @@ -1968,10 +1831,6 @@ class GitService(BaseService): ) -> tuple[str, bool, list[str], list[str], list[str], int, int]: """Fetch changes from origin without merging and return post-fetch status. - `--prune` drops local remote-tracking refs for branches deleted - upstream, so the manual Fetch button self-heals the same staleness - `prune_remote_best_effort` targets for the branches-list route. - Uses _network_git_timeout() because the operation talks to origin. Returns: (current_branch, has_changes, staged, unstaged, untracked, @@ -1980,24 +1839,20 @@ class GitService(BaseService): token = await self._token_for_workspace(workspace) await self._run_git( workspace, - ["fetch", "origin", "--prune"], + ["fetch", "origin"], token=token, timeout=_network_git_timeout(), ) return await self.get_status(workspace) async def rebase( - self, workspace: Path, target_branch: str, project_slug: str | None = None + self, workspace: Path, target_branch: str ) -> tuple[bool, list[str]]: """Rebase the current branch onto target_branch. Safety gate: raises :class:`ValidationError` if the HEAD branch or - ``target_branch`` is ``master``/``main``, OR one of the project's own - declared ``protected_branches`` (when ``project_slug`` is given) — - rebasing a protected integration branch is never safe in automation. - ``master``/``main`` are refused unconditionally regardless of the - project's list (see :meth:`_protected_branches_for`): the union can - only tighten what's refused, never loosen it. + ``target_branch`` is ``master`` or ``main`` — rebasing a protected + integration branch is never safe in automation. On conflict (non-zero exit): captures unmerged files via ``git diff --name-only --diff-filter=U``, aborts the rebase to @@ -2005,21 +1860,17 @@ class GitService(BaseService): On success: returns ``(False, [])``. """ - _PROTECTED = frozenset({"master", "main"}) | await self._protected_branches_for( - project_slug - ) + _PROTECTED = frozenset({"master", "main"}) if target_branch in _PROTECTED: raise ValidationError( f"REBASE_FORBIDDEN: Cannot rebase onto '{target_branch}'. " - "Rebasing onto 'master', 'main', or a project-declared " - "protected branch is not allowed in automation." + "Rebasing onto 'master' or 'main' is not allowed in automation." ) head_branch = await self.get_current_branch(workspace) if head_branch in _PROTECTED: raise ValidationError( f"REBASE_FORBIDDEN: Cannot rebase '{head_branch}'. " - "Rebasing 'master', 'main', or a project-declared protected " - "branch is not allowed in automation." + "Rebasing 'master' or 'main' is not allowed in automation." ) result = await self._run_git(workspace, ["rebase", target_branch], check=False) if result.returncode != 0: @@ -2236,19 +2087,26 @@ class GitService(BaseService): async def _find_existing_pr( self, - repo_ref: RepoRef, + owner: str, + repo: str, source_branch: str, target_branch: str, git_token: str, ) -> dict[str, Any] | None: """Return the first open PR for head→base, or None.""" - existing = await self._forge.list_pulls( - repo_ref, - git_token, - head=source_branch, - base=target_branch, - include_api_version=False, - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + existing = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + }, + params={ + "head": f"{owner}:{source_branch}", + "base": target_branch, + "state": "open", + }, + ) if existing.is_success and existing.json(): return cast("dict[str, Any]", existing.json()[0]) return None @@ -2269,24 +2127,34 @@ class GitService(BaseService): if project is None or not project.git_url: return [] try: - repo_ref = self._parse_git_url(project.git_url) + owner, repo = self._parse_git_url(project.git_url) except GitError: return [] git_token = await self._token_for_project(project_slug) if not git_token: return [] - raw = await self._fetch_open_prs(project_slug, repo_ref, git_token) + raw = await self._fetch_open_prs(project_slug, owner, repo, git_token) if raw is None: return [] - base_full = f"{repo_ref.owner}/{repo_ref.repo}" + base_full = f"{owner}/{repo}" return [self._normalize_open_pr(pr, base_full) for pr in raw] async def _fetch_open_prs( - self, project_slug: str, repo_ref: RepoRef, git_token: str + self, project_slug: str, owner: str, repo: str, git_token: str ) -> list[dict[str, Any]] | None: """GET a repo's open PRs; return the raw list, or None on any error.""" + api_base = settings.github_api_base_url.rstrip("/") try: - resp = await self._forge.list_pulls(repo_ref, git_token, per_page=100) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{api_base}/repos/{owner}/{repo}/pulls", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + params={"state": "open", "per_page": 100}, + ) except httpx.HTTPError as e: self.log.warning( "list_open_prs request failed", project=project_slug, error=str(e) @@ -2316,11 +2184,7 @@ class GitService(BaseService): "title": pr.get("title") or "", "head_ref": head.get("ref"), "head_sha": head.get("sha"), - # A null head repo (GitHub sends head.repo=null when the fork was - # deleted) is NOT ours — fail closed to fork/external so the - # branch-ownership skip can never silently swallow a genuine fork - # whose head_ref collides with an org branch name. - "is_fork": (head_full != base_full) if head_full else True, + "is_fork": bool(head_full and head_full != base_full), "user_login": login, # The reviewer reviews PRs the org did NOT author. A PR opened by the # repo-owner account is a self-review (GitHub 422s REQUEST_CHANGES on @@ -2338,7 +2202,6 @@ class GitService(BaseService): *, workflow: str | None = None, head_sha: str | None = None, - branch: str | None = None, ) -> dict[str, Any] | None: """Latest completed CI (GitHub Actions) run on a project's default branch. @@ -2360,19 +2223,16 @@ class GitService(BaseService): if project is None or not project.git_url: return None try: - repo_ref = self._parse_git_url(project.git_url) + owner, repo = self._parse_git_url(project.git_url) except GitError: return None git_token = await self._token_for_project(project_slug) if not git_token: return None - # Default to the head rung (where dev work and the release gate look); - # the release-commit CI wait overrides with the prod rung, where the - # pushed release commit actually lives. - branch = branch or head_branch(project) + branch = project.default_branch or "master" query = _CiRunQuery( project_slug=project_slug, - repo_ref=repo_ref, + owner_repo=(owner, repo), branch=branch, git_token=git_token, ) @@ -2388,6 +2248,43 @@ class GitService(BaseService): "completed_at": run.get("updated_at"), } + async def _get_ci_runs_response( + self, + project_slug: str, + url: str, + headers: dict[str, str], + params: dict[str, str | int], + ) -> httpx.Response | None: + """GET *url* with retry/back-off; return the successful response or None.""" + resp: httpx.Response | None = None + for attempt in range(_CI_FETCH_ATTEMPTS): + last = attempt + 1 == _CI_FETCH_ATTEMPTS + try: + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get(url, headers=headers, params=params) + except httpx.HTTPError as e: + if last: + self.log.warning( + "get_latest_ci_conclusion request failed", + project=project_slug, + error=str(e), + ) + return None + await asyncio.sleep(_CI_FETCH_BACKOFF_SECONDS * (attempt + 1)) + continue + if resp.is_success: + return resp + if resp.status_code in _CI_RETRYABLE_STATUS and not last: + await asyncio.sleep(_CI_FETCH_BACKOFF_SECONDS * (attempt + 1)) + continue + self.log.warning( + "get_latest_ci_conclusion non-2xx", + project=project_slug, + status=resp.status_code, + ) + return None + return resp + async def _fetch_latest_ci_run( self, query: _CiRunQuery, @@ -2409,31 +2306,29 @@ class GitService(BaseService): branch, so only pushes to the default branch (not pull-request runs, whose head is a feature branch) count — exactly the "is the default branch red" signal self-heal needs. Transient network / 429 / 5xx errors - are retried a few times (inside the provider) before giving up so a - single blip doesn't silently skip the cycle. + are retried a few times before giving up so a single blip doesn't + silently skip the cycle. """ - try: - resp = await self._forge.list_ci_runs( - query.repo_ref, - query.git_token, - workflow=workflow, - branch=query.branch, - head_sha=head_sha, - per_page=_CI_RUN_WINDOW, - ) - except httpx.HTTPError as e: - self.log.warning( - "get_latest_ci_conclusion request failed", - project=query.project_slug, - error=str(e), - ) - return None - if not resp.is_success: - self.log.warning( - "get_latest_ci_conclusion non-2xx", - project=query.project_slug, - status=resp.status_code, - ) + owner, repo = query.owner_repo + api_base = settings.github_api_base_url.rstrip("/") + base = f"{api_base}/repos/{owner}/{repo}/actions" + url = f"{base}/workflows/{workflow}/runs" if workflow else f"{base}/runs" + headers = { + "Authorization": f"Bearer {query.git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + params: dict[str, str | int] = { + "branch": query.branch, + "status": "completed", + "per_page": _CI_RUN_WINDOW, + } + if head_sha: + params["head_sha"] = head_sha + resp = await self._get_ci_runs_response( + query.project_slug, url, headers, params + ) + if resp is None or not resp.is_success: return None data = resp.json() runs = data.get("workflow_runs") if isinstance(data, dict) else None @@ -2443,102 +2338,29 @@ class GitService(BaseService): async def _post_pr( self, - repo_ref: RepoRef, + owner: str, + repo: str, git_token: str, payload: dict[str, Any], ) -> httpx.Response: """POST the PR payload to GitHub; translate HTTP errors to GitError.""" try: - return cast( - "httpx.Response", - await self._forge.create_pr( - repo_ref, - git_token, - head=str(payload.get("head", "")), - base=str(payload.get("base", "")), - title=str(payload.get("title", "")), - body=str(payload.get("body", "")), - ), - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + return await client.post( + f"{_api_base()}/repos/{owner}/{repo}/pulls", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + json=payload, + ) except httpx.HTTPError as e: raise GitError( f"GitHub API error while creating PR: {e}", - { - "owner": repo_ref.owner, - "repo": repo_ref.repo, - "head": payload.get("head"), - }, + {"owner": owner, "repo": repo, "head": payload.get("head")}, ) from e - # A single neutral color — labels are distinguished by name, not hue, and - # GitHub's create-label endpoint requires a color (it won't auto-assign). - _PR_LABEL_COLOR = "5e6ad2" - - async def _ensure_label_exists( - self, repo_ref: RepoRef, git_token: str, name: str - ) -> None: - """Create a repo label if missing (GitHub's add-label API 404s on an - unknown label instead of auto-creating). Swallow 'already exists' - (422/409). Best-effort: logs and never raises — a missing label must not - block PR creation.""" - try: - resp = await self._forge.ensure_label( - repo_ref, git_token, name, self._PR_LABEL_COLOR - ) - except Exception as e: - self.log.warning("PR label ensure HTTP error", label=name, error=str(e)) - return - # 422 (already_exists) / 409 (conflict) = the label is already present. - if resp.is_success or resp.status_code in (409, 422): - return - self.log.warning( - "could not ensure PR label exists", - label=name, - status=resp.status_code, - body=(resp.text or "")[:200], - ) - - async def _apply_pr_labels( - self, - repo_ref: RepoRef, - git_token: str, - pr_number: int, - labels: list[str], - ) -> None: - """Best-effort: create each label (GitHub won't auto-create on add) then - add them to the PR. Re-adding is a no-op, so the 422 'PR already exists' - path is safe to re-label. Never raises — labeling must not block PR - creation (same posture as ``_record_pr_atomically``).""" - if not labels: - return - for name in labels: - await self._ensure_label_exists(repo_ref, git_token, name) - try: - resp = await self._forge.add_labels(repo_ref, git_token, pr_number, labels) - except Exception as e: - self.log.warning("add PR labels HTTP error", pr=pr_number, error=str(e)) - return - if not resp.is_success: - self.log.warning( - "could not add PR labels", - pr=pr_number, - status=resp.status_code, - body=(resp.text or "")[:200], - ) - - async def _task_has_children(self, task_id: UUID) -> bool: - """True iff the task has any subtask (a one-row probe). PR creation is - rare; the query is negligible and keeps ``has_children`` honest instead - of assumed per call site.""" - from sqlalchemy import select - - from roboco.db.tables import TaskTable - - result = await self.session.execute( - select(TaskTable.id).where(TaskTable.parent_task_id == task_id).limit(1) - ) - return result.first() is not None - async def _pr_base_on_remote( self, workspace: Path, @@ -2611,9 +2433,10 @@ class GitService(BaseService): workspace, request, source_branch, default_branch, git_token ) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) resp = await self._post_pr( - repo_ref, + owner, + repo, git_token, { "title": pr_title or "", @@ -2623,284 +2446,28 @@ class GitService(BaseService): }, ) - labels = await self._labels_for_pr_request(request, target_branch) - existing = await self._existing_pr_tuple( - resp, repo_ref, (source_branch, target_branch), git_token, pr_title + resp, (owner, repo), (source_branch, target_branch), git_token, pr_title ) if existing is not None: - await self._apply_pr_labels(repo_ref, git_token, existing[0], labels) return existing if not resp.is_success: raise GitError( f"GitHub API refused PR creation ({resp.status_code}): " f"{resp.text[:200]}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "head": source_branch}, + {"owner": owner, "repo": repo, "head": source_branch}, ) pr_data = resp.json() - pr_number = int(pr_data["number"]) - await self._apply_pr_labels(repo_ref, git_token, pr_number, labels) return ( - pr_number, + int(pr_data["number"]), str(pr_data["html_url"]), pr_title or "", source_branch, target_branch, ) - async def sync_env_branch( - self, project_slug: str, target_branch: str, source_branch: str - ) -> dict[str, Any]: - """Merge ``source_branch`` (an upper env rung) into ``target_branch`` (the - lower rung) server-side via GitHub's merges API — one step of the - prod→head cascade. The merge commit lands on ``target_branch`` (the - clean-cascade auto-push). The cascade's target is never prod by - construction (``ladder_pairs``), so prod is never pushed here. - - Returns ``{"status": ...}``: - - * ``already_ancestor`` — target already contains source (HTTP 204). - * ``merged`` — merge commit created + pushed to target (HTTP 201; ``sha``). - * ``conflict`` — non-fast-forward / merge conflict (HTTP 409); no commit. - * ``missing_ref`` — no token / unparseable remote / a branch absent (422). - - Never raises into the engine loop. Does NOT open a PR on conflict — - the caller decides that. - """ - project = await get_project_service(self.session).get_by_slug(project_slug) - if project is None or not project.git_url: - return {"status": "missing_ref"} - git_token = await self._token_for_project(project_slug) - if not git_token: - return {"status": "missing_ref"} - try: - repo_ref = self._parse_git_url(project.git_url) - except GitError: - return {"status": "missing_ref"} - try: - resp = await self._forge.merge_branch( - repo_ref, - git_token, - base=target_branch, - head=source_branch, - commit_message=f"sync: {source_branch} → {target_branch}", - ) - except httpx.HTTPError as exc: - self.log.warning( - "env-sync merges API error", project=project_slug, error=str(exc) - ) - return {"status": "missing_ref"} - if resp.status_code == httpx.codes.NOT_IMPLEMENTED: - # Gitea/GitLab have no server-side merges API — their providers - # return a shaped 501 and the shared local-git fallback runs. - return await self._local_merge_branch( - project.git_url, git_token, target_branch, source_branch - ) - return self._env_merge_status(resp, project_slug) - - async def _local_merge_branch( - self, - git_url: str, - git_token: str, - target_branch: str, - source_branch: str, - ) -> dict[str, Any]: - """Local-git env-sync merge for forges without a merges API (the - forge spec's shared fallback): throwaway clone of the target rung, - merge the source rung, push. Same status vocabulary as - ``_env_merge_status``; a conflict aborts with the clone discarded, - so the remote is never touched on failure. - """ - with tempfile.TemporaryDirectory(prefix="roboco-envsync-") as tmp: - workdir = Path(tmp) - clone_dir = workdir / "clone" - clone = await self._run_git( - workdir, - ["clone", "--branch", target_branch, git_url, str(clone_dir)], - token=git_token, - timeout=_network_git_timeout(), - check=False, - ) - if clone.returncode != 0: - return {"status": "missing_ref"} - for config_args in ( - ["config", "user.email", "envsync@roboco.local"], - ["config", "user.name", "RoboCo Env Sync"], - ["config", "commit.gpgsign", "false"], - ): - await self._run_git(clone_dir, config_args) - fetch = await self._run_git( - clone_dir, - ["fetch", "origin", source_branch], - token=git_token, - timeout=_network_git_timeout(), - check=False, - ) - if fetch.returncode != 0: - return {"status": "missing_ref"} - ancestor = await self._run_git( - clone_dir, - ["merge-base", "--is-ancestor", "FETCH_HEAD", "HEAD"], - check=False, - ) - if ancestor.returncode == 0: - return {"status": "already_ancestor"} - merge = await self._run_git( - clone_dir, - [ - "merge", - "--no-edit", - "-m", - f"sync: {source_branch} → {target_branch}", - "FETCH_HEAD", - ], - check=False, - ) - if merge.returncode != 0: - return {"status": "conflict"} - push = await self._run_git( - clone_dir, - ["push", "origin", f"HEAD:{target_branch}"], - token=git_token, - timeout=_network_git_timeout(), - check=False, - ) - if push.returncode != 0: - return {"status": "missing_ref"} - sha_result = await self._run_git(clone_dir, ["rev-parse", "HEAD"]) - return {"status": "merged", "sha": sha_result.stdout.strip()} - - def _env_merge_status( - self, resp: httpx.Response, project_slug: str - ) -> dict[str, Any]: - """Map a GitHub merges-API response to an env-sync status dict. - - ``merged`` carries the new merge ``sha``; ``conflict`` (409) leaves the - target untouched; any other code (incl. 422 missing-ref / no-merge) is - ``missing_ref`` so the engine skips without opening a PR. - """ - if resp.status_code == _HTTP_CREATED: - return {"status": "merged", "sha": resp.json().get("sha")} - if resp.status_code == _HTTP_NO_CONTENT: - return {"status": "already_ancestor"} - if resp.status_code == _HTTP_CONFLICT: - return {"status": "conflict"} - self.log.warning( - "env-sync merges API unexpected status", - project=project_slug, - status=resp.status_code, - body=resp.text[:200], - ) - return {"status": "missing_ref"} - - async def open_sync_pr( - self, project_slug: str, source_branch: str, target_branch: str, body: str - ) -> dict[str, Any] | None: - """Open (or reuse) a sync PR ``source_branch → target_branch``. - - Idempotent: reuses an already-open PR for the same head→base. Returns - ``{"number", "url"}`` or None on a missing token / unparseable remote / - GitHub error — never raises into the engine loop. - """ - project = await get_project_service(self.session).get_by_slug(project_slug) - if project is None or not project.git_url: - return None - git_token = await self._token_for_project(project_slug) - if not git_token: - return None - try: - repo_ref = self._parse_git_url(project.git_url) - except GitError: - return None - return await self._post_sync_pr( - repo_ref, git_token, (source_branch, target_branch), body, project_slug - ) - - async def _post_sync_pr( - self, - repo_ref: RepoRef, - git_token: str, - branches: tuple[str, str], - body: str, - project_slug: str, - ) -> dict[str, Any] | None: - """Reuse an open sync PR or create a new one for ``source→target``. - - Never raises into the engine loop: a missing existing PR, a rejected - create, or a transport error all return None. - """ - source_branch, target_branch = branches - existing = await self._find_existing_pr( - repo_ref, source_branch, target_branch, git_token - ) - if existing is not None: - return { - "number": int(existing["number"]), - "url": str(existing.get("html_url", "")), - } - try: - resp = await self._post_pr( - repo_ref, - git_token, - { - "title": f"sync: {source_branch} → {target_branch}", - "body": body, - "head": source_branch, - "base": target_branch, - }, - ) - except GitError as exc: - self.log.warning( - "env-sync PR create failed", project=project_slug, error=str(exc) - ) - return None - if not resp.is_success: - self.log.warning( - "env-sync PR create rejected", - project=project_slug, - status=resp.status_code, - body=resp.text[:200], - ) - return None - data = resp.json() - return {"number": int(data["number"]), "url": str(data.get("html_url", ""))} - - async def _labels_for_pr_request( - self, - request: GitCreatePRRequest, - base_branch: str, - ) -> list[str]: - """The org-structure labels for the REST/task PR path. A task PR derives - team / batch / has_children from the task; a freeform PR (``task_id`` - None) carries only the tree + root flags. ``base_branch`` is the PR's - REAL resolved target (post default-branch fallback), never assumed.""" - if request.task_id is None: - return derive_pr_labels( - base_branch=base_branch, - is_root_pr=request.is_root_pr, - task_team=None, - batch_id=None, - has_children=False, - ) - task = await get_task_service(self.session).get(request.task_id) - if task is None: - return derive_pr_labels( - base_branch=base_branch, - is_root_pr=request.is_root_pr, - task_team=None, - batch_id=None, - has_children=False, - ) - return derive_pr_labels( - base_branch=base_branch, - is_root_pr=request.is_root_pr, - task_team=task.team, - batch_id=task.batch_id, - has_children=await self._task_has_children(UUID(str(task.id))), - ) - async def _resolve_new_pr_context( self, workspace: Path, @@ -2934,20 +2501,21 @@ class GitService(BaseService): async def _existing_pr_tuple( self, resp: httpx.Response, - repo_ref: RepoRef, + owner_repo: tuple[str, str], branches: tuple[str, str], git_token: str, pr_title: str | None, ) -> tuple[int, str, str, str, str] | None: """Idempotency: if the create hit an 'already exists' 422, return that PR. - ``branches`` is (source, target). + ``owner_repo`` is (owner, repo); ``branches`` is (source, target). """ if resp.status_code != _GH_UNPROCESSABLE or "already exists" not in resp.text: return None + owner, repo = owner_repo source_branch, target_branch = branches found = await self._find_existing_pr( - repo_ref, source_branch, target_branch, git_token + owner, repo, source_branch, target_branch, git_token ) if not found: return None @@ -2961,7 +2529,8 @@ class GitService(BaseService): async def _patch_pr_title_body( self, - repo_ref: RepoRef, + owner: str, + repo: str, pr_number: int, git_token: str, payload: dict[str, str], @@ -2973,28 +2542,36 @@ class GitService(BaseService): other non-2xx surfaces the GitHub validation text inline. """ try: - resp = await self._forge.update_pr( - repo_ref, git_token, pr_number, payload=payload - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.patch( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + json=payload, + ) except httpx.HTTPError as e: raise GitError( f"GitHub API error while updating PR #{pr_number}: {e}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) from e if resp.status_code == _HTTP_NOT_FOUND: raise GitError( - f"PR not found: #{pr_number} on {repo_ref.owner}/{repo_ref.repo}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + f"PR not found: #{pr_number} on {owner}/{repo}", + {"owner": owner, "repo": repo, "pr": pr_number}, ) if not resp.is_success: raise GitError( f"GitHub API refused PR update ({resp.status_code}): {resp.text[:200]}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) async def _post_pr_reviewers( self, - repo_ref: RepoRef, + owner: str, + repo: str, pr_number: int, git_token: str, reviewers: list[str], @@ -3006,24 +2583,32 @@ class GitService(BaseService): agent slugs onto GitHub usernames where the project records that. """ try: - resp = await self._forge.request_reviewers( - repo_ref, git_token, pr_number, reviewers - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.post( + f"{_api_base()}/repos/{owner}/{repo}/pulls/" + f"{pr_number}/requested_reviewers", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"reviewers": reviewers}, + ) except httpx.HTTPError as e: raise GitError( f"GitHub API error while adding reviewers to PR #{pr_number}: {e}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) from e if resp.status_code == _HTTP_NOT_FOUND: raise GitError( - f"PR not found: #{pr_number} on {repo_ref.owner}/{repo_ref.repo}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + f"PR not found: #{pr_number} on {owner}/{repo}", + {"owner": owner, "repo": repo, "pr": pr_number}, ) if not resp.is_success: raise GitError( f"GitHub API refused reviewer request ({resp.status_code}): " f"{resp.text[:200]}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) async def post_pr_review( @@ -3054,24 +2639,29 @@ class GitService(BaseService): project = await get_project_service(self.session).get_by_slug(project_slug) if project is None or not project.git_url: raise GitError(f"unknown project for PR review: {project_slug!r}", details) - repo_ref = self._parse_git_url(project.git_url) + owner, repo = self._parse_git_url(project.git_url) git_token = await self._token_for_project(project_slug) if not git_token: raise GitError(f"no git token for project {project_slug!r}", details) + api_base = settings.github_api_base_url.rstrip("/") try: - resp = await self._forge.post_review( - repo_ref, git_token, pr_number, body=body, event=event - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.post( + f"{api_base}/repos/{owner}/{repo}/pulls/{pr_number}/reviews", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"body": body, "event": event}, + ) except httpx.HTTPError as e: raise GitError( f"GitHub API error while posting review to PR #{pr_number}: {e}", details, ) from e if resp.status_code == _HTTP_NOT_FOUND: - raise GitError( - f"PR not found: #{pr_number} on {repo_ref.owner}/{repo_ref.repo}", - details, - ) + raise GitError(f"PR not found: #{pr_number} on {owner}/{repo}", details) if ( resp.status_code == _GH_UNPROCESSABLE and event != "COMMENT" @@ -3108,14 +2698,23 @@ class GitService(BaseService): if project is None or not project.git_url: return "" try: - repo_ref = self._parse_git_url(project.git_url) + owner, repo = self._parse_git_url(project.git_url) except GitError: return "" git_token = await self._token_for_project(project_slug) if not git_token: return "" + api_base = settings.github_api_base_url.rstrip("/") try: - resp = await self._forge.get_pr_diff(repo_ref, git_token, pr_number) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{api_base}/repos/{owner}/{repo}/pulls/{pr_number}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github.v3.diff", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) except httpx.HTTPError as e: self.log.warning( "get_pr_diff request failed", @@ -3132,7 +2731,7 @@ class GitService(BaseService): status=resp.status_code, ) return "" - return cast("str", resp.text) + return resp.text async def get_pr_head_sha(self, project_slug: str, pr_number: int) -> str | None: """Fetch a PR's current head commit SHA READ-ONLY via the GitHub API. @@ -3154,14 +2753,23 @@ class GitService(BaseService): if project is None or not project.git_url: return None try: - repo_ref = self._parse_git_url(project.git_url) + owner, repo = self._parse_git_url(project.git_url) except GitError: return None git_token = await self._token_for_project(project_slug) if not git_token: return None + api_base = settings.github_api_base_url.rstrip("/") try: - resp = await self._forge.get_pr(repo_ref, git_token, pr_number) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{api_base}/repos/{owner}/{repo}/pulls/{pr_number}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) if not resp.is_success: self.log.warning( "get_pr_head_sha non-2xx", @@ -3220,48 +2828,54 @@ class GitService(BaseService): config = await self._ci_status_config(project_slug) if isinstance(config, dict): return config - repo_ref, git_token = config + owner, repo, headers = config head_sha_or_gap = await self._resolve_ci_head_sha( - project_slug, pr_number, repo_ref, git_token + project_slug, pr_number, owner, repo, headers ) if isinstance(head_sha_or_gap, dict): return head_sha_or_gap head_sha = head_sha_or_gap check_runs = await self._fetch_check_runs( - project_slug, repo_ref, head_sha, git_token + project_slug, owner, repo, head_sha, headers ) if isinstance(check_runs, dict): return check_runs if check_runs: return self._classify_check_runs(check_runs, head_sha) return await self._classify_zero_check_runs( - project_slug, repo_ref, head_sha, git_token + project_slug, owner, repo, head_sha, headers ) async def _ci_status_config( self, project_slug: str - ) -> tuple[RepoRef, str] | dict[str, Any]: - """Resolve ``(repo_ref, git_token)`` for a CI-status lookup, or a - terminal ``no_ci_configured`` gap dict when the project, its + ) -> tuple[str, str, dict[str, str]] | dict[str, Any]: + """Resolve ``(owner, repo, auth headers)`` for a CI-status lookup, or + a terminal ``no_ci_configured`` gap dict when the project, its git_url, or a git token is missing, or the git_url doesn't parse.""" project = await get_project_service(self.session).get_by_slug(project_slug) if project is None or not project.git_url: return {"state": "no_ci_configured", "head_sha": None} try: - repo_ref = self._parse_git_url(project.git_url) + owner, repo = self._parse_git_url(project.git_url) except GitError: return {"state": "no_ci_configured", "head_sha": None} git_token = await self._token_for_project(project_slug) if not git_token: return {"state": "no_ci_configured", "head_sha": None} - return repo_ref, git_token + headers = { + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + return owner, repo, headers async def _resolve_ci_head_sha( self, project_slug: str, pr_number: int, - repo_ref: RepoRef, - git_token: str, + owner: str, + repo: str, + headers: dict[str, str], ) -> str | dict[str, Any]: """Resolve the PR's head SHA for ``get_pr_ci_status`` specifically. @@ -3275,7 +2889,11 @@ class GitService(BaseService): treat as green. """ try: - resp = await self._forge.get_pr(repo_ref, git_token, pr_number) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers=headers, + ) except httpx.HTTPError as e: self.log.warning( "get_pr_ci_status pr lookup unreachable", @@ -3305,9 +2923,10 @@ class GitService(BaseService): async def _fetch_check_runs( self, project_slug: str, - repo_ref: RepoRef, + owner: str, + repo: str, head_sha: str, - git_token: str, + headers: dict[str, str], ) -> list[dict[str, Any]] | dict[str, Any]: """GET the check-runs for ``head_sha``. @@ -3318,9 +2937,12 @@ class GitService(BaseService): unparseable body) is ``error``. """ try: - resp = await self._forge.list_check_runs( - repo_ref, git_token, head_sha, per_page=100 - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/commits/{head_sha}/check-runs", + headers=headers, + params={"per_page": 100}, + ) except httpx.HTTPError as e: self.log.warning( "get_pr_ci_status check-runs request failed", @@ -3352,32 +2974,26 @@ class GitService(BaseService): def _classify_check_runs( check_runs: list[dict[str, Any]], head_sha: str ) -> dict[str, Any]: - """Map a non-empty check-runs list to a failure/pending/success state. - - Deduped per check name first (see ``_latest_check_runs_by_name``): a - superseded duplicate workflow run (the push + pull_request - double-trigger) leaves cancelled same-name check-runs on the same - SHA that would otherwise mask the surviving run's green forever. - """ - latest = _latest_check_runs_by_name(check_runs) + """Map a non-empty check-runs list to a failure/pending/success state.""" failing = [ - name - for name, cr in latest.items() + str(cr.get("name") or "check") + for cr in check_runs if cr.get("status") == "completed" and cr.get("conclusion") in _FAILING_CHECK_CONCLUSIONS ] if failing: return {"state": "failure", "failing_checks": failing, "head_sha": head_sha} - if any(cr.get("status") != "completed" for cr in latest.values()): + if any(cr.get("status") != "completed" for cr in check_runs): return {"state": "pending", "head_sha": head_sha} return {"state": "success", "head_sha": head_sha} async def _classify_zero_check_runs( self, project_slug: str, - repo_ref: RepoRef, + owner: str, + repo: str, head_sha: str, - git_token: str, + headers: dict[str, str], ) -> dict[str, Any]: """No check-runs exist yet for ``head_sha`` — tell "not scheduled" apart from "no CI configured" by asking whether the repo has any workflows. @@ -3386,7 +3002,12 @@ class GitService(BaseService): ``no_ci_configured``; any other failure is ``error``. """ try: - resp = await self._forge.list_workflows(repo_ref, git_token, per_page=1) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/actions/workflows", + headers=headers, + params={"per_page": 1}, + ) except httpx.HTTPError as e: self.log.warning( "get_pr_ci_status workflows request failed", @@ -3455,7 +3076,7 @@ class GitService(BaseService): workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) git_token = await self._get_project_token_or_raise(project.slug) pr_number = int(task.pr_number) @@ -3469,10 +3090,10 @@ class GitService(BaseService): updated.append("body") if patch_payload: await self._patch_pr_title_body( - repo_ref, pr_number, git_token, patch_payload + owner, repo, pr_number, git_token, patch_payload ) if reviewers is not None: - await self._post_pr_reviewers(repo_ref, pr_number, git_token, reviewers) + await self._post_pr_reviewers(owner, repo, pr_number, git_token, reviewers) updated.append("reviewers") return { @@ -3591,44 +3212,40 @@ class GitService(BaseService): async def _call_merge_api( self, - repo_ref: RepoRef, + owner: str, + repo: str, pr_number: int, git_token: str, merge_method: str, ) -> httpx.Response: """PUT the merge request to GitHub; HTTP errors → GitError.""" try: - return cast( - "httpx.Response", - await self._forge.merge_pr( - repo_ref, - git_token, - pr_number, - merge_method=merge_method, - ), - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + return await client.put( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}/merge", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"merge_method": merge_method}, + ) except httpx.HTTPError as e: raise GitError( f"GitHub API error while merging PR #{pr_number}: {e}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) from e async def _sync_target_branch( self, workspace: Path, target_branch: str, git_token: str ) -> str: - """Checkout + hard-sync the target branch to origin, return its tip. + """Checkout + pull the target branch, return the tip commit hash. If the target branch has no local ref (common in agent workspaces that only ever checked out their own task branch), fetch it from origin and - create a tracking branch first. This prevents the "parent branch - doesn't exist locally" SERVICE_ERROR that blocks every leaf→cell + create a tracking branch before pulling. This prevents the "parent + branch doesn't exist locally" SERVICE_ERROR that blocks every leaf→cell merge in a shared workspace. - - The sync is fetch + ``reset --hard origin/``, never ``pull``: - a bare pull fatals on a divergent local ref ("Need to specify how to - reconcile divergent branches"), and a local target branch that has - drifted from origin in a workspace clone is cruft by definition — the - remote side of the merge is authoritative. """ checkout = await self._run_git( workspace, ["checkout", target_branch], check=False @@ -3656,10 +3273,7 @@ class GitService(BaseService): "tracking_stderr": tracking.stderr.strip(), }, ) - await self._run_git( - workspace, ["fetch", "origin", target_branch], token=git_token - ) - await self._run_git(workspace, ["reset", "--hard", f"origin/{target_branch}"]) + await self._run_git(workspace, ["pull"], token=git_token) log_result = await self._run_git(workspace, ["log", "-1", "--format=%H"]) return log_result.stdout.strip() @@ -3691,7 +3305,7 @@ class GitService(BaseService): return None async def _branch_has_open_dependents( - self, repo_ref: RepoRef, branch: str, git_token: str + self, owner: str, repo: str, branch: str, git_token: str ) -> bool: """True if any OPEN PR still targets ``branch`` as its base. @@ -3703,9 +3317,16 @@ class GitService(BaseService): so the branch is preserved (cleanup is best-effort; stranding is not). """ try: - resp = await self._forge.list_pulls( - repo_ref, git_token, base=branch, per_page=1, timeout=10.0 - ) + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls", + params={"base": branch, "state": "open", "per_page": 1}, + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) if not resp.is_success: return True return bool(resp.json()) @@ -3713,114 +3334,73 @@ class GitService(BaseService): return True async def _delete_remote_branch_best_effort( - self, - repo_ref: RepoRef, - branch: str, - git_token: str, - project_slug: str | None = None, - ) -> bool: + self, owner: str, repo: str, branch: str, git_token: str + ) -> None: """Best-effort: delete a remote branch by name. Silently swallows errors — cleanup is not critical. Skips branches that - look like project defaults (main / master / develop), any branch in - the project's own declared ``protected_branches`` OR one of its - environment-ladder rungs (when ``project_slug`` is given — see - :meth:`_protected_branches_for_deletion`; a UNION with the hardcoded - set, so a missing/emptied field or a null ladder only ever loses the - extra protection, never the main/master/develop floor), and any - branch that still has open dependent PRs (an active integration - target — deleting it would strand in-flight child work). This is the - SHARED chokepoint every remote-delete caller routes through - (``delete_task_branch``, the stale-branch sweep, and - ``_delete_pr_branch_best_effort``'s post-merge PR-source cleanup), so - rung protection here covers all of them, not just task-branch - cleanup. A project-lookup failure (as opposed to a resolved project - or a genuinely-gone one) fails CLOSED — the whole delete is skipped, - not just floor-only-protected — since a silent floor-only fallback - could delete a custom-named rung the lookup couldn't see. Returns - True if the delete request was issued with no transport error, False - on any skip/failure — callers that only fire-and-forget can ignore - it; the branch-cleanup sweep uses it to report counts. + look like project defaults (main / master / develop) and any branch that + still has open dependent PRs (an active integration target — deleting it + would strand in-flight child work). """ - project_protected = await self._protected_branches_for_deletion(project_slug) - if project_protected is None: - self.log.warning( - "branch delete skipped: protected-branch lookup failed; " - "refusing rather than risk deleting an unresolvable rung", - branch=branch, - owner=repo_ref.owner, - repo=repo_ref.repo, - project_slug=project_slug, - ) - return False - protected = frozenset(("main", "master", "develop", "")) | project_protected - if branch in protected: - return False - if await self._branch_has_open_dependents(repo_ref, branch, git_token): + if branch in ("main", "master", "develop", ""): + return + if await self._branch_has_open_dependents(owner, repo, branch, git_token): self.log.info( "branch delete skipped: open dependent PRs target it as base", branch=branch, - owner=repo_ref.owner, - repo=repo_ref.repo, + owner=owner, + repo=repo, ) - return False + return try: - await self._forge.delete_branch_ref( - repo_ref, git_token, branch, timeout=10.0 - ) - return True + async with httpx.AsyncClient(timeout=10.0) as client: + await client.delete( + f"{_api_base()}/repos/{owner}/{repo}/git/refs/heads/{branch}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) except httpx.HTTPError: - return False + return async def _delete_pr_branch_best_effort( - self, - repo_ref: RepoRef, - pr_number: int, - git_token: str, - project_slug: str | None = None, + self, owner: str, repo: str, pr_number: int, git_token: str ) -> None: """Best-effort: delete the PR's source branch on the remote after merge. Silently swallows errors — branch cleanup is not critical. - ``project_slug``, when given, is forwarded to - :meth:`_delete_remote_branch_best_effort` so its own protected-branch - + environment-ladder-rung union covers this path too — a PR's own - source branch can be a ladder rung (e.g. an env-sync cascade PR), and - it is refused just like a task branch would be. """ try: - pr_resp = await self._forge.get_pr( - repo_ref, git_token, pr_number, timeout=10.0 - ) - if not pr_resp.is_success: - return - branch = (pr_resp.json().get("head") or {}).get("ref") - if not branch: - return - await self._delete_remote_branch_best_effort( - repo_ref, branch, git_token, project_slug - ) + async with httpx.AsyncClient(timeout=10.0) as client: + pr_resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + if not pr_resp.is_success: + return + branch = (pr_resp.json().get("head") or {}).get("ref") + if not branch: + return + await self._delete_remote_branch_best_effort(owner, repo, branch, git_token) except httpx.HTTPError: return - async def delete_task_branch(self, project_slug: str, branch_name: str) -> bool: + async def delete_task_branch(self, project_slug: str, branch_name: str) -> None: """Delete a remote task branch after cancel/discard. Best-effort. Called by `TaskService` on cancellation so abandoned task - branches don't accumulate on the remote. Returns whether the delete - was actually issued (see ``_delete_remote_branch_best_effort``). - - The environment-ladder guard is NOT re-checked here: it lives in the - shared ``_delete_remote_branch_best_effort`` chokepoint (via - ``_protected_branches_for_deletion``), which every remote-delete - caller — this one, the stale-branch sweep, and the merged-PR - source-branch cleanup — routes through, so a task's ``branch_name`` - that coincides with a ladder rung is refused there regardless of - which caller asked. + branches don't accumulate on the remote. """ git_token = await self._token_for_project(project_slug) if not git_token: - return False + return # Resolve remote from any workspace — branch deletion only needs # the owner/repo, not a checkout. Use a service-root probe path # if no agent workspace is available. @@ -3828,232 +3408,18 @@ class GitService(BaseService): project_service = get_project_service(self.session) project = await project_service.get_by_slug(project_slug) if not project or not project.git_url: - return False - repo_ref = self._parse_git_url(project.git_url) + return + owner, repo = self._parse_git_url(project.git_url) except Exception: - return False - return await self._delete_remote_branch_best_effort( - repo_ref, branch_name, git_token, project_slug + return + await self._delete_remote_branch_best_effort( + owner, repo, branch_name, git_token ) - async def close_task_pr_best_effort( - self, project_slug: str, pr_number: int - ) -> bool: - """Close a task's still-open PR on cancel/discard. Best-effort. - - Called by `TaskService` on cancellation so a task that never lands - doesn't leave its PR open on the forge forever. Mirrors - ``delete_task_branch``: resolves owner/repo straight off the - project's ``git_url`` rather than a workspace checkout, so closing - never depends on a live agent clone existing (unlike - ``close_pull_request``, which needs one to read the remote). A no-op - when the token/project lookup fails or the PR is already - closed/merged. Returns whether a close request was actually issued. - """ - git_token = await self._token_for_project(project_slug) - if not git_token: - return False - try: - project_service = get_project_service(self.session) - project = await project_service.get_by_slug(project_slug) - if not project or not project.git_url: - return False - repo_ref = self._parse_git_url(project.git_url) - except Exception: - return False - try: - existing = await self._forge.get_pr( - repo_ref, git_token, pr_number, timeout=10.0 - ) - if not existing.is_success or existing.json().get("state") != "open": - return False - resp = await self._forge.update_pr( - repo_ref, git_token, pr_number, payload={"state": "closed"} - ) - return bool(resp.is_success) - except httpx.HTTPError: - return False - - # Per-call cap on the stale-branch sweep so one request can't hang on an - # unbounded fan-out of remote-delete calls. - _CLEANUP_BRANCH_LIMIT = 200 - - async def cleanup_stale_branches( - self, project_slug: str, after_task_id: UUID | None = None - ) -> tuple[int, int, int, int, bool, str | None]: - """Sweep a project's terminal tasks and delete their spent branches. - - Candidates are TERMINAL (completed/cancelled) tasks with a - ``branch_name`` that isn't an environment-ladder rung (a ladder branch - outlives any one task — see ``roboco.models.env_branches``) and isn't - still load-bearing for a live task — either a NON-terminal task still - records this exact branch as its own, or a NON-terminal task is a - direct child of the branch's owning task (see - ``_live_task_dependents``). Capped at - ``_CLEANUP_BRANCH_LIMIT`` per call; the window is deterministic - (``ORDER BY id``) and cursor-resumable via ``after_task_id`` — task - rows never change as a side effect of the sweep, so without a cursor a - repeat call would re-scan the identical first window forever instead - of progressing past the cap. Ladder-branch rows still advance the - cursor (processed-as-excluded), so ``truncated`` can't go false- - negative when rungs land inside the window. Per branch, best-effort: - remote delete (the same guarded ``delete_task_branch`` cancel already - uses — main/master/develop and open-dependent-PR branches are skipped - there too) and, in the assignee's clone, a force local delete (a - completed task's branch was squash-merged, so a safe ``-d`` would - refuse unconditionally; a cancelled one's work is discarded by - decision). - - Returns ``(remote_deleted, local_deleted, skipped, errors, truncated, - next_cursor)`` — ``next_cursor`` is the last processed task id when - truncated, to pass back as ``after_task_id``. ``local_deleted`` counts - a local delete as ATTEMPTED (assignee/clone resolved), not confirmed — - the underlying ``git branch -D`` is itself best-effort and reports no - outcome. ``skipped`` counts branches with no resolvable assignee/clone - (nothing to locally clean up, though the remote delete may still have - run); ``errors`` counts branches that raised unexpectedly while - resolving the assignee's workspace. - """ - project_service = get_project_service(self.session) - project = await project_service.get_by_slug(project_slug) - if not project: - return (0, 0, 0, 0, False, None) - - candidates, truncated, next_cursor = await self._stale_branch_window( - project, after_task_id - ) - - remote_deleted = local_deleted = skipped = errors = 0 - workspace_service = get_workspace_service(self.session) - for task in candidates: - branch = str(task.branch_name) - try: - remote_ok, local_attempted = await self._cleanup_one_stale_branch( - project_slug, task, branch, workspace_service - ) - except Exception as e: - errors += 1 - self.log.warning( - "Stale-branch cleanup skipped for branch", - project_slug=project_slug, - branch=branch, - error=str(e), - ) - continue - remote_deleted += int(remote_ok) - if local_attempted: - local_deleted += 1 - else: - skipped += 1 - - return (remote_deleted, local_deleted, skipped, errors, truncated, next_cursor) - - async def _stale_branch_window( - self, project: ProjectTable, after_task_id: UUID | None - ) -> tuple[list[TaskTable], bool, str | None]: - """Fetch one deterministic, cursor-resumable window of terminal-task - branch-cleanup candidates for ``cleanup_stale_branches``. - - Returns ``(candidates, truncated, next_cursor)`` — ladder-branch rows - and still-load-bearing rows (see ``_live_task_dependents``) stay in - the window (and so still advance the cursor) but are excluded from - ``candidates``, matching the caller's docstring. - """ - from sqlalchemy import select - - from roboco.db.tables import TaskTable - - ladder_branches = {rung.branch for rung in effective_environments(project)} - live_branches, live_parent_ids = await self._live_task_dependents( - cast("UUID", project.id) - ) - query = ( - select(TaskTable) - .where(TaskTable.project_id == project.id) - .where(TaskTable.branch_name.is_not(None)) - .where(TaskTable.status.in_([TaskStatus.COMPLETED, TaskStatus.CANCELLED])) - .order_by(TaskTable.id) - .limit(self._CLEANUP_BRANCH_LIMIT + 1) - ) - if after_task_id is not None: - query = query.where(TaskTable.id > after_task_id) - result = await self.session.execute(query) - window = list(result.scalars().all()) - truncated = len(window) > self._CLEANUP_BRANCH_LIMIT - window = window[: self._CLEANUP_BRANCH_LIMIT] - next_cursor = str(window[-1].id) if truncated and window else None - candidates = [ - t - for t in window - if str(t.branch_name) not in ladder_branches - and str(t.branch_name) not in live_branches - and t.id not in live_parent_ids - ] - return candidates, truncated, next_cursor - - async def _live_task_dependents( - self, project_id: UUID - ) -> tuple[set[str], set[UUID]]: - """Non-terminal tasks' own branches + parent ids, scoped to one project. - - Feeds the stale-branch guard: a terminal candidate is still load- - bearing when either a NON-terminal task still records this exact - branch as its own (a defensive check against a task row reusing a - spent branch name), or a NON-terminal task is a direct child of the - candidate's owning task — that child's PR base resolves to the - parent's own ``branch_name`` (``resolve_parent_branch`` in - ``gateway/merge_chain.py``), even before the child has opened a PR - (so ``_branch_has_open_dependents``, which only sees OPEN PRs, can't - catch it). Mirrors the env-ladder rung exclusion in the same window - builder — one query, sets checked in Python. - """ - from sqlalchemy import select - - from roboco.db.tables import TaskTable - - terminal = (TaskStatus.COMPLETED, TaskStatus.CANCELLED) - result = await self.session.execute( - select(TaskTable.branch_name, TaskTable.parent_task_id) - .where(TaskTable.project_id == project_id) - .where(TaskTable.status.notin_(terminal)) - ) - rows = result.all() - live_branches = {str(branch) for branch, _ in rows if branch} - live_parent_ids = {parent_id for _, parent_id in rows if parent_id is not None} - return live_branches, live_parent_ids - - async def _cleanup_one_stale_branch( - self, - project_slug: str, - task: TaskTable, - branch: str, - workspace_service: WorkspaceService, - ) -> tuple[bool, bool]: - """Delete one candidate's remote + local branch. - - Returns ``(remote_deleted, local_attempted)`` — see - ``cleanup_stale_branches`` for what each means. Raises on an - unexpected failure so the caller's per-branch try/except counts it. - """ - remote_deleted = await self.delete_task_branch(project_slug, branch) - - assignee = task.assignee - if assignee is None or assignee.team is None or assignee.slug is None: - return remote_deleted, False - - clone_root = workspace_service.get_clone_root_path( - project_slug, assignee.team, assignee.slug - ) - # force for every terminal candidate: a completed task's PR was - # squash-merged (its local ref is never an ancestor of the base, so - # -d refuses unconditionally), a cancelled one's work is discarded - # by decision — the ref is spent either way. - await workspace_service.delete_local_branch(clone_root, branch, force=True) - return remote_deleted, True - async def _first_allowed_merge_method( self, - repo_ref: RepoRef, + owner: str, + repo: str, git_token: str, *, exclude: str | None = None, @@ -4065,7 +3431,15 @@ class GitService(BaseService): merge method in its settings. """ try: - resp = await self._forge.get_repo(repo_ref, git_token) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) if not resp.is_success: return None data = resp.json() @@ -4089,30 +3463,32 @@ class GitService(BaseService): Returns: (target_branch, merge_commit) """ git_token = await self._get_project_token_or_raise(project_slug) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) if merge_method not in {"merge", "squash", "rebase"}: merge_method = "squash" - resp = await self._call_merge_api(repo_ref, pr_number, git_token, merge_method) + resp = await self._call_merge_api( + owner, repo, pr_number, git_token, merge_method + ) # A 405 means the repo disallows this merge method (e.g. "Squash merges # are not allowed on this repository" when that button is off). Fall back # to a method the repo permits and retry once, so a repo's merge-button # settings can't permanently wedge the PM on an open, mergeable PR. if resp.status_code == httpx.codes.METHOD_NOT_ALLOWED: fallback = await self._first_allowed_merge_method( - repo_ref, git_token, exclude=merge_method + owner, repo, git_token, exclude=merge_method ) if fallback and fallback != merge_method: self.log.info( "Merge method refused by repo; retrying with a permitted one", requested=merge_method, fallback=fallback, - owner=repo_ref.owner, - repo=repo_ref.repo, + owner=owner, + repo=repo, pr=pr_number, ) resp = await self._call_merge_api( - repo_ref, pr_number, git_token, fallback + owner, repo, pr_number, git_token, fallback ) if not resp.is_success: # A merge PUT on an already-merged PR returns the same 405/409 as a @@ -4126,24 +3502,22 @@ class GitService(BaseService): # already succeeded. None (HTTPError — indeterminate) is treated # as "assume merged" so a network blip can't surface a spurious # failure on the CEO-only master-merge path. - merged = await self._pr_is_merged(repo_ref, pr_number, git_token) + merged = await self._pr_is_merged(owner, repo, pr_number, git_token) if merged is False: raise GitError( f"GitHub API refused PR merge ({resp.status_code}):" f" {resp.text[:200]}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) self.log.info( "PR already merged on GitHub; treating as idempotent success", - owner=repo_ref.owner, - repo=repo_ref.repo, + owner=owner, + repo=repo, pr=pr_number, status_code=resp.status_code, ) - await self._delete_pr_branch_best_effort( - repo_ref, pr_number, git_token, project_slug - ) + await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token) target_branch = await self._project_default_branch(project_slug) # Default branch always exists on origin, so the plain sync is correct @@ -4397,162 +3771,6 @@ class GitService(BaseService): workspace = await self.get_workspace(project.slug, actor_agent_id) return await run_quality_commands(workspace, commands) - @staticmethod - def _codegen_command_for(project: Any) -> str | None: - """The project's ``codegen_command``, or ``None`` if unset. - - The ``isinstance`` check is defensive (the column is ``str | None``) - and also keeps a loosely-specced test double inert: a bare - ``MagicMock`` auto-vivifies any attribute access, so without it every - unrelated GitService test would spuriously trip the codegen path. - """ - command = getattr(project, "codegen_command", None) - return command if isinstance(command, str) and command else None - - @staticmethod - def _porcelain_paths(status_output: str) -> set[str]: - """Path(s) named by each ``git status --porcelain`` line. - - A rename line (``R old -> new``) contributes BOTH sides — staging - only the new path would leave the old path's deletion unstaged. A - quoted path (git wraps a path containing unusual characters in - double quotes) has its surrounding quotes stripped. - """ - paths: set[str] = set() - for line in status_output.splitlines(): - if len(line) <= _PORCELAIN_PATH_OFFSET: - continue - rest = line[_PORCELAIN_PATH_OFFSET:] - raw_tokens = rest.split(" -> ") if " -> " in rest else (rest,) - for raw_token in raw_tokens: - candidate = raw_token.strip() - if ( - len(candidate) >= _MIN_QUOTED_TOKEN_LEN - and candidate[0] == '"' - and candidate[-1] == '"' - ): - candidate = candidate[1:-1] - if candidate: - paths.add(candidate) - return paths - - @classmethod - def _new_codegen_drift_paths(cls, before: str, after: str) -> list[str]: - """Paths newly dirty in ``after`` that were clean in ``before``. - - Identity is the path, not the full status line, so a file already - dirty pre-codegen is excluded even if codegen also touched it — - only genuinely new drift gets staged. - """ - return sorted(cls._porcelain_paths(after) - cls._porcelain_paths(before)) - - async def _link_codegen_commit( - self, - worktree: Path, - task: Any, - task_id: UUID, - message: str, - actor_agent_id: UUID | None, - ) -> None: - """Best-effort link of the just-made codegen commit to its task. - - Mirrors every other commit path's ``_link_commit_to_task`` call so - this commit lands in ``task.commits``/the WorkSession instead of - being invisible to QA/PM/CEO review surfaces. A resolution failure - here only logs — the commit already landed and the caller's push - must proceed regardless (see ``_run_codegen_and_commit``). - """ - link_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) - if link_agent_id is None: - self.log.warning( - "codegen_commit_link_skipped_no_agent", task_id=str(task_id) - ) - return - sha_result = await self._run_git(worktree, ["rev-parse", "HEAD"], check=False) - sha = sha_result.stdout.strip() - if not sha: - self.log.warning("codegen_commit_sha_unresolved", task_id=str(task_id)) - return - await self._link_commit_to_task(task_id, sha, message, link_agent_id) - - async def _run_codegen_and_commit( - self, - branch_name: str, - workspace: Path, - *, - actor_agent_id: UUID | None = None, - ) -> None: - """Regenerate + commit codegen drift in the branch's worktree before push. - - Some projects check in generated artifacts (rendered docs, generated - verb tables, ...) that drift whenever their source changes. Left - unregenerated, drift only ever surfaces later as CI's own drift gate - (a `git diff --exit-code` hard-fail) — a failure with no obvious link - back to the task that caused it. Running the project's - ``codegen_command`` here means any drift lands in the SAME push that's - about to open/update the PR, so CI never sees it stale. - - Staging is scoped to what codegen ITSELF newly dirtied: a - ``git status --porcelain`` snapshot taken before the codegen command - runs is diffed against one taken after, and only the paths absent - from the first are staged. A file already dirty in the worktree - before codegen ran (e.g. crash-orphaned edits the resume path left - behind) is never swept into this commit, even if codegen also - touched it — CI's own drift gate stays the safety net for any - residual drift. The resulting commit is linked to the task/work - session (`_link_codegen_commit`) so it isn't invisible to - QA/PM/CEO review surfaces. - - Fail-open by design: a broken/timing-out codegen command, or any - resolution failure (missing task/project, worktree trouble), logs a - warning and is skipped — the push proceeds without a commit rather - than blocking delivery. A red CI drift-gate on the resulting PR is the - safety net, not a silent pass. A null/absent ``codegen_command`` (most - projects) is a pure no-op. - """ - try: - task = await self._task_for_branch(branch_name) - if task is None: - return - project = await self._project_for_task(task) - if project is None: - return - command = self._codegen_command_for(project) - if command is None: - return - task_id = require_uuid(task.id) - worktree = self._worktree_for_task(workspace, task_id) - await self._ensure_worktree_for_commit(workspace, worktree, branch_name) - before = await self._run_git( - worktree, ["status", "--porcelain"], check=False - ) - result = await run_quality_commands(worktree, [("codegen", command)]) - if not result.passed: - self.log.warning( - "codegen_command_failed", - project=getattr(project, "slug", None), - task_id=str(task_id), - output=result.output_excerpt, - ) - return - after = await self._run_git( - worktree, ["status", "--porcelain"], check=False - ) - new_paths = self._new_codegen_drift_paths(before.stdout, after.stdout) - if not new_paths: - self.log.info("codegen_no_new_drift", task_id=str(task_id)) - return # codegen touched nothing beyond pre-existing drift - await self._run_git(worktree, ["add", "--", *new_paths]) - message = f"[{str(task_id)[:8]}] regenerate generated artifacts" - await self._run_git(worktree, ["commit", "-m", message]) - await self._link_codegen_commit( - worktree, task, task_id, message, actor_agent_id - ) - except Exception as exc: - self.log.warning( - "codegen_and_commit_failed", branch=branch_name, error=str(exc) - ) - async def toolchain_status_for_task( self, actor_agent_id: UUID, task: Any ) -> str | None: @@ -4688,11 +3906,6 @@ class GitService(BaseService): workspace = await self._workspace_for_branch( branch_name, actor_agent_id=actor_agent_id ) - # Regenerate + commit any codegen drift BEFORE this first push opens - # the PR — a no-op unless the project sets codegen_command. - await self._run_codegen_and_commit( - branch_name, workspace, actor_agent_id=actor_agent_id - ) # Push the NAMED branch, not the workspace's current checkout. The # clone root is shared across a dev's tasks and (F123) parked on the # default branch while the task branch lives in a per-task worktree; @@ -4792,7 +4005,7 @@ class GitService(BaseService): branch_name, actor_agent_id=actor_agent_id ) git_token = await self._get_project_token_or_raise(project.slug) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) # `open_pr` targets an ancestor task's branch (e.g. the cell-PM # integration branch) that may not exist on origin — a PM paused @@ -4807,7 +4020,8 @@ class GitService(BaseService): pr_body = task.description or "" resp = await self._post_pr( - repo_ref, + owner, + repo, git_token, { "title": pr_title, @@ -4817,21 +4031,9 @@ class GitService(BaseService): }, ) - # Org-structure labels: create_pr is always an assembled PM PR - # (cell->root or root->master), so has_children is True by construction. - # `parent` is the REAL resolved base (post _ensure_base_on_remote), not - # assumed from is_root_pr. - labels = derive_pr_labels( - base_branch=parent, - is_root_pr=is_root_pr, - task_team=task.team, - batch_id=task.batch_id, - has_children=True, - ) - if resp.status_code == _GH_UNPROCESSABLE and "already exists" in resp.text: found = await self._find_existing_pr( - repo_ref, branch_name, parent, git_token + owner, repo, branch_name, parent, git_token ) if found: pr_number = int(found["number"]) @@ -4843,7 +4045,6 @@ class GitService(BaseService): await _await_shielded( self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url) ) - await self._apply_pr_labels(repo_ref, git_token, pr_number, labels) return { "pr_number": pr_number, "pr_url": pr_url, @@ -4854,7 +4055,7 @@ class GitService(BaseService): raise GitError( f"GitHub API refused PR creation ({resp.status_code}): " f"{resp.text[:200]}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "head": branch_name}, + {"owner": owner, "repo": repo, "head": branch_name}, ) pr_data = resp.json() @@ -4869,7 +4070,6 @@ class GitService(BaseService): await _await_shielded( self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url) ) - await self._apply_pr_labels(repo_ref, git_token, pr_number, labels) return {"pr_number": pr_number, "pr_url": pr_url, "is_root_pr": is_root_pr} async def _lock_parent_task_for_merge(self, parent_task_id: UUID | None) -> None: @@ -4917,7 +4117,8 @@ class GitService(BaseService): class _MergeContext: """Bundle of params for `_merge_with_retry` (keeps arg count under 5).""" - repo_ref: RepoRef + owner: str + repo: str pr_number: int git_token: str workspace: Path @@ -4927,7 +4128,7 @@ class GitService(BaseService): """Single-retry merge: on 409 (race) sync target then retry; on 405 (repo disallows the merge method) fall back to a permitted method.""" resp = await self._call_merge_api( - ctx.repo_ref, ctx.pr_number, ctx.git_token, "squash" + ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash" ) if resp.status_code == _HTTP_CONFLICT: # Another PM merged a sibling subtask first and our local target @@ -4935,7 +4136,7 @@ class GitService(BaseService): # conflict the PM resolves manually. await self._sync_target_branch(ctx.workspace, ctx.target, ctx.git_token) resp = await self._call_merge_api( - ctx.repo_ref, ctx.pr_number, ctx.git_token, "squash" + ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash" ) if resp.status_code == _HTTP_METHOD_NOT_ALLOWED: # The repo's settings disallow squash (the button is off). Try a @@ -4945,19 +4146,19 @@ class GitService(BaseService): # A 405 with no permitted fallback (or a second 405) falls through # to the already-merged disambiguation / MergeConflictError below. fallback = await self._first_allowed_merge_method( - ctx.repo_ref, ctx.git_token, exclude="squash" + ctx.owner, ctx.repo, ctx.git_token, exclude="squash" ) if fallback and fallback != "squash": self.log.info( "Merge method refused by repo; retrying with a permitted one", requested="squash", fallback=fallback, - owner=ctx.repo_ref.owner, - repo=ctx.repo_ref.repo, + owner=ctx.owner, + repo=ctx.repo, pr=ctx.pr_number, ) resp = await self._call_merge_api( - ctx.repo_ref, ctx.pr_number, ctx.git_token, fallback + ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, fallback ) if not resp.is_success: # A merge PUT on an ALREADY-MERGED PR returns the same 405 as a @@ -4969,7 +4170,7 @@ class GitService(BaseService): # "assume merged" so a network blip can't respawn the PM against an # already-merged PR; only a clean False is a real conflict. merged = await self._pr_is_merged( - ctx.repo_ref, ctx.pr_number, ctx.git_token + ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token ) if merged is False: # A real merge refusal (typically 405 "not mergeable") means the @@ -4980,17 +4181,13 @@ class GitService(BaseService): raise MergeConflictError( f"GitHub API refused PR merge ({resp.status_code}):" f" {resp.text[:200]}", - { - "owner": ctx.repo_ref.owner, - "repo": ctx.repo_ref.repo, - "pr": ctx.pr_number, - }, + {"owner": ctx.owner, "repo": ctx.repo, "pr": ctx.pr_number}, ) return resp return resp async def _pr_is_merged( - self, repo_ref: RepoRef, pr_number: int, git_token: str + self, owner: str, repo: str, pr_number: int, git_token: str ) -> bool | None: """True if PR ``pr_number`` is already merged on GitHub. @@ -5004,7 +4201,15 @@ class GitService(BaseService): non-success response is still False (GitHub answered, just not merged). """ try: - resp = await self._forge.get_pr(repo_ref, git_token, pr_number) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) except httpx.HTTPError: return None if not resp.is_success: @@ -5038,8 +4243,8 @@ class GitService(BaseService): workspace_agent_id = self._resolve_workspace_agent_id(task, None) workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id) git_token = await self._get_project_token_or_raise(project.slug) - repo_ref = self._parse_github_remote(workspace) - merged = await self._pr_is_merged(repo_ref, task.pr_number, git_token) + owner, repo = self._parse_github_remote(workspace) + merged = await self._pr_is_merged(owner, repo, task.pr_number, git_token) return True if merged is None else bool(merged) async def pr_merge( @@ -5089,25 +4294,22 @@ class GitService(BaseService): workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id) git_token = await self._get_project_token_or_raise(project.slug) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) - # CEO is the only one who merges into the project's head environment - # branch (ladder index 0 — "master" only when no ladder is declared; - # see _project_default_branch). This agent-facing merge path (a cell - # PM merging a leaf/cell PR up the chain) may NEVER target it — that - # PR is merged solely by the CEO via approve-&-merge - # (merge_pr_for_task, CEO-gated from awaiting_ceo_approval). Agents - # open the PR to it and escalate. + # CEO is the only one who merges to master. This agent-facing merge path + # (a cell PM merging a leaf/cell PR up the chain) may NEVER target a + # repo's default branch — a root→master PR is merged solely by the CEO + # via approve-&-merge (merge_pr_for_task, CEO-gated from + # awaiting_ceo_approval). Agents open the master PR and escalate. default_branch = await self._project_default_branch(project.slug) if target == default_branch: raise UnauthorizedError( action="pr_merge", reason=( - f"CEO_ONLY: merging into '{default_branch}' (this " - "project's head environment branch) is reserved for the " - "CEO via approve-&-merge from awaiting_ceo_approval. " - "Open the PR and escalate; agents never merge directly " - f"into '{default_branch}'." + "CEO_ONLY: merging into the default branch " + f"('{default_branch}') is reserved for the CEO via " + "approve-&-merge from awaiting_ceo_approval. Open the PR " + "and escalate; agents never merge to master." ), ) @@ -5116,16 +4318,15 @@ class GitService(BaseService): await self._merge_with_retry( self._MergeContext( - repo_ref=repo_ref, + owner=owner, + repo=repo, pr_number=pr_number, git_token=git_token, workspace=workspace, target=target, ) ) - await self._delete_pr_branch_best_effort( - repo_ref, pr_number, git_token, project.slug - ) + await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token) merge_commit = await self._sync_target_branch_best_effort( workspace, target, git_token ) @@ -5139,11 +4340,19 @@ class GitService(BaseService): return {"merge_commit_sha": merge_commit or None} async def _get_pr_refs( - self, repo_ref: RepoRef, pr_number: int, git_token: str + self, owner: str, repo: str, pr_number: int, git_token: str ) -> tuple[str, str] | None: """Return ``(head_ref, base_ref)`` for a PR, or ``None`` if unavailable.""" try: - resp = await self._forge.get_pr(repo_ref, git_token, pr_number) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) except httpx.HTTPError: return None if not resp.is_success: @@ -5180,18 +4389,6 @@ class GitService(BaseService): can now merge cleanly. - ``{"status": "conflicts", "files": [...]}`` — the rebase hit conflicts and was aborted; a developer must resolve by hand. - - ``{"status": "diverged", "local_only": int, "origin_only": int}`` - — the local ``head_branch`` and ``origin/`` each - carry commits the other lacks with no patch-equivalent on the - other side. Most often this is the residue of a PRIOR call to - this same method that rebased locally but whose force-push then - failed (network blip, a flow-verb timeout kill, a container - reap between rebase and push) — that case is recognized by - patch-equivalence and self-heals as "ahead" instead (see - :meth:`_reset_head_or_diverged`). What's left is a genuine - divergence, e.g. the task bounced to a different agent's clone - that pushed meanwhile. Refused outright: neither side is - touched and nothing is pushed — a human must reconcile. Any of the above may carry ``"stash_pop_conflict": True`` when ``stash`` popped into a conflict (see below). @@ -5200,47 +4397,32 @@ class GitService(BaseService): legitimate when it is the head's true merge target; the choreographer refuses only a mis-resolved one. - Safety gate (mirrors :meth:`pull`): refuses on a dirty worktree so - uncommitted agent edits are never discarded — UNLESS ``stash=True``, - in which case the dirty worktree (tracked + untracked, ``-u``) is - stashed first and popped back after the rebase instead of refusing - outright (the dev-facing dead end this closes: DIRTY_WORKSPACE had no - in-gate remedy other than a raw ``git`` the agent is denied). A pop - conflict is never auto-resolved — the stash is left in place (never - dropped) and the result gets ``stash_pop_conflict: True`` so the - caller returns an actionable envelope; the agent's uncommitted work - is never lost. - - Beyond uncommitted edits, a COMMITTED local tip is never discarded - either. The ``commit`` do-verb never pushes, so mid-rework a dev - routinely has committed-but-unpushed commits on ``head_branch`` — the - old unconditional ``reset --hard origin/`` right after - checkout silently rewound past them before the force-with-lease push - republished the truncated branch as authoritative. This now - classifies local vs ``origin/`` (post-fetch) first: an - absent local ref is recovered from origin (checkout, never reset — - nothing local to discard); local behind-or-equal resets to origin as - before (origin has nothing to lose); local strictly ahead skips the - reset and rebases from the local tip instead (a superset the push - below publishes); a genuine divergence refuses via ``"diverged"`` - rather than guessing which side to keep. + Safety gate (mirrors :meth:`pull`): refuses on a dirty worktree so the + ``git reset --hard`` below can't discard uncommitted agent edits — + UNLESS ``stash=True``, in which case the dirty worktree (tracked + + untracked, ``-u``) is stashed first and popped back after the rebase + instead of refusing outright (the dev-facing dead end this closes: + DIRTY_WORKSPACE had no in-gate remedy other than a raw ``git`` the + agent is denied). A pop conflict is never auto-resolved — the stash + is left in place (never dropped) and the result gets + ``stash_pop_conflict: True`` so the caller returns an actionable + envelope; the agent's uncommitted work is never lost. """ stashed = await self._stash_if_dirty(workspace, stash=stash) await self._run_git(workspace, ["fetch", "origin"], token=git_token) - await self._ensure_local_head_ref(workspace, head_branch) await self._run_git(workspace, ["checkout", head_branch]) - diverged = await self._reset_head_or_diverged(workspace, head_branch) - if diverged is not None: - if stashed: - await self._pop_stash_into(workspace, diverged) - return diverged + await self._run_git(workspace, ["reset", "--hard", f"origin/{head_branch}"]) rebase = await self._run_git( workspace, ["rebase", f"origin/{base_branch}"], check=False ) if rebase.returncode != 0: return await self._abort_rebase_conflict(workspace, stashed=stashed) - unique = await self._rev_list_count(workspace, f"origin/{base_branch}..HEAD") + count = await self._run_git( + workspace, + ["rev-list", "--count", f"origin/{base_branch}..HEAD"], + ) + unique = int(count.stdout.strip() or "0") if unique == 0: result: dict[str, Any] = {"status": "superseded"} else: @@ -5254,97 +4436,6 @@ class GitService(BaseService): await self._pop_stash_into(workspace, result) return result - async def _rev_list_count(self, workspace: Path, range_spec: str) -> int: - """``git rev-list --count `` as an int (empty stdout → 0).""" - result = await self._run_git(workspace, ["rev-list", "--count", range_spec]) - return int(result.stdout.strip() or "0") - - async def _ensure_local_head_ref(self, workspace: Path, head_branch: str) -> None: - """Recover an absent local ``head_branch`` ref from origin before checkout. - - Mirrors :meth:`_assert_on_task_branch`'s recovery: worktree flows - normally guarantee the local ref already exists, but a caller running - against a bare clone root (or a re-provisioned workspace) may only - have the branch on ``origin`` (this runs post-fetch) — create the - local ref (never reset one that already exists) so the unconditional - checkout right after this never fails on a missing branch. - """ - exists = await self._run_git( - workspace, - ["rev-parse", "--verify", "--quiet", f"refs/heads/{head_branch}"], - check=False, - ) - if exists.returncode != 0: - await self._run_git( - workspace, ["branch", head_branch, f"origin/{head_branch}"] - ) - - async def _reset_head_or_diverged( - self, workspace: Path, head_branch: str - ) -> dict[str, Any] | None: - """Classify checked-out ``head_branch`` against ``origin/``. - - Resets local to origin when local carries nothing origin lacks - (behind or equal — origin is authoritative, today's behavior). - Leaves the local tip untouched when it's strictly ahead - (committed-but-unpushed work — a superset of origin the - force-with-lease push below will publish along with the rebase). - - BOTH sides carrying unique commits by raw SHA isn't proof of a - genuine divergence: a prior run of this same method can rebase - locally and then have its force-push fail after, leaving local and - origin both non-empty forever on retry even though origin's tip is - just local's old history under new SHAs. :meth:`_origin_rewritten_locally` - tells the two apart by patch-equivalence and this self-heals as - "ahead" instead. Only a real two-sided divergence — e.g. the task - bounced to a different agent's clone that pushed meanwhile — still - returns a ``{"status": "diverged", ...}`` dict; neither side is - silently discarded. - """ - local_only = await self._rev_list_count( - workspace, f"origin/{head_branch}..HEAD" - ) - origin_only = await self._rev_list_count( - workspace, f"HEAD..origin/{head_branch}" - ) - if local_only > 0 and origin_only > 0: - if await self._origin_rewritten_locally(workspace, head_branch): - return None - return { - "status": "diverged", - "local_only": local_only, - "origin_only": origin_only, - } - if local_only == 0: - await self._run_git(workspace, ["reset", "--hard", f"origin/{head_branch}"]) - return None - - async def _origin_rewritten_locally( - self, workspace: Path, head_branch: str - ) -> bool: - """True when every origin-only commit has a patch-equivalent local one. - - Rescues the self-inflicted wedge from a rebase whose force-push - failed afterwards: local already carries origin's commits under new - SHAs, so a raw SHA rev-list count sees both sides positive forever. - ``rev-list --cherry-pick --right-only`` drops any origin-only commit - whose patch (context-adjusted patch-id, same mechanism as - :meth:`unmerged_child_commits`'s ``git cherry``) matches one of - local's exclusive commits; whatever survives is truly exclusive to - origin, so a non-zero count still refuses as a real divergence. - """ - result = await self._run_git( - workspace, - [ - "rev-list", - "--count", - "--right-only", - "--cherry-pick", - f"HEAD...origin/{head_branch}", - ], - ) - return int(result.stdout.strip() or "0") == 0 - async def _stash_if_dirty(self, workspace: Path, *, stash: bool) -> bool: """Clean-tree gate for :meth:`rebase_onto_base`. @@ -5431,9 +4522,9 @@ class GitService(BaseService): workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) clone_root = await self.get_workspace(project.slug, agent_id=workspace_agent_id) git_token = await self._get_project_token_or_raise(project.slug) - repo_ref = self._parse_github_remote(clone_root) + owner, repo = self._parse_github_remote(clone_root) - refs = await self._get_pr_refs(repo_ref, pr_number, git_token) + refs = await self._get_pr_refs(owner, repo, pr_number, git_token) if refs is None: return {"status": "unknown"} head_branch, base_branch = refs @@ -5466,37 +4557,21 @@ class GitService(BaseService): rebase through the dev ``sync_branch`` verb instead of the CEO/PM-only ``/rebase`` HTTP route. Mirrors ``rebase_pr_for_task``'s workspace/token resolution and delegates to :meth:`rebase_onto_base`, returning the same - classification dict (``rebased`` / ``superseded`` / ``conflicts`` / - ``diverged``). + classification dict (``rebased`` / ``superseded`` / ``conflicts``). ``stash`` forwards to :meth:`rebase_onto_base` — auto-stash a dirty worktree instead of refusing DIRTY_WORKSPACE. A master/main base is legitimate when it is the task's true merge - target (standalone task, branchless-parent child); the choreographer's - ``_sync_base_refused`` guards THAT (untouched by this refusal). This - method separately guards what it actually force-pushes: the HEAD/task - branch. If ``task.branch_name`` itself is master/main or one of the - project's declared ``protected_branches``, ``branch_name`` was - mis-set and force-pushing (with lease) over it is exactly what the - field exists to prevent — refuse before touching any workspace. + target (standalone task, branchless-parent child); the choreographer + refuses only a mis-resolved one. The push only ever targets the task + branch. """ if not task.branch_name: raise ValueError("sync_task_branch requires a task with a branch_name") project = await self._project_for_task(task) if project is None: raise NotFoundError("Project for task", str(task.id)) - protected_heads = frozenset( - {"master", "main"} - ) | await self._protected_branches_for(project.slug) - if str(task.branch_name) in protected_heads: - raise ValidationError( - f"REBASE_FORBIDDEN: task branch_name '{task.branch_name}' is a " - "protected branch (master/main or a project-declared " - "protected_branches entry) — branch_name was mis-set; " - "force-pushing over it is exactly what protected_branches " - "exists to prevent. Escalate via i_am_blocked(reason='...')." - ) workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) clone_root = await self.get_workspace(project.slug, agent_id=workspace_agent_id) git_token = await self._get_project_token_or_raise(project.slug) @@ -5707,30 +4782,42 @@ class GitService(BaseService): workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id) git_token = await self._get_project_token_or_raise(project.slug) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) - existing = await self._forge.get_pr(repo_ref, git_token, pr_number) - already_closed = ( - existing.is_success and existing.json().get("state") == "closed" - ) - if not already_closed: - if comment: - await self._forge.create_issue_comment( - repo_ref, git_token, pr_number, comment - ) - resp = await self._forge.update_pr( - repo_ref, git_token, pr_number, payload={"state": "closed"} + headers = { + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + existing = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers=headers, ) - if not resp.is_success: - raise GitError( - f"GitHub API refused PR close ({resp.status_code}): " - f"{resp.text[:200]}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + already_closed = ( + existing.is_success and existing.json().get("state") == "closed" + ) + if not already_closed: + if comment: + await client.post( + f"{_api_base()}/repos/{owner}/{repo}/issues/" + f"{pr_number}/comments", + headers=headers, + json={"body": comment}, + ) + resp = await client.patch( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers=headers, + json={"state": "closed"}, ) + if not resp.is_success: + raise GitError( + f"GitHub API refused PR close ({resp.status_code}): " + f"{resp.text[:200]}", + {"owner": owner, "repo": repo, "pr": pr_number}, + ) if delete_branch: - await self._delete_pr_branch_best_effort( - repo_ref, pr_number, git_token, project.slug - ) + await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token) async def pr_target( self, @@ -5772,28 +4859,33 @@ class GitService(BaseService): workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id) workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) git_token = await self._get_project_token_or_raise(project.slug) try: - resp = await self._forge.get_pr( - repo_ref, git_token, pr_number, include_api_version=False - ) + async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: + resp = await client.get( + f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", + headers={ + "Authorization": f"Bearer {git_token}", + "Accept": "application/vnd.github+json", + }, + ) except httpx.HTTPError as e: raise GitError( f"GitHub API error fetching PR #{pr_number}: {e}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) from e if not resp.is_success: raise GitError( f"GitHub API refused PR fetch ({resp.status_code}): {resp.text[:200]}", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) base_ref = (resp.json().get("base") or {}).get("ref") if not base_ref: raise GitError( f"PR #{pr_number} has no base ref", - {"owner": repo_ref.owner, "repo": repo_ref.repo, "pr": pr_number}, + {"owner": owner, "repo": repo, "pr": pr_number}, ) return str(base_ref) @@ -5904,24 +4996,9 @@ class GitService(BaseService): had an unresolvable head and returned an empty diff (QA saw no changes on a real PR). ``open_pr`` pushes the leaf branch, so ``origin/`` is the workspace-independent source of truth. - Fetch it, then prefer origin and fall back to the local branch; - last resort the bare name so the diff command stays well-formed. - - Every caller here is a READER (QA/PM/documenter/PR-gate/panel - inspecting a branch they don't own), never the branch's own author - mid-write — so origin, not local, is the source of truth whenever - it carries anything the local ref lacks. ``origin_only`` counts - commits on origin the local ref doesn't have (via ``rev-list``, - mirroring ``_reset_head_or_diverged``'s write-path classification): - zero means local already contains everything origin has (ahead on - unpushed commits, or equal) and stays authoritative; non-zero means - origin has moved — whether by a plain fast-forward OR by a - force-push that rewrote history (a parked local ref left over from - an earlier inspection, or the routine rebase-sync every task branch - gets) — and origin wins either way. Unlike the write path's - ``_origin_rewritten_locally``, a read never needs to tell a genuine - divergence apart from a rewritten one: both resolve to the same - action (serve origin), so no patch-equivalence check is needed here. + Fetch it, then prefer the local branch (dev's own clone) and fall + back to ``origin/``; last resort the bare name so the + diff command stays well-formed. """ await self._run_git( workspace, ["fetch", "origin", branch_name], check=False, token=token @@ -5930,10 +5007,18 @@ class GitService(BaseService): local_exists = await self._ref_exists(workspace, branch_name) origin_exists = await self._ref_exists(workspace, origin_ref) if local_exists and origin_exists: - origin_only = await self._rev_list_count( - workspace, f"{branch_name}..{origin_ref}" + # An assembled branch advances on ORIGIN when child PRs merge on + # GitHub, while the inspecting clone's local ref stays parked — a + # diff off the stale local ref re-flags work that already landed + # (live 2026-07-02: two false pr_fails on the S6 cell PR). Prefer + # origin when the local ref is strictly behind it; a local ref + # that is ahead (unpushed) or diverged keeps priority. + behind = await self._run_git( + workspace, + ["merge-base", "--is-ancestor", branch_name, origin_ref], + check=False, ) - return origin_ref if origin_only > 0 else branch_name + return origin_ref if behind.returncode == 0 else branch_name if local_exists: return branch_name if origin_exists: @@ -6018,6 +5103,63 @@ class GitService(BaseService): ) return [line for line in result.stdout.splitlines() if line.strip()] + async def diff_and_files( + self, + *, + branch_name: str, + base: str | None = None, + actor_agent_id: UUID | None = None, + preferred_parent: str | None = None, + ) -> tuple[str, list[str]]: + """Combined ``diff()`` + ``list_changed_files()`` in one call. + + Both methods 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. This resolves the shared state ONCE, then runs the + `diff` and `diff --name-only` subprocesses concurrently. + + ``diff()``/``list_changed_files()`` keep their own signatures and + behavior unchanged for callers that only need one of the two + (``roboco_git_diff``, ``doc.py``'s evidence path, etc.) — this is + an additive accessor, not a replacement. + """ + t0 = time.monotonic() + workspace = await self._workspace_for_branch( + branch_name, actor_agent_id=actor_agent_id + ) + token = await self._token_for_branch(branch_name) + head_ref = await self._resolve_head_ref(workspace, branch_name, token=token) + base_ref = ( + base + if base is not None + else await self._resolve_diff_base( + workspace, branch_name, token=token, preferred_parent=preferred_parent + ) + ) + resolve_ms = (time.monotonic() - t0) * 1000.0 + + t1 = time.monotonic() + diff_result, files_result = await asyncio.gather( + self._run_git(workspace, ["diff", f"{base_ref}...{head_ref}"], check=False), + self._run_git( + workspace, + ["diff", "--name-only", f"{base_ref}...{head_ref}"], + check=False, + ), + ) + diff_ms = (time.monotonic() - t1) * 1000.0 + self.log.info( + "evidence diff_and_files timing", + branch_name=branch_name, + resolve_ms=round(resolve_ms), + diff_ms=round(diff_ms), + ) + files_changed = [ + line for line in files_result.stdout.splitlines() if line.strip() + ] + return diff_result.stdout, files_changed + async def read_file_at_branch( self, *, @@ -6133,6 +5275,7 @@ class GitService(BaseService): task: Any, *, preferred_parent: str | None = None, + changed_files: list[str] | None = None, ) -> dict[str, Any]: """Run the conventions validator on a task's changed files. @@ -6148,28 +5291,13 @@ class GitService(BaseService): ``preferred_parent`` threads to ``list_changed_files`` — the in-path PR-review gate's cross-team parent (see ``diff``'s docstring). - The changed-file LIST above comes from git objects (``list_changed_files`` - fetches + diffs ``origin/``); the validator below reads CONTENT - off the physical worktree, which only ``_ensure_worktree_for_commit`` - touches here (re-add if pruned, no refresh — no fetch, no classify). - These could disagree on a worktree that predates the branch's current - tip. For the FIRST claim of a review session, they don't: - ``ensure_worktree_self_heal`` (the spawn chokepoint, run once before - this agent's session started — including its re-add-from-a-surviving- - local-ref path) already classified the worktree against origin, and - the assembled PR under review can gain no further commits while it - sits in this task's own review state, so list and content are the - same origin tip by the time this runs. - - Narrow accepted ceiling: a reviewer session that claims a SECOND task - mid-session (same container, no respawn) never gets another spawn-time - refresh — that only runs once, before the session started. A fresh - worktree this session creates for that claim has nothing to disagree - with (both list and content start at its own branch tip), but a - worktree this session INHERITS from an earlier, still-open claim of - that same task could be stale by whatever origin gained since. No - claim-time refresh exists to close this; it is accepted, not fixed. + ``changed_files``, when given, is used AS-IS instead of calling + ``list_changed_files`` again — the caller (``_build_qa_claim_evidence``) + already computed it via ``diff_and_files``, and re-deriving it here was + a redundant THIRD ``list_changed_files`` call on the same claim_review + request. """ + t0 = time.monotonic() try: branch = task.branch_name if not branch: @@ -6177,10 +5305,14 @@ class GitService(BaseService): clone_root = await self._workspace_for_branch( branch, actor_agent_id=actor_agent_id ) - changed = await self.list_changed_files( - branch_name=branch, - actor_agent_id=actor_agent_id, - preferred_parent=preferred_parent, + changed = ( + changed_files + if changed_files is not None + else await self.list_changed_files( + branch_name=branch, + actor_agent_id=actor_agent_id, + preferred_parent=preferred_parent, + ) ) except Exception as exc: return { @@ -6196,11 +5328,20 @@ class GitService(BaseService): # content and false-passes on newly-added files. workspace = self._worktree_for_task(clone_root, require_uuid(task.id)) await self._ensure_worktree_for_commit(clone_root, workspace, branch) - return await self._run_conventions_validator(workspace, changed) + result = await self._run_conventions_validator(workspace, changed) + self.log.info( + "conventions_check_for_task timing", + task_id=str(task.id), + reused_changed_files=changed_files is not None, + total_ms=round((time.monotonic() - t0) * 1000.0), + ) + return result async def _run_conventions_validator( self, workspace: Path, files: list[str] ) -> dict[str, Any]: + validator_timeout = _conventions_validator_timeout() + t0 = time.monotonic() proc = await asyncio.create_subprocess_exec( sys.executable, "-m", @@ -6216,7 +5357,7 @@ class GitService(BaseService): try: out, err = await asyncio.wait_for( proc.communicate(), - timeout=_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS, + timeout=validator_timeout, ) except TimeoutError: # Fail closed (could_not_run=True → block gate refuses the submit), @@ -6224,14 +5365,22 @@ class GitService(BaseService): # killed proc so it isn't orphaned on orchestrator restart. proc.kill() await proc.wait() + self.log.warning( + "conventions validator timed out", + validator_ms=round((time.monotonic() - t0) * 1000.0), + timeout_s=validator_timeout, + file_count=len(files), + ) return { "findings": [], "could_not_run": True, - "reason": ( - f"validator timed out after " - f"{_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS}s" - ), + "reason": f"validator timed out after {validator_timeout}s", } + self.log.info( + "conventions validator timing", + validator_ms=round((time.monotonic() - t0) * 1000.0), + file_count=len(files), + ) if proc.returncode != 0: reason = err.decode(errors="replace").strip() or "validator crashed" return {"findings": [], "could_not_run": True, "reason": reason[:300]} @@ -6286,7 +5435,7 @@ class GitService(BaseService): ws = None if ws is None or not ws.exists(): return None - base = head_branch(project) + base = project.default_branch or "master" spec = _ConventionsPr( content=content, branch=CONVENTIONS_SCAFFOLD_BRANCH, @@ -6358,9 +5507,10 @@ class GitService(BaseService): return unopened try: await self.push(workspace, force=True) - repo_ref = self._parse_github_remote(workspace) + owner, repo = self._parse_github_remote(workspace) resp = await self._post_pr( - repo_ref, + owner, + repo, token, { "title": spec.title, @@ -6374,16 +5524,9 @@ class GitService(BaseService): if not resp.is_success: return unopened data = resp.json() - pr_number = data.get("number") - if pr_number is not None: - # Static label — a project-level scaffold/restore PR has no task or - # org layer; best-effort, never blocks. - await self._apply_pr_labels( - repo_ref, token, int(pr_number), CONVENTIONS_PR_LABELS - ) return { "branch": spec.branch, - "pr_number": pr_number, + "pr_number": data.get("number"), "pr_url": data.get("html_url"), } diff --git a/tests/integration/test_git_routes.py b/tests/integration/test_git_routes.py index 6448e295..c6a29c74 100644 --- a/tests/integration/test_git_routes.py +++ b/tests/integration/test_git_routes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import uuid from http import HTTPStatus from types import SimpleNamespace @@ -266,37 +267,6 @@ async def test_log_with_branch_success(git_client: dict) -> None: assert [c["author"] for c in commits] == ["me", "you"] -@pytest.mark.asyncio -async def test_log_resolves_through_head_ref_not_bare_branch( - git_client: dict, -) -> None: - """The route must route the requested branch through - ``_resolve_head_ref`` (fetch + prefer origin) instead of handing git the - bare branch name straight off whatever this clone happens to have on - disk — this clone is the CALLER's own, never the branch owner's, and a - left-over local ref from an earlier inspection can be pinned stale - (live 2026-07-24: a QA clone read a commit 5 review rounds old).""" - log_result = MagicMock() - log_result.returncode = 0 - log_result.stdout = "" - with patch("roboco.api.routes.git.get_git_service") as mock_get: - svc = AsyncMock() - svc.get_workspace = AsyncMock(return_value="/tmp/ws") - svc._token_for_branch = AsyncMock(return_value="tok") - svc._resolve_head_ref = AsyncMock(return_value="origin/feature/x") - svc._run_git = AsyncMock(return_value=log_result) - mock_get.return_value = svc - response = await git_client["client"].get( - f"/api/git/log?project_slug={git_client['project'].slug}&branch=feature/x", - headers=_HDR, - ) - assert response.status_code == HTTPStatus.OK - svc._resolve_head_ref.assert_awaited_once_with("/tmp/ws", "feature/x", token="tok") - svc._run_git.assert_awaited_once() - logged_args = svc._run_git.await_args.args[1] - assert logged_args[-1] == "origin/feature/x" - - @pytest.mark.asyncio async def test_log_no_branch_fetches_current(git_client: dict) -> None: log_result = MagicMock() @@ -355,7 +325,7 @@ async def test_log_service_error(git_client: dict) -> None: @pytest.mark.asyncio async def test_branches_local_only(git_client: dict) -> None: branch_result = MagicMock() - branch_result.stdout = "refs/heads/main|abc123\nrefs/heads/feature/x|def456\n" + branch_result.stdout = "main|abc123\nfeature/x|def456\n" with patch("roboco.api.routes.git.get_git_service") as mock_get: svc = AsyncMock() svc.get_workspace = AsyncMock(return_value="/tmp/ws") @@ -367,27 +337,12 @@ async def test_branches_local_only(git_client: dict) -> None: headers=_HDR, ) assert response.status_code == HTTPStatus.OK - names = {b["name"]: b for b in response.json()["branches"]} - assert names["main"]["is_remote"] is False - assert names["feature/x"]["is_remote"] is False - # include_remote=False (default) never prunes. - svc.prune_remote_best_effort.assert_not_awaited() @pytest.mark.asyncio async def test_branches_with_remote(git_client: dict) -> None: - """Regression: `%(refname)` renders a remote-tracking ref as - `refs/remotes/origin/` (real git never emits the old stub's - `remotes/origin/` shape) — it must classify as remote with the - `refs/remotes/origin/` prefix stripped down to the bare branch name, and - the symbolic `origin/HEAD` ref must be dropped, not surfaced as a fake - branch named "HEAD".""" branch_result = MagicMock() - branch_result.stdout = ( - "refs/heads/main|abc123\n" - "refs/remotes/origin/feature/y|def456\n" - "refs/remotes/origin/HEAD|abc123\n" - ) + branch_result.stdout = "main|abc123\nremotes/origin/feature/y|def456\n" with patch("roboco.api.routes.git.get_git_service") as mock_get: svc = AsyncMock() svc.get_workspace = AsyncMock(return_value="/tmp/ws") @@ -400,11 +355,6 @@ async def test_branches_with_remote(git_client: dict) -> None: headers=_HDR, ) assert response.status_code == HTTPStatus.OK - names = {b["name"]: b for b in response.json()["branches"]} - assert names["feature/y"]["is_remote"] is True - assert "origin/feature/y" not in names - assert "HEAD" not in names - svc.prune_remote_best_effort.assert_awaited_once_with("/tmp/ws") @pytest.mark.asyncio @@ -412,7 +362,7 @@ async def test_branches_skips_empty_lines(git_client: dict) -> None: """Line 246: empty line in branch output triggers continue.""" branch_result = MagicMock() # Embed an empty line between two branches. - branch_result.stdout = "refs/heads/main|abc\n\nrefs/heads/feature/x|def\n" + branch_result.stdout = "main|abc\n\nfeature/x|def\n" with patch("roboco.api.routes.git.get_git_service") as mock_get: svc = AsyncMock() svc.get_workspace = AsyncMock(return_value="/tmp/ws") @@ -490,6 +440,33 @@ async def test_diff_service_error(git_client: dict) -> None: assert response.status_code == HTTPStatus.NOT_FOUND +@pytest.mark.asyncio +async def test_diff_bounded_timeout_returns_504( + git_client: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + """Task #62845be1: a genuinely slow diff computation trips the bounded + ``evidence_assembly_timeout_seconds`` guard and returns a structured 504 + naming the slow component, instead of hanging indefinitely.""" + monkeypatch.setattr( + "roboco.api.routes.git.settings.evidence_assembly_timeout_seconds", 0.05 + ) + with patch("roboco.api.routes.git.get_git_service") as mock_get: + svc = AsyncMock() + + async def _slow_workspace(*_args: object, **_kwargs: object) -> str: + await asyncio.sleep(1) + return "/tmp/ws" + + svc.get_workspace = AsyncMock(side_effect=_slow_workspace) + mock_get.return_value = svc + response = await git_client["client"].get( + f"/api/git/diff?project_slug={git_client['project'].slug}", + headers=_HDR, + ) + assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT + assert "bounded" in response.json()["detail"].lower() + + # --------------------------------------------------------------------------- # commit # --------------------------------------------------------------------------- @@ -1053,78 +1030,5 @@ async def test_merge_pr_without_task_id_no_422(git_client: dict) -> None: assert response.status_code == HTTPStatus.OK -# --------------------------------------------------------------------------- -# branches/cleanup -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_cleanup_branches_success(pm_git_client: dict) -> None: - with patch("roboco.api.routes.git.get_git_service") as mock_get: - svc = AsyncMock() - svc.cleanup_stale_branches = AsyncMock(return_value=(3, 2, 1, 0, False, None)) - mock_get.return_value = svc - response = await pm_git_client["client"].post( - "/api/git/branches/cleanup", - json={"project_slug": pm_git_client["project"].slug}, - headers=_HDR, - ) - assert response.status_code == HTTPStatus.OK - data = response.json() - assert ( - data["remote_deleted"], - data["local_deleted"], - data["skipped"], - data["errors"], - data["truncated"], - ) == (3, 2, 1, 0, False) - svc.cleanup_stale_branches.assert_awaited_once_with( - pm_git_client["project"].slug, after_task_id=None - ) - - -@pytest.mark.asyncio -async def test_cleanup_branches_reports_truncation(pm_git_client: dict) -> None: - with patch("roboco.api.routes.git.get_git_service") as mock_get: - svc = AsyncMock() - svc.cleanup_stale_branches = AsyncMock( - return_value=(200, 190, 0, 0, True, "0" * 32) - ) - mock_get.return_value = svc - response = await pm_git_client["client"].post( - "/api/git/branches/cleanup", - json={"project_slug": pm_git_client["project"].slug}, - headers=_HDR, - ) - assert response.status_code == HTTPStatus.OK - assert response.json()["truncated"] is True - - -@pytest.mark.asyncio -async def test_cleanup_branches_developer_gets_403(git_client: dict) -> None: - """git_client carries a DEVELOPER-role agent — same role gate as /rebase.""" - with patch("roboco.api.routes.git.get_git_service") as mock_get: - svc = AsyncMock() - mock_get.return_value = svc - response = await git_client["client"].post( - "/api/git/branches/cleanup", - json={"project_slug": git_client["project"].slug}, - headers=_HDR, - ) - assert response.status_code == HTTPStatus.FORBIDDEN - assert "BRANCH_CLEANUP_ROLE_RESTRICTED" in response.json()["detail"] - mock_get.assert_not_called() - - -@pytest.mark.asyncio -async def test_cleanup_branches_project_not_found(pm_git_client: dict) -> None: - response = await pm_git_client["client"].post( - "/api/git/branches/cleanup", - json={"project_slug": "does-not-exist"}, - headers=_HDR, - ) - assert response.status_code == HTTPStatus.NOT_FOUND - - # Re-export to keep import alive (TC reorders imports) _ = SimpleNamespace diff --git a/tests/unit/gateway/test_choreographer_qa.py b/tests/unit/gateway/test_choreographer_qa.py index b9da0c22..429dc6d7 100644 --- a/tests/unit/gateway/test_choreographer_qa.py +++ b/tests/unit/gateway/test_choreographer_qa.py @@ -2,12 +2,14 @@ from __future__ import annotations +import asyncio from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest +from roboco.config import settings from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps @@ -80,8 +82,7 @@ async def test_claim_review_returns_evidence_inline() -> None: task_svc.qa_claim.return_value = t_claimed work_svc = AsyncMock() git_svc = AsyncMock() - git_svc.diff.return_value = "+++ diff content" - git_svc.list_changed_files.return_value = ["README.md"] + git_svc.diff_and_files.return_value = ("+++ diff content", ["README.md"]) deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc) c = Choreographer(deps) @@ -147,7 +148,7 @@ async def test_claim_review_marks_evidence_inspected() -> None: task_svc.list_paused_for_agent.return_value = [] task_svc.qa_claim.return_value = t_claimed git_svc = AsyncMock() - git_svc.diff.return_value = "" + git_svc.diff_and_files.return_value = ("", []) deps = _make_deps(task=task_svc, git=git_svc) c = Choreographer(deps) @@ -169,6 +170,69 @@ async def test_claim_review_task_not_found_returns_not_found() -> None: assert body["error"] == "not_found" +@pytest.mark.asyncio +async def test_claim_review_returns_gateway_timeout_on_slow_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Task #62845be1: the claim itself (qa_claim + mark_evidence_inspected) + already committed before evidence assembly starts. A genuinely slow + evidence segment (git diff/fetch, conventions validation, or a DB read) + must trip the bounded ``evidence_assembly_timeout_seconds`` guard and + return a structured ``gateway_timeout`` envelope naming the slow + component and pointing at the already-succeeded claim — not hang into + the outer 120s server-side rollback.""" + monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05) + qa_id = uuid4() + task_id = uuid4() + t_initial = MagicMock( + id=task_id, + status="awaiting_qa", + assigned_to=None, + pr_number=_EXPECTED_PR_NUMBER, + pr_url=_EXPECTED_PR_URL, + commits=[{"sha": "abc123", "message": "feat: x"}], + team="backend", + branch_name="feature/backend/abc--def", + work_session_id=uuid4(), + documents=[], + dev_notes="implemented x", + acceptance_criteria=["AC1"], + acceptance_criteria_status=[ + {"criterion": "AC1", "referencing_artifact_id": "abc123"}, + ], + ) + t_claimed = MagicMock( + **{**t_initial.__dict__, "assigned_to": qa_id, "status": "claimed"}, + ) + task_svc = AsyncMock() + task_svc.get.return_value = t_initial + task_svc.agent_for.return_value = MagicMock(role="qa", team="backend") + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + task_svc.qa_claim.return_value = t_claimed + work_svc = AsyncMock() + git_svc = AsyncMock() + + async def _slow_diff_and_files(**_kwargs: object) -> tuple[str, list[str]]: + await asyncio.sleep(1) + return "+++ diff content", ["README.md"] + + git_svc.diff_and_files.side_effect = _slow_diff_and_files + deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc) + c = Choreographer(deps) + + env = await c.claim_review(qa_id, task_id) + body = env.as_dict() + + assert body["error"] == "gateway_timeout" + assert "evidence" in body["message"].lower() + # The claim itself already committed — the remediation must not tell the + # agent to retry claim_review (which would re-run the whole slow path). + assert "evidence(task_id)" in body["remediate"] + task_svc.qa_claim.assert_awaited_once() + task_svc.mark_evidence_inspected.assert_awaited_once_with(task_id) + + @pytest.mark.asyncio async def test_pass_review_task_not_found_returns_not_found() -> None: """Line 117 of qa.py: _verify_qa_owner emits not_found when task is None.""" @@ -320,94 +384,6 @@ async def test_pass_review_succeeds_and_transitions() -> None: a2a_svc.send.assert_awaited_once() -@pytest.mark.asyncio -async def test_pass_review_rejects_without_criteria_verified_when_acs_present() -> None: - """A task with real acceptance criteria demands criteria_verified — a - gestalt "looks good" notes string alone is no longer enough.""" - qa_id = uuid4() - task_id = uuid4() - t = _qa_owned_task( - task_id, qa_id, acceptance_criteria=["returns 200", "includes timestamp"] - ) - task_svc = AsyncMock() - task_svc.get.return_value = t - task_svc.agent_for.return_value = _qa_agent_mock(qa_id) - journal_svc = AsyncMock() - journal_svc.has_learning_for_task.return_value = True - deps = _make_deps(task=task_svc, journal=journal_svc) - c = Choreographer(deps) - - notes = "x" * 100 - env = await c.pass_review(qa_id, task_id, notes=notes) - body = env.as_dict() - assert body["error"] == "invalid_state", body - assert "returns 200" in body["message"] - assert "includes timestamp" in body["message"] - - -@pytest.mark.asyncio -async def test_pass_review_renders_criteria_verified_into_notes() -> None: - """Happy path: every AC matched + evidenced renders '[AC] ...' lines into - the persisted qa_notes and the transition still fires.""" - qa_id = uuid4() - task_id = uuid4() - t = _qa_owned_task( - task_id, qa_id, acceptance_criteria=["returns 200", "includes timestamp"] - ) - after = MagicMock( - id=task_id, - status="awaiting_documentation", - assigned_to=qa_id, - team="backend", - pr_url="https://x/pr/8", - qa_evidence_inspected=True, - ) - task_svc = AsyncMock() - task_svc.get.return_value = t - task_svc.agent_for.return_value = _qa_agent_mock(qa_id) - task_svc.qa_pass.return_value = after - task_svc.documenter_for_team.return_value = MagicMock(id=uuid4()) - task_svc.session = MagicMock() - task_svc.session.begin_nested = MagicMock( - return_value=MagicMock( - __aenter__=AsyncMock(return_value=None), - __aexit__=AsyncMock(return_value=False), - ) - ) - _stub_empty_ledger(task_svc.session) - journal_svc = AsyncMock() - journal_svc.has_learning_for_task.return_value = True - a2a_svc = AsyncMock() - deps = _make_deps(task=task_svc, journal=journal_svc, a2a=a2a_svc) - c = Choreographer(deps) - - notes = ( - "Reviewed PR carefully. Rendered every scene and checked each frame " - "against the brief before approving." - ) - env = await c.pass_review( - qa_id, - task_id, - notes=notes, - criteria_verified=[ - {"criterion": "returns 200", "evidence": "test_healthz asserts 200"}, - { - "criterion": "includes timestamp", - "evidence": "frame diff shows ts field at README.md line 12", - }, - ], - ) - assert env.error is None, env.as_dict() - assert env.status == "awaiting_documentation" - task_svc.qa_pass.assert_awaited_once() - persisted_notes = task_svc.qa_pass.call_args.args[2] - assert "[AC] returns 200 — verified: test_healthz asserts 200" in persisted_notes - assert ( - "[AC] includes timestamp — verified: frame diff shows ts field at " - "README.md line 12" in persisted_notes - ) - - @pytest.mark.asyncio async def test_pass_review_not_assigned_returns_not_authorized() -> None: qa_id = uuid4() @@ -488,34 +464,6 @@ async def test_fail_review_requires_at_least_one_issue() -> None: assert "finding" in body["message"].lower() -@pytest.mark.asyncio -async def test_fail_review_rejects_prose_file_names_evidence_in_remediate() -> None: - qa_id = uuid4() - task_id = uuid4() - t = _qa_owned_task(task_id, qa_id) - task_svc = AsyncMock() - task_svc.get.return_value = t - task_svc.agent_for.return_value = _qa_agent_mock(qa_id) - journal_svc = AsyncMock() - journal_svc.has_learning_for_task.return_value = True - deps = _make_deps(task=task_svc, journal=journal_svc) - c = Choreographer(deps) - - findings = [ - { - "file": "PR #676 description", - "severity": "major", - "expected": "matches the acceptance criteria", - "actual": "diverges from the acceptance criteria", - } - ] - env = await c.fail_review(qa_id, task_id, findings=findings) - body = env.as_dict() - assert body["error"] == "invalid_state" - assert "evidence" in body["remediate"] - assert "file" in body["remediate"] - - @pytest.mark.asyncio async def test_fail_review_not_assigned_returns_not_authorized() -> None: qa_id = uuid4() diff --git a/tests/unit/gateway/test_content_actions.py b/tests/unit/gateway/test_content_actions.py index 47cc6b70..96108e40 100644 --- a/tests/unit/gateway/test_content_actions.py +++ b/tests/unit/gateway/test_content_actions.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -32,6 +34,7 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps: git = AsyncMock() git.commit.return_value = {"sha": "abc12345"} git.diff.return_value = "" + git.diff_and_files.return_value = ("", []) a2a = overrides.get("a2a", AsyncMock()) journal = overrides.get("journal", AsyncMock()) @@ -645,7 +648,10 @@ async def test_evidence_valid_task_returns_ok_with_pr_diff() -> None: task_svc = AsyncMock() task_svc.get.return_value = task_obj git_svc = AsyncMock() - git_svc.diff.return_value = "diff --git a/foo.py b/foo.py\n+added line" + git_svc.diff_and_files.return_value = ( + "diff --git a/foo.py b/foo.py\n+added line", + ["foo.py"], + ) workspace_svc = AsyncMock() deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc) @@ -659,7 +665,7 @@ async def test_evidence_valid_task_returns_ok_with_pr_diff() -> None: assert body["evidence"]["pr_number"] == pr_number assert "diff --git" in body["evidence"]["pr_diff_summary"] workspace_svc.fetch_branch_for_inspection.assert_awaited_once() - git_svc.diff.assert_awaited_once() + git_svc.diff_and_files.assert_awaited_once() @pytest.mark.asyncio @@ -680,6 +686,97 @@ async def test_evidence_task_not_found_returns_not_found() -> None: assert str(task_id) in body["message"] +@pytest.mark.asyncio +async def test_evidence_returns_gateway_timeout_on_slow_git( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Task #62845be1: a genuinely slow git diff/fetch trips the bounded + ``evidence_assembly_timeout_seconds`` guard and returns a structured + ``gateway_timeout`` envelope naming the slow component — not a bare + 120s rollback of the outer verb.""" + monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05) + agent_id = uuid4() + task_id = uuid4() + task_obj = MagicMock( + id=task_id, + status="awaiting_qa", + assigned_to=None, + branch_name="feature/backend/abc", + work_session_id=uuid4(), + commits=["sha1"], + pr_number=1, + pr_url="https://github.com/org/repo/pull/1", + dev_notes="done", + acceptance_criteria_status=[], + ) + task_svc = AsyncMock() + task_svc.get.return_value = task_obj + + async def _slow_diff_and_files(**_kwargs: object) -> tuple[str, list[str]]: + await asyncio.sleep(1) + return "diff", ["f.py"] + + git_svc = AsyncMock() + git_svc.diff_and_files.side_effect = _slow_diff_and_files + workspace_svc = AsyncMock() + + deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc) + ca = ContentActions(deps) + + env = await ca.evidence(agent_id=agent_id, task_id=task_id) + body = env.as_dict() + + assert body["error"] == "gateway_timeout" + assert "git" in body["message"].lower() + assert "0" in body["message"] # names the ~0.05s bounded timeout + + +@pytest.mark.asyncio +async def test_evidence_returns_gateway_timeout_on_slow_db_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The bounded timeout also fires when the slow segment is a DB read + rather than git — proving the guard covers the whole gathered batch, + not just the git branch.""" + monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05) + agent_id = uuid4() + task_id = uuid4() + task_obj = MagicMock( + id=task_id, + status="awaiting_qa", + assigned_to=None, + branch_name="feature/backend/abc", + work_session_id=uuid4(), + commits=["sha1"], + pr_number=1, + pr_url="https://github.com/org/repo/pull/1", + dev_notes="done", + acceptance_criteria_status=[], + ) + task_svc = AsyncMock() + task_svc.get.return_value = task_obj + git_svc = AsyncMock() + git_svc.diff_and_files.return_value = ("diff", ["f.py"]) + workspace_svc = AsyncMock() + evidence_repo = AsyncMock() + + async def _slow_journal_highlights(*_args: object, **_kwargs: object) -> list[Any]: + await asyncio.sleep(1) + return [] + + evidence_repo.journal_highlights_for_task.side_effect = _slow_journal_highlights + + deps = _make_deps( + task=task_svc, git=git_svc, workspace=workspace_svc, evidence_repo=evidence_repo + ) + ca = ContentActions(deps) + + env = await ca.evidence(agent_id=agent_id, task_id=task_id) + body = env.as_dict() + + assert body["error"] == "gateway_timeout" + + # --------------------------------------------------------------------------- # notify: invalid priority and explicit-ownership rejections # --------------------------------------------------------------------------- diff --git a/tests/unit/services/test_git_conventions_check_fail_closed.py b/tests/unit/services/test_git_conventions_check_fail_closed.py index 3fc6b39f..168ffc10 100644 --- a/tests/unit/services/test_git_conventions_check_fail_closed.py +++ b/tests/unit/services/test_git_conventions_check_fail_closed.py @@ -141,7 +141,13 @@ async def test_validator_timeout_fails_closed_and_reaps( return fake_proc monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) - monkeypatch.setattr(git_module, "_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS", 0.01) + # The fixed module constant became a settings-backed accessor (task + # #62845be1: bounded well under the outer 120s gateway-verb budget + # instead of matching it) — patch the setting, not the removed module + # attribute. + monkeypatch.setattr( + git_module.settings, "conventions_validator_timeout_seconds", 0.01 + ) svc = _service() result = await svc._run_conventions_validator(tmp_path, ["a.py"]) diff --git a/tests/unit/services/test_git_diff_and_files.py b/tests/unit/services/test_git_diff_and_files.py new file mode 100644 index 00000000..458ac712 --- /dev/null +++ b/tests/unit/services/test_git_diff_and_files.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"]