[F008] evidence_builder: surface persisted pr_review verdict+issues in the PM task_handoff

The pr_fail a2a steer to the owning PM is fire-and-forget; a PM respawned
into needs_revision later read none of it (build_task_handoff never looked
at notes_structured), saw a generic 'needs revision' with zero concrete
change-requests, and re-submitted the same PR (the 2026-06-27 infinite
pr_fail loop on 9980d0a0 / PR #138). The signal-gap was only partially
closed by the a2a.

build_task_handoff now extracts notes_structured.pr_review
(verdict/summary/issues/head_sha — the slot pr_fail authors on every fail)
into a pr_review field on the handoff, so every PM briefing for the task
carries the concrete change-requests. A prior pr_fail alone now counts as
prior-work-worth-resuming. Type-guarded + capped; absent => no key (no
misleading empty slot).

TDD red->green; ruff + mypy clean; evidence_builder suite green (14 passed).
This commit is contained in:
Renn F
2026-06-28 10:25:53 +02:00
parent 2ed7f5978f
commit 366800299b
2 changed files with 94 additions and 1 deletions
@@ -187,3 +187,52 @@ class TestTaskHandoff:
assert digest["journal_highlights"] == []
assert digest["pr_url"] is None
assert digest["branch_name"] is None
class TestPrReviewSurface:
"""F008 — the persisted pr_fail verdict + issues must surface in the PM
briefing's task_handoff, not just the fire-and-forget a2a. A PM respawned
into ``needs_revision`` after a pr_fail otherwise sees a generic "needs
revision" with zero concrete change-requests and re-submits the same PR."""
def test_surfaces_pr_fail_verdict_and_issues(self) -> None:
t = _task(pr_number=138, commits=[{"sha": "abc", "message": "feat: x"}])
t.notes_structured = {
"pr_review": {
"verdict": "failed",
"summary": "In-path PR-review gate requested changes.",
"issues": ["missing null guard", "no test for the edge case"],
"head_sha": "aaaa1111bbbb2222",
}
}
digest = build_task_handoff(t, [])
assert digest is not None
pr_review = digest["pr_review"]
assert pr_review["verdict"] == "failed"
assert pr_review["issues"] == [
"missing null guard",
"no test for the edge case",
]
assert pr_review["head_sha"] == "aaaa1111bbbb2222"
def test_no_pr_review_field_when_none_present(self) -> None:
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
t.notes_structured = None
digest = build_task_handoff(t, [])
assert digest is not None
# Absent pr_review ⇒ no key (not a None-valued key) so a PM without a
# prior gate verdict doesn't see a misleading empty slot.
assert "pr_review" not in digest
def test_pr_review_alone_is_prior_work_worth_resuming(self) -> None:
"""A task with no commits/dev-notes but a prior pr_fail verdict still
surfaces the handoff so the owning PM reads the change-requests."""
t = _task(pr_number=None, pr_url=None, dev_notes="")
t.commits = []
t.acceptance_criteria_status = []
t.notes_structured = {
"pr_review": {"verdict": "failed", "issues": ["fix the off-by-one"]}
}
digest = build_task_handoff(t, [])
assert digest is not None
assert digest["pr_review"]["issues"] == ["fix the off-by-one"]