Repository navigation
pr-review: rate-limited reviews are never auto-retried (no equivalent of dev-lead-retry) #711
Description
Problem
When the pr-review agent runs while its advisory bots are rate-limited / out of quota (e.g. Codex "usage limits", CodeRabbit "review limit reached"), the advisory-gate correctly withholds donpetry-bot's auto-approval rather than approving prematurely (the behavior from #657/#658). The run completes "successfully" but posts no approval — leaving the PR at REVIEW_REQUIRED with green CI and mergeable_state: blocked.
Nothing then auto-recovers that PR once the rate limits reset. dev-lead has a dedicated remediator for exactly this class of bug (dev-lead-retry.yml — scans for status=rate-limited markers and re-dispatches when the limit clears), but pr-review has no equivalent.
Observed instance
On PR #683 the pr-review run at 02:48Z withheld approval because Codex was out of quota and CodeRabbit was rate-limited. ~10 hours and ~10 pr-review-sweep passes later, the PR still had no donpetry-bot approval. It only merged after a manual pr-review-trigger.yml dispatch (force_review=true) once the limits had reset. So the existing hourly sweep is demonstrably not recovering the rate-limited case.
Why the existing sweep doesn't cover it
pr-review-sweep.yml re-dispatches PRs that are REVIEW_REQUIRED + CI green + not already reviewed at head (built for the event-gap/stuck case, #573). A rate-limited-and-withheld review falls outside that net, most likely because:
- the withheld run left an idempotency marker, so the sweep treats the PR as "already reviewed at head" and skips it; and/or
- the sweep's "CI green" gate sees sibling
cancelled/failedcheck runs (e.g. concurrency-cancelleddev-lead / dispatch) and concludes CI isn't green, so it skips.
Root cause to confirm in scripts/review-one-pr.sh / scripts/sweep-stuck-reviews.sh / scripts/engine.sh (marker-write behavior on a rate-limited bail, and the sweep's green-CI predicate).
Options
Option 1 — Make pr-review-sweep rate-limit-aware (preferred).
Treat a PR whose most recent pr-review run withheld approval due to status=rate-limited (advisory-gate) as retryable — i.e. ignore the "already reviewed at head" marker for that specific case, and don't let unrelated cancelled/failed sibling checks suppress the re-dispatch. Lowest cost, reuses the existing dispatch path, no new workflow.
Option 2 — Add pr-review-retry.yml mirroring dev-lead-retry.yml.
A dedicated scheduled scanner keying on status=rate-limited markers left by the pr-review cascade, re-dispatching pr-review-trigger.yml (as the bot PAT) once the limit clears. Most consistent with the dev-lead pattern and cleanly separates concerns, at the cost of a second sweep-like workflow.
Recommendation
Lean Option 1: it closes the gap with the least surface area and keeps a single re-trigger path. Pivot to Option 2 only if the marker/idempotency semantics make the rate-limited case hard to express cleanly inside the existing sweep.
Acceptance criteria
- A pr-review run that withholds approval specifically because advisory bots were rate-limited leaves a machine-detectable marker (
status=rate-limited) on the PR. - After the limit window resets, the PR is automatically re-reviewed (no manual
force_reviewdispatch) and donpetry-bot can approve. - Sibling
cancelled/failedchecks that are unrelated to the actual CI gate do not suppress the retry. - Default behavior for non-rate-limited PRs is unchanged (no extra reviews, idempotency preserved).
- Covered by a bats test mocking the rate-limited marker + green CI and asserting a re-dispatch.
Context / references
dev-lead-retry.yml,scripts/dev-lead-retry.sh— the proven retry pattern.pr-review-sweep.yml,scripts/sweep-stuck-reviews.sh— existing stuck-review remediator (pr-review: PRs skipped as ci-pending are never re-reviewed after CI goes green #573).#657/#658— advisory-gate: pr-review never auto-approves when advisory bots are rate-limited/out-of-quota.- Discovered while monitoring the merge of fix(initiative-driver): auto-drive every initiative:auto epic, not just #495 #683.
Filed by Claude Code while monitoring PR #683.
Activity
don-petry commentedon Jun 14, 2026
Analysis & review (code-verified)
I traced the full path —
scripts/lib/advisory-review-gate.sh,scripts/review-one-pr.sh,scripts/sweep-stuck-reviews.sh,scripts/lib/ci-status.sh, and thedev-lead-retrypattern (scripts/dev-lead-retry.sh). A few of the issue's stated mechanics don't match what the code actually does today, which changes the recommended fix. Findings below, then a refined recommendation.What the code actually does
1. The advisory gate does not detect rate-limiting at all.
get_advisory_bot_states(scripts/lib/advisory-review-gate.sh:59-85) only records whether each monitored bot submitted a review/comment. The bots it watches aregemini-code-assist,copilot-pull-request-reviewer,sonarqubecloud,chatgpt-codex-connector(:32-37). ARATE_LIMITEDstate exists informat_bot_status(:110) but nothing ever sets it — it's dead code. A bot that is out of quota / "review limit reached" simply never submits, so the gate sees it as absent, not rate-limited. Notably CodeRabbit is not in the monitored set at all, so its limits don't directly gate approval.2. The withheld run writes no marker. When the gate returns
1(waiting),review-one-pr.sh:160-170logs a JSON line andexit 100— it does notgh pr comment. The idempotency marker<!-- pr-review-agent v1 sha=<sha> -->is only written bypost-pr-review.shon an actual approval/escalation. The ci-pending path writes a different marker (<!-- pr-review-agent ci-pending-ack -->,review-one-pr.sh:121) that the idempotency check ignores.➡️ This contradicts the issue's hypothesis #1. A rate-limited withhold leaves no "already reviewed at head" marker, so the sweep's
reviewed_at_headpredicate (sweep-stuck-reviews.sh, checks<!-- pr-review-agent v1 sha=<head>) does not skip the PR.3. Cancelled sibling checks already don't suppress the sweep.
compute_ci_status(scripts/lib/ci-status.sh:60-64, 84) explicitly treatsCANCELLEDas non-blocking (issue #608) — exactly the concurrency-cancelleddev-lead / dispatchcase. Only a genuineFAILURE/ACTION_REQUIRED/TIMED_OUTresolves tofailingand makes the sweep skip.➡️ This partially contradicts hypothesis #2: cancelled siblings don't block; only a real failed sibling does.
4. The gate already self-heals on re-trigger — but only on re-trigger. It is a non-blocking instant check with two timeout fallbacks (
:222-231): head-commit age > 20 min, or quiescence > 10 min, both → return0(approve). For any PR re-dispatched more than ~20 min after its head push, the gate would proceed. So if #683 had been re-dispatched even once, it would have approved.Most likely real root cause
Because (2)+(4) hold, the sweep should have recovered #683 — unless its selection predicate skipped it every pass. The remaining suppressors are:
FAILURE/non-cancelled sibling check keepingci_status != passing(most likely), orreviewDecision != REVIEW_REQUIRED(e.g. a bot leftCHANGES_REQUESTED), or_get_head_committer_date,:93-99) returned empty and something kept resetting quiescence.These are testable against #683's actual
statusCheckRollup/reviewDecisionhistory. The headline is: the problem is in the sweep's CI-green predicate (and the absence of any explicit rate-limit signal), not in idempotency-marker semantics.Recommendation — lean Option 2, more strongly than the issue frames it
Two foundational pieces are required regardless of option, and neither exists yet:
Make the gate actually detect rate-limit. Parse the monitored bots' submitted review/comment bodies for the known strings ("usage limits", "review limit reached", quota/limit language), populate the already-stubbed
RATE_LIMITEDstate, and havereview-one-pr.shwrite a machine-detectable marker on withhold:<!-- pr-review-agent status=rate-limited sha=<head> reset=<ISO> -->. This is the linchpin of every acceptance criterion.Recover via the proven
dev-lead-retrypattern.dev-lead-retry.shalready scansstatus=rate-limitedmarkers, readsreset=<ISO>, gates onis_reset_in_future, guards against re-fire with terminal markers, and re-dispatches — exactly this shape. Mirror it as apr-review-retry(or extenddev-lead-retry.shto also scan the pr-review marker) and re-dispatch withforce_review=trueso the gate doesn't simply re-withhold.I lean Option 2 more than the issue does, because the failure on #683 implicates the sweep's
compute_ci_statuspredicate — the one thing a marker-keyed retry can bypass — and because the retry needsforce_review=true, a behavior the generic sweep deliberately does not apply to ordinary PRs (sweep-stuck-reviews.shdispatches withpr_urlonly). Bolting rate-limit-special-casing onto the sweep (Option 1) means teaching it to (a) read a new marker, (b) ignore its own CI-green gate for that case, and (c) passforce_review— at which point you've effectively rebuiltdev-lead-retryinside the sweep. Reusing the battle-tested retry is cleaner and keeps the two concerns (stuck-event recovery vs. rate-limit recovery) separate.Net: Option 2, with the gate-side detection + marker as the prerequisite. The bats coverage in the acceptance criteria can follow
tests/dev-lead/e2e/scenarios/07-rate-limit-retry.sh(stub engine + stubgh, assert marker written on all-rate-limited, assert retry re-dispatches onceresethas passed and is suppressed before).Code-verified analysis by Claude Code.
Generated by Claude Code
don-petry commentedon Jun 14, 2026
Dev-Lead Implementation Plan
Issue: #711 — pr-review: rate-limited reviews are never auto-retried (no equivalent of dev-lead-retry)
Scope
Make the pr-review withhold-due-to-advisory-bot-rate-limit path leave a machine-detectable
status=rate-limitedmarker, and teach the existingpr-review-sweepto auto-retry such PRs once the limit window clears — closing the gap with the least surface area (Option 1, no new workflow).Root-cause confirmation
review-one-pr.shadvisory-gate withhold (gate rc=1) exits 100 leaving no marker → the run is invisible to any retry mechanism.<!-- pr-review-agent v1 sha=<head> -->is present at head, or (b)compute_ci_statusis notpassing. (Note:lib/ci-status.shalready treatsCANCELLEDas non-blocking per pr-review CI gate should gate on REQUIRED checks only — non-required CANCELLED *and* FAILED checks falsely block reviews #608, so concurrency-cancelled siblings are handled; genuinely-failing siblings remain.)Design
<!-- pr-review-agent rate-limited v1 sha=<HEAD> status=rate-limited reset=<ISO> -->.reset(parsed if available, else now+RATE_LIMIT_RETRY_BACKOFF_MIN, default 60m).resethas passed and which has no newer idempotency marker at head, is re-dispatched via the normal trigger even if the CI gate isn'tpassing(so cancelled/failed siblings can't suppress it). review-one-pr.sh remains the authoritative gate; after the limit clears, its head-age timeout fallback lets donpetry-bot approve. Non-force dispatch — never bypasses the advisory gate, so no premature approval.Implementation Checklist
scripts/lib/advisory-review-gate.sh— adddetect_advisory_rate_limit(pure, over reviews+comments JSON) +maybe_post_rate_limited_marker(fetch, detect, dedup-post)scripts/review-one-pr.sh— in the gate rc=1 / non-FORCE_REVIEW branch, post the rate-limited marker before exiting 100scripts/sweep-stuck-reviews.sh— add the rate-limited retry branch (marker + reset + newer-idempotency-marker checks)Tests to Write
tests/test_sweep_stuck_reviews.bats— rate-limited marker + green CI → re-dispatch; reset-in-future → no dispatch; newer idempotency marker → no dispatch; failing/cancelled sibling does not suppress the rate-limited retrytests/dev-lead/unit/test_advisory_review_gate.bats—detect_advisory_rate_limittrue/false cases + dedup behavior ofmaybe_post_rate_limited_markerTest Command
bats tests/test_sweep_stuck_reviews.bats tests/dev-lead/unit/test_advisory_review_gate.bats(full suite: thebatslist inlint.yml+bats tests/dev-lead/unit/), thenbash .dev-lead/scripts/dev-lead-lint.shdon-petry commentedon Jun 14, 2026
Dev-Lead Implementation Report — issue #711
Status: complete (Option 1 — make
pr-review-sweeprate-limit-aware). Changes are local only; no commit/push/PR per the engagement rules.What changed
The withhold-due-to-advisory-bot-rate-limit path (introduced by #657/#658) correctly keeps donpetry-bot from auto-approving while Codex/CodeRabbit are out of quota — but it left no marker, so once CI settled green no event re-fired and the PR stranded at
REVIEW_REQUIRED. Two coordinated pieces close the gap:scripts/lib/advisory-review-gate.sh— two new pure-ish helpers:detect_advisory_rate_limit <reviews/comments json>: returns 0 when a known advisory/review bot's latest submission body matches a focused rate-limit phrase pattern (so a newer real review supersedes an older notice, and vice versa). Detection set is the gate'sADVISORY_BOTSpluscoderabbitai(CodeRabbit posts an explicit out-of-quota comment but isn't gated on). Non-bot authors are ignored.maybe_post_rate_limited_marker <pr_url> <head_sha> <reset_iso> <comments_json>: posts a deduplicated marker comment. The marker prefix (rate-limitedbeforev1) deliberately never matches the idempotency regex<!-- pr-review-agent v1 sha=….scripts/review-one-pr.sh— in the non-FORCE_REVIEWwithhold branch (gate rc=1), beforeexit 100, an isolated subshell sources the gate and, if the withhold is rate-limit-driven, stamps:reset= now +RATE_LIMIT_RETRY_BACKOFF(default 3600s). Non-rate-limited withholds are untouched.scripts/sweep-stuck-reviews.sh— a rate-limit retry branch placed before the CI gate so unrelated cancelled/failed sibling checks can't suppress the retry. For a marker at the current head: if already reviewed at head → skip (resolved); ifresetelapsed (or unparseable → fail-open) → re-dispatch via the normal trigger (neverforce_review) so the advisory gate stays armed; ifresetis still in the future → defer. Dispatch logic refactored into a shareddispatch_reviewhelper (honoursDRY_RUN/MAX_DISPATCH).Acceptance criteria
status=rate-limitedmarker — ✅ (maybe_post_rate_limited_marker, wired into the withhold path)force_review— ✅ (sweep re-dispatches normal trigger; gate's head-age timeout then lets approval proceed once the window passes)Tests (TDD — written red first, then green)
tests/test_sweep_stuck_reviews.bats: +7 tests (elapsed-reset dispatch & no force_review; FAILING sibling AC3; CANCELLED sibling; future-reset deferral; reviewed-at-head resolution; stale-head no-op; DRY_RUN). 21/21 pass.tests/dev-lead/unit/test_advisory_review_gate.bats: +11 tests (detect_advisory_rate_limittrue/false incl. latest-wins both directions, non-bot author, coderabbit, empty input;maybe_post_rate_limited_markerpost + dedup). 50/50 pass.Verification
bats tests/test_sweep_stuck_reviews.bats→ 21/21bats tests/dev-lead/unit/→ 485/485bash .dev-lead/scripts/dev-lead-lint.sh→ all checks passed (shellcheck on 51 files + 4 agent profiles)shellcheckon the three changed scripts → only pre-existing SC1091/SC2016 info findings; nothing newNotes / trade-offs
lint.ymltest-list change is required (both edited files are already covered).feat: implement issue #711 — pr-review: rate-limited reviews are neve…
fix(dev-lead): label opened PRs auto-rebase:ready to break the #711 r…
fix(agents): label standards-sync + readme-refresh PRs auto-rebase:re…
fix(agents): auto-rebase:ready labels + standards-sync summary dedup (#…