Fix: Make main_pm + task_type=code impossible

This commit is contained in:
Renn F
2026-06-27 19:52:01 +02:00
parent 5b931c367f
commit e202ce397d
17 changed files with 1439 additions and 33 deletions
@@ -5780,12 +5780,15 @@ class Choreographer:
main_pm_agent_id, root_task_id
),
)
# A code root must pass the in-path PR-review gate first: submit_root
# opens the root→master PR and moves it in_progress → awaiting_pr_review,
# then the main reviewer pr_passes it to awaiting_pm_review. So complete
# accepts only awaiting_pm_review for a code root. A branchless
# coordination root (product fan-out, no repo/PR) skips the gate, so it
# may still be walked from in_progress here.
# A branch-bearing root must pass the in-path PR-review gate first:
# submit_root opens the root→master PR and moves it in_progress →
# awaiting_pr_review, then the main reviewer pr_passes it to
# awaiting_pm_review. So complete accepts only awaiting_pm_review for a
# branch-bearing root (a Main-PM root-subtask is planning-typed, never
# code, but it still assembles the cells' merged work into a real PR).
# A branchless coordination root (product fan-out, no repo/PR) skips the
# gate, so it may still be walked from in_progress here. The split is
# branch-keyed, not task_type-keyed.
root_is_branchless = not bool(t.branch_name)
allowed_statuses = (
("awaiting_pm_review", "in_progress")
@@ -245,7 +245,7 @@ class PRGateMixin(_Base):
# what keeps notes_structured.pr_review in lock-step with the decision —
# a later pr_fail overwrites an earlier pr_pass verdict instead of
# leaving a stale "passed" on a task that was just sent back.
self._record_gate_verdict(t, verb, notes)
self._record_gate_verdict(t, verb, notes, issues=issues)
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
@@ -265,6 +265,49 @@ class PRGateMixin(_Base):
# 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)
# Deliver the change-requests to the owner that now has to act on them
# — the cell PM the runner just re-assigned via _revision_pm_for_task.
# The reviewer posts the verdict on the PR itself but that never reaches
# any PM-readable channel (no a2a, and _briefing_for / build_task_handoff
# read neither pr_reviewer_notes nor notes_structured.pr_review). Without
# this the owning PM respawned into needs_revision saw a generic "needs
# revision" with zero concrete issues, concluded nothing to rework, and
# re-submitted the same PR — an infinite pr_fail loop (live on
# 9980d0a0 / PR #138). Mirrors QA's fail_review a2a to the dev (qa.py:671).
# Best-effort: the transition already committed, so a delivery failure
# must not roll the verdict back or 500 the reviewer.
if verb == "pr_fail" and t.assigned_to is not None:
# A Main-PM branch-bearing root is an assembled cell→root / root→master
# PR — coordination, not the Main PM's own code. The rejection is
# about the cells' merged code, which the Main PM cannot fix directly
# (no code verb). Steer the a2a body to re-delegate + wait for
# re-assembly so the PM doesn't re-submit the unchanged root (the
# 2026-06-27 infinite pr_fail loop). The Envelope ``next`` hint makes
# the same steer via _next_hint_pr_fail.
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)
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
@@ -302,27 +345,50 @@ class PRGateMixin(_Base):
)
return None
def _record_gate_verdict(self, t: Any, verb: str, notes: str) -> None:
def _record_gate_verdict(
self, t: Any, verb: str, notes: str, issues: tuple[str, ...] = ()
) -> 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. Best-effort: content
validation (e.g. a too-short summary) must never roll back the gate, so a
malformed payload is logged and skipped rather than raised.
failed) so it can never contradict the transition. For ``pr_fail`` the
free-text ``issues`` land in the structured ``issues`` slot (not the
format-enforced ``findings`` list, which needs file/severity/expected/
actual) so a reader of ``notes_structured.pr_review`` — or the owning
PM's briefing that mirrors it — gets the concrete change-requests.
Best-effort: content validation (e.g. a too-short summary) must never
roll back the gate, so a malformed payload is logged and skipped.
"""
from roboco.foundation.policy.content import ContentValidationError
from roboco.services.content_notes import apply_structured_note
verdict = "passed" if verb == "pr_pass" else "failed"
try:
apply_structured_note(
t,
"pr_review",
{"summary": notes, "findings": [], "verdict": verdict},
if verb == "pr_fail" and issues:
# The free-text issues render under their own ``## Issues`` section
# (render_markdown). Baking them into ``summary`` too duplicated each
# issue on the Task Details "PR Reviewer Notes" card (once under
# ## Summary, once under ## Issues). The summary is a substantive
# non-issues sentence; ``notes`` (with the issues) 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.
summary = (
f"In-path PR-review gate requested changes - "
f"{len(issues)} issue(s) listed below."
)
else:
summary = notes
payload: dict[str, Any] = {
"summary": summary,
"findings": [],
"verdict": verdict,
}
if issues:
payload["issues"] = list(issues)
try:
apply_structured_note(t, "pr_review", payload)
except ContentValidationError:
logger.warning(
"gate verdict note skipped (invalid content)",
@@ -160,6 +160,23 @@ class PRReviewerMixin(_Base):
apply_structured_note(t, "pr_review", structured)
return structured.render_markdown()
@staticmethod
def _is_hand_formatted_verdict(body: str) -> bool:
"""True when a free-text ``body`` carries verdict/section markdown headers
the system would otherwise generate — i.e. the reviewer hand-formatted a
verdict into ``body`` instead of passing structured ``findings``.
Matches the section headers the canonical renderer emits (``## Findings``)
plus the ones a hand-formatter reaches for (``## Summary`` / ``## Issues``
/ ``## Verdict``). A real one-paragraph summary does not contain ``## ``
headers, so the prose word "summary" never trips this.
"""
lowered = (body or "").lower()
return any(
header in lowered
for header in ("## summary", "## issues", "## verdict", "## findings")
)
async def _post_review_side_effects(
self,
t: Any,
@@ -219,11 +236,15 @@ class PRReviewerMixin(_Base):
if isinstance(pre, Envelope):
return pre
agent, role_str, briefing, spec_ctx = pre
# Refuse a verdict that contradicts the findings BEFORE anything is
# recorded or posted to the contributor's PR (e.g. a forgotten
# event='APPROVE' that defaults to a blocking REQUEST_CHANGES with no
# findings cited).
conflict = await self._verdict_consistency_gate(
# Content gates BEFORE anything is recorded or posted to the
# contributor's PR: (1) refuse a verdict that contradicts the findings
# (a forgotten event='APPROVE' defaulting to a blocking REQUEST_CHANGES
# with no findings); (2) refuse a hand-formatted verdict body with no
# findings (the tool contract is "body = a one-paragraph summary; the
# system GENERATES the comment from structured findings — do not
# hand-format"). Folded into one helper so neither slips through and the
# verb body stays under the return-count lint ceiling.
rejection = await self._post_pr_review_content_gates(
t,
reviewer_agent_id,
task_id,
@@ -231,9 +252,10 @@ class PRReviewerMixin(_Base):
briefing,
event=event,
findings=findings,
body=body,
)
if conflict is not None:
return conflict
if rejection is not None:
return rejection
slug = await self._project_slug_for(t)
pr_number = t.pr_number
post_body = self._resolve_post_body(t, body, findings, event)
@@ -351,6 +373,72 @@ class PRReviewerMixin(_Base):
verb="post_pr_review",
)
async def _post_pr_review_content_gates(
self,
t: Any,
reviewer_agent_id: UUID,
task_id: UUID,
role_str: str,
briefing: dict[str, Any],
*,
event: str,
findings: list[dict[str, Any]] | None,
body: str,
) -> Envelope | None:
"""Pre-side-effect content gates for ``post_pr_review``: verdict
consistency, then the no-hand-formatted-body guard. Returns the first
rejection ``Envelope`` or ``None`` to proceed.
The hand-format guard: the tool contract is "``body`` = a one-paragraph
summary; the system GENERATES the GitHub comment from structured
findings — do not hand-format it in ``body``". Nothing enforced that, so
a reviewer could pass ``findings=[]`` and dump a self-formatted
``## Summary`` / ``## Issues`` / ``## Verdict`` blob into ``body``,
which ``_resolve_post_body`` posts verbatim (the renderer emits
``## Findings``, never ``## Issues`` — so a ``## Issues`` section on the
PR is proof the body was hand-formatted). Observed live: a duplicated,
self-redundant hand-formatted verdict posted to a contributor's PR.
Refuse it and point the reviewer at the structured path. Scoped to empty
findings: with structured findings the system generates the comment, so
a header-shaped word in the summary is harmless; a genuine plain-note
``COMMENT`` (no verdict headers) is still allowed.
"""
conflict = await self._verdict_consistency_gate(
t,
reviewer_agent_id,
task_id,
role_str,
briefing,
event=event,
findings=findings,
)
if conflict is not None:
return conflict
if not findings and self._is_hand_formatted_verdict(body):
return await self._emit_rejection(
Envelope.invalid_state(
message=(
"post_pr_review body is hand-formatted as a verdict — "
"pass structured findings instead"
),
remediate=(
"do not hand-format the review. Pass a one-paragraph "
"summary in `body` plus structured "
"`findings=[{file, line?, severity "
"(blocker|major|minor|nit), expected, actual}, ...]`; the "
"system generates the GitHub comment (summary + findings "
"table + verdict). event='REQUEST_CHANGES' requires >=1 "
"finding; a bare event='COMMENT' with no findings is for a "
"plain note, not a verdict"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="post_pr_review",
)
return None
async def _resolve_role(
self,
t: Any,