Files
roboco/roboco/services/gateway/choreographer/pr_gate.py
T
Renn F ce4d02b3c2 fix(gateway): bounded fail-open evidence assembly + PM decision transient-failure bypass
Evidence-assembly git legs (diff, changed-files, branch fetch, advisory
conventions run) ran unbounded inside claim_review / claim_doc_task /
claim_gate_review / evidence() / i_am_done's envelope build, so a slow
clone turned the whole verb into a silent 120s FlowVerbTimeout 504.
Each leg now runs through run_bounded_leg under a shared LegBudget
(evidence_assembly_timeout_seconds, 45s total): a timed-out leg — both
asyncio TimeoutError and git's own GitTimeoutError — degrades into an
evidence_gaps note on the envelope instead of hanging the verb, while
non-timeout git errors still propagate. The advisory conventions run
gets an inner-only timeout (conventions_validator_advisory_timeout_
seconds, 30s) threaded down to the subprocess so it is never orphaned
by an outer cancel; the fail-closed i_am_done/pr_pass conventions
gates keep their hardcoded 120s.

_ensure_pm_decision now reports a PmDecisionOutcome: a transient DB
failure recording the PM's decision journal (e.g. lock timeout under
load) no longer launders into a journal:decision gate rejection that
escalates and BLOCKS the task — the verb's own rationale satisfies the
gate with a structured warning, across all seven PM verbs.

Also: repo-wide ruff realignment to the lockfile-pinned ruff (8
format-only diffs, 14 UP038 isinstance conversions) that a transiently
newer venv ruff had masked.

Gate: 15474 passed, 459 skipped; ruff/mypy/xenon/vulture/bandit/
pip-audit/deptry/import-linter/foundation-check all green.
2026-07-31 13:56:19 +02:00

1322 lines
54 KiB
Python

