Problem
When any engine invocation (Claude, Gemini, Copilot) hits a rate limit and run_writer_with_fallback exhausts all three fallbacks, the workflow exits 1 and the original triggering event is permanently lost. There is no queue, no retry, no deferred execution. The PR stays broken.
This affected at least PRs #80, #126, #175 yesterday — all got status=failed markers from rate limits, not from real engine errors.
Root cause
1. Rate limits are not distinguished from real failures
run_writer_with_fallback exits 2 on rate limit, but fix-ci.sh maps any non-zero exit from the engine to post_summary "failed":
# dev-lead-fix-ci.sh L147-161
if [ "$engine_rc" -ne 0 ]; then
post_summary "failed" "Engine invocation failed (exit ${engine_rc})"
fail_count=$(count_recent_failures)
if [ "$fail_count" -ge "$MAX_FAIL_ATTEMPTS" ]; then
post_exhaustion "$reason"
fi
exit 1
fi
A rate-limit exit (2) and a real engine error (1) both result in status=failed. The exhaustion guard then counts rate-limit failures toward the MAX_FAIL_ATTEMPTS threshold (default: 2), which can lock a PR out of all future automated help — even when the rate limit has long since reset.
Observed on PR #80: 5 status=failed markers accumulated across 24h, mostly from rate limits. Exhaustion was posted at 20:20 UTC on 2026-05-15.
2. The triggering event is one-shot with no queue
All intents that require engine work (fix-ci, fix-reviews, fix-bot-comment, human) are triggered by transient GitHub events (check_run completed, pull_request_review submitted, issue_comment created). Once the workflow run fails:
- The event payload is gone
- GitHub does not re-fire the event
- No retry is scheduled
fix-ci at least leaves a status=failed marker, but nothing reads it to retry
fix-reviews / fix-bot-comment / human leave no marker at all — the work is silently discarded
3. The fix-reviews path has zero recovery
dev-lead-fix-reviews.sh calls run_writer_with_fallback and propagates its exit code directly (line 32). No comment, no marker, no log artifact beyond the failed Actions run. The bot review that triggered the run is unaddressed with no trace.
Scope
All 8 repos are affected. Any org-level rate limit event (shared Claude subscription) will cascade across repos simultaneously — the worst case is the entire fleet hitting rate limits at the same time, losing all in-flight work at once.
Options considered
Option A — Separate status=rate-limited from status=failed (low effort, partial fix)
Change fix-ci.sh to detect whether the non-zero exit from run_writer_with_fallback was a rate limit (exit 2) vs. a real engine failure (exit 1), and post status=rate-limited instead of status=failed. Exclude status=rate-limited from the exhaustion count_recent_failures query.
Pros: Fixes the exhaustion miscounting immediately. Simple change, low risk.
Cons: Work is still lost. Just quieter about it.
Effort: ~1h
Option B — In-workflow sleep-and-retry (wrong tool for the job)
Parse the "resets N:XXpm" string from the engine error, sleep until then, retry in the same runner.
Pros: No new infrastructure.
Cons: Rate-limit windows can be 6+ hours (observed: reset at 23:20 UTC from a 17:05 hit). The job timeout-minutes: 30 would expire. Even if extended, burning a runner for 6 hours of sleep to save one LLM call is wasteful and blocks the per-PR concurrency slot. Not viable.
Option C — workflow_run self-trigger on failure (too coarse)
Add a workflow_run trigger that fires when dev-lead completes with failure conclusion.
Pros: Immediate, automatic.
Cons: Doesn't know why it failed or what work to retry. Any non-rate-limit failure would trigger an infinite retry loop. Needs complex state to distinguish rate-limit failures from real ones. The trigger also fires for the ci-relay job (which never calls an engine), adding noise. High risk of runaway loops.
Option D — Scheduled retry scanner (recommended for fix-ci)
A new lightweight workflow dev-lead-retry.yml runs on a schedule (e.g., every 2 hours), scans all open PRs across all 8 repos for status=rate-limited markers on the current HEAD SHA, and re-dispatches repository_dispatch dev-lead-ci-failure for each. This reuses the existing ci-relay dispatch infrastructure and the existing idempotency check — no duplicate work if the rate limit already cleared and a new push re-triggered the run.
Pros:
- Reuses existing
repository_dispatch + idempotency infrastructure unchanged
- Clean separation of concerns (retry logic is its own workflow)
- Testable in dry-run mode
- Handles org-wide rate limit bursts gracefully (all PRs picked up on next tick)
- The staggered cron pattern from
pr-review.yml (4x/hour) is available precedent if faster recovery is wanted
Cons:
- Up to 2-hour delay between rate limit clearing and retry (acceptable for a background agent)
- Requires cross-repo read access via
GH_PAT_WORKFLOWS (already available)
- Does not cover
fix-reviews / fix-bot-comment / human (see Option E)
- If rate limits haven't cleared by the time the cron fires, it re-dispatches and immediately fails again → adds another
status=rate-limited marker. Mitigation: parse reset time from marker body and skip if reset time is in the future.
Effort: ~1 day
Option E — status=rate-limited marker for fix-reviews + retry cron (recommended for fix-reviews)
Pros: Full coverage of all intent types.
Cons: Adds a new dispatch event type. fix-reviews has no existing marker infrastructure to build on. Moderate implementation complexity.
Effort: ~1 day (after Option D)
Recommended implementation plan
Phase 1 (quick win — fixes exhaustion miscounting):
Phase 2 (retry for fix-ci):
Phase 3 (retry for fix-reviews, fix-bot-comment, human):
Related
Problem
When any engine invocation (Claude, Gemini, Copilot) hits a rate limit and
run_writer_with_fallbackexhausts all three fallbacks, the workflow exits 1 and the original triggering event is permanently lost. There is no queue, no retry, no deferred execution. The PR stays broken.This affected at least PRs #80, #126, #175 yesterday — all got
status=failedmarkers from rate limits, not from real engine errors.Root cause
1. Rate limits are not distinguished from real failures
run_writer_with_fallbackexits 2 on rate limit, butfix-ci.shmaps any non-zero exit from the engine topost_summary "failed":A rate-limit exit (2) and a real engine error (1) both result in
status=failed. The exhaustion guard then counts rate-limit failures toward theMAX_FAIL_ATTEMPTSthreshold (default: 2), which can lock a PR out of all future automated help — even when the rate limit has long since reset.Observed on PR #80: 5
status=failedmarkers accumulated across 24h, mostly from rate limits. Exhaustion was posted at 20:20 UTC on 2026-05-15.2. The triggering event is one-shot with no queue
All intents that require engine work (
fix-ci,fix-reviews,fix-bot-comment,human) are triggered by transient GitHub events (check_run completed,pull_request_review submitted,issue_comment created). Once the workflow run fails:fix-ciat least leaves astatus=failedmarker, but nothing reads it to retryfix-reviews/fix-bot-comment/humanleave no marker at all — the work is silently discarded3. The
fix-reviewspath has zero recoverydev-lead-fix-reviews.shcallsrun_writer_with_fallbackand propagates its exit code directly (line 32). No comment, no marker, no log artifact beyond the failed Actions run. The bot review that triggered the run is unaddressed with no trace.Scope
All 8 repos are affected. Any org-level rate limit event (shared Claude subscription) will cascade across repos simultaneously — the worst case is the entire fleet hitting rate limits at the same time, losing all in-flight work at once.
Options considered
Option A — Separate
status=rate-limitedfromstatus=failed(low effort, partial fix)Change
fix-ci.shto detect whether the non-zero exit fromrun_writer_with_fallbackwas a rate limit (exit 2) vs. a real engine failure (exit 1), and poststatus=rate-limitedinstead ofstatus=failed. Excludestatus=rate-limitedfrom the exhaustioncount_recent_failuresquery.Pros: Fixes the exhaustion miscounting immediately. Simple change, low risk.
Cons: Work is still lost. Just quieter about it.
Effort: ~1h
Option B — In-workflow sleep-and-retry (wrong tool for the job)
Parse the "resets N:XXpm" string from the engine error, sleep until then, retry in the same runner.
Pros: No new infrastructure.
Cons: Rate-limit windows can be 6+ hours (observed: reset at 23:20 UTC from a 17:05 hit). The job
timeout-minutes: 30would expire. Even if extended, burning a runner for 6 hours of sleep to save one LLM call is wasteful and blocks the per-PR concurrency slot. Not viable.Option C —
workflow_runself-trigger on failure (too coarse)Add a
workflow_runtrigger that fires when dev-lead completes withfailureconclusion.Pros: Immediate, automatic.
Cons: Doesn't know why it failed or what work to retry. Any non-rate-limit failure would trigger an infinite retry loop. Needs complex state to distinguish rate-limit failures from real ones. The trigger also fires for the
ci-relayjob (which never calls an engine), adding noise. High risk of runaway loops.Option D — Scheduled retry scanner (recommended for fix-ci)
A new lightweight workflow
dev-lead-retry.ymlruns on a schedule (e.g., every 2 hours), scans all open PRs across all 8 repos forstatus=rate-limitedmarkers on the current HEAD SHA, and re-dispatchesrepository_dispatch dev-lead-ci-failurefor each. This reuses the existingci-relaydispatch infrastructure and the existing idempotency check — no duplicate work if the rate limit already cleared and a new push re-triggered the run.Pros:
repository_dispatch+ idempotency infrastructure unchangedpr-review.yml(4x/hour) is available precedent if faster recovery is wantedCons:
GH_PAT_WORKFLOWS(already available)fix-reviews/fix-bot-comment/human(see Option E)status=rate-limitedmarker. Mitigation: parse reset time from marker body and skip if reset time is in the future.Effort: ~1 day
Option E —
status=rate-limitedmarker for fix-reviews + retry cron (recommended for fix-reviews)Pros: Full coverage of all intent types.
Cons: Adds a new dispatch event type. fix-reviews has no existing marker infrastructure to build on. Moderate implementation complexity.
Effort: ~1 day (after Option D)
Recommended implementation plan
Phase 1 (quick win — fixes exhaustion miscounting):
fix-ci.sh: detect exit 2 fromrun_writer_with_fallbackexplicitly; poststatus=rate-limitedinstead ofstatus=failed; do not count toward exhaustion thresholdcount_recent_failures: filter tostatus=failedonly (already is, but add explicit comment)Phase 2 (retry for fix-ci):
dev-lead-retry.yml— scheduled every 2 hours, scans open PRs across all 8 repos forstatus=rate-limitedon current HEAD SHA, re-dispatchesdev-lead-ci-failure07-rate-limit-retryPhase 3 (retry for fix-reviews, fix-bot-comment, human):
fix-reviews.shRelated
docs/dev-lead/monitor-and-close-prompt.md— mentionsworkflow_runtrigger as a future consideration (Option C above — not recommended)