Files
roboco/roboco/services/git.py
T
89254f796c [62845be1] Fix claim_review/evidence 120s timeout: dedupe git calls, parallelize DB reads, bound conventions-validator timeout, add bounded-timeout guard (#756)
* [62845be1] test(gateway): prove claim_review/evidence timeout fix — dedup, timeout guards

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

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

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

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

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
2026-07-31 12:55:52 +00:00

5550 lines
234 KiB
Python

"""
Git Service
Handles git operations for agents working on code tasks.
All business logic for git commands, commit templates, PR generation.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID
import httpx
if TYPE_CHECKING:
from collections.abc import Coroutine
from sqlalchemy.ext.asyncio import AsyncSession
# `api.schemas.git` would trigger `api/__init__.py` (which historically
# loaded the FastAPI app + every route module). The package init no
# longer re-exports `app`, but keeping these imports type-only respects
# the layer rule: services don't depend on the api layer at runtime.
# Routes already pass the Pydantic models, so the methods are duck-typed
# at call time.
from roboco.api.schemas.git import (
GitCheckoutRequest,
GitCommitRequest,
GitCreateBranchRequest,
GitCreatePRRequest,
GitMergePRRequest,
)
from roboco.db.tables import TaskTable
from roboco.config import settings
from roboco.exceptions import (
GitCommandError,
GitError,
GitTimeoutError,
MergeConflictError,
)
from roboco.foundation.policy import lifecycle
from roboco.models.base import AgentRole, TaskStatus
from roboco.services.base import (
BaseService,
NotFoundError,
ServiceError,
UnauthorizedError,
ValidationError,
)
from roboco.services.gateway.quality_gate import GateResult, run_quality_commands
from roboco.services.project import get_project_service
from roboco.services.task import TaskService, get_task_service
from roboco.services.work_session import get_work_session_service
from roboco.services.workspace import (
WorkspaceError,
WorkspaceService,
get_workspace_service,
)
from roboco.templates.git import (
BranchNameError,
CommitContext,
InternalPRContext,
RootPRContext,
SubtaskInfo,
build_branch_name,
build_commit_message,
build_pr_body_internal,
build_pr_body_root,
build_pr_title_internal,
build_pr_title_root,
get_root_task_id,
)
from roboco.templates.git.pr_internal import InternalCommitInfo
from roboco.templates.git.pr_root import CommitInfo as PRCommitInfo
from roboco.utils.converters import require_uuid
# Git command timeout in seconds. Sourced from settings so operators can
# raise it without a code change; `_run_git` accepts a per-call override for
# the rare long-running op (staging/committing a large changeset). Read via a
# helper rather than at import time so a test-time settings patch is honored.
def _default_git_timeout() -> int:
return settings.git_command_timeout_seconds
def _commit_git_timeout() -> int:
return settings.git_commit_timeout_seconds
async def _await_shielded[T](coro: Coroutine[Any, Any, T]) -> T:
"""Run a session write shielded from cancellation, waiting it out.
A bare ``asyncio.shield(coro)`` detaches the write on cancellation but
lets the CancelledError propagate immediately — the still-running write
then races ``get_db``'s rollback on the SAME AsyncSession and asyncpg
raises ``InterfaceError: another operation is in progress`` (a 500
instead of the middleware's clean 504). On cancellation, await the
in-flight write to completion BEFORE re-raising, so the session is quiet
by the time the rollback runs. Mirrors
``VideoPostService._commit_shielded``.
"""
task = asyncio.ensure_future(coro)
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
await asyncio.gather(task, return_exceptions=True)
raise
def _completed_branchful_children(children: list[Any]) -> list[Any]:
"""Children whose completed work can be integrity-checked: completed,
branch-bearing, with recorded commits."""
return [
c
for c in children
if str(getattr(c, "status", "")) in ("completed", "TaskStatus.COMPLETED")
and getattr(c, "branch_name", None)
and getattr(c, "commits", None)
]
def _network_git_timeout() -> int:
"""Budget for git ops that talk to origin (fetch / pull / push).
A push/fetch on a large private monorepo from a self-hosted runner can
take far longer than the sub-second local-op default — short-budgeting it
is what made open_pr time out before the branch ever reached the remote.
"""
return settings.git_network_timeout_seconds
# Dedicated thread pool for git subprocesses + the post-op ownership repair.
# These run in threads (subprocess + an os.walk chown); under concurrent agent
# load they must not queue behind — or starve — the event loop's default
# executor that every other to_thread call shares. A bounded dedicated pool
# isolates git work and caps host parallelism so a burst of commits/pushes
# can't thrash the box. Never shut down: lives for the process.
_GIT_EXECUTOR = ThreadPoolExecutor(
max_workers=int(os.environ.get("ROBOCO_GIT_EXECUTOR_WORKERS", "16")),
thread_name_prefix="git",
)
# A git subprocess or ownership repair slower than this is logged so a run can
# pinpoint where time goes (e.g. a push to origin). Below it, the fast path
# stays quiet. Set above the normal cost of a push/fetch to a private monorepo
# from a self-hosted runner (~1-2s) so routine ops don't spam "slow git op";
# only a genuinely slow op (>5s) is worth a warning.
_SLOW_GIT_OP_MS = 5000.0
def resolve_git_dir(workspace: Path) -> Path | None:
"""Resolve the real ``.git`` directory for a workspace or linked worktree.
A normal clone's ``.git`` is a directory. A linked worktree's ``.git`` is a
*file* containing ``gitdir: <path>`` pointing into the clone root's
``.git/worktrees/<id>/``. Callers that rglob locks / parse config must go
through here, not assume ``workspace / ".git"`` is a directory.
Returns the resolved git dir, or None if the workspace has no git metadata.
"""
dot_git = workspace / ".git"
if dot_git.is_dir():
return dot_git
if dot_git.is_file():
try:
first = dot_git.read_text().splitlines()[0].strip()
except (OSError, IndexError):
return None
if not first.startswith("gitdir: "):
return None
target = Path(first[len("gitdir: ") :].strip())
if not target.is_absolute():
target = (workspace / target).resolve()
return target if target.is_dir() else None
return None
def _remove_stale_git_locks(workspace: Path) -> None:
"""Best-effort removal of orphaned ``.git/**/*.lock`` files.
F019: a git mutation op (commit / merge --ff-only / rebase / reset --hard /
add) killed by ``_run_git``'s timeout (SIGKILL) can orphan lock files —
``index.lock`` especially — that wedge the workspace for every subsequent
op (incl. the next fresh-claim ``reset --hard``) with
"Another git process seems to be running in this repository". By the time
the timeout fires the git process is dead, so its orphaned locks are safe
to remove. Best-effort: any error (no .git, race with a real process) is
swallowed — this only ever *helps*, never blocks.
Worktree-aware (F123): a linked worktree's ``.git`` is a gitdir pointer —
route through ``resolve_git_dir`` so locks inside ``.git/worktrees/<id>/``
are reached.
"""
git_dir = resolve_git_dir(workspace)
if git_dir is None or not git_dir.is_dir():
return
try:
for lock in git_dir.rglob("*.lock"):
# A lock a real process just grabbed, or a permission issue —
# leave it. The TTL/next-op path is the backstop.
with contextlib.suppress(OSError):
lock.unlink()
except OSError:
return
# Verbs that never write anything — `_run_git`'s post-op ownership repair is
# pure waste after these (live NAS: a "rev-parse --verify" cost 5165ms of
# chown for a command that touches nothing). Read-only forms of `branch` /
# `symbolic-ref` are handled separately below since they share a verb name
# with a mutating form.
_READ_ONLY_GIT_VERBS = frozenset(
{
"status",
"log",
"diff",
"rev-parse",
"rev-list",
"ls-remote",
"merge-base",
"show",
"cherry",
}
)
# Verbs that write only inside `.git/` (refs, objects, index) — never the
# working tree. Ownership repair can be scoped to `.git/` alone instead of a
# full-workspace walk.
_GIT_SCOPED_VERBS = frozenset({"add", "commit", "fetch", "push"})
def _branch_or_symbolic_ref_scope(verb: str, rest: list[str]) -> str:
"""Query vs SET form of `branch` / `symbolic-ref` (same verb, different
scope): query reads only, SET writes a ref under `.git/`.
Query: `branch --show-current` (0 positional) or `symbolic-ref [-q]
<name>` (1 positional — naming which ref to read). SET (`branch <name>
<start>`, `symbolic-ref <name> <ref>`) always carries 2+ positional args.
"""
positional = [a for a in rest if not a.startswith("-")]
is_query = (verb == "branch" and not positional) or (
verb == "symbolic-ref" and len(positional) <= 1
)
return "none" if is_query else "git"
def _git_ownership_scope(args: list[str]) -> str:
"""Classify a git invocation's post-op ownership-repair scope.
Root cause of the chown cost: the orchestrator's git subprocess runs as
root, so a MUTATING op leaves root-owned files under `.git/` (and, for
checkout/reset/rebase/pull, the working tree) that the agent container
(uid 1000) can't write. A read-only op never writes anything, so
repairing ownership after one is pure waste — on the NAS this cost
5-10s PER git call, and a single `i_am_done` chains ~a dozen ops.
Returns "none" (skip repair — zero syscalls), "git" (repair `.git/`
only, worktree-aware via `_resolve_clone_root`), or "full" (repair the
whole workspace — unchanged behavior, and the safe default for
checkout/reset/rebase/pull/stash or any verb this classifier doesn't
recognize, so an unclassified op is never under-repaired). `stash`
(rebase_onto_base's `stash=True` path, #337 scoping) writes both the
working tree (push/pop) and `.git/` (the stash ref + objects) — it is
deliberately left unclassified so it falls to the safe "full" default
rather than a `.git/`-only repair that would strand agent-owned files.
"""
if not args:
return "full"
verb = args[0]
if verb in _READ_ONLY_GIT_VERBS:
return "none"
if verb in ("branch", "symbolic-ref"):
return _branch_or_symbolic_ref_scope(verb, args[1:])
if verb in _GIT_SCOPED_VERBS:
return "git"
return "full"
# `_get_gh_env` and the gh-CLI code paths were removed in favor of direct
# GitHub REST API calls — no CLI dependency, and the PAT no longer touches
# subprocess argv / environ.
# Expected number of parts in various git outputs
_REV_LIST_PARTS = 2
# GitHub REST API status codes
_GH_UNPROCESSABLE = 422
# 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.
_HTTP_NOT_FOUND = 404
# 409 means the PR can't be merged in its current state — typically because
# a concurrent sibling-subtask merge updated the target branch and our local
# refs are stale. `pr_merge` re-syncs and retries exactly once on this code.
_HTTP_CONFLICT = 409
# GitHub returns 405 when the repo's settings disallow the requested merge
# method (e.g. "Squash merges are not allowed on this repository" with the
# squash button off) — distinct from a 405 on an already-merged PR. The agent
# `_merge_with_retry` falls back to a permitted method on this code, mirroring
# the CEO `merge_pull_request` path.
_HTTP_METHOD_NOT_ALLOWED = 405
# --- Self-heal CI signal -------------------------------------------------
# Pull a WINDOW of recent completed runs (not just the single newest) so the
# conclusion can be resolved against the branch's current HEAD rather than
# whichever run finished most recently — otherwise a green run on an older
# 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. 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
# run) are not failing.
_FAILING_CHECK_CONCLUSIONS = frozenset(
{"failure", "cancelled", "timed_out", "action_required"}
)
# A 404 resolving a PR's head SHA means the repo/PR is unreachable or doesn't
# exist — classified as no_ci_configured, distinct from any other non-2xx
# (a genuine API failure on a real, reachable repo) which is `error`.
_HTTP_NOT_FOUND = 404
def _select_ci_head_run(runs: list[dict[str, Any]]) -> dict[str, Any]:
"""Pick the run reflecting the branch's current-HEAD CI conclusion.
GitHub returns completed runs newest-created first, so ``runs[0]`` belongs to
the newest commit. Among all returned runs sharing that ``head_sha`` we take
the highest ``run_attempt`` so a green re-run supersedes the original
failure. This stops a stale green run on an older commit (or an unrelated
workflow on the all-workflows endpoint) from masking the HEAD failure.
"""
head_sha = runs[0].get("head_sha")
same_head = [r for r in runs if r.get("head_sha") == head_sha] or [runs[0]]
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 (owner/repo, branch, token,
slug for logging) so ``_fetch_latest_ci_run`` stays under the arg-count gate —
owner_repo alone was already bundled for the same reason."""
project_slug: str
owner_repo: tuple[str, str]
branch: str
git_token: str
class GitService(BaseService):
"""
Service for git operations on agent workspaces.
Handles:
- Git command execution
- Commit creation with templates
- PR creation with templates
- Branch management
"""
service_name: ClassVar[str] = "git"
async def _run_git(
self,
workspace: Path,
args: list[str],
check: bool = True,
token: str | None = None,
timeout: int | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a git command in the workspace (non-blocking).
If `token` is given, injects an Authorization header via `-c
http.extraheader=...` for this one invocation. The token never
lands in argv (it goes in a config flag that git consumes and
then forgets) and never touches `.git/config` on disk. Use this
for push / fetch / ls-remote — any op that talks to origin.
`timeout` overrides the default per-command budget
(``settings.git_command_timeout_seconds``). Staging and committing
a large changeset (e.g. the Next.js panel) can exceed the short
default while git hashes every object and we re-chown the tree, so
the commit choreography passes the longer
``settings.git_commit_timeout_seconds``.
After every orchestrator-side git op, hand ownership back to the
agent user — SCOPED to what this op could actually have written
(see `_git_ownership_scope`). Git commands here run as root and
create root-owned files under .git/ (refs, logs/refs, packed-refs,
index, objects). If we don't re-chown, the agent container (uid
1000) can't append to those files on its next commit and fails
with "unable to append to .git/logs/refs/heads/...". A read-only
op (status, log, diff, ...) never writes, so it skips the repair
entirely.
"""
effective_timeout = timeout if timeout is not None else _default_git_timeout()
prefix: list[str] = []
if token:
# GitHub's git-over-HTTPS (smart HTTP) authenticates with HTTP
# Basic, NOT Bearer. Using Bearer here causes git to fall
# through to the credential prompt and fail with
# "could not read Username for 'https://github.com'".
# Bearer is only correct for the REST API (PR create/merge,
# via httpx) — keep them separate.
basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
prefix = [
"-c",
f"http.extraheader=Authorization: Basic {basic}",
]
def _run() -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *prefix, *args],
cwd=workspace,
capture_output=True,
text=True,
timeout=effective_timeout,
check=check,
)
loop = asyncio.get_running_loop()
op = " ".join(args[:2])
t0 = time.monotonic()
try:
result = await loop.run_in_executor(_GIT_EXECUTOR, _run)
except subprocess.TimeoutExpired as e:
# Timed-out git process was SIGKILL'd mid-mutation and may orphan
# .git/*.lock files; clear them so the workspace isn't wedged.
await loop.run_in_executor(
_GIT_EXECUTOR, _remove_stale_git_locks, workspace
)
raise GitTimeoutError(" ".join(args), effective_timeout) from e
except subprocess.CalledProcessError as e:
raise GitCommandError(
" ".join(args), e.stderr or e.stdout or "Unknown error"
) from e
git_ms = (time.monotonic() - t0) * 1000.0
chown_ms = await self._reown_after_git_op(loop, workspace, args)
# Surface slow git/chown ops (instrumentation): a single line that
# pinpoints where an op's time went — the subprocess (e.g. a push to
# origin) vs the ownership repair — without flooding the fast path.
if git_ms > _SLOW_GIT_OP_MS or chown_ms > _SLOW_GIT_OP_MS:
self.log.warning(
"slow git op",
op=op,
git_ms=round(git_ms),
chown_ms=round(chown_ms),
timeout_s=effective_timeout,
workspace=str(workspace),
)
return result
@staticmethod
async def _reown_after_git_op(
loop: asyncio.AbstractEventLoop, workspace: Path, args: list[str]
) -> float:
"""Run the scope-appropriate post-op ownership repair; return its ms cost.
Extracted out of `_run_git` so the classify-then-dispatch logic stays
out of that method's cyclomatic budget. Runs in the dedicated git
executor so it doesn't compete with the event loop's default pool.
A "none"-scope op costs zero syscalls and returns 0.0 so the slow-op
instrumentation still sees the true (near-zero) cost.
"""
from roboco.services.workspace import _ensure_agent_owned, _ensure_git_dir_owned
scope = _git_ownership_scope(args)
if scope == "none":
return 0.0
repair = _ensure_git_dir_owned if scope == "git" else _ensure_agent_owned
t1 = time.monotonic()
await loop.run_in_executor(_GIT_EXECUTOR, repair, workspace)
return (time.monotonic() - t1) * 1000.0
async def _token_for_project(self, project_slug: str) -> str | None:
"""Decrypted project token for orchestrator-side remote git ops.
A decryption failure (an encryption-key rotation left the stored PAT
encrypted with the old key) is logged loudly with the project slug
before returning None — without this every best-effort workspace git
op (push, PR, clone-with-token) silently looks like 'this project has
no token', indistinguishable from a project that genuinely never set
one, and the operator can't tell which project a key rotation wedged.
The best-effort skip behavior is preserved (callers still get None and
skip the remote op); this only makes the cause diagnosable.
"""
from roboco.utils.crypto import EncryptionError
project_service = get_project_service(self.session)
try:
return await project_service.get_decrypted_token_by_slug(project_slug)
except EncryptionError as exc:
self.log.error(
"git token decryption failed — encryption key may have rotated;"
" treating as no-token for this project's remote git ops",
project_slug=project_slug,
error=str(exc),
)
return None
async def _token_for_workspace(self, workspace: Path) -> str | None:
"""Derive project_slug from workspace path, then load its token.
Workspace layout is `/data/workspaces/{project}/{team}/{agent}/`, so
the project slug is the first component after `/data/workspaces/`.
Returns None if it can't be derived or the project has no token.
"""
try:
parts = (
workspace.resolve().relative_to(Path(settings.workspaces_root)).parts
)
except (ValueError, OSError):
return None
if not parts:
return None
return await self._token_for_project(parts[0])
async def get_workspace(
self, project_slug: str, agent_id: UUID | None = None
) -> Path:
"""Get the workspace path for an agent on a project."""
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project:
raise NotFoundError("Project", project_slug)
if agent_id is None:
if not project.workspace_path:
raise ValidationError(
f"Project '{project_slug}' has no workspace configured "
"and no agent_id provided for dynamic workspace resolution"
)
workspace = Path(project.workspace_path)
if not workspace.exists():
raise ValidationError(f"Workspace path does not exist: {workspace}")
return workspace
workspace_service = get_workspace_service(self.session)
try:
if settings.workspace_auto_clone:
workspace = await workspace_service.ensure_workspace(
project_slug=project_slug,
agent_id=agent_id,
git_url=project.git_url,
default_branch=project.default_branch or "master",
)
else:
workspace = await workspace_service.resolve_workspace(
project_slug=project_slug,
agent_id=agent_id,
)
if not workspace.exists():
raise ValidationError(
f"Workspace does not exist: {workspace}. "
"Clone the repository first or enable auto_clone."
)
except WorkspaceError as e:
raise ValidationError(str(e)) from e
return workspace
async def branch_exists_on_remote(
self,
project_slug: str,
branch_name: str,
agent_id: UUID | None = None,
) -> bool | None:
"""Probe whether ``branch_name`` exists on the project's ``origin``.
Returns True when the ref is present, False when confirmed absent, and
None when the probe itself errored (network blip, missing workspace or
token) — callers fail-soft on None so a transient glitch can't fail a
normal claim. Mirrors the ``ls-remote --heads origin <branch>`` idiom
in :meth:`create_branch`'s parent-branch check, reusing the same
workspace + decrypted-token resolution so the probe is authoritative.
"""
try:
workspace = await self.get_workspace(project_slug, agent_id)
token = await self._token_for_project(project_slug)
result = await self._run_git(
workspace,
["ls-remote", "--heads", "origin", branch_name],
check=False,
token=token,
timeout=_network_git_timeout(),
)
except Exception as exc:
self.log.warning(
"branch_exists_on_remote probe failed; failing soft",
project_slug=project_slug,
branch_name=branch_name,
error=str(exc),
)
return None
return bool(result.stdout.strip())
# =========================================================================
# STATUS / INFO METHODS
# =========================================================================
_PORCELAIN_STATUS_WIDTH: ClassVar[int] = 2
@staticmethod
def _classify_porcelain(
lines: list[str],
) -> tuple[list[str], list[str], list[str]]:
"""Split `git status --porcelain` lines into staged/unstaged/untracked.
Canonical porcelain format is `XY PATH` — 2 status chars, 1 space
separator, filename. Slicing `line[3:]` ASSUMES both X and Y are
always present as chars. Git sometimes emits a single-column
status (e.g., `M README.md` from very old git or non-canonical
callers), which trips `line[3:]` into producing "EADME.md".
Split on the first whitespace run to survive either layout.
"""
staged: list[str] = []
unstaged: list[str] = []
untracked: list[str] = []
min_len = GitService._PORCELAIN_STATUS_WIDTH + 1 # status + separator
expected_parts = 2 # split gives [status, path]
for line in lines:
if not line or len(line) < min_len:
continue
# Canonical: XY is positions [0:2], separator at [2], path [3:].
# Defensive: if the char at position 2 isn't whitespace (broken
# status line), fall back to splitting on whitespace.
if line[2] in (" ", "\t"):
status_code = line[:2]
file_path = line[3:]
else:
parts = line.split(maxsplit=1)
if len(parts) < expected_parts:
continue
status_code = parts[0].ljust(GitService._PORCELAIN_STATUS_WIDTH)[
: GitService._PORCELAIN_STATUS_WIDTH
]
file_path = parts[1]
if not file_path:
continue
if status_code[0] in "MADRC":
staged.append(file_path)
if status_code[1] in "MADRC":
unstaged.append(file_path)
if status_code == "??":
untracked.append(file_path)
return staged, unstaged, untracked
async def _ahead_behind(self, workspace: Path, branch: str) -> tuple[int, int]:
"""Return (ahead, behind) vs origin/<branch>; 0,0 on any error."""
try:
rev_cmd = f"{branch}...origin/{branch}"
rev_result = await self._run_git(
workspace, ["rev-list", "--left-right", "--count", rev_cmd], check=False
)
if rev_result.returncode != 0:
return 0, 0
parts = rev_result.stdout.strip().split()
if len(parts) != _REV_LIST_PARTS:
return 0, 0
return int(parts[0]), int(parts[1])
except GitError:
return 0, 0
async def get_status(
self, workspace: Path
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
"""Get git status for a workspace.
Returns tuple of:
(current_branch, has_changes, staged, unstaged, untracked, ahead, behind)
"""
branch_result = await self._run_git(workspace, ["branch", "--show-current"])
current_branch = branch_result.stdout.strip()
status_result = await self._run_git(workspace, ["status", "--porcelain"])
# Use splitlines(), NOT stdout.strip().split("\n"): porcelain encodes the
# index column in position 0, which is a SPACE for a worktree-only change
# (e.g. " D file" = unstaged deletion). strip() eats that leading space on
# the first line, turning " D file" into "D file" — which then parses as a
# STAGED deletion. That false "staged" caused 6 wasted QA cycles when a
# dev deleted a file but hadn't staged it. splitlines() preserves columns.
lines = status_result.stdout.splitlines() if status_result.stdout else []
staged_files, unstaged_files, untracked_files = self._classify_porcelain(lines)
ahead, behind = await self._ahead_behind(workspace, current_branch)
has_changes = bool(staged_files or unstaged_files or untracked_files)
return (
current_branch,
has_changes,
staged_files,
unstaged_files,
untracked_files,
ahead,
behind,
)
async def get_current_branch(self, workspace: Path) -> str:
"""Get the current branch name.
`git branch --show-current` returns empty on detached HEAD. Raise
a clear error in that case — previously we returned the empty
string, and callers forwarded that to downstream git commands or
fell back to parsing `git branch` (plain), which produces the
literal "(HEAD detached at ...)" that then leaked into
`checkout -b` as a would-be branch name. Fail loud instead.
"""
result = await self._run_git(workspace, ["branch", "--show-current"])
branch = result.stdout.strip()
if not branch:
raise GitError(
"Workspace is on a detached HEAD (no current branch). "
"Checkout an actual branch before running this operation.",
{"workspace": str(workspace)},
)
return branch
# =========================================================================
# COMMIT METHODS
# =========================================================================
@staticmethod
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
"""
path_match = re.search(
r"github\.com[:/]+(?P<owner>[^/]+)/(?P<repo>[^/\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) -> tuple[str, str]:
"""Read the origin remote URL from a workspace and parse owner/repo."""
cfg = workspace / ".git" / "config"
try:
text = cfg.read_text()
except OSError as e:
raise GitError(
f"Could not read git config: {e}",
{"workspace": str(workspace)},
) from e
match = re.search(
r"^\s*url\s*=\s*(?P<url>\S+)",
text,
flags=re.MULTILINE,
)
if not match:
raise GitError(
"No remote URL in git config",
{"workspace": str(workspace)},
)
return self._parse_git_url(match.group("url"))
def _get_primary_session_id(self, _task: TaskTable | None) -> str | None:
"""Discussion sessions are retired; always None.
``CommitContext.session_id`` is Optional and the commit template
renders the trailer only ``if set``, so None is safe.
"""
return None
def _parse_commit_stats(self, stat_output: str) -> tuple[int, int, int]:
"""Parse git diff --stat output for insertions, deletions, files_changed."""
insertions, deletions, files_changed = 0, 0, 0
for line in stat_output.split("\n"):
if "insertion" not in line and "deletion" not in line:
continue
parts = line.split(",")
for part in parts:
if "insertion" in part:
insertions = int(part.strip().split()[0])
elif "deletion" in part:
deletions = int(part.strip().split()[0])
elif "file" in part:
files_changed = int(part.strip().split()[0])
return insertions, deletions, files_changed
async def create_commit(
self,
workspace: Path,
agent_id: UUID,
request: GitCommitRequest,
) -> tuple[str, str, int, int, int]:
"""Create a git commit with template-based message.
When ``request.task_id`` is ``None`` the traceability template is
skipped and a plain conventional-commit message is built instead
(``type(scope): description``). The git commit still happens; the
commit is just not linked to any task record.
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
"""
# Stage files. Large changesets get the longer commit-timeout budget
# (see `git_commit_timeout_seconds`) — the same reason the gateway
# `commit()` adapter uses it.
commit_timeout = _commit_git_timeout()
if request.files:
for file in request.files:
await self._run_git(workspace, ["add", file], timeout=commit_timeout)
else:
await self._run_git(workspace, ["add", "-A"], timeout=commit_timeout)
if request.task_id is not None:
# Get task info for commit template
task_service = get_task_service(self.session)
task = await task_service.get(request.task_id)
# Get root task ID (walk up hierarchy)
root_task_id = await get_root_task_id(request.task_id, task_service)
# Get session ID
session_id = self._get_primary_session_id(task)
# Build commit message using template
commit_ctx = CommitContext(
task_id=str(request.task_id),
root_task_id=str(root_task_id),
agent_slug=str(agent_id),
session_id=session_id,
commit_type=request.commit_type,
scope=request.scope,
description=request.message,
body=request.body,
)
full_message = build_commit_message(
commit_ctx, settings.public_base_url.rstrip("/") + "/api"
)
else:
# No task context — use plain conventional-commit format so the
# git op still proceeds without raising CommitMessageError.
type_scope = (
f"{request.commit_type}({request.scope})"
if request.scope
else request.commit_type
)
full_message = f"{type_scope}: {request.message}"
if request.body:
full_message = f"{full_message}\n\n{request.body}"
# Create commit with agent attribution
author = f"{agent_id} <{agent_id}@roboco.ai>"
await self._run_git(
workspace,
["commit", "-m", full_message, "--author", author],
timeout=commit_timeout,
)
# Get commit info
log_result = await self._run_git(workspace, ["log", "-1", "--format=%H|%s"])
parts = log_result.stdout.strip().split("|")
commit_hash = parts[0] if parts else "unknown"
# Get stats
stat_result = await self._run_git(workspace, ["diff", "--stat", "HEAD~1..HEAD"])
insertions, deletions, files_changed = self._parse_commit_stats(
stat_result.stdout
)
return commit_hash, full_message, files_changed, insertions, deletions
async def _assert_task_owned_with_branch(
self, task_id: UUID, agent_id: UUID
) -> TaskTable:
"""Load task + check assignee + branch. Raises typed service errors."""
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(task_id))
if task.assigned_to != agent_id:
raise UnauthorizedError(
action="commit",
reason=(
"NOT_ASSIGNED: Only the assigned agent can operate on "
"this task's branch."
),
)
if not task.branch_name:
raise ValidationError(
"NO_BRANCH: Task has no branch set. Claim the task to "
"generate one before committing."
)
return task
@staticmethod
def _worktree_for_task(clone_root: Path, task_id: UUID) -> Path:
"""Per-task worktree path under a clone root (F123).
Matches ``create_branch``'s ``{clone_root}/.worktrees/{task_id[:8]}``
layout so commit/checkout/rebase paths resolve the same worktree the
claim cut and the spawn cwd pointed the agent at.
"""
return clone_root / ".worktrees" / str(task_id)[:8]
async def _ensure_worktree_for_commit(
self, clone_root: Path, worktree: Path, branch: str | None
) -> None:
"""Ensure a task's worktree is attached before a cwd-dependent git op.
Resume re-adds a pruned worktree, but a worktree can also be evicted
mid-task (disk pressure, manual cleanup); a commit/checkout against a
missing dir fails opaquely. Idempotent — no-op when the worktree is
present, re-adds (no ``-b``) from the surviving branch ref when pruned.
"""
if not branch:
return
workspace_service = get_workspace_service(self.session)
await workspace_service.ensure_worktree_for_resume(clone_root, worktree, branch)
async def _assert_on_task_branch(
self, workspace: Path, task_branch: str | None
) -> None:
"""Ensure the workspace is on the task's branch, recovering a resumed
clone that drifted — instead of hard-failing.
A dev/documenter/QA clone is shared across tasks; on a respawn/resume it
can sit on a sibling task's branch, or a re-provisioned clone can lack
the task branch as a local ref (commits only on origin). The
fresh-claim path git-reset-hards the clone, but resume short-circuits
before it — so the agent's next commit hit BRANCH_MISMATCH and the task
wedged in a blocked respawn loop (the documented resume deadlock). This
now fetches + checks out the task branch (recreating a missing local ref
from origin) and only raises if it genuinely cannot switch (uncommitted
changes block it). It NEVER discards local work — checkout, not reset, so
a resumed agent's unpushed commits are preserved.
"""
if not task_branch:
return
current_branch = await self.get_current_branch(workspace)
if not current_branch or current_branch == task_branch:
return
# Resumed/re-provisioned clone parked on the wrong branch — try to
# recover onto the task branch before rejecting.
token = await self._token_for_workspace(workspace)
local = await self._run_git(
workspace,
["rev-parse", "--verify", "--quiet", f"refs/heads/{task_branch}"],
check=False,
)
if local.returncode != 0:
# Local ref absent (re-provisioned clone) — recover it from origin.
await self._run_git(
workspace,
["fetch", "origin", task_branch],
token=token,
check=False,
timeout=_network_git_timeout(),
)
await self._run_git(
workspace,
["branch", task_branch, f"origin/{task_branch}"],
check=False,
)
switched = await self._run_git(
workspace, ["checkout", task_branch], check=False
)
if (
switched.returncode == 0
and await self.get_current_branch(workspace) == task_branch
):
self.log.info(
"recovered workspace onto task branch on resume",
task_branch=task_branch,
from_branch=current_branch,
)
return
raise ValidationError(
f"BRANCH_MISMATCH: Workspace is on '{current_branch}' but task "
f"requires '{task_branch}', and it could not be switched "
f"automatically (uncommitted changes likely block the switch). "
f"Re-call your role's claim verb on this task "
f"(`i_will_work_on` / `i_will_plan` / `claim_doc_task` / "
f"`claim_review`); if it persists, unclaim and re-claim to rebuild "
f"the clone, then replay your commits."
)
async def _link_commit_to_task(
self,
task_uuid: UUID,
commit_hash: str,
message: str,
agent_id: UUID,
) -> None:
"""Attach a new commit to the task + work session (best effort).
Linking failures are logged but do not fail the commit: the commit
itself has already landed on the branch. Silent-swallow would hide
regressions in either add_commit path, so we log warnings instead.
"""
task_service = get_task_service(self.session)
try:
task = await task_service.get(task_uuid)
await task_service.add_commit(
task_id=task_uuid,
hash=commit_hash,
message=message,
agent_id=agent_id,
)
if task and task.work_session_id:
work_session_service = get_work_session_service(self.session)
await work_session_service.add_commit(
require_uuid(task.work_session_id), commit_hash
)
await self.session.flush()
except Exception as e:
self.log.warning(
"Commit linking failed; commit present on branch but "
"task rows not updated",
task_id=str(task_uuid),
commit_hash=commit_hash,
error=str(e),
)
async def commit_for_task(
self,
agent_id: UUID,
data: GitCommitRequest,
) -> tuple[str, str, int, int, int]:
"""Create a commit for the caller's assigned task.
Orchestrates precondition checks, workspace resolution, the git
commit itself, and commit-to-task linking. Raises typed service
errors; the API layer translates them to HTTP status codes.
When ``data.task_id`` is ``None`` the ownership, assignment, and
branch-mismatch checks are skipped — the git commit proceeds
unconditionally, and no commit-to-task linking is recorded.
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
"""
if data.task_id is not None:
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
clone_root = await self.get_workspace(data.project_slug, agent_id)
# Commit inside the task's per-task worktree (F123), not the shared
# clone — the clone's HEAD may be parked on the default branch.
worktree = self._worktree_for_task(clone_root, data.task_id)
await self._ensure_worktree_for_commit(
clone_root, worktree, task.branch_name
)
workspace = worktree
await self._assert_on_task_branch(workspace, task.branch_name)
else:
workspace = await self.get_workspace(data.project_slug, agent_id)
(
commit_hash,
full_message,
files_changed,
insertions,
deletions,
) = await self.create_commit(workspace, agent_id, data)
if data.task_id is not None:
await self._link_commit_to_task(
data.task_id, commit_hash, data.message, agent_id
)
return commit_hash, full_message, files_changed, insertions, deletions
# =========================================================================
# BRANCH METHODS
# =========================================================================
async def _resolve_base_branch(
self,
task_id: UUID,
parent_branch_override: str | None,
project_slug: str,
task_service: Any,
) -> str:
"""Work out which branch the new task branch should be cut from.
Priority: explicit override → parent task's branch → project default
branch → "main".
"""
if parent_branch_override:
return parent_branch_override
task = await task_service.get(task_id)
if task and task.parent_task_id:
parent = await task_service.get(UUID(str(task.parent_task_id)))
if parent and parent.branch_name:
return str(parent.branch_name)
return await self._project_default_branch(project_slug)
async def _project_default_branch(self, project_slug: str) -> str:
"""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)
return (
str(project.default_branch)
if project and project.default_branch
else "master"
)
async def _checkout_base_with_fallback(
self,
workspace: Path,
base_branch: str,
default_branch: str,
task_id: UUID,
) -> str:
"""Checkout `base_branch`, falling back to default if it's missing.
Returns the branch actually checked out.
"""
result = await self._run_git(workspace, ["checkout", base_branch], check=False)
if result.returncode == 0:
return base_branch
# Local branch missing — try a tracking branch from remote
tracking = await self._run_git(
workspace,
["checkout", "-b", base_branch, f"origin/{base_branch}"],
check=False,
)
if tracking.returncode == 0:
return base_branch
# Neither local nor remote has this branch — fall back to default
self.log.warning(
"Parent branch unavailable locally and on origin; "
"falling back to default branch",
base_branch=base_branch,
default_branch=default_branch,
task_id=str(task_id),
)
await self._run_git(workspace, ["checkout", default_branch])
return default_branch
async def create_branch(
self,
workspace: Path,
team: str,
request: GitCreateBranchRequest,
) -> tuple[str, str]:
"""Create a task branch with hierarchical naming.
Returns: (branch_name, created_from)
"""
task_id = request.task_id
task_service = get_task_service(self.session)
try:
branch_name = await build_branch_name(
task_id=task_id,
branch_type=request.branch_type,
team=team,
task_service=task_service,
)
except BranchNameError as e:
raise ValidationError(str(e)) from e
base_branch = await self._resolve_base_branch(
task_id, request.parent_branch, request.project_slug, task_service
)
default_branch = await self._project_default_branch(request.project_slug)
# Token for any remote-touching git command below (fetch, ls-remote,
# pull, push). Injected into a single `http.extraheader` config for
# the subprocess — never stored in .git/config on disk.
project_token = await self._token_for_project(request.project_slug)
if base_branch != default_branch:
# Parent is not the default branch - check it exists on remote.
# If it doesn't (ancestor task was claimed but never pushed —
# e.g. PM paused before any commit), we fall back to the default
# branch below rather than hard-failing. The checkout logic has
# a matching fallback and logs a warning so this doesn't go
# unnoticed.
result = await self._run_git(
workspace,
["ls-remote", "--heads", "origin", base_branch],
check=False,
token=project_token,
)
if not result.stdout.strip():
self.log.warning(
"Parent branch not on remote; will fall back to default "
"branch when creating child branch",
base_branch=base_branch,
default_branch=default_branch,
task_id=str(task_id),
)
base_branch = default_branch
# Fetch ONLY the refs this path needs (base + default), not every
# remote branch — an all-refs `fetch origin` on a monorepo with dozens
# of stale branches (dependabot, feature, abandoned) is pure network
# cost on every branch creation. check=False so a base that isn't on
# the remote yet doesn't abort (the fallback below handles it).
fetch_refs = list(dict.fromkeys([base_branch, default_branch]))
await self._run_git(
workspace,
["fetch", "origin", *fetch_refs],
token=project_token,
check=False,
timeout=_network_git_timeout(),
)
# --- F123: per-task worktree, not a shared-clone checkout. ---
# The dev clone is one persistent checkout shared across this dev's
# tasks, and a coordinator PM may hold several in_progress roots at
# once. The old `reset --hard` + `checkout -b` on the shared clone
# clobbered a still-active sibling root's working tree (live on NAS:
# main-pm ping-ponged two roots on one clone). Each task now gets its
# own linked worktree under {clone_root}/.worktrees/{task-short}/ via
# `git worktree add`; the clone's HEAD is never moved by a claim, so
# sibling roots' trees are isolated. This runs only on a FRESH claim
# (resume short-circuits in _dev_reentry before reaching here).
worktree_path = workspace / ".worktrees" / str(task_id)[:8]
# Branch from the fetched remote tip (matches the old
# `merge --ff-only origin/<base>` intent — build on the latest remote
# base, not a stale local checkout). Fall back to origin/<default> if
# <base> isn't on the remote yet (the ls-remote above already retargets
# base_branch to default in that case; this covers a residual miss).
base_ref = f"origin/{base_branch}"
ref_check = await self._run_git(
workspace, ["rev-parse", "--verify", "--quiet", base_ref], check=False
)
if ref_check.returncode != 0:
base_ref = f"origin/{default_branch}"
base_branch = default_branch
# ensure_worktree: `git worktree add -b <branch> <base>` for a new
# branch, or `worktree add <branch>` (reuse) for an existing on-disk
# branch (a prior attempt that rolled back DB fields but left the
# branch). Idempotent on an already-present worktree (re-claim).
workspace_service = get_workspace_service(self.session)
await workspace_service.ensure_worktree(
workspace, worktree_path, branch_name, base_ref
)
# An existing branch with no commits of its own — a dependency-blocked
# task re-claimed after its upstream merged — is re-pointed at the fresh
# base so the agent builds on the current tip. Runs on the WORKTREE,
# never the shared clone. A freshly `-b`'d branch is already at base, so
# this is a no-op for new branches; a branch carrying real work
# (unique > 0) is left exactly as-is.
unique = await self._run_git(
workspace,
["rev-list", "--count", f"{base_ref}..{branch_name}"],
check=False,
)
if unique.returncode == 0 and unique.stdout.strip() == "0":
await self._run_git(
worktree_path, ["reset", "--hard", base_ref], check=False
)
await self._run_git(
workspace,
["push", "-u", "origin", branch_name],
token=project_token,
timeout=_network_git_timeout(),
)
# Ownership re-chown happens automatically inside `_run_git` now.
# Store branch name on task
await task_service.update(task_id, branch_name=branch_name)
return branch_name, base_branch
async def merge_dependency_lineage(
self,
workspace: Path,
task_id: UUID,
branch_name: str,
source_branch: str,
project_slug: str,
) -> dict[str, Any]:
"""Backfill a freshly cut branch with a dependency's merged content.
The claim-time dependency gate only holds a task until every
``dependency_ids`` entry is terminal (TIMING) — it never checks
whether that entry's merged work is actually reachable from this
branch's base (CONTENT). A cross-subtree/cross-cell dependency edge
can complete on a branch this one never descends from (e.g. a UX
cell task merges into the UX cell branch; a sibling frontend cell
task cut from the frontend cell branch has no ancestry into it until
the UX cell itself submits up).
No-op when ``source_branch`` is already an ancestor of
``branch_name`` (the common, transitively-safe case: same-parent
siblings, same-project root waves — master/the shared ancestor
already carries the work). On a real conflict the merge is aborted
and the branch is left exactly at its cut point: this is a
claim-time content assist, never a gate, so it always returns a
status rather than raising.
Returns ``{"status": ...}``, one of ``already_ancestor`` /
``missing_ref`` / ``merged`` / ``merged_push_failed`` / ``conflict``
(the last carries ``"files"``).
"""
token = await self._token_for_project(project_slug)
await self._run_git(
workspace,
["fetch", "origin", source_branch],
check=False,
token=token,
timeout=_network_git_timeout(),
)
origin_ref = f"origin/{source_branch}"
if not await self._ref_exists(workspace, origin_ref):
return {"status": "missing_ref"}
worktree = self._worktree_for_task(workspace, task_id)
await self._ensure_worktree_for_commit(workspace, worktree, branch_name)
ancestor = await self._run_git(
worktree,
["merge-base", "--is-ancestor", origin_ref, branch_name],
check=False,
)
if ancestor.returncode == 0:
return {"status": "already_ancestor"}
merge = await self._run_git(
worktree, ["merge", "--no-edit", origin_ref], check=False
)
if merge.returncode != 0:
return await self._abort_lineage_merge_conflict(worktree)
push = await self._run_git(
worktree,
["push", "origin", branch_name],
check=False,
token=token,
timeout=_network_git_timeout(),
)
return {"status": "merged" if push.returncode == 0 else "merged_push_failed"}
async def _abort_lineage_merge_conflict(self, worktree: Path) -> dict[str, Any]:
"""Collect conflicted files and abort a failed dependency-lineage merge."""
conflict = await self._run_git(
worktree, ["diff", "--name-only", "--diff-filter=U"], check=False
)
files = [f for f in conflict.stdout.splitlines() if f.strip()]
await self._run_git(worktree, ["merge", "--abort"], check=False)
return {"status": "conflict", "files": files}
async def create_branch_from_pr_head(
self,
workspace: Path,
project_slug: str,
pr_number: int,
branch_name: str,
) -> str:
"""Create + push a roboco-owned branch off a fork PR's head commits.
Fork PR heads are NOT branches on origin; GitHub exposes them at the
special ref ``refs/pull/{n}/head``. We fetch that ref into a
roboco-owned local branch and push it to origin, so a dev cell can
finish the contribution on a branch WE own and merge — we NEVER push to
the contributor's fork. This is the first point untrusted contributor
code enters a roboco branch, so the caller MUST only invoke it for a
human-confirmed (``confirmed_by_human``) supersede.
"""
project_token = await self._token_for_project(project_slug)
pull_ref = f"refs/pull/{pr_number}/head"
# Force the refspec (``+``) so a retry after a prior push (e.g. the
# commit after the push failed and rolled the umbrella back) updates the
# leftover local branch in the persistent system workspace instead of
# hard-erroring on the existing ref — the branch cut is then idempotent.
await self._run_git(
workspace,
["fetch", "origin", f"+{pull_ref}:{branch_name}"],
token=project_token,
timeout=_network_git_timeout(),
)
await self._run_git(workspace, ["checkout", branch_name])
await self._run_git(
workspace,
["push", "-u", "origin", branch_name],
token=project_token,
timeout=_network_git_timeout(),
)
return branch_name
@staticmethod
def _enum_str(value: Any) -> str | None:
"""Return .value when present; otherwise str(), preserving None."""
if value is None:
return None
return value.value if hasattr(value, "value") else str(value)
def _resolve_branch_team(self, project_slug: str, project: Any, task: Any) -> str:
"""Pick the `team` segment for a create_branch call.
Uses the TASK's team first (who is doing the work), then falls
back to the project's assigned cell. This ensures a main-pm
planning task gets a main_pm branch even if the project cell
is backend.
"""
project_cell = (
self._enum_str(project.assigned_cell)
if project and project.assigned_cell
else None
)
task_team = self._enum_str(task.team) if task and task.team else None
if project_cell == "fullstack":
return f"{project_slug}/{task_team or 'cross'}"
if task_team:
return task_team
if project_cell:
return project_cell
return "cross"
async def create_branch_for_task(
self,
agent_id: UUID,
data: GitCreateBranchRequest,
) -> tuple[str, str]:
"""Resolve workspace + team segment, create the branch, commit.
Returns: (branch_name, created_from)
"""
task_service = get_task_service(self.session)
project_service = get_project_service(self.session)
workspace = await self.get_workspace(data.project_slug, agent_id)
task = await task_service.get(data.task_id)
project = await project_service.get_by_slug(data.project_slug)
team_for_branch = self._resolve_branch_team(data.project_slug, project, task)
branch_name, created_from = await self.create_branch(
workspace, team_for_branch, data
)
await self.session.commit()
return branch_name, created_from
async def checkout(self, workspace: Path, branch: str) -> None:
"""Checkout a branch.
Fetches from origin first to ensure remote branches are available.
If the branch doesn't exist locally, creates a tracking branch from remote.
"""
token = await self._token_for_workspace(workspace)
# Fetch just the branch we're about to check out, not every remote ref.
# check=False: a not-yet-pushed branch makes this a no-op, and the
# local/tracking checkout below still works.
await self._run_git(
workspace,
["fetch", "origin", branch],
token=token,
check=False,
timeout=_network_git_timeout(),
)
# Try direct checkout first (works if local branch exists)
result = await self._run_git(workspace, ["checkout", branch], check=False)
if result.returncode != 0:
# Branch doesn't exist locally - create tracking branch from remote
await self._run_git(
workspace, ["checkout", "-b", branch, f"origin/{branch}"]
)
async def _allowed_checkout_branches(
self, project_slug: str, agent_id: UUID
) -> set[str]:
"""Collect branches this agent is allowed to checkout."""
from sqlalchemy import or_, select
from roboco.db.tables import TaskTable
project_service = get_project_service(self.session)
task_service = get_task_service(self.session)
project = await project_service.get_by_slug(project_slug)
allowed: set[str] = set()
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(
select(TaskTable)
.where(
or_(
TaskTable.assigned_to == agent_id,
TaskTable.claimed_by == agent_id,
)
)
.where(TaskTable.branch_name.is_not(None))
)
for t in result.scalars().all():
allowed.add(str(t.branch_name))
# Also include ancestor branches (parent task branches) so devs
# can checkout parent branches when working on subtasks.
my_tasks = await task_service.list_by_assignee(agent_id)
for t in my_tasks:
if t.branch_name:
allowed.add(str(t.branch_name))
return allowed
async def checkout_branch_for_agent(
self,
agent_id: UUID,
data: GitCheckoutRequest,
) -> None:
"""Enforce the checkout allowlist + run the checkout.
Agents can only check out branches they have reason to be on: any
of their own assigned tasks' branches, or the project default
branch (read-only inspection). Hierarchical prefixes are allowed
so a PM can checkout the parent branch of a subtask they own.
"""
allowed = await self._allowed_checkout_branches(data.project_slug, agent_id)
if data.branch not in allowed and not any(
owned.startswith(f"{data.branch}--") for owned in allowed
):
raise UnauthorizedError(
action="checkout",
reason=(
f"CHECKOUT_RESTRICTED: Cannot checkout '{data.branch}'. "
f"Allowed: {sorted(allowed)} (and their ancestors). "
"Claim the task whose branch you want to work on."
),
)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self.checkout(workspace, data.branch)
async def _ensure_pushable_branch(
self, workspace: Path, branch: str, token: str | None
) -> None:
"""Make sure ``branch`` exists as a local ref before a push-by-name.
Push-by-name (``git push origin <branch>``) decouples the push from the
workspace checkout, but it still requires the named ref to exist LOCALLY.
A dev's clone is shared across tasks and may be freshly re-provisioned
(the per-task workspace-collision recovery re-clones it), so by push time
the task branch can be ABSENT as a local ref even though its commits are
already safe on origin — the workspace is parked on a different task's
branch. ``git push`` then dies with the cryptic ``src refspec <branch>
does not match any`` and the task wedges in a blocked respawn loop.
Recover idempotently: if the local ref is missing, fetch ``origin
<branch>`` and recreate the local tracking ref so the subsequent
push-by-name is a clean no-op. If origin has no such branch either, the
commits are genuinely not in this clone — fail loud with a recoverable
instruction instead of the raw refspec error.
"""
local = await self._run_git(
workspace,
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"],
check=False,
)
if local.returncode == 0:
return
# Local ref missing — try to recover it from origin (commits often
# already pushed in a prior cycle / clone).
await self._run_git(
workspace,
["fetch", "origin", branch],
token=token,
check=False,
timeout=_network_git_timeout(),
)
remote = await self._run_git(
workspace,
["rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
check=False,
)
if remote.returncode == 0:
# Recreate the local ref from origin so push-by-name is a no-op
# instead of a refspec error. The local ref is known-absent here, so
# this only ever creates (never clobbers local-only commits).
await self._run_git(
workspace,
["branch", branch, f"origin/{branch}"],
check=False,
)
return
raise GitCommandError(
"push",
f"the task branch '{branch}' does not exist in this workspace and "
"is not on origin — your commits are not in this clone (it was "
"likely re-provisioned after a reassignment). unclaim the task and "
"re-claim it to rebuild the branch, then replay your commits via "
"commit(...).",
)
async def push(
self, workspace: Path, force: bool = False, branch: str | None = None
) -> tuple[str, int]:
"""Push commits to remote.
``branch`` pushes that named local branch by ref (``git push origin
<branch>``), independent of the workspace's current checkout — a dev's
single clone is shared across many tasks, so by push time it is often
parked on a LATER task's branch. Defaults to the current branch.
Returns: (branch, commits_pushed)
"""
if branch is None:
branch = await self.get_current_branch(workspace)
token = await self._token_for_workspace(workspace)
# The named ref must exist locally for push-by-name; recover it from
# origin if a re-provisioned/shared clone is missing it (else the push
# dies on "src refspec ... does not match any" and the task wedges).
await self._ensure_pushable_branch(workspace, branch, token)
count_result = await self._run_git(
workspace,
["rev-list", "--count", f"origin/{branch}..{branch}"],
check=False,
)
commits_to_push = (
int(count_result.stdout.strip()) if count_result.returncode == 0 else 0
)
args = ["push", "-u", "origin", branch]
if force:
# --force-with-lease fails fast on a concurrent remote advance
# instead of silently clobbering someone else's commits.
args.insert(1, "--force-with-lease")
try:
await self._run_git(
workspace, args, token=token, timeout=_network_git_timeout()
)
except GitCommandError as e:
# A >100MB file trips GitHub's GH001 pre-receive hook — a PERMANENT
# rejection that retrying can never fix. Restate it unmistakably so
# the agent stops blind-retrying (it otherwise mis-reads the raw
# output as a transient timeout) and removes the file / blocks.
blob = f"{e}".lower()
if any(
m in blob
for m in (
"gh001",
"exceeds github's file size",
"100.00 mb",
"pre-receive hook declined",
)
):
raise GitCommandError(
"push",
"rejected — a committed file exceeds GitHub's 100 MB limit"
" (GH001). Retrying will NOT help: remove the oversized file"
" (usually a build/dependency artifact like a node or pnpm"
" store) from the commit and re-commit, or call i_am_blocked"
" if you cannot.",
) from e
raise
return branch, commits_to_push
async def push_for_task(
self,
agent_id: UUID,
agent_role: AgentRole | str,
data: Any,
) -> tuple[str, int]:
"""Push commits for the caller's assigned task.
Force-push is CEO-only. Otherwise standard assignee + branch
preconditions. Raises typed service errors; the API layer
translates them to HTTP status codes.
When ``data.task_id`` is ``None`` the ownership and branch-mismatch
checks are skipped — the push proceeds unconditionally (force-push
role check still applies).
Returns: (branch, commits_pushed)
"""
if getattr(data, "force", False) and agent_role != AgentRole.CEO:
raise UnauthorizedError(
action="force_push",
reason=(
"FORCE_PUSH_FORBIDDEN: Force-push is CEO-only. If your "
"branch diverged, `unclaim` the task and re-`claim` it — "
"the choreographer will rebuild the branch and you can "
"replay your commits via `commit(...)`."
),
)
force = getattr(data, "force", False)
if data.task_id is not None:
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
# Push the task's branch BY NAME, not the current checkout — the
# shared clone is often parked on a later task's branch by now.
return await self.push(workspace, force, branch=str(task.branch_name))
workspace = await self.get_workspace(data.project_slug, agent_id)
return await self.push(workspace, force)
async def push_task_branch(self, agent_id: UUID, task_id: UUID) -> int:
"""Idempotently push a task's branch to origin; return commits pushed.
Reviewers see the remote PR branch, not the developer's workspace. A
fix committed during a revision cycle lives only in that local clone
until it is pushed — so without an explicit push at the QA-submission
boundary, QA re-reviews the stale remote and fails the same task on
every cycle. Self-resolves the project/workspace from the task so the
choreographer can call it with just (agent, task). A no-op when there
is nothing unpushed; raises typed service errors on a real failure.
"""
task = await self._assert_task_owned_with_branch(task_id, agent_id)
project = await self._project_for_task(task)
if project is None:
return 0
workspace = await self.get_workspace(project.slug, 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
# old assert-on-current-branch then push-current rejected the push, and
# the locally-committed work never reached origin → open_pr then saw
# "No commits between" (origin branch empty/missing). The local task
# branch ref carries the commits; push it by name.
_branch, pushed = await self.push(workspace, branch=str(task.branch_name))
return pushed
# =========================================================================
# PULL / FETCH / REBASE METHODS
# =========================================================================
async def pull(
self, workspace: Path
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
"""Pull latest changes from origin and return post-pull status.
Safety gates (both raise :class:`ValidationError`):
1. **Dirty workspace** — refuses to pull when there are any
uncommitted changes (staged, unstaged, or untracked). A pull
onto a dirty tree can produce unexpected merge conflicts or
silently clobber un-staged edits.
2. **Diverged branch** — uses ``--ff-only`` so a diverged branch
is rejected rather than creating a merge commit. The agent
must rebase or reset before pulling.
Uses _network_git_timeout() because the operation talks to origin.
Returns: (current_branch, has_changes, staged, unstaged, untracked,
ahead, behind)
"""
# Gate 1: dirty workspace check
status_result = await self._run_git(
workspace, ["status", "--porcelain"], check=False
)
if status_result.stdout.strip():
raise ValidationError(
"DIRTY_WORKSPACE: Cannot pull with uncommitted changes. "
"Stage and commit (or stash) your changes before pulling."
)
token = await self._token_for_workspace(workspace)
# Gate 2: fast-forward only — raises if branches have diverged
pull_result = await self._run_git(
workspace,
["pull", "--ff-only"],
token=token,
timeout=_network_git_timeout(),
check=False,
)
if pull_result.returncode != 0:
stderr = (pull_result.stderr or "").lower()
if any(
kw in stderr
for kw in (
"not possible to fast-forward",
"diverged",
"fatal: not possible to fast",
"fast-forward",
)
):
raise ValidationError(
"DIVERGED_BRANCH: Branch has diverged from remote; "
"cannot fast-forward. Rebase your local commits onto "
"the remote tip with `git rebase origin/<branch>` "
"before pulling."
)
raise ValidationError(
f"PULL_FAILED: git pull --ff-only exited non-zero. "
f"stderr: {pull_result.stderr or '(none)'}"
)
return await self.get_status(workspace)
async def fetch(
self, workspace: Path
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
"""Fetch changes from origin without merging and return post-fetch status.
Uses _network_git_timeout() because the operation talks to origin.
Returns: (current_branch, has_changes, staged, unstaged, untracked,
ahead, behind)
"""
token = await self._token_for_workspace(workspace)
await self._run_git(
workspace,
["fetch", "origin"],
token=token,
timeout=_network_git_timeout(),
)
return await self.get_status(workspace)
async def rebase(
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`` 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
restore a clean workspace, and returns ``(True, conflicted_files)``.
On success: returns ``(False, [])``.
"""
_PROTECTED = frozenset({"master", "main"})
if target_branch in _PROTECTED:
raise ValidationError(
f"REBASE_FORBIDDEN: Cannot rebase onto '{target_branch}'. "
"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' or 'main' is not allowed in automation."
)
result = await self._run_git(workspace, ["rebase", target_branch], check=False)
if result.returncode != 0:
conflict_result = await self._run_git(
workspace,
["diff", "--name-only", "--diff-filter=U"],
check=False,
)
conflicted_files = [
f.strip()
for f in conflict_result.stdout.strip().split("\n")
if f.strip()
]
await self._run_git(workspace, ["rebase", "--abort"], check=False)
return True, conflicted_files
return False, []
# =========================================================================
# PR METHODS
# =========================================================================
@staticmethod
def _collect_root_commits(
root: TaskTable, descendants: list[TaskTable]
) -> list[PRCommitInfo]:
"""Flatten commits across root + every descendant."""
out: list[PRCommitInfo] = []
for d in [root, *descendants]:
for c in d.commits or []:
out.append(
PRCommitInfo(
hash=str(c.get("hash", "")),
message=str(c.get("message", "")),
agent_slug=str(c.get("agent_id", "unknown")),
)
)
return out
@staticmethod
def _collect_agent_slugs(
root: TaskTable, descendants: list[TaskTable]
) -> list[str]:
"""Unique agent slugs involved in root + descendants."""
slugs = [str(d.assigned_to) for d in descendants if d.assigned_to]
if root.assigned_to:
slugs.append(str(root.assigned_to))
return list(set(slugs))
@staticmethod
def _primary_session_id(_task: TaskTable) -> str | None:
"""Discussion sessions are retired; always None.
``RootPRContext.primary_session_id`` is Optional and renders only
``if set``, so None is safe.
"""
return None
async def _build_root_pr_context(
self,
task: TaskTable,
task_service: TaskService,
task_uuid: UUID,
source_branch: str,
) -> RootPRContext:
"""Build context for root task PR template."""
descendants = await task_service.get_all_descendants(task_uuid)
subtask_infos = [
SubtaskInfo(
id=str(d.id),
title=str(d.title),
status=str(d.status.value) if d.status else "unknown",
assigned_to=str(d.assigned_to) if d.assigned_to else None,
branch_name=str(d.branch_name) if d.branch_name else None,
commit_count=len(d.commits) if d.commits else 0,
)
for d in descendants
]
task_type = (
source_branch.split("/", maxsplit=1)[0]
if "/" in source_branch
else "feature"
)
return RootPRContext(
root_task_id=str(task.id),
root_task_title=str(task.title),
root_task_description=str(task.description) if task.description else "",
root_task_assigned_to=str(task.assigned_to) if task.assigned_to else None,
root_task_type=task_type,
subtasks=subtask_infos,
commits=self._collect_root_commits(task, descendants),
primary_session_id=self._primary_session_id(task),
agent_slugs=self._collect_agent_slugs(task, descendants),
acceptance_criteria=list(task.acceptance_criteria)
if task.acceptance_criteria
else [],
)
@staticmethod
def _str_or(value: Any, default: str = "") -> str:
"""Helper: str(value) when truthy, else default."""
return str(value) if value else default
@staticmethod
def _str_or_none(value: Any) -> str | None:
"""Helper: str(value) when truthy, else None."""
return str(value) if value else None
async def _build_internal_pr_context(
self,
task: TaskTable,
task_service: TaskService,
source_branch: str,
target_branch: str,
) -> InternalPRContext:
"""Build context for internal PR template."""
parent_task = None
if task.parent_task_id:
parent_task = await task_service.get(UUID(str(task.parent_task_id)))
task_commits = [
InternalCommitInfo(
hash=str(c.get("hash", "")),
message=str(c.get("message", "")),
)
for c in (task.commits or [])
]
status_value = self._str_or(
task.status.value if task.status else None, "unknown"
)
qa_passed = status_value in ("awaiting_documentation", "awaiting_pm_review")
return InternalPRContext(
task_id=str(task.id),
task_title=str(task.title),
task_description=self._str_or(task.description),
task_status=status_value,
task_assigned_to=self._str_or_none(task.assigned_to),
parent_task_id=self._str_or_none(parent_task.id if parent_task else None),
parent_task_title=self._str_or_none(
parent_task.title if parent_task else None
),
source_branch=source_branch,
target_branch=target_branch,
commits=task_commits,
session_id=None,
qa_notes=self._str_or_none(task.qa_notes),
qa_passed=qa_passed,
)
async def _get_project_token_or_raise(self, project_slug: str) -> str:
"""Fetch + decrypt the project's GitHub PAT, raising GitError on problem."""
from roboco.utils.crypto import EncryptionError
project_service = get_project_service(self.session)
try:
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
except EncryptionError as e:
raise GitError(
f"Failed to decrypt git token for project '{project_slug}'. "
"The encryption key may have been rotated; re-set the project token."
) from e
if not git_token:
raise GitError(
f"Project '{project_slug}' has no git token configured. "
"Configure a GitHub PAT in the project settings to create PRs."
)
return git_token
async def _resolve_pr_target_branch(
self, request: GitCreatePRRequest, task: Any, default_branch: str
) -> str:
"""Pick the PR target branch: parent's branch (non-root) or default."""
if request.is_root_pr:
return default_branch
if task.parent_task_id:
task_service = get_task_service(self.session)
parent = await task_service.get(UUID(str(task.parent_task_id)))
branch = parent.branch_name if parent else None
return str(branch) if branch else default_branch
return default_branch
async def _generate_pr_title_body(
self,
request: GitCreatePRRequest,
task: Any,
source_branch: str,
target_branch: str,
task_id: UUID,
) -> tuple[str | None, str | None]:
"""Auto-generate title/body from templates when either is missing."""
pr_title = request.title
pr_body = request.body
if pr_title and pr_body:
return pr_title, pr_body
task_service = get_task_service(self.session)
api_base = settings.internal_api_url
if request.is_root_pr:
root_ctx = await self._build_root_pr_context(
task, task_service, task_id, source_branch
)
pr_title = pr_title or build_pr_title_root(root_ctx)
pr_body = pr_body or build_pr_body_root(root_ctx, api_base)
else:
internal_ctx = await self._build_internal_pr_context(
task, task_service, source_branch, target_branch
)
pr_title = pr_title or build_pr_title_internal(internal_ctx)
pr_body = pr_body or build_pr_body_internal(internal_ctx, api_base)
return pr_title, pr_body
async def _find_existing_pr(
self,
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."""
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
async def list_open_prs(self, project_slug: str) -> list[dict[str, Any]]:
"""List a project's open PRs, normalized with fork/author classification.
The inbound counterpart to the org's outbound PR calls: lists ALL open
PRs (no ``head=`` filter), so it sees external/fork contributions the org
did not create. Each record carries ``number``, ``url``, ``title``,
``head_ref``, ``head_sha`` (the head commit — the change signal for
re-review), ``is_fork`` (head repo differs from base repo),
``user_login`` and ``author_association`` so the caller can classify
trust. Returns ``[]`` on a missing token, unparseable remote, or any
GitHub error — it never raises into the poll loop.
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return []
try:
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, owner, repo, git_token)
if raw is None:
return []
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, 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:
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)
)
return None
if not resp.is_success:
self.log.warning(
"list_open_prs non-2xx",
project=project_slug,
status=resp.status_code,
)
return None
data = resp.json()
return data if isinstance(data, list) else []
@staticmethod
def _normalize_open_pr(pr: dict[str, Any], base_full: str) -> dict[str, Any]:
"""Normalize one GitHub PR payload into the inbound-review record."""
head = pr.get("head") or {}
head_repo = head.get("repo") or {}
head_full = head_repo.get("full_name")
login = (pr.get("user") or {}).get("login")
base_owner = (base_full or "").split("/")[0]
return {
"number": pr.get("number"),
"url": pr.get("html_url") or "",
"title": pr.get("title") or "",
"head_ref": head.get("ref"),
"head_sha": head.get("sha"),
"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
# your own PR) and re-reviewing the org's own in-flight work is noise —
# ingestion skips these.
"author_is_owner": bool(
login and base_owner and login.lower() == base_owner.lower()
),
"author_association": pr.get("author_association"),
}
async def get_latest_ci_conclusion(
self,
project_slug: str,
*,
workflow: str | None = None,
head_sha: str | None = None,
) -> dict[str, Any] | None:
"""Latest completed CI (GitHub Actions) run on a project's default branch.
The inbound telemetry signal for self-healing: the most recent COMPLETED
workflow run on the project's default branch, normalized to
``conclusion`` (``success`` / ``failure`` / ``timed_out`` / ...),
``head_sha``, ``run_url``, ``run_name``, ``branch`` and ``completed_at``.
Resolves owner/repo and the git token PER PROJECT. ``workflow`` (a
workflow file name like ``ci.yml``) scopes the signal to one workflow —
without it the latest run across ALL workflows is used, which is
imprecise on a multi-workflow repo. ``head_sha`` further scopes the run
window to a specific commit (the release gate uses this so a later push
to the default branch can't mask the release commit's own CI). Returns
``None`` on a missing token, unparseable remote, GitHub error, or a repo
with no matching Actions runs (a repo that doesn't use GitHub Actions
yields no signal, not a false one). It never raises into the poll loop.
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return None
try:
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
branch = project.default_branch or "master"
query = _CiRunQuery(
project_slug=project_slug,
owner_repo=(owner, repo),
branch=branch,
git_token=git_token,
)
run = await self._fetch_latest_ci_run(query, workflow, head_sha)
if run is None:
return None
return {
"conclusion": run.get("conclusion"),
"head_sha": run.get("head_sha"),
"run_url": run.get("html_url") or "",
"run_name": run.get("name") or "",
"branch": branch,
"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,
workflow: str | None = None,
head_sha: str | None = None,
) -> dict[str, Any] | None:
"""Resolve ``branch``'s current-HEAD CI conclusion; None on error.
Scopes to ``workflow`` (a workflow file name) when given — the precise
signal — otherwise reads across ALL workflows, which on a multi-workflow
repo is unreliable (an unrelated green run can mask a red CI run).
``head_sha`` further scopes the window to one commit so the release gate
can wait on a SPECIFIC release commit's CI without a later push to the
default branch masking it. Pulls a WINDOW of recent completed runs and
selects the newest commit's latest attempt (see
``_select_ci_head_run``) rather than the single most-recently-completed
run, so a green run on an older commit can't mask the HEAD's failure and
a green re-run correctly supersedes it. ``branch`` filters by head
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 before giving up so a single blip doesn't
silently skip the cycle.
"""
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
if not runs:
return None
return _select_ci_head_run(runs)
async def _post_pr(
self,
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:
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": owner, "repo": repo, "head": payload.get("head")},
) from e
async def _pr_base_on_remote(
self,
workspace: Path,
target_branch: str,
default_branch: str,
git_token: str,
task_id: UUID,
) -> str:
"""Return a PR base branch that actually exists on origin.
GitHub rejects PR creation with 422 "base field invalid" when the base
branch is absent on the remote — which happens when an ancestor task's
branch was claimed but never pushed (e.g. a PM paused before any commit),
stranding every child at PR time. Mirror the branch-cutting fallback in
``create_branch``: if the resolved base is missing on origin, retarget to
the default branch instead of hard-failing. The default branch is assumed
present, so it is returned unchecked.
"""
if target_branch == default_branch:
return target_branch
ls = await self._run_git(
workspace,
["ls-remote", "--heads", "origin", target_branch],
check=False,
token=git_token,
)
if ls.stdout.strip():
return target_branch
self.log.warning(
"PR base branch not on remote; retargeting PR to the default branch",
base_branch=target_branch,
default_branch=default_branch,
task_id=str(task_id),
)
return default_branch
async def _pr_head_branch(
self, workspace: Path, request: GitCreatePRRequest
) -> str:
"""The PR head branch — the task's recorded branch by name.
A dev's single clone is shared across many tasks, so by open_pr time the
workspace is often parked on a LATER task's branch; ``get_current_branch``
would open the PR for the wrong head (or fail). Use the task's
``branch_name`` when ``task_id`` is set; fall back to the current branch.
"""
if request.task_id is not None:
task_service = get_task_service(self.session)
task = await task_service.get(request.task_id)
if task and task.branch_name:
return str(task.branch_name)
return await self.get_current_branch(workspace)
async def create_pull_request(
self, workspace: Path, request: GitCreatePRRequest
) -> tuple[int, str, str, str, str]:
"""Create a pull request via the GitHub REST API.
When ``request.task_id`` is ``None`` the task lookup, target-branch
resolution, and template generation are skipped. The PR targets the
project's default branch and uses ``request.title`` / ``request.body``
directly (falling back to ``source_branch`` / ``""`` when absent).
Returns: (pr_number, pr_url, title, source_branch, target_branch)
"""
source_branch = await self._pr_head_branch(workspace, request)
default_branch = await self._project_default_branch(request.project_slug)
git_token = await self._get_project_token_or_raise(request.project_slug)
target_branch, pr_title, pr_body = await self._resolve_new_pr_context(
workspace, request, source_branch, default_branch, git_token
)
owner, repo = self._parse_github_remote(workspace)
resp = await self._post_pr(
owner,
repo,
git_token,
{
"title": pr_title or "",
"body": pr_body or "",
"head": source_branch,
"base": target_branch,
},
)
existing = await self._existing_pr_tuple(
resp, (owner, repo), (source_branch, target_branch), git_token, pr_title
)
if existing is not None:
return existing
if not resp.is_success:
raise GitError(
f"GitHub API refused PR creation ({resp.status_code}): "
f"{resp.text[:200]}",
{"owner": owner, "repo": repo, "head": source_branch},
)
pr_data = resp.json()
return (
int(pr_data["number"]),
str(pr_data["html_url"]),
pr_title or "",
source_branch,
target_branch,
)
async def _resolve_new_pr_context(
self,
workspace: Path,
request: GitCreatePRRequest,
source_branch: str,
default_branch: str,
git_token: str,
) -> tuple[str, str | None, str | None]:
"""Resolve (target_branch, title, body) for a new PR.
With a ``task_id``: look the task up, resolve the target + remote base,
and generate the title/body from templates. Without one: target the
default branch and use the provided title/body (or minimal fallbacks).
"""
if request.task_id is None:
return default_branch, request.title or source_branch, request.body or ""
task = await get_task_service(self.session).get(request.task_id)
if not task:
raise NotFoundError("Task", str(request.task_id))
target_branch = await self._resolve_pr_target_branch(
request, task, default_branch
)
target_branch = await self._pr_base_on_remote(
workspace, target_branch, default_branch, git_token, request.task_id
)
pr_title, pr_body = await self._generate_pr_title_body(
request, task, source_branch, target_branch, request.task_id
)
return target_branch, pr_title, pr_body
async def _existing_pr_tuple(
self,
resp: httpx.Response,
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.
``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(
owner, repo, source_branch, target_branch, git_token
)
if not found:
return None
return (
int(found["number"]),
found["html_url"],
found.get("title", pr_title or ""),
source_branch,
target_branch,
)
async def _patch_pr_title_body(
self,
owner: str,
repo: str,
pr_number: int,
git_token: str,
payload: dict[str, str],
) -> None:
"""PATCH /repos/{owner}/{repo}/pulls/{pr_number} with title/body.
Translates HTTP failures into GitError so the verb layer can map
them onto invalid_state envelopes. 404 → "PR not found"; any
other non-2xx surfaces the GitHub validation text inline.
"""
try:
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": owner, "repo": repo, "pr": pr_number},
) from e
if resp.status_code == _HTTP_NOT_FOUND:
raise GitError(
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": owner, "repo": repo, "pr": pr_number},
)
async def _post_pr_reviewers(
self,
owner: str,
repo: str,
pr_number: int,
git_token: str,
reviewers: list[str],
) -> None:
"""POST /repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers.
Mirrors `_patch_pr_title_body` error handling. The reviewers list
is passed through verbatim — caller is responsible for mapping
agent slugs onto GitHub usernames where the project records that.
"""
try:
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": owner, "repo": repo, "pr": pr_number},
) from e
if resp.status_code == _HTTP_NOT_FOUND:
raise GitError(
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": owner, "repo": repo, "pr": pr_number},
)
async def post_pr_review(
self,
project_slug: str,
pr_number: int,
body: str,
*,
event: str = "REQUEST_CHANGES",
) -> dict[str, Any]:
"""Post ONE review to a PR — ``POST /pulls/{n}/reviews``.
``event`` is ``REQUEST_CHANGES`` (default), ``APPROVE``, or ``COMMENT``.
Authenticates as the project's PAT owner (the bot account); the agent /
role that authored the review is named in ``body``, not the GitHub
identity. Raises ``GitError`` on any GitHub failure so the calling
side-effect can surface it (and stays idempotent — it runs once, after
the DB commit).
Self-review fallback: GitHub forbids ``APPROVE`` / ``REQUEST_CHANGES`` on
a PR authored by the token's own account (422 "...on your own pull
request"). The org's internal PRs — and any PR the PAT owner opened —
hit this, so the review would otherwise never land. A plain ``COMMENT``
review IS allowed on your own PR, so this retries once as ``COMMENT``
(the verdict is already stated in ``body``).
"""
details = {"project": project_slug, "pr": pr_number}
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)
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:
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 {owner}/{repo}", details)
if (
resp.status_code == _GH_UNPROCESSABLE
and event != "COMMENT"
and "own pull request" in resp.text.lower()
):
# Self-review: downgrade to a COMMENT review (allowed on your own PR)
# so the review actually posts instead of being silently dropped.
self.log.warning(
"post_pr_review: self-review forbidden, retrying as COMMENT",
project=project_slug,
pr=pr_number,
requested_event=event,
)
return await self.post_pr_review(
project_slug, pr_number, body, event="COMMENT"
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR review ({resp.status_code}): {resp.text[:200]}",
details,
)
return cast("dict[str, Any]", resp.json())
async def get_pr_diff(self, project_slug: str, pr_number: int) -> str:
"""Fetch a PR's unified diff READ-ONLY via the GitHub API.
``GET /pulls/{n}`` with the diff media type returns the unified diff
text without checking out or running any of the contributor's code —
the review is read-only; untrusted fork code never executes here.
Returns ``""`` on a missing token / unparseable remote / GitHub error
so the reviewer's claim still returns context instead of crashing.
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return ""
try:
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:
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",
project=project_slug,
pr=pr_number,
error=str(e),
)
return ""
if not resp.is_success:
self.log.warning(
"get_pr_diff non-2xx",
project=project_slug,
pr=pr_number,
status=resp.status_code,
)
return ""
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.
Used by the hard ``submit_root`` gate to detect that the assembled root
PR is byte-identical to the one a prior ``pr_fail`` already rejected —
i.e. no new cell work landed on the root branch since the fail — so the
gate can structurally refuse the re-submit instead of looping a weak
coordinator back into the same failed review (the 2026-06-27
``pr_fail`` re-submit loop on PR #139). The head SHA equals the branch
HEAD at PR-open time, so two PRs opened from an unchanged branch share a
head SHA — the comparison holds whether or not a new PR number was cut.
Returns ``None`` on a missing token / unparseable remote / GitHub error
/ a closed-or-missing PR so the gate FAILS OPEN rather than wedging the
PM (only the exact-unchanged case is hard-blocked; ambiguous cases pass
through and rely on the reviewer to re-fail).
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return None
try:
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:
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",
project=project_slug,
pr=pr_number,
status=resp.status_code,
)
return None
return str(resp.json()["head"]["sha"])
except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
self.log.warning(
"get_pr_head_sha request/parse failed",
project=project_slug,
pr=pr_number,
error=str(e),
)
return None
async def get_pr_ci_status(
self, project_slug: str, pr_number: int
) -> dict[str, Any] | None:
"""CI status of a PR's current head commit, for the in-path ``pr_pass`` gate.
Reads GitHub check-runs on the PR's head SHA — the same signal GitHub
Actions surfaces on the PR page — and classifies it into one
``state``: ``success`` (every check-run completed with no failing
conclusion), ``failure`` (at least one completed check-run failed —
``failing_checks`` names them), ``pending`` (at least one check-run
has not completed yet), ``pending_not_scheduled`` (zero check-runs
exist for this commit but the repo has workflows configured — CI
just hasn't started), ``no_ci_configured`` (zero check-runs AND the
repo has no workflows at all — or the project/git_url/token is
missing, or the repo/PR is unreachable/nonexistent), or ``error`` (a
genuine GitHub API failure on a real, reachable repo).
Every unresolvable case is now classified explicitly rather than
returning ``None``: a missing project/git_url/git-token, or an
unreachable/nonexistent repo or PR (a network failure or a 404 on
the PR-head, check-runs, or workflows lookup) all classify as
``no_ci_configured`` — ``pr_pass`` passes through cleanly and still
stamps the evidence note, instead of silently skipping the guard. A
genuine GitHub API failure on a real, reachable repo (any other
non-2xx, or an unparseable response) classifies as ``error`` so
``pr_pass`` stays fail-closed and retryable. This mirrors ``get_pr_head_sha`` /
``_capture_pr_head_sha`` in spirit (never mistake a configuration gap
for a CI signal) but resolves head-sha lookups via a dedicated helper
so the two failure classes above stay distinguishable — the shared
``get_pr_head_sha`` (used by the unrelated pr_fail head-sha capture)
is untouched.
ponytail: reads GitHub check-runs only (the project's own CI is
GitHub Actions). A repo whose only signal is the legacy commit-status
API would show zero check-runs here; add a statuses fallback if that
ever becomes a real CI provider for a project.
"""
config = await self._ci_status_config(project_slug)
if isinstance(config, dict):
return config
owner, repo, headers = config
head_sha_or_gap = await self._resolve_ci_head_sha(
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, 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, owner, repo, head_sha, headers
)
async def _ci_status_config(
self, project_slug: str
) -> 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:
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}
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,
owner: str,
repo: str,
headers: dict[str, str],
) -> str | dict[str, Any]:
"""Resolve the PR's head SHA for ``get_pr_ci_status`` specifically.
Returns the head SHA (``str``) on success, or a terminal gap-state
dict to return directly from the caller on failure. A network error
or a 404 (the repo or PR doesn't exist / isn't reachable) is
``no_ci_configured`` — there is no way to determine a CI signal, so
the guard should pass through, not block. Any other non-2xx or an
unparseable response is a real, reachable repo whose API call itself
failed, so it is ``error`` — a retryable signal the guard must not
treat as green.
"""
try:
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",
project=project_slug,
error=str(e),
)
return {"state": "no_ci_configured", "head_sha": None}
if resp.status_code == _HTTP_NOT_FOUND:
return {"state": "no_ci_configured", "head_sha": None}
if not resp.is_success:
self.log.warning(
"get_pr_ci_status pr lookup non-2xx",
project=project_slug,
status=resp.status_code,
)
return {"state": "error", "head_sha": None}
try:
return str(resp.json()["head"]["sha"])
except (ValueError, KeyError, TypeError) as e:
self.log.warning(
"get_pr_ci_status pr lookup parse failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": None}
async def _fetch_check_runs(
self,
project_slug: str,
owner: str,
repo: str,
head_sha: str,
headers: dict[str, str],
) -> list[dict[str, Any]] | dict[str, Any]:
"""GET the check-runs for ``head_sha``.
Returns the (possibly empty) check-runs list on success, or a
terminal gap-state dict to return directly from the caller: a 404
(the repo/commit isn't reachable, e.g. no CI integration at all) is
``no_ci_configured``; any other failure (network, non-2xx,
unparseable body) is ``error``.
"""
try:
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",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
if resp.status_code == _HTTP_NOT_FOUND:
return {"state": "no_ci_configured", "head_sha": head_sha}
if not resp.is_success:
self.log.warning(
"get_pr_ci_status check-runs non-2xx",
project=project_slug,
status=resp.status_code,
)
return {"state": "error", "head_sha": head_sha}
try:
runs = resp.json().get("check_runs")
except (ValueError, AttributeError) as e:
self.log.warning(
"get_pr_ci_status check-runs parse failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
return runs if isinstance(runs, list) else []
@staticmethod
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."""
failing = [
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 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,
owner: str,
repo: str,
head_sha: 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.
A 404 here (repo unreachable/nonexistent — no CI integration) is
``no_ci_configured``; any other failure is ``error``.
"""
try:
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",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
if resp.status_code == _HTTP_NOT_FOUND:
return {"state": "no_ci_configured", "head_sha": head_sha}
if not resp.is_success:
self.log.warning(
"get_pr_ci_status workflows non-2xx",
project=project_slug,
status=resp.status_code,
)
return {"state": "error", "head_sha": head_sha}
try:
total = int(resp.json().get("total_count") or 0)
except (ValueError, AttributeError, TypeError) as e:
self.log.warning(
"get_pr_ci_status workflows parse failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
state = "pending_not_scheduled" if total > 0 else "no_ci_configured"
return {"state": state, "head_sha": head_sha}
async def update_pr_for_task(
self,
task_id: UUID,
*,
title: str | None = None,
body: str | None = None,
reviewers: list[str] | None = None,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Update an open PR's title/body and/or request reviewers.
Looks up the task, resolves the project's GitHub PAT, and routes
through `_patch_pr_title_body` (when title or body is set) and
`_post_pr_reviewers` (when reviewers is set). Either or both run;
the verb layer guarantees at least one is provided.
``actor_agent_id`` is the agent who actually performed the action
(e.g. a PM editing a dev's PR); it is threaded through to
workspace resolution so a creator who never cloned the project
is never selected as the workspace owner.
Returns a dict with `pr_number`, `pr_url`, and a `updated_fields`
list naming which of title/body/reviewers actually went out.
"""
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
if task is None:
raise NotFoundError("Task", str(task_id))
if task.pr_number is None:
raise GitError(
f"Task {task_id} has no PR open; cannot update.",
{"task_id": str(task_id)},
)
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
owner, repo = self._parse_github_remote(workspace)
git_token = await self._get_project_token_or_raise(project.slug)
pr_number = int(task.pr_number)
updated: list[str] = []
patch_payload: dict[str, str] = {}
if title is not None:
patch_payload["title"] = title
updated.append("title")
if body is not None:
patch_payload["body"] = body
updated.append("body")
if patch_payload:
await self._patch_pr_title_body(
owner, repo, pr_number, git_token, patch_payload
)
if reviewers is not None:
await self._post_pr_reviewers(owner, repo, pr_number, git_token, reviewers)
updated.append("reviewers")
return {
"pr_number": pr_number,
"pr_url": str(task.pr_url) if task.pr_url else "",
"updated_fields": updated,
}
# PR-open-eligible states — the lifecycle policy owns the canon
# (``PR_OPEN_STATES``); the HTTP path derives its str set from it so the
# gateway ``open_pr`` spec gate and this HTTP gate can never drift.
_PR_OPEN_STATES: ClassVar[frozenset[str]] = frozenset(
s.value for s in lifecycle.PR_OPEN_STATES
)
@staticmethod
def _status_value(task: Any) -> str:
"""Task.status may be Enum or str across code paths — normalize."""
status_attr = task.status
return status_attr.value if hasattr(status_attr, "value") else str(status_attr)
async def _assert_pr_create_allowed(
self, task_id: UUID, agent_id: UUID
) -> TaskTable:
"""Enforce assignee + state gate for opening a PR."""
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(task_id))
if task.assigned_to != agent_id:
raise UnauthorizedError(
action="create_pr",
reason="NOT_ASSIGNED: Only the assignee can open the PR.",
)
current_status = self._status_value(task)
if current_status not in self._PR_OPEN_STATES:
raise ValidationError(
f"INVALID_STATE_FOR_PR: Task is '{current_status}'; PR "
f"can only be opened during active dev states "
f"({sorted(self._PR_OPEN_STATES)})."
)
return task
async def _record_pr_atomically(
self,
task_uuid: UUID,
pr_number: int,
pr_url: str,
audit_agent_id: UUID | None = None,
) -> None:
"""Task flags + work_session PR fields must commit together.
If either write fails, roll back and raise a typed error — the
GitHub PR exists on remote but local bookkeeping is inconsistent,
and the caller must know.
"""
task_service = get_task_service(self.session)
work_session_service = get_work_session_service(self.session)
try:
task = await task_service.mark_pr_created(
task_id=task_uuid,
pr_number=pr_number,
pr_url=pr_url,
audit_agent_id=audit_agent_id,
)
if task is None:
raise ServiceError(
"PR_MARK_FAILED: mark_pr_created returned None. "
"The task may be in an invalid state for PR recording. "
"Check task status and retry."
)
if task.work_session_id:
await work_session_service.create_pr(
require_uuid(task.work_session_id), pr_number, pr_url
)
await self.session.commit()
except Exception as exc:
await self.session.rollback()
raise ServiceError(
"PR_STATE_SYNC_FAILED: GitHub PR was created but the local "
f"state sync failed ({type(exc).__name__}). The PR exists "
"on GitHub but task/work_session fields are unchanged. "
"Retry the PR creation to reconcile, or update manually."
) from exc
async def create_pr_for_task(
self,
agent_id: UUID,
data: GitCreatePRRequest,
) -> tuple[int, str, str, str, str]:
"""Preconditions + PR creation + state sync.
When ``data.task_id`` is ``None`` the ownership/state gate and the
post-creation task-state sync are both skipped — the GitHub PR is still
created, but no task record is updated.
Returns: (pr_number, pr_url, title, source_branch, target_branch)
"""
if data.task_id is not None:
await self._assert_pr_create_allowed(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
(
pr_number,
pr_url,
title,
source_branch,
target_branch,
) = await self.create_pull_request(workspace, data)
if data.task_id is not None:
await self._record_pr_atomically(
data.task_id, pr_number, pr_url, audit_agent_id=agent_id
)
return pr_number, pr_url, title, source_branch, target_branch
async def _call_merge_api(
self,
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:
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": owner, "repo": repo, "pr": pr_number},
) from e
async def _sync_target_branch(
self, workspace: Path, target_branch: str, git_token: str
) -> str:
"""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 before pulling. This prevents the "parent
branch doesn't exist locally" SERVICE_ERROR that blocks every leaf→cell
merge in a shared workspace.
"""
checkout = await self._run_git(
workspace, ["checkout", target_branch], check=False
)
if checkout.returncode != 0:
# Local ref missing — fetch the single branch from origin and create
# a tracking branch. A full `git fetch` is avoided because the
# workspace may hold many refs; narrowing to the target keeps it
# cheap and avoids token-burn on a large repo.
await self._run_git(
workspace, ["fetch", "origin", target_branch], token=git_token
)
tracking = await self._run_git(
workspace,
["checkout", "-b", target_branch, f"origin/{target_branch}"],
check=False,
)
if tracking.returncode != 0:
raise GitError(
f"Could not check out target branch '{target_branch}' "
"locally or from origin.",
{
"target_branch": target_branch,
"checkout_stderr": checkout.stderr.strip(),
"tracking_stderr": tracking.stderr.strip(),
},
)
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()
async def _sync_target_branch_best_effort(
self, workspace: Path, target_branch: str, git_token: str
) -> str | None:
"""Post-merge local sync of ``target_branch`` — best-effort, never raises.
Callers reach this only AFTER the authoritative GitHub merge has already
landed (``merge_pull_request`` / ``pr_merge`` raise if it did not), so
refreshing the local workspace copy of the target branch is cosmetic.
If the branch is gone from origin — e.g. an integration (cell/root)
branch deleted after a sibling cell→root merge, stranding a late
straggler leaf — ``_sync_target_branch`` raises ``fetch origin <branch>
— couldn't find remote ref``. Letting that propagate turned an
already-completed merge into a retryable ``SERVICE_ERROR`` that re-blocked
the task and respawn-looped the PM. Swallow + log instead: the merge is
done; the local sync is not worth failing on.
"""
try:
return await self._sync_target_branch(workspace, target_branch, git_token)
except GitError as exc:
self.log.warning(
"post-merge target-branch sync failed; merge already landed,"
" continuing without local sync",
target_branch=target_branch,
error=str(exc),
)
return None
async def _branch_has_open_dependents(
self, owner: str, repo: str, branch: str, git_token: str
) -> bool:
"""True if any OPEN PR still targets ``branch`` as its base.
Such a branch is an active integration target — a leaf still merging
into its cell branch, or a cell still merging into the
``feature/main_pm/{root}`` integration branch. Deleting it strands those
in-flight child PRs (their base vanishes), which is the run-zombifying
"branch gone from origin" wedge. Fails SAFE: on any error returns True
so the branch is preserved (cleanup is best-effort; stranding is not).
"""
try:
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())
except httpx.HTTPError:
return True
async def _delete_remote_branch_best_effort(
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) and any branch that
still has open dependent PRs (an active integration target — deleting it
would strand in-flight child work).
"""
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=owner,
repo=repo,
)
return
try:
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
async def _delete_pr_branch_best_effort(
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.
"""
try:
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) -> 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.
"""
git_token = await self._token_for_project(project_slug)
if not git_token:
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.
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
owner, repo = self._parse_git_url(project.git_url)
except Exception:
return
await self._delete_remote_branch_best_effort(
owner, repo, branch_name, git_token
)
async def _first_allowed_merge_method(
self,
owner: str,
repo: str,
git_token: str,
*,
exclude: str | None = None,
) -> str | None:
"""Return a merge method the repo permits (squash > merge > rebase),
skipping ``exclude``; ``None`` if the lookup fails.
Recovers when a merge is refused with 405 because the repo disables that
merge method in its settings.
"""
try:
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()
except httpx.HTTPError:
return None
allowed = {
"squash": data.get("allow_squash_merge", True),
"merge": data.get("allow_merge_commit", True),
"rebase": data.get("allow_rebase_merge", True),
}
for method in ("squash", "merge", "rebase"):
if method != exclude and allowed.get(method):
return method
return None
async def merge_pull_request(
self, workspace: Path, pr_number: int, merge_method: str, project_slug: str
) -> tuple[str, str]:
"""Merge a PR via the GitHub REST API.
Returns: (target_branch, merge_commit)
"""
git_token = await self._get_project_token_or_raise(project_slug)
owner, repo = self._parse_github_remote(workspace)
if merge_method not in {"merge", "squash", "rebase"}:
merge_method = "squash"
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(
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=owner,
repo=repo,
pr=pr_number,
)
resp = await self._call_merge_api(
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
# genuine "not mergeable" refusal. Disambiguate: if the PR is in
# fact already merged (a CEO double-click, or a first PUT that
# landed before a network blip made us retry), treat it as
# idempotent success and fall through to the post-merge cleanup —
# mirroring the agent-facing _merge_with_retry. Without this a
# CEO retry raised GitError on the very master-merge path only the
# CEO owns, surfacing a spurious failure for an action that had
# 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(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": owner, "repo": repo, "pr": pr_number},
)
self.log.info(
"PR already merged on GitHub; treating as idempotent success",
owner=owner,
repo=repo,
pr=pr_number,
status_code=resp.status_code,
)
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
# here. The best-effort variant guards the agent-facing pr_merge path,
# whose target can be an integration branch deleted from origin.
merge_commit = await self._sync_target_branch(
workspace, target_branch, git_token
)
return target_branch, merge_commit
_PM_MERGE_ROLES: ClassVar[frozenset[AgentRole]] = frozenset(
{AgentRole.CELL_PM, AgentRole.MAIN_PM}
)
def _assert_merge_role(self, current_status: str, agent_role: AgentRole) -> None:
"""PR approval chain — PM for PM-review, CEO for CEO-approval."""
if current_status == TaskStatus.AWAITING_CEO_APPROVAL.value:
if agent_role != AgentRole.CEO:
raise UnauthorizedError(
action="merge_pr",
reason=(
"CEO_ONLY: Merging from awaiting_ceo_approval "
"requires the CEO role. PMs escalate; CEO merges "
"to master."
),
)
return
if current_status == TaskStatus.AWAITING_PM_REVIEW.value:
if agent_role not in self._PM_MERGE_ROLES:
raise UnauthorizedError(
action="merge_pr",
reason=(
"PM_ONLY: Merging from awaiting_pm_review requires "
"a PM role (cell_pm or main_pm)."
),
)
return
raise ValidationError(
f"INVALID_STATE_FOR_MERGE: Task is '{current_status}'. Only "
"awaiting_pm_review (PM merge) or awaiting_ceo_approval "
"(CEO merge) can be merged."
)
async def _auto_complete_on_merge(
self,
task_uuid: UUID,
agent_id: UUID,
agent_role: AgentRole,
) -> None:
"""Auto-transition task after PR merge based on status + merger role.
- awaiting_ceo_approval + CEO merger → ceo_approve (→ completed)
- awaiting_pm_review + PM merger → complete
Otherwise leaves the task alone.
"""
task_service = get_task_service(self.session)
task = await task_service.get(task_uuid)
if not task:
return
current_status = self._status_value(task)
if (
current_status == TaskStatus.AWAITING_CEO_APPROVAL.value
and agent_role == AgentRole.CEO
):
await task_service.ceo_approve(task_uuid)
elif (
current_status == TaskStatus.AWAITING_PM_REVIEW.value
and agent_role in self._PM_MERGE_ROLES
):
await task_service.complete(task_uuid, agent_id)
async def merge_pr_for_task(
self,
agent_id: UUID,
agent_role: AgentRole,
data: GitMergePRRequest,
) -> tuple[str, str]:
"""Role-gated merge + work-session record + auto-complete.
When ``data.task_id`` is ``None`` the task lookup, role gate,
work-session update, and auto-complete are all skipped — the GitHub PR
merge still happens using the caller-provided ``project_slug`` and
``pr_number``.
Returns: (target_branch, merge_commit)
"""
if data.task_id is not None:
task_service = get_task_service(self.session)
work_session_service = get_work_session_service(self.session)
task = await task_service.get(data.task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(data.task_id))
self._assert_merge_role(self._status_value(task), agent_role)
# The recorded PR (task.pr_number, set when the PR was opened) is
# the source of truth for which PR belongs to this task. The
# caller's data.pr_number is unverified client input — a stale panel
# form, a cached old number, a buggy client — and merging it blindly
# would merge the wrong PR against this task's work-session and
# auto-complete. Refuse unless the caller's number matches the
# recorded one; a task with no recorded PR has nothing to verify
# against and is refused outright (re-opening the wrong-PR gap for
# any task that lost its pr_number is worse than a clear error).
recorded_pr = getattr(task, "pr_number", None)
if recorded_pr is None:
raise ValidationError(
"PR_MISMATCH: task has no recorded pr_number; refusing to"
f" merge caller-provided PR #{data.pr_number} for task"
f" {data.task_id} — the merge path requires the task's own"
" recorded PR."
)
if recorded_pr != data.pr_number:
raise ValidationError(
"PR_MISMATCH: caller asked to merge PR"
f" #{data.pr_number} but task {data.task_id}'s recorded PR"
f" is #{recorded_pr}. The recorded PR is the source of"
" truth; merge the task's own PR, not a caller-provided"
" number."
)
# A coordination root has no project of its own, so the CEO's merge
# request can't carry a project_slug. Resolve the root's repo from
# its product server-side; non-root tasks keep the client slug.
project_slug = data.project_slug
if task.project_id is None:
root_project = await self._project_for_task(task)
if root_project is not None:
project_slug = root_project.slug
workspace = await self.get_workspace(project_slug, agent_id)
target_branch, merge_commit = await self.merge_pull_request(
workspace=workspace,
pr_number=data.pr_number,
merge_method=data.merge_method,
project_slug=project_slug,
)
if task.work_session_id:
await work_session_service.merge_pr(
require_uuid(task.work_session_id), agent_id
)
await self._auto_complete_on_merge(data.task_id, agent_id, agent_role)
await self.session.commit()
else:
# No task context — proceed directly to the merge without
# role/ownership checks or post-merge state transitions.
project_slug = data.project_slug
workspace = await self.get_workspace(project_slug, agent_id)
target_branch, merge_commit = await self.merge_pull_request(
workspace=workspace,
pr_number=data.pr_number,
merge_method=data.merge_method,
project_slug=project_slug,
)
return target_branch, merge_commit
# =========================================================================
# GATEWAY (CHOREOGRAPHER) BACKFILL
#
# Branch-keyed entry points. The gateway holds branch_name + pr_number
# but not project_slug or workspace path; these methods derive both
# from the task that owns the branch.
# =========================================================================
async def _task_for_branch(self, branch_name: str) -> TaskTable | None:
"""Find the task whose branch_name matches `branch_name`."""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable).where(_TaskTable.branch_name == branch_name).limit(1)
)
return result.scalar_one_or_none()
async def _project_for_task(self, task: Any) -> Any | None:
"""Resolve the Project whose repo a task's branch lives in.
A normal task carries ``project_id``. A coordination root carries a
product (cell->repo map) but no project of its own — its
``feature/main_pm/{root}`` integration branch lives in the product's
repo(s). Fall through to the product's first distinct repo (monorepo =>
the single repo) so root-level git ops (create_pr root->master, the CEO
merge) resolve a workspace. Purely additive: a task WITH project_id
resolves exactly as before.
"""
project_service = get_project_service(self.session)
if task.project_id is not None:
return await project_service.get(UUID(str(task.project_id)))
product_id = getattr(task, "product_id", None)
if product_id is not None:
from roboco.services.product import get_product_service
product_service = get_product_service(self.session)
project_ids = await product_service.distinct_project_ids(
UUID(str(product_id))
)
if not project_ids:
return None
return await project_service.get(project_ids[0])
# Ad-hoc per-cell map root-subtask: mirror the product root's first-project
# resolution so root-level git ops resolve a workspace for the mapped repo.
cell_map = getattr(task, "cell_projects", None) or []
seen: set[UUID] = set()
for mapping in sorted(cell_map, key=lambda m: m.team.value):
pid = UUID(str(mapping.project_id))
if pid not in seen:
seen.add(pid)
return await project_service.get(pid)
return None
@staticmethod
def _fast_gate_commands(project: Any) -> list[tuple[str, str]]:
"""The project's non-mutating fast-gate commands.
A configured ``quality_command`` is the project's complete fast gate
(lint + types + complexity, no tests; e.g. ``make gate``) and takes
precedence — it runs alone. Otherwise fall back to the lint + typecheck
pair. Format and the test suite are intentionally excluded: format
mutates files, and the slow test run stays on CI.
"""
quality = getattr(project, "quality_command", None)
if quality:
return [("quality", quality)]
candidates = (
("lint", getattr(project, "lint_command", None)),
("typecheck", getattr(project, "typecheck_command", None)),
)
return [(name, cmd) for name, cmd in candidates if cmd]
async def run_pre_submit_quality_gate(
self, actor_agent_id: UUID, task: Any
) -> GateResult:
"""Run the project's fast quality gate in the developer's workspace.
Resolves the task's project and the developer's workspace clone, then
runs the configured lint + typecheck commands there. Returns a skipped
pass when the project configures no fast-gate commands (so projects that
opt out are never blocked). Raises only on workspace-resolution failure;
the caller treats any such failure as fail-open.
"""
project = await self._project_for_task(task)
if project is None:
return GateResult(passed=True, skipped=True)
commands = self._fast_gate_commands(project)
if not commands:
return GateResult(passed=True, skipped=True)
workspace = await self.get_workspace(project.slug, actor_agent_id)
return await run_quality_commands(workspace, commands)
async def toolchain_status_for_task(
self, actor_agent_id: UUID, task: Any
) -> str | None:
"""The recorded toolchain status (``ok`` | ``broken`` | ``unknown``) for
the acting agent's workspace clone of the task's project, or ``None``.
``None`` on any resolution/read failure — the caller fails open and
never blocks a gate on an inability to read the marker.
"""
try:
project = await self._project_for_task(task)
if project is None:
return None
workspace = await self.get_workspace(project.slug, actor_agent_id)
_python, status = WorkspaceService.read_toolchain_status(workspace)
return status
except Exception:
return None
async def _project_slug_for_branch(self, branch_name: str) -> str | None:
"""Resolve project slug via the task that owns the branch."""
task = await self._task_for_branch(branch_name)
if task is None:
return None
project = await self._project_for_task(task)
return project.slug if project else None
@staticmethod
def _resolve_workspace_agent_id(
task: Any, actor_agent_id: UUID | None
) -> UUID | None:
"""Workspace-agent resolution priority.
actor_agent_id → task.assigned_to → None. The creator is NOT
consulted: a PM who created the task but never cloned the
project's workspace would 404 on get_workspace(PM-id), so
falling back to created_by manufactures a broken lookup.
Callers without a real actor get None (project.workspace_path).
Centralised so push_branch/create_pr/commit/diff/pr_target/pr_merge
share one chain — and individual methods stay below the
cyclomatic-complexity gate (xenon B).
"""
if actor_agent_id is not None:
return actor_agent_id
if task.assigned_to is not None:
return UUID(str(task.assigned_to))
return None
async def _workspace_for_branch(
self,
branch_name: str,
*,
actor_agent_id: UUID | None = None,
) -> Path:
"""Get a workspace where this branch can be operated on.
Resolves the workspace via ``_resolve_workspace_agent_id`` (the
actor → assignee → None fallback, with project.workspace_path as
the final fallback). Without it, post-handoff calls (e.g.
pr_target on a task whose assigned_to was cleared by submit_qa)
raise ValidationError when project.workspace_path is unset.
"""
task = await self._task_for_branch(branch_name)
if task is None:
raise NotFoundError("Branch", branch_name)
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project", str(task.project_id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
return await self.get_workspace(project.slug, agent_id=workspace_agent_id)
async def _token_for_branch(self, branch_name: str) -> str | None:
"""Best-effort project PAT for authenticated fetch in the diff path.
An unauthenticated ``git fetch`` fails on a private
repo ("could not read Username for github.com"), so the diff base
stays the stale clone-time ``origin/<default>`` and the three-dot
diff spans the whole repo delta instead of the branch's change.
Returns None on ANY resolution failure so the fetch degrades to
unauthenticated (prior behaviour) rather than raising inside an
evidence-assembly path — authentication is an optimisation here,
never a hard dependency of producing a diff.
"""
try:
task = await self._task_for_branch(branch_name)
if task is None:
return None
project = await self._project_for_task(task)
if project is None:
return None
return await self._get_project_token_or_raise(project.slug)
except Exception:
return None
async def checkout_branch_in_agent_workspace(
self,
branch_name: str,
*,
actor_agent_id: UUID,
) -> None:
"""Check out `branch_name` into the actor's own clone.
Dev/PM workspaces land on the right branch because
``_auto_create_branch`` runs ``git checkout -b`` in the dev's
clone at claim time. The documenter's clone is a *separate*
workspace; when it claims an awaiting_documentation task the
branch already exists (created by the dev) so no checkout ever
ran in the doc's clone — it stayed on the default branch and
``roboco_docs_write`` / ``commit`` failed with BRANCH_MISMATCH.
This puts the doc's workspace on the task branch (fetch +
tracking-branch create). Best-effort: a checkout failure must
not break the claim itself — the caller surfaces a remediation.
"""
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
await self.checkout(workspace, branch_name)
async def push_branch(
self,
branch_name: str,
*,
actor_agent_id: UUID | None = None,
) -> tuple[str, int]:
"""Push `branch_name` to origin from the assignee's workspace.
Gateway-only entry point — the legacy `push(workspace, force)`
signature stays intact for non-gateway callers. Resolves the
workspace from the task that owns the branch (with caller-actor
fallback), then delegates. Returns
(branch, commits_pushed).
"""
workspace = await self._workspace_for_branch(
branch_name, 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;
# push() with branch=None defaults to get_current_branch(workspace),
# which pushed the wrong ref. The dev's commit then never reached
# origin, so create_pr 422'd with "No commits between" and the dev was
# forced into i_am_blocked — stranded work the PM's unblock can't fix
# (it only flips status, not git state). Push-by-name also survives a
# clone parked on a LATER task's branch.
return await self.push(workspace, branch=branch_name)
async def _ensure_base_on_remote(
self,
workspace: Path,
base_branch: str,
project_slug: str,
git_token: str,
) -> str:
"""Ensure the PR base branch exists on origin; create it if missing.
``open_pr`` (via :meth:`create_pr`) targets an ancestor task's branch
— e.g. the cell-PM integration branch — which may never have been
pushed (a PM paused before its first push, or the workspace was
wiped). GitHub then rejects the PR with 422 "base field invalid".
Rather than fail, create the missing base on the remote off the
default branch's tip so the PR has a valid base and the integration
layering is preserved. Fall back to the default branch only if the
create push itself fails.
"""
default_branch = await self._project_default_branch(project_slug)
if base_branch == default_branch:
return base_branch
ls = await self._run_git(
workspace,
["ls-remote", "--heads", "origin", base_branch],
check=False,
token=git_token,
)
if ls.stdout.strip():
return base_branch
await self._run_git(
workspace,
["fetch", "origin", default_branch],
check=False,
token=git_token,
)
push = await self._run_git(
workspace,
["push", "origin", f"origin/{default_branch}:refs/heads/{base_branch}"],
check=False,
token=git_token,
)
if push.returncode != 0:
self.log.warning(
"could not create missing PR base on remote; retargeting to default",
base_branch=base_branch,
default_branch=default_branch,
stderr=(push.stderr or "")[:200],
)
return default_branch
self.log.info(
"created missing PR base branch on remote off default",
base_branch=base_branch,
default_branch=default_branch,
)
return base_branch
async def create_pr(
self,
branch_name: str,
*,
parent: str,
is_root_pr: bool,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Open a PR for `branch_name` targeting `parent`.
Returns: ``{"pr_number": int, "pr_url": str}``. Resolves the
underlying project + task from the branch name; the gateway never
passes a project_slug. The `parent` arg supersedes the task's
natural parent so root PRs can target master.
``actor_agent_id`` lets PMs opening the master PR (where
``task.assigned_to`` may be None at completion time) resolve a
workspace via the actor's clone.
"""
task = await self._task_for_branch(branch_name)
if task is None:
raise NotFoundError("Branch", branch_name)
# Resolve via _project_for_task so a coordination root (project_id null)
# opening its root->master PR resolves the repo from its product.
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project", str(task.project_id))
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
git_token = await self._get_project_token_or_raise(project.slug)
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
# before pushing it, or the workspace was wiped. Create it on the
# remote off the default branch so GitHub doesn't 422 'base invalid'
# and the integration hierarchy is preserved.
parent = await self._ensure_base_on_remote(
workspace, parent, project.slug, git_token
)
pr_title = f"[{str(task.id)[:8]}] {task.title}"
pr_body = task.description or ""
resp = await self._post_pr(
owner,
repo,
git_token,
{
"title": pr_title,
"body": pr_body,
"head": branch_name,
"base": parent,
},
)
if resp.status_code == _GH_UNPROCESSABLE and "already exists" in resp.text:
found = await self._find_existing_pr(
owner, repo, branch_name, parent, git_token
)
if found:
pr_number = int(found["number"])
pr_url = str(found["html_url"])
# Shielded + waited-out: the PR already exists on GitHub, so
# a cancellation here must not skip recording it locally —
# and shield alone would leave the detached write racing
# get_db's rollback on the same session (see _await_shielded).
await _await_shielded(
self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url)
)
return {
"pr_number": pr_number,
"pr_url": pr_url,
"is_root_pr": is_root_pr,
}
if not resp.is_success:
raise GitError(
f"GitHub API refused PR creation ({resp.status_code}): "
f"{resp.text[:200]}",
{"owner": owner, "repo": repo, "head": branch_name},
)
pr_data = resp.json()
pr_number = int(pr_data["number"])
pr_url = str(pr_data["html_url"])
# _post_pr already created the PR on GitHub — shield the local record
# so a cancellation landing between the POST and this commit can't
# leave it unrecorded (self-heals on retry via _find_existing_pr, but
# only after this window closes). Waited-out, not bare shield: the
# detached write must finish before get_db's rollback touches the
# same session (see _await_shielded).
await _await_shielded(
self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url)
)
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:
"""SELECT FOR UPDATE on the parent task row, if any.
Two PMs completing different subtasks of the same parent could
race on the gh API merge call. Holding a row-level lock on the
parent task serializes those merges at the DB layer — the second
PM's transaction blocks until the first commits, by which time
the first PR is already merged. The lock is auto-released when
the surrounding transaction commits or rolls back.
Root tasks (no parent) skip the lock — there's nothing to
contend on at parent level, and master-bound merges are
serialized by GitHub's PR-state machine alone.
"""
if parent_task_id is None:
return
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
await self.session.execute(
select(_TaskTable)
.where(_TaskTable.id == parent_task_id)
.with_for_update(of=_TaskTable)
)
@staticmethod
def _resolve_merger_id(task: Any, actor_agent_id: UUID | None) -> UUID:
"""merged_by attribution priority for pr_merge.
actor → assigned_to → created_by → UUID(int=0) sentinel.
``UUID(0)`` is the explicit "nothing was recoverable" marker
instead of the silent NULL we used to write.
"""
merger = (
actor_agent_id
or (UUID(str(task.assigned_to)) if task.assigned_to else None)
or (UUID(str(task.created_by)) if task.created_by else None)
)
return merger or UUID(int=0)
@dataclass(frozen=True)
class _MergeContext:
"""Bundle of params for `_merge_with_retry` (keeps arg count under 5)."""
owner: str
repo: str
pr_number: int
git_token: str
workspace: Path
target: str
async def _merge_with_retry(self, ctx: GitService._MergeContext) -> Any:
"""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.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
# ref is stale. Refresh and retry once; a second 409 is a real
# conflict the PM resolves manually.
await self._sync_target_branch(ctx.workspace, ctx.target, ctx.git_token)
resp = await self._call_merge_api(
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
# method the repo permits before treating this as a conflict —
# mirrors the CEO merge_pull_request 405 fallback so a repo's
# merge-button config can't wedge the PM on an open, mergeable PR.
# 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.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.owner,
repo=ctx.repo,
pr=ctx.pr_number,
)
resp = await self._call_merge_api(
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
# genuine "not mergeable" conflict. An already-merged PR (a prior
# cycle, a sibling, or the CEO already landed it) is idempotent
# success — NOT a conflict to rebase/escalate. Treating it as one is
# the cell_pm_complete block<->unblock respawn loop, so disambiguate
# before raising. None (HTTPError — indeterminate) is treated as
# "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.owner, ctx.repo, ctx.pr_number, ctx.git_token
)
if merged is False:
# A real merge refusal (typically 405 "not mergeable") means the
# branch conflicts with the base — a sibling landed overlapping
# work first. Raise the specific subclass so the completion path
# can rebase / close-superseded / escalate instead of
# respawn-looping.
raise MergeConflictError(
f"GitHub API refused PR merge ({resp.status_code}):"
f" {resp.text[:200]}",
{"owner": ctx.owner, "repo": ctx.repo, "pr": ctx.pr_number},
)
return resp
return resp
async def _pr_is_merged(
self, owner: str, repo: str, pr_number: int, git_token: str
) -> bool | None:
"""True if PR ``pr_number`` is already merged on GitHub.
Disambiguates an already-merged PR from a genuine conflict (both surface
as a 405 on the merge PUT): an already-merged PR is idempotent success,
not something to rebase/escalate. Returns ``None`` on an
``httpx.HTTPError`` so an indeterminate lookup is NOT mistaken for a
clean "not merged" — callers treat ``None`` as "assume merged" and
fall through to the already-merged cleanup path instead of raising a
conflict and respawning the PM against an already-merged PR. A
non-success response is still False (GitHub answered, just not merged).
"""
try:
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:
return False
return bool(resp.json().get("merged"))
async def is_pr_merged_for_task(self, task_id: UUID) -> bool:
"""True if the task's PR is already merged on GitHub.
Idempotency check for ``cell_pm_complete`` re-issues: a re-issue after
a None-complete would otherwise 405 on the already-merged PR. An
indeterminate ``_pr_is_merged`` (``None`` on ``httpx.HTTPError``) is
treated as "assume merged" → ``True`` so the choreographer skips the
merge call instead of 405-ing into a respawn loop on an already-merged
PR. A clean False (GitHub answered, not merged) returns False — the
caller proceeds to ``pr_merge``, which surfaces the real error.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable).where(_TaskTable.id == task_id).limit(1)
)
task = result.scalar_one_or_none()
if task is None or not task.pr_number:
return False
project = await self._project_for_task(task)
if project is None:
return False
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)
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(
self,
pr_number: int,
*,
target: str,
project_id: UUID,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Merge PR `pr_number` into `target`.
Returns: ``{"merge_commit_sha": str | None}``. Looks up the
task/project that owns the PR to resolve workspace + token.
``pr_number`` alone is ambiguous across projects — GitHub numbers
PRs per-repo, but ``tasks.pr_number`` stores the bare integer with
no repo scoping, so two tasks on different repos can share a number.
The caller MUST pass the ``project_id`` the PR belongs to so the
task lookup is scoped to it: a same-numbered PR in another
project's repo is never resolved (and merged, or its work session
marked merged) by accident. Mirrors :meth:`close_pull_request`.
Concurrency: takes a row-level lock on the parent task before
invoking the GitHub merge API so that two PMs completing
sibling subtasks of the same parent are serialized. On a 409
merge conflict the local target branch is re-pulled and the
merge is retried exactly once before giving up with `GitError`.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable)
.where(_TaskTable.pr_number == pr_number)
.where(_TaskTable.project_id == project_id)
.limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
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)
owner, repo = self._parse_github_remote(workspace)
# 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=(
"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."
),
)
parent_id = UUID(str(task.parent_task_id)) if task.parent_task_id else None
await self._lock_parent_task_for_merge(parent_id)
await self._merge_with_retry(
self._MergeContext(
owner=owner,
repo=repo,
pr_number=pr_number,
git_token=git_token,
workspace=workspace,
target=target,
)
)
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
)
if task.work_session_id:
ws_service = get_work_session_service(self.session)
await ws_service.merge_pr(
require_uuid(task.work_session_id),
self._resolve_merger_id(task, actor_agent_id),
)
return {"merge_commit_sha": merge_commit or None}
async def _get_pr_refs(
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:
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:
return None
data = resp.json()
head = (data.get("head") or {}).get("ref")
base = (data.get("base") or {}).get("ref")
if not head or not base:
return None
return str(head), str(base)
async def rebase_onto_base(
self,
workspace: Path,
*,
head_branch: str,
base_branch: str,
git_token: str,
stash: bool = False,
) -> dict[str, Any]:
"""Rebase ``head_branch`` onto the latest ``base_branch`` from origin.
This is the primitive both the sequence-ordered merge (rebase a later
sibling onto the prior one's merged result) and the conflict resolver
(rebase a wedged PR before re-merging) build on.
Returns one of:
- ``{"status": "superseded"}`` — the rebase was clean and the head
has NO commits the base lacks; all its work is already in the base
(e.g. a sibling that merged a superset landed first). Safe to close
the PR + complete the task without a merge.
- ``{"status": "rebased", "unique_commits": int}`` — clean rebase
with unique work; the rebased branch was force-pushed to origin and
can now merge cleanly.
- ``{"status": "conflicts", "files": [...]}`` — the rebase hit
conflicts and was aborted; a developer must resolve by hand.
Any of the above may carry ``"stash_pop_conflict": True`` when
``stash`` popped into a conflict (see below).
Never touches the base branch and only ever force-pushes
``head_branch`` (with ``--force-with-lease``). A master/main base is
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 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._run_git(workspace, ["checkout", head_branch])
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)
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:
await self._run_git(
workspace,
["push", "--force-with-lease", "origin", f"HEAD:{head_branch}"],
token=git_token,
)
result = {"status": "rebased", "unique_commits": unique}
if stashed:
await self._pop_stash_into(workspace, result)
return result
async def _stash_if_dirty(self, workspace: Path, *, stash: bool) -> bool:
"""Clean-tree gate for :meth:`rebase_onto_base`.
Refuses a dirty worktree (``DIRTY_WORKSPACE``) unless ``stash`` is
set, in which case it auto-stashes (tracked + untracked) and returns
``True`` so the caller knows to pop it back later.
"""
status_result = await self._run_git(
workspace, ["status", "--porcelain"], check=False
)
if not status_result.stdout.strip():
return False
if not stash:
raise ValidationError(
"DIRTY_WORKSPACE: Cannot rebase with uncommitted changes. "
"Stage and commit (or stash) your changes before rebasing."
)
await self._run_git(
workspace, ["stash", "push", "-u", "-m", "sync_branch autostash"]
)
return True
async def _abort_rebase_conflict(
self, workspace: Path, *, stashed: bool
) -> dict[str, Any]:
"""Collect conflicted files and abort a failed rebase.
The stash (if one was taken) is left untouched here — popping it onto
an aborted, still-conflicted rebase would just stack a second conflict
on top of the first. ``stash_preserved`` is only added when a stash
was actually taken, so the non-stash result shape is unchanged.
"""
conflict = await self._run_git(
workspace, ["diff", "--name-only", "--diff-filter=U"], check=False
)
files = [f for f in conflict.stdout.splitlines() if f.strip()]
await self._run_git(workspace, ["rebase", "--abort"], check=False)
result: dict[str, Any] = {"status": "conflicts", "files": files}
if stashed:
result["stash_preserved"] = True
return result
async def _pop_stash_into(self, workspace: Path, result: dict[str, Any]) -> None:
"""Pop the autostash, flagging (never auto-resolving) a pop conflict."""
pop = await self._run_git(workspace, ["stash", "pop"], check=False)
if pop.returncode != 0:
result["stash_pop_conflict"] = True
async def rebase_pr_for_task(
self,
pr_number: int,
*,
project_id: UUID,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Resolve workspace/refs for a PR and rebase its branch onto the base.
Thin wrapper over :meth:`rebase_onto_base` that loads the task/project
that owns the PR (mirrors :meth:`pr_merge`) and reads the PR's head/base
refs from GitHub. Returns the same classification dict, or
``{"status": "unknown"}`` when refs can't be resolved.
``pr_number`` is ambiguous across projects (GitHub numbers PRs per-repo)
so the caller MUST pass ``project_id`` to scope the task lookup — the
same cross-repo guard as :meth:`pr_merge` / :meth:`close_pull_request`.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable)
.where(_TaskTable.pr_number == pr_number)
.where(_TaskTable.project_id == project_id)
.limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
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)
owner, repo = self._parse_github_remote(clone_root)
refs = await self._get_pr_refs(owner, repo, pr_number, git_token)
if refs is None:
return {"status": "unknown"}
head_branch, base_branch = refs
# Rebase inside the per-task worktree (F123): the PR head branch is
# checked out there, so a checkout in the clone root would be refused.
workspace = self._worktree_for_task(clone_root, require_uuid(task.id))
await self._ensure_worktree_for_commit(clone_root, workspace, head_branch)
return await self.rebase_onto_base(
workspace,
head_branch=head_branch,
base_branch=base_branch,
git_token=git_token,
)
async def sync_task_branch(
self,
task: Any,
*,
base_branch: str,
actor_agent_id: UUID | None = None,
stash: bool = False,
) -> dict[str, Any]:
"""Rebase a task's branch onto ``base_branch`` through the gate.
Task-keyed twin of :meth:`rebase_pr_for_task`: ``head_branch`` is the
task's own ``branch_name`` and ``base_branch`` is supplied by the caller
(the choreographer resolves it via ``merge_chain.resolve_parent_branch``),
so this works BEFORE a PR exists — a developer mid-work whose branch
fell behind its base (a sibling's PR merged into the parent branch) can
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``).
``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
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))
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)
# Rebase inside the per-task worktree (F123): the branch is checked out
# there, so a checkout in the clone root would be refused ("already
# checked out at '<worktree>'") and the behind-base recovery loop dies.
workspace = self._worktree_for_task(clone_root, require_uuid(task.id))
await self._ensure_worktree_for_commit(clone_root, workspace, task.branch_name)
return await self.rebase_onto_base(
workspace,
head_branch=task.branch_name,
base_branch=base_branch,
git_token=git_token,
stash=stash,
)
async def unmerged_child_commits(
self,
task: Any,
*,
actor_agent_id: UUID | None = None,
) -> list[dict[str, Any]]:
"""Completed children whose commits are NOT in the assembled branch.
Patch-equivalence via ``git cherry`` (rebase-safe — the assembled
branch may have been rebased, rewriting SHAs). Best-effort per child:
a child with no branch / no commits / a branch pruned from origin
after merge contributes nothing. Returns
``[{"task_id", "title", "unmerged"}, ...]`` for children with at
least one patch missing from the parent branch (live incident #11:
a completed revert absent from the assembled cell PR).
"""
if not getattr(task, "branch_name", None):
return []
task_service = get_task_service(self.session)
children = await task_service.get_subtasks(require_uuid(task.id))
candidates = _completed_branchful_children(children)
if not candidates:
return []
project = await self._project_for_task(task)
if project is None:
return []
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)
refs = [str(task.branch_name)] + [str(c.branch_name) for c in candidates]
await self._run_git(
workspace,
["fetch", "origin", *dict.fromkeys(refs)],
token=git_token,
check=False,
timeout=_network_git_timeout(),
)
missing: list[dict[str, Any]] = []
for child in candidates:
entry = await self._cherry_unmerged_entry(
workspace, str(task.branch_name), child
)
if entry is not None:
missing.append(entry)
return missing
async def _cherry_unmerged_entry(
self, workspace: Path, parent_branch: str, child: Any
) -> dict[str, Any] | None:
"""One child's unmerged-commit entry, or None (merged / unprobeable).
A child branch pruned from origin after merge, or any git error,
contributes nothing — best-effort per child.
"""
child_ref = f"origin/{child.branch_name}"
ref_ok = await self._run_git(
workspace,
["rev-parse", "--verify", "--quiet", child_ref],
check=False,
)
if ref_ok.returncode != 0:
return None # branch pruned after merge — nothing to compare
cherry = await self._run_git(
workspace,
["cherry", f"origin/{parent_branch}", child_ref],
check=False,
)
if cherry.returncode != 0:
return None
unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")]
if not unmerged:
return None
# Squash-merge relief: cherry can't patch-match N child commits against
# the one squashed commit, but every commit (incl. the squash) carries
# the [taskid8] prefix — a marker commit on the parent proves the child
# landed (live false positive 2026-07-02: 3 squash-merged children).
marker = await self._run_git(
workspace,
[
"log",
f"origin/{parent_branch}",
"--grep",
rf"^\[{str(child.id)[:8]}\] ",
"--oneline",
"-1",
],
check=False,
)
if marker.returncode == 0 and marker.stdout.strip():
return None
return {
"task_id": str(child.id)[:8],
"title": str(getattr(child, "title", ""))[:80],
"unmerged": len(unmerged),
}
async def is_behind_base(
self,
task: Any,
*,
base_branch: str,
actor_agent_id: UUID | None = None,
) -> tuple[int, int]:
"""Return ``(behind, ahead)`` commit counts: head vs base branch.
``behind`` = commits on ``origin/{base_branch}`` NOT on
``origin/{head_branch}`` — the work the head has fallen behind by (e.g.
a sibling's PR merged into the parent branch while this dev worked).
``ahead`` = commits on the head NOT on the base — the head's own work.
Both are read from origin after a fetch, so they reflect the pushed
state a merge / PR base would actually see.
The submit-time behind-base gate (``i_am_done``) uses this: a non-zero
``behind`` means the branch fell behind its base and the dev must
``sync_branch`` before submitting, else the PR can't merge cleanly /
a sibling's merged work is missing from this branch. Mirrors
:meth:`sync_task_branch`'s workspace/token resolution. Raises on git
failure (consistent with :meth:`rebase_onto_base`); the gate handler
fail-opens on a raised error so a flaky fetch can't strand a task at
the submit gate — the merge layer has its own behind checks.
"""
if not task.branch_name:
raise ValueError("is_behind_base 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))
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)
await self._run_git(workspace, ["fetch", "origin"], token=git_token)
# --left-right --count A...B → "<left> <right>": left = commits only in
# A (base, what the head is BEHIND by); right = commits only in B (head,
# the head's own ahead work). Triple-dot = symmetric difference.
count = await self._run_git(
workspace,
[
"rev-list",
"--left-right",
"--count",
f"origin/{base_branch}...origin/{task.branch_name}",
],
)
left, _, right = count.stdout.strip().partition(" ")
behind = int(left) if left.strip().isdigit() else 0
ahead = int(right) if right.strip().isdigit() else 0
return behind, ahead
async def close_pull_request(
self,
pr_number: int,
*,
project_id: UUID,
comment: str | None = None,
delete_branch: bool = False,
actor_agent_id: UUID | None = None,
) -> None:
"""Close PR ``pr_number`` on GitHub, optionally with an explanatory comment.
Used to retire a PR whose work is already in the base (superseded) so a
wedged task can complete without a merge — the "close the dead PR"
action agents had no verb for. Branch deletion is opt-in
(``delete_branch=False`` by default): a superseded PR's branch may still
be referenced or useful for audit, so close does not destroy it unless
the caller explicitly asks — matching the orchestrator supersede path.
``pr_number`` alone is ambiguous across projects (GitHub numbers PRs
per-repo, but ``tasks.pr_number`` stores the bare integer with no repo
scoping, so two tasks on different repos can share a number). The
caller MUST pass the ``project_id`` the PR belongs to — the task
lookup is scoped to it so a same-numbered PR in another project's repo
is never resolved (and closed) by accident. Mirrors :meth:`pr_merge`.
Idempotent: a PR that is already closed is a no-op (no duplicate
comment), so a retried close-on-land never re-comments.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable)
.where(_TaskTable.pr_number == pr_number)
.where(_TaskTable.project_id == project_id)
.limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
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)
owner, repo = self._parse_github_remote(workspace)
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,
)
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(owner, repo, pr_number, git_token)
async def pr_target(
self,
pr_number: int,
*,
project_id: UUID,
actor_agent_id: UUID | None = None,
) -> str:
"""Return the current target (base) branch of an open PR.
Workspace resolution mirrors pr_merge: actor → assigned_to → None
(project.workspace_path as the final fallback). Lets the Main PM
call pr_target after ``submit_qa`` has cleared ``assigned_to``
without ValidationError.
``pr_number`` alone is ambiguous across projects (GitHub numbers PRs
per-repo, but ``tasks.pr_number`` stores the bare integer with no repo
scoping, so two tasks on different repos can share a number). The
caller MUST pass the ``project_id`` the PR belongs to — the task lookup
is scoped to it so a same-numbered PR in another project's repo is
never resolved by accident. Mirrors :meth:`pr_merge`.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable)
.where(_TaskTable.pr_number == pr_number)
.where(_TaskTable.project_id == project_id)
.limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
owner, repo = self._parse_github_remote(workspace)
git_token = await self._get_project_token_or_raise(project.slug)
try:
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": 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": 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": owner, "repo": repo, "pr": pr_number},
)
return str(base_ref)
async def _ref_exists(self, workspace: Any, ref: str) -> bool:
"""True iff `ref` resolves in `workspace` (e.g. 'origin/<branch>')."""
result = await self._run_git(
workspace,
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
check=False,
)
return result.returncode == 0
async def _default_branch_ref(
self, workspace: Any, token: str | None = None
) -> str:
"""Resolve the repo's default remote branch ref.
Tries ``origin/HEAD`` (the canonical pointer), then common
defaults. Always returns a usable ref string; falls back to
``origin/master`` so the diff command is still well-formed even
on a misconfigured remote (an empty/garbage diff is recoverable;
a malformed git invocation is not). This only resolves the ref
NAME — the caller is responsible for fetching it fresh.
"""
head = await self._run_git(
workspace,
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
check=False,
)
target = head.stdout.strip()
if head.returncode == 0 and target:
# refs/remotes/origin/HEAD -> refs/remotes/origin/<name>
return target.replace("refs/remotes/", "", 1)
for candidate in ("origin/master", "origin/main"):
await self._run_git(
workspace,
["fetch", "origin", candidate.split("/", 1)[1]],
check=False,
token=token,
)
if await self._ref_exists(workspace, candidate):
return candidate
return "origin/master"
async def _resolve_diff_base(
self,
workspace: Any,
branch_name: str,
token: str | None = None,
*,
preferred_parent: str | None = None,
) -> str:
"""Best diff base for `branch_name` when no explicit base is given.
A leaf dev branch's ``parent_branch_for`` is the
cell-PM branch (``feature/{team}/{root}--{cellpm}``) which is
NEVER pushed — only devs push their own leaf branch. Diffing
against a non-existent ``origin/<parent>`` returns an empty
diff, so QA / docs see nothing. Fall back to the repo default
branch when the computed parent ref is absent on origin.
The default-branch ref in an inspecting clone is the
stale clone-time tip (origin/HEAD is set, so _default_branch_ref
early-returns its NAME without fetching). A three-dot diff against
a stale base spans the whole repo delta, not the branch's change.
Re-fetch the resolved base authenticated (unauth fails on private
repos) so the base is current.
``preferred_parent``, when given, overrides the string-derived
``parent_branch_for`` with an authoritative parent branch name (e.g.
``merge_chain.resolve_parent_branch``, which reads the parent TASK's
own ``branch_name`` — correct across a team boundary, unlike the
derivation below which reuses ``branch_name``'s own team segment).
Still falls back to the repo default branch when that parent was
never pushed, so an unassembled/branchless parent can't crash the
diff.
"""
from roboco.services.gateway.merge_chain import parent_branch_for
parent = (
preferred_parent
if preferred_parent is not None
else parent_branch_for(branch_name)
)
await self._run_git(
workspace, ["fetch", "origin", parent], check=False, token=token
)
if await self._ref_exists(workspace, f"origin/{parent}"):
return f"origin/{parent}"
default = await self._default_branch_ref(workspace, token=token)
short = default.split("/", 1)[1] if "/" in default else default
await self._run_git(
workspace, ["fetch", "origin", short], check=False, token=token
)
return default
async def _resolve_head_ref(
self, workspace: Any, branch_name: str, token: str | None = None
) -> str:
"""Ref for the branch tip that actually resolves in `workspace`.
The local ``<branch_name>`` ref only exists in
the clone where the dev ran ``git checkout -b`` at claim. QA /
documenter / PM inspect from their OWN clones, which never had
that local branch — a bare ``<branch_name>`` resolves
``refs/heads`` then ``refs/remotes/<name>`` but NEVER
``refs/remotes/origin/<name>``, so ``git diff base...<branch>``
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/<branch>`` is the workspace-independent source of truth.
Fetch it, then prefer the local branch (dev's own clone) and fall
back to ``origin/<branch>``; 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
)
origin_ref = f"origin/{branch_name}"
local_exists = await self._ref_exists(workspace, branch_name)
origin_exists = await self._ref_exists(workspace, origin_ref)
if local_exists and origin_exists:
# 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 behind.returncode == 0 else branch_name
if local_exists:
return branch_name
if origin_exists:
return origin_ref
return branch_name
async def diff(
self,
*,
branch_name: str,
base: str | None = None,
actor_agent_id: UUID | None = None,
preferred_parent: str | None = None,
) -> str:
"""Return the git diff for `branch_name` against `base`.
When `base` is omitted, diffs against the branch's parent (per
`parent_branch_for`), falling back to the repo default branch
when that parent was never pushed. Content_actions
evidence path can pass `HEAD~1` for an incremental diff.
``actor_agent_id`` resolves the workspace via the caller's clone
when ``task.assigned_to`` is None — important for
QA reviewing post-submit_qa.
``preferred_parent`` is ignored once ``base`` is explicit; it only
overrides the derived-parent lookup (see ``_resolve_diff_base``) for
a caller with an authoritative parent branch name (a cross-team
assembled-PR review) — never a literal ref like ``base="HEAD~1"``.
"""
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
)
)
diff_result = await self._run_git(
workspace, ["diff", f"{base_ref}...{head_ref}"], check=False
)
return diff_result.stdout
async def list_changed_files(
self,
*,
branch_name: str,
base: str | None = None,
actor_agent_id: UUID | None = None,
preferred_parent: str | None = None,
) -> list[str]:
"""Return the file paths changed on `branch_name` relative to `base`.
Mirrors ``diff`` but invokes ``git diff --name-only`` so the
gateway evidence path can populate ``files_changed`` from the
authoritative git state — independent of whether the agent
ever called the legacy ``add_files_modified`` HTTP endpoint
(which the gateway commit() does not call). Empty paths are
skipped; output preserves git's order. Same default-
branch fallback as ``diff`` (including ``preferred_parent``).
"""
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
)
)
result = await self._run_git(
workspace,
["diff", "--name-only", f"{base_ref}...{head_ref}"],
check=False,
)
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,
*,
branch_name: str,
path: str,
actor_agent_id: UUID | None = None,
) -> str | None:
"""Return the content of ``path`` as committed on ``branch_name``.
Reads the file straight out of the branch tip with ``git show`` in the
resolved workspace, so the orchestrator can capture a doc an agent
authored (and committed) in its OWN clone — without any shared
filesystem mount. A missing file, an uncommitted file, or a bad ref
yields ``None`` rather than raising; callers treat this as best-effort.
"""
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)
result = await self._run_git(
workspace, ["show", f"{head_ref}:{path}"], check=False
)
if result.returncode != 0:
return None
return result.stdout
async def commit(
self,
*,
branch_name: str,
message: str,
task_id: UUID,
files: list[str] | None = None,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Gateway adapter — commit on `branch_name` with a free-form message.
Resolves the workspace + project from the branch (same approach as
`push_branch`/`create_pr`), stages the requested files (or all),
and runs `git commit -m {message}`. Bypasses the conventional-commit
template — content_actions has its own validator that asserts the
message is descriptive, and the orchestrator-side git template only
applies to the structured `commit_for_task` API path.
``actor_agent_id`` falls back through the same chain as pr_merge
when ``task.assigned_to`` was cleared by an earlier transition.
Returns a dict shaped for the gateway: ``{"sha": str, "message": str,
"files_changed": int, "insertions": int, "deletions": int}``. Tests
and downstream gateway code only consume `sha`; the rest is included
so we don't have to invent a new shape later.
"""
clone_root = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
# Commit inside the task's per-task worktree (F123), not the shared
# clone — keyed by the task id so it matches create_branch's layout.
workspace = self._worktree_for_task(clone_root, task_id)
await self._ensure_worktree_for_commit(clone_root, workspace, branch_name)
await self._assert_on_task_branch(workspace, branch_name)
# Stage files explicitly when provided; otherwise stage everything
# the agent has touched. Mirrors create_commit's staging logic.
# Staging + committing a large changeset (the Next.js panel runs into
# the hundreds of files) can exceed the short default git timeout, so
# these ops get the longer `git_commit_timeout_seconds` budget.
commit_timeout = _commit_git_timeout()
if files:
for file in files:
await self._run_git(workspace, ["add", file], timeout=commit_timeout)
else:
await self._run_git(workspace, ["add", "-A"], timeout=commit_timeout)
# Free-form gateway commit — message is passed verbatim. The
# gateway's commit_validator already rejected garbage messages
# before we got here; we don't double-validate.
await self._run_git(
workspace, ["commit", "-m", message], timeout=commit_timeout
)
log_result = await self._run_git(workspace, ["log", "-1", "--format=%H|%s"])
parts = log_result.stdout.strip().split("|", 1)
commit_hash = parts[0] if parts else "unknown"
full_message = parts[1] if len(parts) > 1 else message
stat_result = await self._run_git(
workspace, ["diff", "--stat", "HEAD~1..HEAD"], check=False
)
insertions, deletions, files_changed = self._parse_commit_stats(
stat_result.stdout
)
# Best-effort link to the task; mirrors commit_for_task. A linking
# failure must not lose the commit.
task = await self._task_for_branch(branch_name)
if task is not None and task.assigned_to is not None:
await self._link_commit_to_task(
task_id, commit_hash, message, UUID(str(task.assigned_to))
)
return {
"sha": commit_hash,
"message": full_message,
"files_changed": files_changed,
"insertions": insertions,
"deletions": deletions,
}
async def conventions_check_for_task(
self,
actor_agent_id: UUID | None,
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.
Resolves the acting agent's workspace + the branch's changed files and
runs ``python -m roboco.conventions`` over them. A resolution error
(workspace missing, diff failed) fails CLOSED — returns
``could_not_run=True`` so the block gate refuses the submit instead of
silently passing on an unanalyzable diff (the validator's OWN fail-loud
exit-3 philosophy). The two empty-result paths stay fail-open: a
branchless task (no ``branch_name``) and a task with no changed files
genuinely have nothing to validate, so the gate correctly passes.
``preferred_parent`` threads to ``list_changed_files`` — the in-path
PR-review gate's cross-team parent (see ``diff``'s docstring).
``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:
return {"findings": [], "could_not_run": False}
clone_root = await self._workspace_for_branch(
branch, actor_agent_id=actor_agent_id
)
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 {
"findings": [],
"could_not_run": True,
"reason": f"resolution failed: {exc}"[:300],
}
if not changed:
return {"findings": [], "could_not_run": False}
# Validate the worktree's working tree (F123): the dev's changes live in
# the per-task worktree, not the clone root (which sits on the default
# branch). A validator run against the clone root reads stale/default
# 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)
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",
"roboco.conventions",
"check",
"--root",
str(workspace),
"--files",
*files,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(
proc.communicate(),
timeout=validator_timeout,
)
except TimeoutError:
# Fail closed (could_not_run=True → block gate refuses the submit),
# matching the validator's own fail-loud philosophy, and reap the
# 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 {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]}
findings: list[dict[str, Any]] = []
for line in out.decode(errors="replace").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
findings.append(json.loads(stripped))
except json.JSONDecodeError:
continue
return {"findings": findings, "could_not_run": False, "reason": None}
async def open_conventions_pr(
self,
project_slug: str,
*,
content: str,
title: str,
body: str,
workspace: Path | None = None,
) -> dict[str, Any] | None:
"""Commit ``.roboco/conventions.yml`` on the scaffold branch + open a PR.
Best-effort and project-level (no task): writes ``content`` on a fresh
``CONVENTIONS_SCAFFOLD_BRANCH`` cut from the default branch in
``workspace`` (an agent's fresh clone) or the project's configured
``workspace_path``, commits it (always), then pushes + opens a PR (only
with a git token + remote), and restores the clone to its original
branch so a shared workspace is never stranded on the scaffold branch.
Returns ``{"branch", "pr_number", "pr_url"}`` (``pr_number=None`` when no
remote PR was opened) or ``None`` when there is no usable workspace.
"""
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if project is None:
return None
ws = workspace
if ws is None and project.workspace_path:
# workspace_path is API-settable (PM-gated route): only a path
# inside THIS project's workspace tree may receive the scaffold
# commit — anything else (arbitrary dir, another project's clone)
# is refused as "no usable workspace".
candidate = Path(project.workspace_path)
try:
candidate.resolve().relative_to(
Path(settings.workspaces_root) / project.slug
)
ws = candidate
except (ValueError, OSError):
ws = None
if ws is None or not ws.exists():
return None
base = project.default_branch or "master"
spec = _ConventionsPr(
content=content,
branch=CONVENTIONS_SCAFFOLD_BRANCH,
title=title,
body=body,
)
original = await self.get_current_branch(ws)
# A dirty tree is an agent's active workspace. Cutting the scaffold
# branch here would sweep their uncommitted work into the conventions
# commit — ``checkout <base>`` no-ops or is refused, ``checkout -B
# <scaffold>`` carries the dirty change, and ``commit`` captures it,
# so the agent's in-progress edit rides a project-level PR they never
# intended and vanishes from their working tree. Refuse outright before
# any checkout touches the tree.
if not await self._working_tree_is_clean(ws):
return None
try:
if not await self._commit_conventions_file(ws, base, spec):
return None
return await self._push_and_open_conventions_pr(
project_slug, ws, base, spec
)
finally:
await self._run_git(ws, ["checkout", original], check=False)
async def _working_tree_is_clean(self, workspace: Path) -> bool:
"""True iff ``git status --porcelain`` is empty (no staged/unstaged
changes, no untracked entries). Used to gate workspace-mutating
project-level ops (the conventions scaffold) away from an agent's
active dirty tree."""
result = await self._run_git(workspace, ["status", "--porcelain"])
return not result.stdout.strip()
async def _commit_conventions_file(
self, workspace: Path, base: str, spec: _ConventionsPr
) -> bool:
"""Commit the conventions file on the scaffold branch cut from ``base``.
Returns False when the scaffold cannot be safely cut from ``base`` (the
``checkout <base>`` with ``check=False`` failed — a missing base ref,
or a dirty tree that refused the switch). In that case ``checkout -B
<scaffold>`` would cut the scaffold from the *current* branch (the
agent's task branch), basing a project-level PR on the agent's work;
the caller refuses rather than commit on the wrong base.
"""
await self._run_git(workspace, ["checkout", base], check=False)
# check=False swallows the failed checkout; verify we actually landed
# on base before cutting the scaffold from here.
if await self.get_current_branch(workspace) != base:
return False
await self._run_git(workspace, ["checkout", "-B", spec.branch])
target = workspace / ".roboco" / "conventions.yml"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(spec.content)
await self._run_git(workspace, ["add", ".roboco/conventions.yml"])
await self._run_git(workspace, ["commit", "-m", spec.title])
return True
async def _push_and_open_conventions_pr(
self, project_slug: str, workspace: Path, base: str, spec: _ConventionsPr
) -> dict[str, Any]:
unopened: dict[str, Any] = {
"branch": spec.branch,
"pr_number": None,
"pr_url": None,
}
token = await self._token_for_project(project_slug)
if not token:
return unopened
try:
await self.push(workspace, force=True)
owner, repo = self._parse_github_remote(workspace)
resp = await self._post_pr(
owner,
repo,
token,
{
"title": spec.title,
"head": spec.branch,
"base": base,
"body": spec.body,
},
)
except GitError:
return unopened
if not resp.is_success:
return unopened
data = resp.json()
return {
"branch": spec.branch,
"pr_number": data.get("number"),
"pr_url": data.get("html_url"),
}
CONVENTIONS_SCAFFOLD_BRANCH = "chore/roboco-conventions-scaffold"
@dataclass(frozen=True)
class _ConventionsPr:
"""The fields for a project-level conventions scaffold/restore PR."""
content: str
branch: str
title: str
body: str
def get_git_service(session: AsyncSession) -> GitService:
"""Factory function to get git service."""
return GitService(session)