fix(claude-code-review): retry once when a review completes with no verdict - #201
Conversation
…erdict Reopened #185 after a fresh reproduction post-#187: the low-denial stub-review signature (is_error:false, permission_denials_count:1, no verdict) still recurs even with #187's "don't stop on a denied tool call" system-prompt fix in place. check-review-execution.sh now surfaces this specific, retryable case (real non-empty text, no SDK error, no verdict) as a stub_review output, distinct from a hard SDK error or genuinely empty output. claude-code-review.yml retries the same review prompt once when it fires, with an added instruction that the retry must end with a verdict regardless of what gets denied along the way, before failing the check for real. The anthropics/claude-code-action call moved into a new run-claude-review-attempt composite action so the retry doesn't duplicate that ~100-line step. The raw execution output is now also uploaded as a workflow artifact on every attempt, so a future recurrence has a downloadable transcript to diagnose instead of needing show-full-output pre-enabled and a lucky re-trigger. Closes #185. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas
|
/review (Re-triggering: the automatic review hit the exact bug this PR fixes — Generated by Claude Code |
Code reviewI reviewed the diff for PR #201 (retry-once-on-stub-verdict logic for Findings1. The That's not what the code does.
These are the same textual shape by every signal Either add an actual exclusion (e.g. a 2. Other notes (no issues)
VerdictNeeds more work — finding 1 is a confirmed mismatch between the code's actual behavior and this PR's explicit, stated design goal (avoiding retries on gha#198's pattern), with a real cost impact on an issue that's open and actively recurring in this very repo. Finding 2 is a minor, cheap-to-fix diagnosability nit. Everything else in the diff (the composite-action extraction, the fixture/selftest wiring, the CLAUDE.md doc sync) looks correct and idiomatic. |
…eshold Review finding on #201: the retry's header comment and PR description claimed gha#198's high-denial-count no-verdict pattern was excluded from the retry, but check-review-execution.sh never actually checked permission_denials_count — stub_review fired purely on "no verdict", which is textually identical between #185 (denials: 1) and #198 (denials: 17-35). Add an actual permission_denials_count <= STUB_RETRY_MAX_DENIALS (default 5) gate, a fixture modeling #198's pattern expecting plain `fail` (not `fail-stub`), and correct every comment that claimed the exclusion without implementing it. Also fixes a second finding: "Resolve final review outcome"'s failure message always attributed a double-failure to attempt 1's stub_review flag, even when the retry failed for a different reason. It now inspects which attempt actually ran and why. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas
|
Both findings addressed in 56e612a: Finding 1 (confirmed bug) — Finding 2 — All 11 fixtures (10 existing + the new one) pass locally. Generated by Claude Code |
| - name: Resolve execution file path (retry) | ||
| id: exec-file-retry | ||
| if: steps.claude-review-retry.outcome == 'success' | ||
| continue-on-error: true | ||
| shell: bash | ||
| env: | ||
| EXECUTION_FILE: ${{ steps.claude-review-retry.outputs.execution_file }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [[ -z "$EXECUTION_FILE" ]]; then | ||
| EXECUTION_FILE="${RUNNER_TEMP:-/home/runner/work/_temp}/claude-execution-output.json" | ||
| echo "::warning::execution_file output not set; falling back to $EXECUTION_FILE" | ||
| fi | ||
| echo "path=$EXECUTION_FILE" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Upload review execution output (retry) | ||
| if: steps.exec-file-retry.outcome == 'success' | ||
| continue-on-error: true | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: claude-review-execution-${{ github.run_id }}-${{ github.run_attempt }}-attempt2 | ||
| path: ${{ steps.exec-file-retry.outputs.path }} | ||
| retention-days: 14 | ||
| if-no-files-found: warn |
There was a problem hiding this comment.
Minor DRY nit (non-blocking): the "Resolve execution file path (retry)"/"Upload review execution output (retry)" steps here duplicate the "Resolve execution file path"/"Upload review execution output" steps at L436-465 — same fallback-path shell logic and the same upload-artifact step shape, differing only in which steps.* outputs they read and the artifact name suffix (attempt1/attempt2).
This is the same category of duplication this PR's own run-claude-review-attempt extraction was built to avoid (per this repo's CLAUDE.md review priorities: "Flag duplicated logic (DRY)... factor shared logic into reusable units rather than copying it between files"). It's much smaller here (~15 lines vs. the ~100-line with: block), so not a blocker, but worth considering folding the resolve+upload pair into a small composite action (or a loop/matrix over attempt1/attempt2) alongside run-claude-review-attempt, for the same reason this PR gave for extracting that one.
Not a functional bug — just flagging for consistency with the PR's own stated DRY rationale.
Code review (re-review after follow-up fix)I re-reviewed PR #201 after the follow-up commit Prior findings — both confirmed fixed1. 2. New finding (minor, non-blocking)Posted inline: the "Resolve execution file path (retry)" / "Upload review execution output (retry)" steps duplicate their attempt-1 counterparts almost verbatim — the same category of duplication this PR's own Other notes (no issues)
VerdictReady for merge — both previously-flagged issues are genuinely fixed, and the new finding is a minor, optional DRY suggestion rather than a defect. |
…osite Review nit on #201: the attempt-1 and retry "Resolve execution file path"/"Upload review execution output" step pairs duplicated the same fallback-path shell logic and upload-artifact shape almost verbatim - the same class of duplication run-claude-review-attempt was extracted to avoid, just smaller. Folded both into a new upload-review-execution composite action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas
|
DRY nit addressed in 6f2a65a — folded the "Resolve execution file path"/"Upload review execution output" pair (both attempt-1 and retry) into a new On the two failed checks ( Generated by Claude Code |
|
Claude finished review — View run Code review (confirming review after DRY-nit fix)I re-reviewed PR #201 after the newest commit Prior finding — resolvedThe inline DRY nit about the duplicated "Resolve execution file path (retry)"/"Upload review execution output (retry)" steps is fixed: commit
No new issues found in this commit.
VerdictReady for merge — the prior "Ready for merge" verdict stands; the only outstanding item (the minor DRY nit) is now fixed, and no new issues were introduced by this commit. |
Review finding on #205: the parenthetical implied #202's diff was fixing the same stub-review bug #201 fixed, and that both PRs' reviews were touching claude-code-review.yml right up until merge. Neither is quite right: #202 fixed a different (related) allowlist gap and only hit #185's stub-review signature as a bystander while it still edited claude-code-review.yml directly, before a rebase onto #201 moved that edit into the new run-claude-review-attempt composite action. Verified the underlying fact via the actual failing run's execution output (permission_denials_count:1, no verdict) rather than dropping the citation outright. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas
…-merge (#205) * docs: note that fixes to claude-code-review.yml can't self-verify pre-merge Learned across gha#201/#202: this repo's own claude-review.yml pins claude-code-review.yml@v2 (the released tag), not a local ref, so a PR fixing a bug in that file hits the pre-fix version on its own automatic review until after merge. Both PRs saw their own claude-review check fail with the exact signature they were fixing, right up until merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas * fix(docs): correct overstated gha#202 citation in self-verify note Review finding on #205: the parenthetical implied #202's diff was fixing the same stub-review bug #201 fixed, and that both PRs' reviews were touching claude-code-review.yml right up until merge. Neither is quite right: #202 fixed a different (related) allowlist gap and only hit #185's stub-review signature as a bystander while it still edited claude-code-review.yml directly, before a rebase onto #201 moved that edit into the new run-claude-review-attempt composite action. Verified the underlying fact via the actual failing run's execution output (permission_denials_count:1, no verdict) rather than dropping the citation outright. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas --------- Co-authored-by: Claude <noreply@anthropic.com>
…m check (#431) * ums: proactive sibling-PR resync after extraction, self-authored claim check From Morrison-Lab/gha#201/#202 (2026-07-03): - sync-with-main.md: extracting an inline block into a reusable unit (a composite action, a shared script) breaks the textual diff of any other open PR still editing that inline block. The mirror image of the already-documented case (there you resync your OWN branch after main moved a copy of your logic; here your OWN merge broke a sibling PR) -- check for and proactively fix affected sibling PRs right after your extraction merges, rather than waiting for their author to hit the conflict. - fact-check-prose.md: the existing "document-internal reasoning" checklist applies to your OWN PR descriptions/comments before posting, not just when reviewing someone else's prose -- a design-choice claim (a stated exclusion/handling of a specific case) is checkable against the code you just wrote. gha#201's PR claimed a retry mechanism excluded a known bad pattern via a flag that, on inspection, was never actually gated on the signal distinguishing that pattern -- caught by a claude[bot] review, avoidable by re-reading the claim against the actual `if` conditions before posting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas * ums: give a concrete git merge-tree invocation in the sibling-resync bullet Review nit on #431: the parenthetical named git merge-tree/gh pr diff without showing what arguments to pass. Spell out the actual invocation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B2aHbEXwTUA13vYMuQXqas --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #185.
Root cause status
#187 (merged) addressed #185's original signature by telling the reviewer up
front that a denied tool call is never a reason to stop early. That reduced
but didn't eliminate it: a fresh reproduction on
Lacaedemon/spartaPR #613— well after #187 was merged and active — showed the same low-denial
fingerprint (
is_error: false,permission_denials_count: 1, no### Verdict), just with a different turn count (7 vs. the original 4). Theissue was reopened with that evidence.
Rather than chase a third prompt-wording tweak against a fundamentally
probabilistic failure mode, this PR adds a structural mitigation: detect the
exact retryable signature and automatically retry once, plus make any future
recurrence actually diagnosable (the original report noted "the exact denied
tool call isn't visible in the log" —
show_full_outputwas off and thetranscript was never persisted anywhere).
Not in scope: #198 tracks a different, much-higher-denial-count stub
signature (17-35 denials vs. #185's 1) that has NOT recovered on retry in its
own investigation and is explicitly paused pending a full transcript capture.
This PR's retry is gated tightly on the
stub_reviewsignal (real non-emptytext, no SDK error, no verdict, distinguishing it from a hard error or empty
output) so it won't fire — or spend extra API cost — on #198's pattern.
What changed
check-review-execution.shnow writesstub_review=trueto$GITHUB_OUTPUTspecifically for the "real text, no SDK error, no verdict"case, distinct from a hard error or genuinely empty output.
run-review-guardpasses that output through.run-claude-review-attemptwraps the singleanthropics/claude-code-actioncall (allowedTools/disallowedTools, reviewprompt) so
claude-code-review.ymlcan invoke it a second time, unchanged,as a same-prompt retry — without duplicating that ~100-line step (a DRY
violation this repo's own
CLAUDE.mdflags). The retry attempt gets oneextra instruction: this is a retry, and it must end with a verdict
regardless of what gets denied along the way.
claude-code-review.yml: attempt 1 → guard (nowcontinue-on-error)→ retry only if
stub_reviewfired → guard again → "Resolve final reviewoutcome" picks whichever attempt succeeded and is the only step that
genuinely fails the job when neither did.
execution-output JSON as a workflow artifact
(
claude-review-execution-<run>-<attempt>-attempt{1,2}), so a futurestub/failure has a downloadable turn-by-turn transcript without needing
show-full-outputpre-enabled.Testing
bash .github/workflows/scripts/tests/run-fixture-tests.sh— extended witha
fail-stubexpectation (fail +stub_review=true) for the four existingstub fixtures, keeping
is-error-result.json/empty-review-text.jsonasplain
fail(not retried). All 10 fixtures pass.bash .github/actions/parse-workflow-ref/tests/run-tests.sh— unaffected, still passes._selftest.yml'sreview-fail-checkjob gets a second realuses: ./.github/actions/run-review-guardstep against a stub fixture,asserting
stub_reviewcomes backtrueend-to-end (mirroring theexisting
genuine-finished-review.jsoncoverage) — this is what wouldhave caught a passthrough-wiring bug that a script-only unit test can't see
(the same class of gap Fix claude-code-review guard script path for consumer repos #191/check-review-execution.sh path-resolution regressed: parse-workflow-ref runs twice, second call omits workflow-ref input #196 taught).
yaml.safe_load.run-claude-review-attemptitself has no offline test coverage — it wrapsa live
anthropics/claude-code-actioncall, which isn't something aselftest job can exercise (same as the inline step it replaced).
Changelog
Added
changelog.d/review-retry-stub-verdict.fixed.mdper this repo'sfragment convention.
Generated by Claude Code
Generated by Claude Code