Symptom
On PR #453, dev-lead posted (comment 4642769821, 2026-06-07T13:10:43Z):
@don-petry I received your request but all AI engines are currently rate-limited. I'll retry automatically once the rate limit clears.
Rate limit resets at: 2026-06-07T13:40:42Z
No AI engine was rate-limited. The engine ran successfully and decided no changes were needed. The "reset" time is exactly now + 30 minutes — the giveaway is the :42 seconds, which can only come from date -u -d '+30 minutes' (dev-lead-fix-reviews.sh hard-blocker backoff path), never from parse_reset_time (which parses whole-minute "resets H:MMam/pm" strings).
Root causes (two distinct defects)
1. The hard-blocker retry path reuses the rate-limit messaging verbatim
In scripts/dev-lead-fix-reviews.sh, the fix-reviews / review-changes intents handle "engine ran, no changes committed, but has_hard_blockers is true" by writing now+30min to /tmp/dev-lead-rate-limit-reset and calling post_reviews_rate_limited — which unconditionally tells the user "all AI engines are currently rate-limited", regardless of why it was called. The marker reuse (status=rate-limited) is intentional so the retry cron re-dispatches, but the user-facing text is simply false. Introduced in #435.
2. cancelled check runs from superseded workflow runs count as hard blockers forever
fetch_pr_context builds CI_STATUS_JSON from GET /commits/{sha}/check-runs with no dedup by check name. Each triggering event creates a new check suite, so GitHub's default filter=latest does not collapse them. On PR #453's head SHA the data was:
review | cancelled | suite=…8170 | started 13:08:53 (Dev-Lead Agent, concurrency-cancelled)
review | cancelled | suite=…8444 | started 13:08:54 (PR Review Agent, concurrency-cancelled)
review | success | suite=…8971 | started 13:08:56 (surviving run)
has_hard_blockers / has_tier1_blockers count conclusion == "cancelled", so the two stale runs permanently register as blockers even though the same-named check later succeeded — putting the PR into an endless 30-minute "rate-limited" retry loop.
Notably, the legacy commit-statuses path in the same function already dedupes by context exactly to avoid this ("so a stale failure overwritten by a later success does not appear as a Tier-1 blocker") — the check-runs path just never got the same treatment.
Proposed fix
- Dedupe check runs by name in
fetch_pr_context, keeping the newest run (branch protection keys required checks by name, so name-level dedup matches GitHub's own merge semantics). A latest cancelled run still counts as a blocker — only superseded runs stop counting.
- Parameterize
post_reviews_rate_limited with a reason (rate-limit vs blocked) and post honest user-facing text for the blocked path, while keeping the machine-readable status=rate-limited marker token so dev-lead-retry.sh and the e2e scenarios continue to work unchanged.
Not related to
PR #458 (advisory review gate) — open, touches the pr-review agent only. Both defects predate it (#435, #426/#353).
Symptom
On PR #453, dev-lead posted (comment 4642769821, 2026-06-07T13:10:43Z):
No AI engine was rate-limited. The engine ran successfully and decided no changes were needed. The "reset" time is exactly
now + 30 minutes— the giveaway is the:42seconds, which can only come fromdate -u -d '+30 minutes'(dev-lead-fix-reviews.shhard-blocker backoff path), never fromparse_reset_time(which parses whole-minute "resets H:MMam/pm" strings).Root causes (two distinct defects)
1. The hard-blocker retry path reuses the rate-limit messaging verbatim
In
scripts/dev-lead-fix-reviews.sh, thefix-reviews/review-changesintents handle "engine ran, no changes committed, buthas_hard_blockersis true" by writingnow+30minto/tmp/dev-lead-rate-limit-resetand callingpost_reviews_rate_limited— which unconditionally tells the user "all AI engines are currently rate-limited", regardless of why it was called. The marker reuse (status=rate-limited) is intentional so the retry cron re-dispatches, but the user-facing text is simply false. Introduced in #435.2.
cancelledcheck runs from superseded workflow runs count as hard blockers foreverfetch_pr_contextbuildsCI_STATUS_JSONfromGET /commits/{sha}/check-runswith no dedup by check name. Each triggering event creates a new check suite, so GitHub's defaultfilter=latestdoes not collapse them. On PR #453's head SHA the data was:has_hard_blockers/has_tier1_blockerscountconclusion == "cancelled", so the two stale runs permanently register as blockers even though the same-named check later succeeded — putting the PR into an endless 30-minute "rate-limited" retry loop.Notably, the legacy commit-statuses path in the same function already dedupes by context exactly to avoid this ("so a stale failure overwritten by a later success does not appear as a Tier-1 blocker") — the check-runs path just never got the same treatment.
Proposed fix
fetch_pr_context, keeping the newest run (branch protection keys required checks by name, so name-level dedup matches GitHub's own merge semantics). A latest cancelled run still counts as a blocker — only superseded runs stop counting.post_reviews_rate_limitedwith a reason (rate-limitvsblocked) and post honest user-facing text for the blocked path, while keeping the machine-readablestatus=rate-limitedmarker token sodev-lead-retry.shand the e2e scenarios continue to work unchanged.Not related to
PR #458 (advisory review gate) — open, touches the pr-review agent only. Both defects predate it (#435, #426/#353).