fix(impl-review): stop treating a legitimate score of 0 as a crashed review - #10179
Conversation
…review The workflow used quality score 0 as its sentinel for "the AI review produced no output". But 0 is also a score the review prompt mandates: the Stage 1 auto-reject gates in prompts/workflow-prompts/ai-quality-review.md require exactly "Score = 0, verdict = REJECTED" for AR-08 (clipped element) and AR-09 (mandatory title not visible). So every plot that correctly tripped those gates was reported as a crashed review: `Validate review output` raised "AI Review did not produce valid output files", applied `ai-review-failed` and exited 1 — skipping the verdict step, which the file itself documents as "the pipeline's only hand-off point". impl-repair was therefore never dispatched and the missing title never fixed. The retry listener rescued once, the re-review scored 0 again (deterministically: the plot had not changed), and the resulting `ai-review-failed` + `ai-review-rescued` pair matches no watchdog case, stranding the PR permanently. Six PRs sat in exactly that state (#10152, #10130, #10009, #10003, #9968, #9776), each with an AR-09 verdict already in hand. Output presence is now its own `has_output` step output, decoupled from the score value. A score of 0 flows into the normal rejected -> repair path; only genuinely absent or malformed output raises `ai-review-failed`. The six gates that keyed off `score != '0'` now key off `has_output`. The comment fallback had a second, latent bug: it read `.comments[-1].body`, but on a retry the workflow's own "auto-retrying" notice is posted after the review, so the fallback searched the notice and found no score. It now selects the last `claude[bot]` comment. Verified with a harness that extracts the step body verbatim from the YAML and exercises 12 cases: score 0 via file and via comment fallback, normal scores (1/73/87/100), and the four genuine no-output shapes (no file + no comment, comment without a score line, non-numeric file, out-of-range file). All pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a workflow logic conflict in the implementation review pipeline by decoupling “review produced output” from the numeric quality score, allowing a legitimate score of 0 (mandatory for specific auto-reject gates) to proceed through the normal ai-rejected → impl-repair path instead of being treated as a crashed review.
Changes:
- Update
.github/workflows/impl-review.ymlto emit ahas_outputsignal alongsidescore, and gate downstream steps onhas_outputinstead ofscore != 0. - Improve the score recovery fallback to read from the last
claude[bot]comment (instead of the last comment overall). - Add a detailed
[Unreleased]changelog entry describing the deadlock chain and the fix.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
CHANGELOG.md |
Adds an [Unreleased] fix entry documenting the pipeline deadlock and resolution. |
.github/workflows/impl-review.yml |
Treats 0 as a valid score and introduces has_output to detect genuinely missing review output; adjusts step gating accordingly. |
| SCORE=$(gh api --paginate "repos/${REPOSITORY}/issues/${PR_NUM}/comments?per_page=100" \ | ||
| --jq '[.[] | select(.user.login == "claude[bot]")] | last | .body // ""' 2>/dev/null \ | ||
| | grep -oP '^###\s+Score:\s+\K\d+' | head -1 || true) |
There was a problem hiding this comment.
Good catch, and it was accurate — for the version you reviewed. That commit used gh api --paginate --jq '...', where --jq is applied per page, so --paginate emitted one filtered result per page and head -1 took the first (oldest) page's match.
The follow-up commit (5c6d24d, pushed while this review was being generated) already replaced that form while closing a separate staleness bug:
gh api --paginate "repos/${REPOSITORY}/issues/${PR_NUM}/comments?per_page=100" 2>/dev/null \
| jq -s --arg since "$REVIEW_STARTED_AT" -r \
'add // []
| [.[] | select(.user.login == "claude[bot]")
| select(.created_at >= $since)]
| last | .body // ""'jq -s slurps every page into one array, add concatenates them, and last then picks the newest match across all pages — not per page. The remaining head -1 operates on a single comment body, where it selects the one ### Score: N/100 heading in that body.
Verified rather than asserted: the test harness stubs gh to emit two concatenated arrays (exactly what --paginate produces), with the older match on page 1 and the current one on page 2.
--- pagination: --paginate emits one array per page (Copilot #10179) ---
PASS two pages, newest match wins score=91 has_output=true
PASS two pages, all stale score=0 has_output=false
The pre-fix form returns 55 (page 1) on that fixture; the current form returns 91. Full suite is 19 cases, all passing.
The comment fallback added in the previous commit selected the last claude[bot] comment, which fixed the ordering bug (the workflow's own "auto-retrying" notice is posted after the review and shadowed the score) but introduced a staleness bug: a PR that has already been reviewed carries older review comments with a score the current implementation never earned. PR #9948 is exactly that shape — its `quality:89` comes from its first review, and a later review that produced no output at all would now have silently inherited 89. A new `Record review start time` step stamps the boundary immediately before the Claude action runs, and the fallback only accepts claude[bot] comments created at or after it. RFC 3339 UTC timestamps compare correctly as strings here: GitHub and `date -u` emit the same fixed-width `...Z` format. One second of slack absorbs clock skew. Test harness extended from 12 to 17 cases, covering both the fresh comment followed by the notice, and the #9948 stale-comment shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bug
impl-review.ymlused quality score0as its sentinel for "the AI review produced no output". But0is also a score the review prompt mandates: the Stage 1 auto-reject gates inprompts/workflow-prompts/ai-quality-review.mdrequire exactlyScore = 0, verdict = REJECTEDfor AR-08 (clipped element) and AR-09 (mandatory title not visible).Prompt and workflow therefore contradicted each other, and the pipeline was guaranteed to dead-end on exactly the plots it is designed to reject hardest.
The deadlock chain
Score: 0/100,Verdict: REJECTED— this is correct behaviourExtract quality scorenormalised it:::warning::Invalid quality score '0', defaulting to 0Validate review outputfired onscore == '0'→::error::AI Review did not produce valid output files→ai-review-failed→exit 1exit 1skippedAdd verdict label and take action— which the file itself documents as "the pipeline's only hand-off point: every downstream workflow (merge, repair) starts from a call made right here"impl-repairwas never dispatched and the missing title was never fixedimpl-review-retry.ymlrescued once → the re-review scored0again (deterministically — the plot was unchanged) →ai-review-failedre-appliedai-review-failed+ai-review-rescuedmatches no watchdog case (watchdog-stuck-jobs.yml:116only emits a::warning::and defers to a human) → PR stranded permanentlyEvidence
Six open PRs sit in exactly that state, each with an AR-09 verdict already in hand:
This was never an infrastructure failure. In run 31035492709 the Claude action reported
"subtype": "success","is_error": false, 15 turns,permission_denials_count: 0— and posted a complete review ending in### Score: 0/100/### Verdict: REJECTED. 94 of the last 100impl-reviewruns are green; the 6 failures are these gate-tripping plots.The fix
Output presence becomes its own signal, decoupled from the score value:
Extract quality scorenow emitshas_outputalongsidescore.0is accepted as a valid score; only a non-numeric or out-of-range value marks output as missing.score != '0'/score == '0'now key offhas_output.0therefore flows into the normalai-rejected→impl-repairpath (threshold floor is 50, so0 < 50→ rejected → repair dispatched), and only genuinely absent output raisesai-review-failed.Second, latent bug fixed in the same step: the comment fallback read
.comments[-1].body, but on a retry the workflow's own "auto-retrying" notice is posted after the review — so the fallback searched the notice and found no score. It now selects the lastclaude[bot]comment.Deliberately not changed:
watchdog-stuck-jobs.yml. With the root cause fixed, "review produced no output twice in a row" (PRs #9953/#9952/#9951, which have noclaude[bot]comment at all) is a genuine failure that should escalate to a human rather than loop forever.Verification
GitHub Actions changes have no verification loop in this repo, so the step's shell body was tested directly: a harness extracts the
Extract quality scorerun:block verbatim from the YAML and exercises it with a stubbedgh.YAML validity re-checked after the edit (
yaml.safe_load, 20 steps parsed).Residual risk: the
if:expression rewrites and theREPOSITORYenv addition are only observable on a real pipeline run. Recovery path for the six stranded PRs after merge: re-dispatchimpl-review.yml -f pr_number=<n>, which will now score them 0, labelai-rejected, and hand them toimpl-repairto fix the titles.🤖 Generated with Claude Code