Skip to content

bug(dev-lead): rate-limited runs are silently lost — no retry, and rate limits miscounted as exhaustion failures #192

Description

@don-petry

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):

  • In fix-ci.sh: detect exit 2 from run_writer_with_fallback explicitly; post status=rate-limited instead of status=failed; do not count toward exhaustion threshold
  • In count_recent_failures: filter to status=failed only (already is, but add explicit comment)
  • Update unit tests

Phase 2 (retry for fix-ci):

  • Add dev-lead-retry.yml — scheduled every 2 hours, scans open PRs across all 8 repos for status=rate-limited on current HEAD SHA, re-dispatches dev-lead-ci-failure
  • Parse rate-limit reset time from marker body (if recorded) to skip retries before the window clears
  • Add E2E scenario: 07-rate-limit-retry

Phase 3 (retry for fix-reviews, fix-bot-comment, human):

  • Add rate-limit marker to fix-reviews.sh
  • Extend retry cron to scan for and re-dispatch review retries

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugBug reports

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions