Summary
When Claude hits its subscription/usage-cap (distinct from a per-minute rate limit), the triage process exits with code 1 but the is_rate_limited regex in engine.sh doesn't match Claude Code's usage-limit message phrasing. The cascade treats this as a fatal error instead of triggering the engine-fallback chain (claude → gemini → copilot).
Observed in run: https://github.com/petry-projects/.github-private/actions/runs/25648497571/job/75282153268
Root cause (two parts)
1. Incomplete is_rate_limited regex
Claude Code's usage-cap message uses phrasing not covered by the current regex (e.g. out of tokens, claude.*usage, plan limit, HTTP 402/529). The function only checks for API-level rate-limit text, not subscription/billing cap text.
2. Rate-limit check only inspects stdout
run_triage redirects stderr to $TRIAGE_LOG and stdout is captured in $TRIAGE_RESULT. The is_rate_limited check only runs on $TRIAGE_RESULT — some providers write their error to stderr instead, so a cap on those providers also falls through to the hard-fail path.
3. Triage stdout swallowed on hard-fail (makes diagnosis impossible)
In the failure path, only $TRIAGE_LOG (stderr) is printed. $TRIAGE_RESULT (stdout) — where Claude Code writes its error — is silently discarded, making the failure opaque in CI logs.
Impact
Any time Claude hits its usage cap the entire PR-review session aborts and retries on the next hourly schedule, rather than immediately retrying with Copilot or Gemini. The fallback chain that already exists in review-batch.sh is never reached.
Fix (tracked in linked PR)
scripts/engine.sh — expand is_rate_limited to cover usage/billing-cap patterns and 402/529 status codes.
scripts/review-one-pr.sh — also pass $TRIAGE_LOG (stderr) through is_rate_limited; print both channels in the hard-fail path for diagnostics.
Summary
When Claude hits its subscription/usage-cap (distinct from a per-minute rate limit), the triage process exits with code 1 but the
is_rate_limitedregex inengine.shdoesn't match Claude Code's usage-limit message phrasing. The cascade treats this as a fatal error instead of triggering the engine-fallback chain (claude → gemini → copilot).Observed in run: https://github.com/petry-projects/.github-private/actions/runs/25648497571/job/75282153268
Root cause (two parts)
1. Incomplete
is_rate_limitedregexClaude Code's usage-cap message uses phrasing not covered by the current regex (e.g.
out of tokens,claude.*usage,plan limit, HTTP 402/529). The function only checks for API-level rate-limit text, not subscription/billing cap text.2. Rate-limit check only inspects stdout
run_triageredirects stderr to$TRIAGE_LOGand stdout is captured in$TRIAGE_RESULT. Theis_rate_limitedcheck only runs on$TRIAGE_RESULT— some providers write their error to stderr instead, so a cap on those providers also falls through to the hard-fail path.3. Triage stdout swallowed on hard-fail (makes diagnosis impossible)
In the failure path, only
$TRIAGE_LOG(stderr) is printed.$TRIAGE_RESULT(stdout) — where Claude Code writes its error — is silently discarded, making the failure opaque in CI logs.Impact
Any time Claude hits its usage cap the entire PR-review session aborts and retries on the next hourly schedule, rather than immediately retrying with Copilot or Gemini. The fallback chain that already exists in
review-batch.shis never reached.Fix (tracked in linked PR)
scripts/engine.sh— expandis_rate_limitedto cover usage/billing-cap patterns and 402/529 status codes.scripts/review-one-pr.sh— also pass$TRIAGE_LOG(stderr) throughis_rate_limited; print both channels in the hard-fail path for diagnostics.