mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fix: Make main_pm + task_type=code impossible
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user