"""In-path PR-review gate verbs (assembled cell→root + root→master PRs).
Mixin for ``claim_gate_review``, ``pr_pass`` and ``pr_fail`` — the reviewer
side of the in-path gate. Distinct from ``PRReviewerMixin`` (inbound
external/fork PRs): these GATE an internal delivery task between the PM's
submit (submit_up / submit_root) and the PM's merge. The reviewer reads the
assembled diff and either passes it on to awaiting_pm_review or fails it back to
needs_revision — exactly like QA's pass_review / fail_review, but at the
assembled-PR level.
Inherits ``ChoreographerHelpers`` under ``TYPE_CHECKING`` only so mypy resolves
``self.task`` etc.; at runtime the composed ``Choreographer`` supplies the real
attributes via MRO (same pattern as ``QAMixin`` / ``PRReviewerMixin``).
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import structlog
from roboco.config import settings
from roboco.foundation.policy import lifecycle as spec_module
from roboco.foundation.policy import tracing as _tr
from roboco.foundation.policy.batch import is_batch_root_subtask
from roboco.foundation.policy.content import (
ContentValidationError,
markers,
)
from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.gateway.choreographer.evidence_legs import (
LegBudget,
run_bounded_leg,
)
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import render_findings
from roboco.services.gateway.merge_chain import resolve_parent_branch
if TYPE_CHECKING:
from uuid import UUID
from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers
_Base = ChoreographerHelpers
else:
_Base = object
logger = structlog.get_logger()
class PRGateMixin(_Base):
"""In-path PR-review-gate verbs (assembled cell→root + root→master PRs)."""
async def claim_gate_review(
self, reviewer_agent_id: UUID, task_id: UUID
) -> Envelope:
"""Reviewer claims an awaiting_pr_review task without transitioning it.
Mirrors QA's claim_review: the spec gate enforces role (pr_reviewer) +
the claim source-status (AWAITING_PR_REVIEW); the verb body then claims
without transition (status stays awaiting_pr_review) so the downstream
pr_pass / pr_fail source-status still matches. The assembled PR's diff is
returned inline (read-only) so the reviewer inspects it before deciding.
"""
pre = await self._claim_gate_preflight(reviewer_agent_id, task_id)
if isinstance(pre, Envelope):
return pre
t, role_str, briefing = pre
guard = await self._run_claim_guards(
agent_id=reviewer_agent_id,
task=t,
skip_dev_guards=True,
)
if guard:
guard.with_introspection(task=t, role=role_str)
return await self._emit_rejection(
self._with_briefing(guard, briefing),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
claimed = await self.task.pr_gate_claim(reviewer_agent_id, task_id)
if claimed is None:
return await self._emit_rejection(
Envelope.invalid_state(
message="this assembled-PR review task is no longer claimable",
remediate="it may already be claimed; give_me_work for the next",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
t = claimed
evidence = await self._build_gate_review_evidence(t)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["claim_gate_review"].next_hint(t),
evidence=evidence,
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _claim_gate_preflight(
self, reviewer_agent_id: UUID, task_id: UUID
) -> Any:
"""Task fetch + role + spec gate for ``claim_gate_review``.
Returns a rejection ``Envelope`` or the ``(t, role_str, briefing)``
tuple on pass.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
agent = await self.task.agent_for(reviewer_agent_id)
role_str = self._role_str_for_agent(agent)
briefing = await self._briefing_for(reviewer_agent_id, task_id, full=True)
role = await self._gate_role_or_rejection(
t=t,
role_str=role_str,
briefing=briefing,
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
if isinstance(role, Envelope):
return role
spec_ctx = spec_module.Context(
actor_id=reviewer_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
agent_team=str(agent.team) if agent is not None and agent.team else None,
)
decision = spec_module.can_invoke_intent(role, "claim_gate_review", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
return (t, role_str, briefing)
@staticmethod
def _role_str_for_agent(agent: Any) -> str:
"""Reviewer role string off the agent view, defaulting to pr_reviewer."""
return str(agent.role) if agent is not None else "pr_reviewer"
async def pr_pass(
self, reviewer_agent_id: UUID, task_id: UUID, notes: str
) -> Envelope:
"""Pass the assembled PR; awaiting_pr_review → awaiting_pm_review."""
return await self._gate_decision(
reviewer_agent_id, task_id, "pr_pass", notes=notes, issues=()
)
@staticmethod
def _validate_pr_fail_findings(
t: Any, issues: list[str] | None, findings: list[dict[str, Any]] | None
) -> tuple[list[Any], Envelope | None]:
"""Normalize + validate pr_fail's findings — mirrors QA's fail_review
helper (``QAMixin._validate_fail_review_findings``).
Returns ``(validated, rejection)``; the caller attaches
``context_briefing``/introspection (this helper has no ``self``).
"""
raw = findings_lib.merge_findings_and_issues(findings, issues)
if not raw:
return [], Envelope.invalid_state(
message="pr_fail requires at least one finding",
remediate=(
"pass findings=[{file, severity, expected, actual}, ...] "
"(issues=['...'] is still accepted this release, deprecated)"
),
)
if cap := findings_lib.findings_count_guard(raw):
return [], cap
validated, bad = findings_lib.validate_or_reject(raw)
if bad is not None:
return [], bad
if t is not None and (
unknown := findings_lib.unknown_finding_criteria(t, validated)
):
return [], findings_lib.criterion_mismatch_rejection(t, unknown)
return validated, None
async def pr_fail(
self,
reviewer_agent_id: UUID,
task_id: UUID,
issues: list[str] | None = None,
findings: list[dict[str, Any]] | None = None,
) -> Envelope:
"""Fail the assembled PR with concrete findings; → needs_revision.
``findings`` is the structured revision-findings ledger entry (wire
Pattern A); ``issues`` (free text) is still accepted for one release
and shimmed into file-less findings (deprecated).
"""
t = await self.task.get(task_id)
validated, bad = self._validate_pr_fail_findings(t, issues, findings)
if bad is not None:
briefing = await self._briefing_for(reviewer_agent_id, task_id)
return await self._emit_rejection(
self._with_briefing(bad, briefing).with_introspection(
task=t, role="pr_reviewer"
),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_fail",
)
# Placeholder (id-less) rendering drives the existing gates — the
# ledger ids don't exist until _gate_decision inserts them.
notes = findings_lib.render_findings_summary([(None, f) for f in validated])
return await self._gate_decision(
reviewer_agent_id,
task_id,
"pr_fail",
notes=notes,
issues=(),
findings=tuple(validated),
)
# -- helpers ----------------------------------------------------------
async def _gate_preflight(
self,
reviewer_agent_id: UUID,
task_id: UUID,
verb: str,
*,
notes: str,
issues: tuple[str, ...],
) -> Any:
"""Ownership + role + spec gate for pr_pass / pr_fail.
Returns a rejection ``Envelope`` or the
``(t, agent, role_str, briefing, spec_ctx)`` tuple on pass.
"""
t = await self._gate_ownership_or_rejection(reviewer_agent_id, task_id, verb)
if isinstance(t, Envelope):
return t
agent = await self.task.agent_for(reviewer_agent_id)
role_str = self._role_str_for_agent(agent)
briefing = await self._briefing_for(reviewer_agent_id, task_id)
role = await self._gate_role_or_rejection(
t=t,
role_str=role_str,
briefing=briefing,
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
if isinstance(role, Envelope):
return role
spec_ctx = self._gate_preflight_spec_ctx(
reviewer_agent_id, agent, t, notes, issues
)
if soup := await self._guard_free_text(
checks=(("notes", notes, 8), ("issues", list(issues), 8)),
task=t,
agent_id=reviewer_agent_id,
role_str=role_str,
verb=verb,
):
return soup
decision = spec_module.can_invoke_intent(role, verb, t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
return (t, agent, role_str, briefing, spec_ctx)
async def _gate_ownership_or_rejection(
self, reviewer_agent_id: UUID, task_id: UUID, verb: str
) -> Any:
"""Fetch the task and verify it is assigned to the reviewer.
Returns the task on success, or a ``not_found`` / ``not_authorized``
rejection ``Envelope`` on failure.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
if t.assigned_to != reviewer_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_gate_review(task_id) first",
context_briefing=await self._briefing_for(
reviewer_agent_id, task_id
),
).with_introspection(task=t, role="pr_reviewer"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
return t
@staticmethod
def _gate_preflight_spec_ctx(
reviewer_agent_id: UUID, agent: Any, t: Any, notes: str, issues: tuple[str, ...]
) -> spec_module.Context:
"""Build the pr_pass / pr_fail spec ``Context``, including the
self-review wiring.
The spec gate's ``self_review_block`` is the only self-review defense
for pr_pass / pr_fail: the service-layer ``_validate_not_self_review``
backstop covers qa/documenter but skips pr_reviewer. For the comparison
to fire, both sides must be populated. ``GatewayAgentView`` carries no
``slug`` field (so ``getattr(agent, "slug", None)`` is always None in
production), and the ``original_developer`` marker stores the dev's
UUID — so resolve both as UUID strings and let the spec's string
equality do the rest. The marker is never set on assembled coordination
tasks (only on dev-leaf tasks at QA/doc claim), so the block is dormant
by design in production — but the gate is now correctly wired to fire
if the marker were ever set to the reviewer.
"""
return spec_module.Context(
actor_id=reviewer_agent_id,
actor_slug=str(reviewer_agent_id),
agent_team=str(agent.team) if agent is not None and agent.team else None,
original_developer_slug=markers.get_original_developer(t),
notes=notes,
issues=issues,
)
async def _record_gate_verdict_for(
self,
verb: str,
t: Any,
notes: str,
*,
issues: tuple[str, ...],
ci_note: str | None = None,
findings: list[Any] | None = None,
) -> str | None:
"""Author the canonical pr_review verdict note before the transition.
On pr_fail also stamp the assembled PR's head SHA so the next submit_root
can structurally refuse to re-submit the unchanged root (the 2026-06-27
infinite pr_fail re-submit loop). Best-effort: a capture failure leaves
head_sha absent and submit_root fails open rather than wedging the PM.
On pr_pass, ``ci_note`` (set by ``_pr_pass_blocked`` when the CI-status
guard passed through a project with no CI configured) is stamped into
the verdict's ``ci_status`` field as evidence the guard actually ran.
``findings`` (pr_fail's already-ledgered revision findings) embed
alongside — the first time this slot's ``findings`` list is ever
non-empty on the in-path gate.
Returns the captured head_sha for pr_fail (None for pr_pass) so the caller
can re-capture after the transition commits and re-stamp if the PR head
advanced in between (#189 staleness).
"""
if verb == "pr_fail":
head_sha = await self._capture_pr_head_sha(t)
self._record_gate_verdict(
t, verb, notes, issues=issues, head_sha=head_sha, findings=findings
)
return head_sha
self._record_gate_verdict(
t, verb, notes, issues=issues, ci_note=ci_note, findings=findings
)
return None
async def _re_stamp_pr_fail_head_sha_if_advanced(
self,
t: Any,
notes: str,
*,
issues: tuple[str, ...],
pre_sha: str | None,
findings: list[Any] | None = None,
) -> None:
"""Re-capture the PR head SHA after the transition commits and re-stamp
the verdict note when it advanced past the pre-transition capture (#189).
The pre-transition capture can go stale if cell work lands on the root
branch between that capture and the commit; a stale recorded SHA makes
``submit_root`` false-allow an unchanged re-submit and re-opens the
pr_fail loop. Re-stamping only on a real advance keeps the no-advance
case to a single note write. Best-effort: a re-capture failure leaves
the pre-transition SHA in place (the fail-open direction). ``findings``
must be re-passed here too — this re-stamp fully replaces the
``pr_review`` slot (``apply_structured_note`` overwrites, not merges),
so omitting it would silently wipe the findings the pre-transition
write just recorded.
"""
try:
post_sha = await self._capture_pr_head_sha(t)
except Exception:
logger.exception(
"pr_fail head-sha re-capture failed (keeping pre-transition sha)",
task_id=str(getattr(t, "id", "")),
)
return
if post_sha is not None and post_sha != pre_sha:
self._record_gate_verdict(
t, "pr_fail", notes, issues=issues, head_sha=post_sha, findings=findings
)
async def _post_gate_review(
self, t: Any, agent: Any, role_str: str, verb: str, notes: str
) -> None:
"""Post the gate verdict to the PR itself (best-effort, after the DB
transition — a GitHub failure must not roll back the gate decision)."""
reviewer_slug = getattr(agent, "slug", None) or role_str
await self._post_gate_review_to_pr(t, verb, reviewer_slug, notes)
async def _deliver_pr_fail_to_owner(
self, t: Any, reviewer_agent_id: UUID, task_id: UUID, notes: str
) -> None:
"""a2a the pr_fail change-requests to the owning PM (best-effort).
The verdict is posted on the PR but never reaches a PM-readable channel
(no a2a, and _briefing_for / build_task_handoff read neither
pr_reviewer_notes nor notes_structured.pr_review), so without this the
owning PM respawned into needs_revision re-submits the same PR blind —
an infinite pr_fail loop (live on 9980d0a0 / PR #138). Mirrors QA's
fail_review a2a. A Main-PM branch-bearing root is assembled cell work the
Main PM can't fix directly, so steer it to re-delegate + wait for
re-assembly rather than re-submit the unchanged root.
"""
if t.assigned_to is None:
return
team = getattr(t, "team", None)
team_value = str(getattr(team, "value", team))
is_main_pm_root = team_value == spec_module.Team.MAIN_PM.value and bool(
getattr(t, "branch_name", None)
)
steer = (
" Assembled cell work failed — re-delegate the fixes to the"
" owning cell PM(s) and wait for re-assembly; do NOT re-submit"
" the root."
if is_main_pm_root
else ""
)
try:
await self.a2a.send(
from_agent=reviewer_agent_id,
to_agent=t.assigned_to,
skill="code_review",
task_id=task_id,
body=f"PR review needs changes. {notes}{steer}",
)
except Exception:
logger.exception("pr_fail a2a to owning PM failed", task_id=str(task_id))
async def _attach_pr_fail_findings(
self, t: Any, agent: Any, role_str: str, findings: list[Any]
) -> str:
"""Insert pr_fail's findings into the ledger; return the id-prefixed
rendering used for the structured note, the PR comment, and the a2a
body to the owning PM (all three read ``notes`` after this call)."""
# GatewayAgentView carries no slug field — falls back to the role
# string (mirrors _post_gate_review's reviewer_slug fallback).
author_slug = getattr(agent, "slug", None) or role_str
_, summary = await findings_lib.insert_and_render(
self.task.session,
task_id=t.id,
origin="pr_gate",
round=findings_lib.next_round(t),
author_slug=author_slug,
findings=findings,
)
return summary
async def _run_gate_verb(
self,
verb: str,
t: Any,
agent: Any,
spec_ctx: spec_module.Context,
*,
reviewer_agent_id: UUID,
task_id: UUID,
role_str: str,
briefing: dict[str, Any],
) -> Envelope | Any:
"""Dispatch the composed atomic action via the verb runner.
Returns the rejection ``Envelope`` on a runner exception or a
concurrent-transition race (the last composed action returning
``None``), else the post-transition task.
"""
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
except Exception as exc:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"verb runner failed: {exc}",
remediate="retry; if persistent, unclaim and notify the CEO",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
# A concurrent transition (cancel, racing reviewer) between the
# precondition gate and the runner's final action makes run_intent
# return None; guard the dereferences below with a clean rejection so
# the reviewer re-fetches and re-issues.
if t is None:
return await self._emit_rejection(
Envelope.invalid_state(
message=(
f"{verb}: the task moved out of awaiting_pr_review before"
" the decision committed — a concurrent transition"
" (cancel or a racing reviewer) beat you to it."
),
remediate=(
"re-fetch with evidence(task_id) and re-issue your gate"
" verb once the task is back in awaiting_pr_review"
),
context_briefing=briefing,
),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
return t
async def _post_gate_transition_effects(
self,
t: Any,
agent: Any,
role_str: str,
verb: str,
notes: str,
*,
reviewer_agent_id: UUID,
task_id: UUID,
issues: tuple[str, ...],
pre_sha: str | None,
findings: list[Any] | None = None,
) -> None:
"""Best-effort side effects after a gate decision commits.
pr_fail: re-capture the PR head SHA AFTER the transition commits. The
pre-transition capture (in ``_record_gate_verdict_for``) can go stale
if cell work lands on the root branch between that capture and the
commit — a stale recorded SHA would make submit_root false-allow an
unchanged re-submit and re-open the pr_fail loop. Re-stamp only when
the head actually advanced, so the no-advance case stays a single
note write — carrying ``findings`` forward too, since the re-stamp
fully replaces the ``pr_review`` slot. Then post the gate verdict on
the PR itself (a GitHub failure must not roll back the gate
decision), and — pr_fail only — a2a the change-requests to the
owning PM (see ``_deliver_pr_fail_to_owner`` for the rationale and
the Main-PM-root steer).
"""
if verb == "pr_fail":
await self._re_stamp_pr_fail_head_sha_if_advanced(
t, notes, issues=issues, pre_sha=pre_sha, findings=findings
)
await self._post_gate_review(t, agent, role_str, verb, notes)
if verb == "pr_fail":
await self._deliver_pr_fail_to_owner(t, reviewer_agent_id, task_id, notes)
async def _gate_decision(
self,
reviewer_agent_id: UUID,
task_id: UUID,
verb: str,
*,
notes: str,
issues: tuple[str, ...],
findings: tuple[Any, ...] = (),
) -> Envelope:
"""Shared body for pr_pass / pr_fail: preflight + tracing + run."""
pre = await self._gate_preflight(
reviewer_agent_id, task_id, verb, notes=notes, issues=issues
)
if isinstance(pre, Envelope):
return pre
t, agent, role_str, briefing, spec_ctx = pre
gate = await self._gate_tracing(
reviewer_agent_id, task_id, t, role_str, verb, notes=notes
)
if gate is not None:
return gate
ci_note: str | None = None
if verb == "pr_pass":
rejection, ci_note = await self._gate_pr_pass_preflight(
reviewer_agent_id, task_id, t, role_str, briefing
)
if rejection is not None:
return rejection
# Insert the ledger rows now that the task + role gates are settled,
# THEN rebuild `notes` with the real ids — every downstream reader
# (the structured note, the PR comment, the a2a to the owning PM)
# sees the id-prefixed rendering from this point on.
if verb == "pr_fail" and findings:
notes = await self._attach_pr_fail_findings(
t, agent, role_str, list(findings)
)
# Author the canonical pr_review verdict note BEFORE the transition so it
# is persisted by the same commit (mirrors post_pr_review) and stays in
# lock-step with the decision (pr_fail overwrites an earlier pr_pass).
pre_sha = await self._record_gate_verdict_for(
verb, t, notes, issues=issues, ci_note=ci_note, findings=list(findings)
)
result = await self._run_gate_verb(
verb,
t,
agent,
spec_ctx,
reviewer_agent_id=reviewer_agent_id,
task_id=task_id,
role_str=role_str,
briefing=briefing,
)
if isinstance(result, Envelope):
return result
t = result
await self._post_gate_transition_effects(
t,
agent,
role_str,
verb,
notes,
reviewer_agent_id=reviewer_agent_id,
task_id=task_id,
issues=issues,
pre_sha=pre_sha,
findings=list(findings),
)
env = Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS[verb].next_hint(t),
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
if verb == "pr_fail" and (hint := findings_lib.findings_count_hint(findings)):
env.warning = hint
return env
async def _gate_pr_pass_preflight(
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
briefing: dict[str, Any],
) -> tuple[Envelope | None, str | None]:
"""pr_pass-only preflight: the block/CI guards, then the same-
transaction verified-stamp. Returns ``(rejection, ci_note)`` —
``rejection`` non-None on any guard/stamp failure (``ci_note`` is
meaningless then), else ``(None, ci_note)`` to proceed. Split out of
``_gate_decision`` to keep its own branching count down.
"""
blocked, ci_note = await self._pr_pass_blocked(
reviewer_agent_id, task_id, t, role_str, briefing
)
if blocked is not None:
return blocked, None
stamp_rejection = await self._stamp_gate_findings_verified_or_rejection(
t,
reviewer_agent_id=reviewer_agent_id,
task_id=task_id,
role_str=role_str,
briefing=briefing,
)
if stamp_rejection is not None:
return stamp_rejection, None
return None, ci_note
async def _pr_pass_blocked(
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
briefing: dict[str, Any],
) -> tuple[Envelope | None, str | None]:
"""Refuse pr_pass on a broken toolchain, a block-level violation, or
non-green CI on the assembled PR's head commit.
A reviewer must not PASS an assembled PR whose suite can't run in the
workspace, that carries unresolved architectural-convention
violations, or whose CI is red/pending/unscheduled/unresolvable; only
red CI is a code defect pr_fail should act on — an unresolvable CI
lookup is a platform blip to retry, never a finding. Returns
``(rejection, None)`` to block, or ``(None, ci_note)`` to proceed —
``ci_note`` is a non-None evidence stamp only when the CI guard
passed through a project with no CI configured at all. The
toolchain/conventions guards are inert when their flag is off; the
CI guard fails open on an unresolvable gate-level slug/PR number
(``None`` from ``_resolve_ci_status``) and also passes through —
with an evidence stamp — when ``get_pr_ci_status`` itself classifies
a missing project/git_url/token or an unreachable/nonexistent repo
as ``no_ci_configured``.
"""
from roboco.config import settings as _settings
# Only the conventions guard consumes the parent — skip the lookup
# entirely (and its failure surface) while the flag is off.
parent = (
await self._gate_diff_parent(t) if _settings.conventions_enabled else None
)
guards = (
lambda: self._toolchain_broken_guard(reviewer_agent_id, t, reviewer=True),
lambda: self._conventions_guard(
reviewer_agent_id, t, briefing, preferred_parent=parent
),
)
for guard in guards:
rejection = await guard()
if rejection is not None:
return (
await self._emit_rejection(
rejection.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
),
None,
)
return await self._ci_status_guard(
reviewer_agent_id, task_id, t, role_str, briefing
)
async def _stamp_gate_findings_verified_or_rejection(
self,
t: Any,
*,
reviewer_agent_id: UUID,
task_id: UUID,
role_str: str,
briefing: dict[str, Any],
) -> Envelope | None:
"""pr_pass's same-transaction verified-stamp for pr_gate-origin
addressed findings (parity with QAMixin's pass_review stamp).
Not best-effort — the ledger's integrity is the point — but a repo
error must not corrupt the pass: returned as a clean rejection
(mirrors ``_run_gate_verb``'s runner-exception rejection) BEFORE the
transition is attempted, so a stamping failure fails pr_pass cleanly
instead of landing a passed gate against a stale ledger.
"""
try:
# Savepoint: without it, a mid-flush failure poisons the session,
# so the rejection built below would itself blow up instead of
# cleanly reaching the reviewer.
async with self.task.session.begin_nested():
await findings_lib.stamp_addressed_verified(
self.task.session, t.id, origin="pr_gate"
)
except Exception as exc:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"verified-stamp failed: {exc}",
remediate=(
"retry pr_pass; if persistent, unclaim and notify the CEO"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
)
return None
async def _resolve_ci_status(self, task_id: UUID, t: Any) -> dict[str, Any] | None:
"""Best-effort CI-status lookup for the assembled PR's head commit.
Returns ``None`` on ANY configuration gap or lookup failure (no
resolvable slug/PR number, a raised exception, or a caller returning
something other than the documented ``dict[str, Any]`` shape) so
``_ci_status_guard`` fails open on every one of them uniformly.
"""
pr_number = getattr(t, "pr_number", None)
try:
slug = await self._project_slug_for(t)
except Exception:
logger.exception(
"ci status guard: slug resolve failed", task_id=str(task_id)
)
return None
if not slug or not pr_number:
return None
try:
status = await self.git.get_pr_ci_status(slug, int(pr_number))
except Exception:
logger.exception(
"ci status guard: get_pr_ci_status raised", task_id=str(task_id)
)
return None
return status if isinstance(status, dict) else None
async def _ci_status_guard(
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
briefing: dict[str, Any],
) -> tuple[Envelope | None, str | None]:
"""Refuse pr_pass unless CI on the assembled PR's head commit is green.
Failing, pending, unscheduled, or unresolvable-via-API CI states all
block, but only ``failure`` remediates via ``pr_fail`` (a reviewer
has no ``i_am_blocked``); pending/unscheduled/error are framed as
retryable (wait and call pr_pass again), never as a defect to route
back to the dev — a GitHub API lookup error is a platform blip, not
a finding. A project with no CI configured at all passes through
cleanly, returning an evidence note so the caller can stamp the
verdict with why the guard did not block.
"""
status = await self._resolve_ci_status(task_id, t)
if status is None:
# A configuration gap or lookup failure — never mistaken for a CI
# signal, so the guard fails open rather than blocking.
return None, None
state = status.get("state")
if state == "success":
return None, None
if state == "no_ci_configured":
return None, "no CI configured on this project"
message, remediate = self._ci_status_block_message(state, status)
return (
await self._emit_rejection(
Envelope.invalid_state(
message=message,
remediate=remediate,
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
),
None,
)
@staticmethod
def _ci_status_block_message(
state: str | None, status: dict[str, Any]
) -> tuple[str, str]:
"""(message, remediate) for a blocking CI state — failure / pending /
pending_not_scheduled / error (the non-terminal, non-green states).
pending/unscheduled/error are framed as retryable (wait and call
pr_pass again), never as a defect to route back to the dev.
"""
if state == "failure":
names = (
", ".join(status.get("failing_checks") or []) or "one or more checks"
)
return (
f"CI is failing on the assembled PR's head commit — {names}",
f"call pr_fail(issues=['CI failing: {names}']) so the PR returns "
"to needs_revision and the dev fixes the failing check(s) — do "
"NOT pr_pass on red CI",
)
if state == "pending":
return (
"CI is still running on the assembled PR's head commit",
"wait for CI to finish and call pr_pass again once it's green "
"— do NOT pr_pass while checks are still running",
)
if state == "pending_not_scheduled":
return (
"CI has not started running on the assembled PR's head commit yet",
"wait for CI to be scheduled and call pr_pass again once it's "
"green — do NOT pr_pass before any check has run",
)
# state == "error" (or an unrecognized value) — a genuine GitHub API
# lookup failure, never treat this as a property of the PR itself.
# Live incident: a reviewer converted this transient blip into an
# unwaivable blocker finding whose own fix text said "no code change
# required" — a platform hiccup is not a diff defect.
return (
"GitHub API error resolving CI status for the assembled PR — "
"this is a transient lookup failure, not a property of the PR",
"wait a few minutes and retry pr_pass — do NOT pr_fail over a "
"CI-status lookup error; a platform blip is not a code finding, "
"findings are for defects in the diff",
)
def _record_gate_verdict(
self,
t: Any,
verb: str,
notes: str,
issues: tuple[str, ...] = (),
*,
head_sha: str | None = None,
ci_note: str | None = None,
findings: list[Any] | None = None,
) -> None:
"""Persist the gate verdict as the canonical ``pr_review`` note.
The tracing gate only threads ``notes`` through a throwaway shim, so
nothing wrote the task's structured PR-reviewer slot — a task passed
once and later failed kept showing the stale ``verdict: passed``. This
authors the slot on every decision (``pr_pass`` → passed, ``pr_fail`` →
failed) so it can never contradict the transition. For ``pr_fail``,
``findings`` (the ledgered, id-prefixed revision findings) populate
the format-enforced ``findings`` list — its own render_markdown table
already displays them, so ``summary`` stays a plain sentence (see
``_gate_verdict_summary``) rather than duplicating every line.
Best-effort: content validation (e.g. a too-short summary) must never
roll back the gate, so a malformed payload is logged and skipped.
On ``pr_fail`` the assembled PR's head SHA is stamped into the slot
(``head_sha``) so the next ``submit_root`` can structurally refuse to
re-submit the unchanged root — the 2026-06-27 infinite ``pr_fail``
re-submit loop. ``None`` (the default) leaves it absent, which the
``submit_root`` gate treats as fail-open.
On ``pr_pass``, ``ci_note`` (set only when the CI-status guard passed
through a project with no CI configured) is stamped into the slot's
``ci_status`` field — the evidence that the guard ran and deliberately
did not block, rather than silently never having checked at all.
"""
from roboco.services.content_notes import apply_structured_note
verdict = "passed" if verb == "pr_pass" else "failed"
summary = self._gate_verdict_summary(verb, notes, issues, findings)
payload = self._gate_verdict_payload(
verdict,
summary,
issues,
verb,
head_sha=head_sha,
ci_note=ci_note,
findings=findings,
)
try:
apply_structured_note(t, "pr_review", payload)
except ContentValidationError:
logger.warning(
"gate verdict note skipped (invalid content)",
verb=verb,
task_id=str(getattr(t, "id", "")),
)
@staticmethod
def _gate_verdict_summary(
verb: str,
notes: str,
issues: tuple[str, ...],
findings: list[Any] | None = None,
) -> str:
"""The verdict note's ``summary`` field.
Findings render under their own ``## Findings`` table
(render_markdown). Baking the per-finding text into ``summary`` too
would duplicate every line on the Task Details "PR Reviewer Notes"
card (once under ## Summary, once under ## Findings). The summary is
a substantive non-issues sentence; ``notes`` (the id-prefixed
rendering) still drives the GitHub PR post and the a2a to the owning
PM — those are raw text, not rendered through render_markdown, so no
duplication there.
"""
count = len(findings) if findings else len(issues)
if verb == "pr_fail" and count:
return (
f"In-path PR-review gate requested changes - "
f"{count} issue(s) listed below."
)
return notes
@staticmethod
def _gate_verdict_payload(
verdict: str,
summary: str,
issues: tuple[str, ...],
verb: str,
*,
head_sha: str | None,
ci_note: str | None,
findings: list[Any] | None = None,
) -> dict[str, Any]:
"""Assemble the structured ``pr_review`` note payload."""
payload: dict[str, Any] = {
"summary": summary,
"findings": [f.model_dump(mode="json") for f in (findings or [])],
"verdict": verdict,
}
if issues:
payload["issues"] = list(issues)
if verb == "pr_fail" and head_sha:
payload["head_sha"] = head_sha
if verb == "pr_pass" and ci_note:
payload["ci_status"] = ci_note
return payload
async def _capture_pr_head_sha(self, t: Any) -> str | None:
"""Best-effort capture of the assembled PR's head SHA at ``pr_fail`` time.
Resolves the project slug via ``_project_slug_for`` (handles a
Main-PM root that carries only a ``product_id``) and asks
``git.get_pr_head_sha``. Returns ``None`` on ANY failure (no PR number,
no resolvable project, git error) so the ``submit_root`` unchanged-PR
gate fails open rather than wedging the PM — only the exact-unchanged
case is hard-blocked.
"""
pr_number = getattr(t, "pr_number", None)
if not pr_number:
return None
try:
slug = await self._project_slug_for(t)
except Exception:
logger.exception(
"pr_fail head-sha capture: slug resolve failed",
task_id=str(getattr(t, "id", "")),
)
return None
if not slug:
return None
try:
sha = await self.git.get_pr_head_sha(slug, int(pr_number))
return sha if isinstance(sha, str) else None
except Exception:
logger.exception(
"pr_fail head-sha capture: git lookup failed",
task_id=str(getattr(t, "id", "")),
pr=pr_number,
)
return None
@staticmethod
def _gate_review_event_verdict(verb: str, is_root: bool) -> tuple[str, str]:
"""Map the gate verb to a (review event, verdict label) pair.
``pr_pass`` → APPROVE, ``pr_fail`` → REQUEST_CHANGES — except on the
root→master PR (``is_root``), which always gets a plain COMMENT so the
gate never leaves an approval that could satisfy branch protection (only
the CEO merges master) nor a blocking review that could impede that merge.
"""
if verb == "pr_pass":
return "COMMENT" if is_root else "APPROVE", "PASSED ✅"
return "COMMENT" if is_root else "REQUEST_CHANGES", "CHANGES REQUESTED 🔴"
@staticmethod
def _gate_review_body(
verdict: str, reviewer_slug: str, notes: str, is_root: bool
) -> str:
"""Render the gate-review comment body posted to the assembled PR."""
body_lines = [
f"## In-path PR-review gate — {verdict}",
"",
f"Reviewed by **{reviewer_slug}** (RoboCo PR reviewer). Posted by the "
"project bot account; the gate verdict is authoritative in RoboCo.",
"",
(notes or "").strip() or "_(no additional notes)_",
]
if is_root:
body_lines += ["", "_Only the CEO merges this PR into `master`._"]
return "\n".join(body_lines)
async def _post_gate_review_to_pr(
self, t: Any, verb: str, reviewer_slug: str, notes: str
) -> None:
"""Post the gate verdict as a review on the assembled PR (best-effort).
``pr_pass`` posts an APPROVE, ``pr_fail`` a REQUEST_CHANGES — EXCEPT on
the root→master PR, which always gets a plain COMMENT: only the CEO acts
on master, so the gate never leaves an approval that could satisfy
branch protection and let anyone else merge, nor a blocking review that
could impede the CEO's merge. A root→master PR is identified by
``is_root``: a task with no ``parent_task_id`` (a plain Main-PM
coordination root) OR a MegaTask root-subtask (which has a parent — the
umbrella — but opens its own root→master PR per repo; detected via
``is_batch_root_subtask``). A non-batch cell-PM coordination root keeps
``batch_id=None`` → not a root-subtask → still a cell→root PR. For the
org's own PRs ``git.post_pr_review`` already downgrades a forbidden
self-review to a COMMENT, so the verdict lands regardless.
"""
try:
slug = await self._project_slug_for(t)
except Exception:
logger.exception(
"gate review PR post: slug resolve failed",
task_id=str(getattr(t, "id", "")),
)
return
pr_number = getattr(t, "pr_number", None)
if not slug or not pr_number:
return
parent_task_id = getattr(t, "parent_task_id", None)
is_root = parent_task_id is None or is_batch_root_subtask(
batch_id=getattr(t, "batch_id", None),
parent_task_id=parent_task_id,
)
event, verdict = self._gate_review_event_verdict(verb, is_root)
body = self._gate_review_body(verdict, reviewer_slug, notes, is_root)
try:
await self.git.post_pr_review(slug, int(pr_number), body, event=event)
except Exception:
logger.exception(
"gate review PR post failed", task_id=str(getattr(t, "id", ""))
)
async def _gate_role_or_rejection(
self,
*,
t: Any,
role_str: str,
briefing: dict[str, Any],
agent_id: UUID,
task_id: UUID,
verb: str,
) -> Any:
"""Parse the role enum, or return a not_authorized rejection Envelope."""
try:
return spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb=verb,
)
async def _gate_tracing(
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
verb: str,
*,
notes: str,
) -> Envelope | None:
"""pr_pass / pr_fail require a journal:learning entry (parity with QA)
plus a substantive pr_reviewer_notes section.
The section note is the verb's own ``notes`` argument (the review
verdict / issues), not yet persisted to the task, so it is threaded
through a SimpleNamespace shim (the foundation checker reads
``task.pr_reviewer_notes`` — same write-then-gate pattern as qa_notes).
"""
from roboco.config import settings as _settings
has_learning = await self.journal.has_learning_for_task(
reviewer_agent_id, task_id
)
ctx = _tr.GateContext(
journal_learning_present=has_learning,
pr_reviewer_notes_min_chars=_settings.pr_reviewer_notes_min_chars,
)
result = _tr.check_requirements(
task=SimpleNamespace(pr_reviewer_notes=notes),
requirements=list(_tr.requirements_for(verb)),
ctx=ctx,
)
if result.passed:
return None
return await self._emit_rejection(
(
await self._build_tracing_gap(
reviewer_agent_id, task_id, result.missing
)
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
async def _gate_diff_parent(self, t: Any) -> str | None:
"""The assembled task's real parent branch, or None (branchless task).
``resolve_parent_branch`` reads the parent TASK's own ``branch_name``
(correct across a team boundary — every cell→root hop, where the
child's own team segment can't derive the root's ``main_pm``
branch), unlike the string-derived ``parent_branch_for`` that
``git.diff``'s default base falls back on. Fail-open on a lookup
error (None → the derived-base fallback), like every other
``resolve_parent_branch`` call site — a transient DB miss degrades
the diff base, never 500s the gate verb.
"""
if not t.branch_name:
return None
try:
return await resolve_parent_branch(t, self.task)
except Exception as exc:
logger.warning("gate_diff_parent_skip", task_id=str(t.id), error=str(exc))
return None
async def _gate_changed_files(self, t: Any, gate_parent: str | None) -> list[str]:
"""The assembled PR's real touched files, same base as the diff — so
the collision map's declared-vs-actual drift is accurate. Best-effort:
a fetch failure yields ``[]`` and drift is simply omitted."""
try:
return list(
await self.git.list_changed_files(
branch_name=t.branch_name, preferred_parent=gate_parent
)
)
except Exception as exc: # best-effort; drift omits on failure
logger.warning(
"gate_review_files_changed_skip",
task_id=str(t.id),
error=str(exc),
)
return []
async def _gate_collision_evidence(
self, t: Any, files_changed: list[str]
) -> list[dict[str, Any]] | None:
"""The collision map for an assembled task — surfaced siblings that
would collide with it, with declared-vs-actual drift (the gate has
the real touched files in hand). Best-effort — mirrors the
``parent_context`` block; a failure omits the block, never breaks the
gate."""
if not t.parent_task_id:
return None
try:
siblings = await self.task.get_subtasks(t.parent_task_id)
return build_collision_context(
task=t, siblings=siblings, actual_files=files_changed or None
)
except Exception as exc:
logger.warning(
"gate_review_collision_context_skip",
task_id=str(t.id),
error=str(exc),
)
return None
async def _build_gate_review_evidence(self, t: Any) -> dict[str, Any]:
"""Inline evidence for claim_gate_review: the assembled diff +
criteria + the task's OPEN findings (so they aren't crowded out by
the full ledger's cap) + the full findings ledger (every status,
newest round first) so the reviewer verifies prior rounds
item-by-item — parity with QA's ``_build_qa_claim_evidence``.
The diff + changed-files legs run bounded via ``run_bounded_leg``
against ONE shared ``LegBudget`` for this build — a timeout skips
that piece and records a note in ``evidence_gaps`` instead of
hanging this advisory (non-gating) verb.
"""
diff = ""
files_changed: list[str] = []
evidence_gaps: list[str] = []
if t.branch_name:
gate_parent = await self._gate_diff_parent(t)
budget = LegBudget(settings.evidence_assembly_timeout_seconds)
diff = await run_bounded_leg(
self.git.diff(
branch_name=t.branch_name,
preferred_parent=gate_parent,
),
default="",
budget=budget,
leg="pr diff",
hint="review the PR diff on GitHub directly",
task_id=t.id,
gaps=evidence_gaps,
)
files_changed = await run_bounded_leg(
self._gate_changed_files(t, gate_parent),
default=[],
budget=budget,
leg="files_changed",
hint="review the PR diff on GitHub directly",
task_id=t.id,
gaps=evidence_gaps,
)
open_findings = await findings_lib.open_findings_for_task(
self.task.session, t.id
)
prior_findings = await findings_lib.full_ledger_for_task(
self.task.session, t.id
)
# The ask: this assembled task's own description + the upstream
# chain (parent → root) so the gate reviewer checks INTENT against
# the intake's original analysis, not only the AC list. Empty chain
# for a parentless root is fine (omitted downstream when empty).
parent_context: list[dict[str, Any]] = []
try:
parent_context = await self.evidence_repo.ancestor_context_for_task(t.id)
except Exception as exc:
logger.warning(
"gate_review_parent_context_skip", task_id=str(t.id), error=str(exc)
)
evidence: dict[str, Any] = {
"pr_number": t.pr_number,
"pr_url": t.pr_url,
"pr_diff": diff,
"acceptance_criteria": list(getattr(t, "acceptance_criteria", None) or []),
"is_assembled_pr": True,
"revision_findings": render_findings(open_findings),
"prior_findings": render_findings(prior_findings),
}
description = getattr(t, "description", None)
if description:
evidence["description"] = description
if parent_context:
evidence["parent_context"] = parent_context
collision = await self._gate_collision_evidence(t, files_changed)
if collision:
evidence["collision_context"] = collision
if evidence_gaps:
evidence["evidence_gaps"] = evidence_gaps
return evidence