feat(content): PR reviewer writes its own slot, stops clobbering QA/Dev

This commit is contained in:
Renn F
2026-06-21 02:56:53 +02:00
parent 0fba4eed01
commit 2e490a06d7
4 changed files with 53 additions and 42 deletions
@@ -27,7 +27,6 @@ from pydantic import (
ValidationError, ValidationError,
ValidationInfo, ValidationInfo,
field_validator, field_validator,
model_validator,
) )
from roboco.foundation.identity import CELL_TEAMS, Team from roboco.foundation.identity import CELL_TEAMS, Team
@@ -160,16 +159,6 @@ class PrReviewContent(_Content):
def _nontrivial_summary(cls, v: str) -> str: def _nontrivial_summary(cls, v: str) -> str:
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN) return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
@model_validator(mode="after")
def _findings_required_for_negative(self) -> PrReviewContent:
if self.verdict in (Verdict.CHANGES_REQUESTED, Verdict.FAILED) and not (
self.findings
):
raise ValueError(
"findings must be non-empty when verdict is changes_requested or failed"
)
return self
def render_markdown(self) -> str: def render_markdown(self) -> str:
parts = [_section("Summary", self.summary)] parts = [_section("Summary", self.summary)]
if self.findings: if self.findings:
+35 -19
View File
@@ -353,6 +353,7 @@ PR_REVIEW_SOURCES = ("external_pr", "internal_pr")
# Approve-&-Starts it; the loop itself never starts/approves/merges it. # Approve-&-Starts it; the loop itself never starts/approves/merges it.
SELF_HEAL_SOURCE = "self_heal" SELF_HEAL_SOURCE = "self_heal"
def extract_self_heal_fingerprint(task: Any) -> str | None: def extract_self_heal_fingerprint(task: Any) -> str | None:
"""The self-heal dedupe fingerprint from a task's markers, or None. """The self-heal dedupe fingerprint from a task's markers, or None.
@@ -799,11 +800,7 @@ class TaskService(BaseService):
TaskTable.confirmed_by_human.is_(False), TaskTable.confirmed_by_human.is_(False),
) )
) )
return [ return [t for t in result.scalars().all() if not markers.is_dismissed(t)]
t
for t in result.scalars().all()
if not markers.is_dismissed(t)
]
async def list_external_pr_reviews(self) -> list[TaskTable]: async def list_external_pr_reviews(self) -> list[TaskTable]:
"""Live external-PR reviews for the panel: in-flight PLUS awaiting-decision. """Live external-PR reviews for the panel: in-flight PLUS awaiting-decision.
@@ -830,11 +827,7 @@ class TaskService(BaseService):
), ),
) )
) )
return [ return [t for t in result.scalars().all() if not markers.is_dismissed(t)]
t
for t in result.scalars().all()
if not markers.is_dismissed(t)
]
async def dismiss_external_pr_review(self, task_id: UUID) -> TaskTable | None: async def dismiss_external_pr_review(self, task_id: UUID) -> TaskTable | None:
"""CEO declines to act on a reviewed external PR — drop it from the queue. """CEO declines to act on a reviewed external PR — drop it from the queue.
@@ -905,8 +898,7 @@ class TaskService(BaseService):
return None return None
if task.status != TaskStatus.IN_PROGRESS: if task.status != TaskStatus.IN_PROGRESS:
return None return None
if notes: self._record_pr_review(task, summary=notes, verdict="changes_requested")
task.qa_notes = notes
reviewer_id = to_python_uuid(task.claimed_by) or reviewer_agent_id reviewer_id = to_python_uuid(task.claimed_by) or reviewer_agent_id
task.assigned_to = None task.assigned_to = None
task.claimed_by = None task.claimed_by = None
@@ -3642,6 +3634,35 @@ class TaskService(BaseService):
return return
markers.set_documenter(task, task.assigned_to) markers.set_documenter(task, task.assigned_to)
@staticmethod
def _record_pr_review(
task: TaskTable,
*,
summary: str | None,
verdict: str,
issues: list[str] | None = None,
) -> None:
"""Record a PR-reviewer verdict in the reviewer's OWN slot.
Routes through the content chokepoint (``pr_reviewer_notes`` mirror +
``notes_structured["pr_review"]``) so a review never overwrites
``qa_notes`` / ``dev_notes``. Structured per-line findings arrive via the
reviewer verb; until then the free-text summary + issues are captured.
Best-effort: a too-trivial review body must never block the transition.
"""
body = (summary or "").strip()
if issues:
bullets = "\n".join(f"- {i}" for i in issues if i and i.strip())
body = f"{body}\n\n{bullets}".strip() if body else bullets
if not body:
return
try:
apply_structured_note(
task, "pr_review", {"summary": body, "verdict": verdict}
)
except ContentValidationError:
task.pr_reviewer_notes = _append_capped(task.pr_reviewer_notes, body)
async def _resolve_pm_for_review(self, task: TaskTable) -> UUID | None: async def _resolve_pm_for_review(self, task: TaskTable) -> UUID | None:
"""Walk up the parent chain to find the PM who owns this work. """Walk up the parent chain to find the PM who owns this work.
@@ -6704,8 +6725,7 @@ class TaskService(BaseService):
claimed_by=str(task.claimed_by), claimed_by=str(task.claimed_by),
) )
captured = to_python_uuid(task.claimed_by) captured = to_python_uuid(task.claimed_by)
if notes: self._record_pr_review(task, summary=notes, verdict="passed")
task.qa_notes = _append_capped(task.qa_notes, "[PR REVIEW]\n" + notes)
task.assigned_to = None task.assigned_to = None
task.claimed_by = None task.claimed_by = None
task.active_claimant_id = cast("Any", None) task.active_claimant_id = cast("Any", None)
@@ -6747,11 +6767,7 @@ class TaskService(BaseService):
claimed_by=str(task.claimed_by), claimed_by=str(task.claimed_by),
) )
captured = to_python_uuid(task.claimed_by) captured = to_python_uuid(task.claimed_by)
if issues: self._record_pr_review(task, summary=notes, verdict="failed", issues=issues)
issue_block = "[PR REVIEW ISSUES]\n" + "\n".join(f"- {i}" for i in issues)
task.dev_notes = _append_capped(task.dev_notes, issue_block)
if notes:
task.qa_notes = _append_capped(task.qa_notes, "[PR REVIEW]\n" + notes)
# Hand the failed assembled task to its PM to revise (cell PM for a cell # Hand the failed assembled task to its PM to revise (cell PM for a cell
# team, Main PM for the root); the revision dispatcher re-spawns whoever # team, Main PM for the root); the revision dispatcher re-spawns whoever
# owns a needs_revision task. Fall back to unassigned if no PM resolves. # owns a needs_revision task. Fall back to unassigned if no PM resolves.
@@ -95,16 +95,19 @@ def test_pr_review_trivial_summary_rejected() -> None:
validate_content("pr_review", {"summary": "wip", "verdict": "approved"}) validate_content("pr_review", {"summary": "wip", "verdict": "approved"})
def test_pr_review_negative_verdict_requires_findings() -> None: def test_pr_review_negative_verdict_allows_summary_only() -> None:
with pytest.raises(ContentValidationError): # A reviewer can fail on a summary alone (e.g. "CI is red"); findings are
validate_content( # format-enforced (file/line/expected/actual) when present, not mandatory.
"pr_review", c = validate_content(
{ "pr_review",
"summary": "Looks broken but no detail given here.", {
"verdict": "failed", "summary": "CI is red on this PR; the failing job blocks merge.",
"findings": [], "verdict": "failed",
}, "findings": [],
) },
)
assert isinstance(c, PrReviewContent)
assert c.verdict.value == "failed"
def test_pr_review_approved_allows_empty_findings() -> None: def test_pr_review_approved_allows_empty_findings() -> None:
+5 -2
View File
@@ -247,12 +247,15 @@ async def test_pr_review_claim_and_complete(db_session: AsyncSession) -> None:
# Re-claiming a non-pending task is a no-op. # Re-claiming a non-pending task is a no-op.
assert await svc.pr_review_claim(reviewer_id, task_id) is None assert await svc.pr_review_claim(reviewer_id, task_id) is None
# Complete: in_progress -> completed, claim cleared, notes recorded. # Complete: in_progress -> completed, claim cleared, review in its OWN slot.
done = await svc.complete_review(reviewer_id, task_id, notes="Posted review.") done = await svc.complete_review(reviewer_id, task_id, notes="Posted review.")
await db_session.flush() await db_session.flush()
assert done is not None assert done is not None
assert done.status == TaskStatus.COMPLETED assert done.status == TaskStatus.COMPLETED
assert done.qa_notes == "Posted review." # Reviewer content lands in pr_reviewer_notes (not qa_notes) and is structured.
assert "Posted review." in (done.pr_reviewer_notes or "")
assert done.qa_notes is None
assert (done.notes_structured or {}).get("pr_review", {}).get("verdict")
assert done.claimed_by is None assert done.claimed_by is None
# Re-completing a completed task is a no-op. # Re-completing a completed task is a no-op.