Parent: #1936
Problem
createAppJwt (src/github/app.ts ~line 381) caches the App-level JWT for 8 minutes (appJwtCache, keyed only by GITHUB_APP_ID), with no eviction path on rejection. #2406 already hardened this for installation tokens (expireCachedInstallationToken + retry-once on a rejected installation token via withInstallationTokenRetry), but the App-level JWT itself has no equivalent.
If GitHub rejects the currently-cached App JWT for any transient reason (a validation hiccup, a clock-skew edge case, a brief App suspend/reinstate) while env.GITHUB_APP_PRIVATE_KEY is unchanged, mintInstallationToken (app.ts ~lines 244-251) keeps handing back the same poisoned JWT from cache (nothing about the cache-validity check — keyed on privateKey === env.GITHUB_APP_PRIVATE_KEY and expiresAtMs > nowMs — changes on a rejection). Every subsequent POST /app/installations/{id}/access_tokens across every installation on the instance gets a 401 and throws, until the 8-minute cache window naturally lapses. appJwtCache is otherwise only ever cleared by the test-only clearInstallationTokenCacheForTest().
Production call sites of createInstallationToken (src/queue/processors.ts, src/services/agent-approval-queue.ts, src/services/agent-action-executor.ts, src/github/backfill.ts, src/github/labels.ts, src/review/rag-index.ts, src/review/grounding-wire.ts) either propagate the throw or silently swallow it — none re-signs a fresh JWT. Net effect: a single JWT rejection can stall merges/comments/check-runs/approvals fleet-wide for up to 8 minutes.
Fix
On a 401 (or bad-credentials-shaped error) from the App-installations access-token endpoint, evict the current appJwtCache entry for env.GITHUB_APP_ID, re-sign a fresh JWT, and retry once — mirroring the withInstallationTokenRetry pattern already used for installation tokens.
Regression tests
- A rejected App JWT triggers eviction + one fresh re-sign + retry, and a subsequent installation-token mint succeeds without waiting out the cache TTL.
- A JWT rejection for a genuinely bad private key (not transient) does not infinite-loop — still terminates after the single retry.
Parent: #1936
Problem
createAppJwt(src/github/app.ts~line 381) caches the App-level JWT for 8 minutes (appJwtCache, keyed only byGITHUB_APP_ID), with no eviction path on rejection. #2406 already hardened this for installation tokens (expireCachedInstallationToken+ retry-once on a rejected installation token viawithInstallationTokenRetry), but the App-level JWT itself has no equivalent.If GitHub rejects the currently-cached App JWT for any transient reason (a validation hiccup, a clock-skew edge case, a brief App suspend/reinstate) while
env.GITHUB_APP_PRIVATE_KEYis unchanged,mintInstallationToken(app.ts~lines 244-251) keeps handing back the same poisoned JWT from cache (nothing about the cache-validity check — keyed onprivateKey === env.GITHUB_APP_PRIVATE_KEYandexpiresAtMs > nowMs— changes on a rejection). Every subsequentPOST /app/installations/{id}/access_tokensacross every installation on the instance gets a 401 and throws, until the 8-minute cache window naturally lapses.appJwtCacheis otherwise only ever cleared by the test-onlyclearInstallationTokenCacheForTest().Production call sites of
createInstallationToken(src/queue/processors.ts,src/services/agent-approval-queue.ts,src/services/agent-action-executor.ts,src/github/backfill.ts,src/github/labels.ts,src/review/rag-index.ts,src/review/grounding-wire.ts) either propagate the throw or silently swallow it — none re-signs a fresh JWT. Net effect: a single JWT rejection can stall merges/comments/check-runs/approvals fleet-wide for up to 8 minutes.Fix
On a 401 (or bad-credentials-shaped error) from the App-installations access-token endpoint, evict the current
appJwtCacheentry forenv.GITHUB_APP_ID, re-sign a fresh JWT, and retry once — mirroring thewithInstallationTokenRetrypattern already used for installation tokens.Regression tests