fix: stop the reconciler retry storm exhausting the GitHub rate limit - #43
Merged
Conversation
Agent runs were failing with "API rate limit exceeded for installation ID 129649310" while the hourly bucket was 99.8% free — the shared GitHub App installation was being drained in per-minute bursts of ~750 requests. Root cause: the merge-conflict edge trigger guarded on `prev.checks !== "conflicts"` but its statusPatch wrote "failing". Nothing ever writes "conflicts", so the guard could never become false and the edge re-fired on every pass. For `failed` tasks that branch proposes FAILED -> NEEDS_ATTENTION, which is not a legal edge, so transitionTask threw, the executor misclassified "Invalid state transition" as `stale`, and the worker re-enqueued after 500ms with no cap and no backoff. Each retry rebuilt the snapshot, and that costs three GitHub calls. Production logs showed 1213 stale retries in 40 minutes from two stuck tasks. Breaking the loop: - write the sentinel the guard compares against - never propose NEEDS_ATTENTION from FAILED (illegal edge) - classify an illegal transition as a new non-retryable `invalid` outcome - bound `stale` retries: 1s/2s/4s... capped at 5min, max 10 attempts The waste it was amplifying: - cache PR reads 30s per URL; a retry moments later cannot see anything new - skip the PR fetch for states that provably never read snapshot.pr — notably needs_attention, which always noops yet is swept into every resync - stop watching tasks idle longer than OPTIO_PR_WATCH_MAX_AGE_DAYS (5), and transition aged-out pr_opened tasks to needs_attention so it is visible - make the reconciler the sole PR fetcher; the watcher only wakes it. Also drops the updatedAt bump that kept losing the reconciler's CAS - carry HTTP status on git-platform errors so a 404 PR is marked gone instead of being re-polled forever, and so the 401 auth check can finally fire Prod effect: watch set 19 -> 4 tasks, and 12 tasks pointing at PRs that are deleted or long abandoned stop being polled.
Interpolating a bare Date into a raw `sql` template typechecks, but postgres.js cannot encode it: Failed query: ... AND "tasks"."last_activity_at" >= $1 params: Sat Aug 01 2026 ...: The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received an instance of Date Both new watcher queries threw on every tick, so nothing was watched and no task ever aged out. Caught by tailing the deployed pod, not by CI — the unit tests never touch the driver. Compare through gte()/lt() instead so the column's type maps the Date to a driver value, and add a test that compiles the predicates and asserts no raw Date survives into the params. The third case pins the broken shape, so the assertion demonstrably distinguishes it from the fix.
genisd
force-pushed
the
pr-watcher-cutoff
branch
from
August 6, 2026 12:04
bdd54c9 to
c106844
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Task 385f2ad2 lost ~3 minutes of agent time unable to read its own CI status:
Everything in Optio — PR watcher, reconciler, and every agent's
ghcall — authenticates as the same GitHub App installation and shares one bucket of 12,500 req/hour.The obvious hypothesis (too many PRs polled) was wrong. Agent traffic peaks at 28
ghcalls/hour. From 40 minutes of production api logs:loadPrStatuscalls per minute show a benign baseline of 8/min punctuated by storms:250/min × 3 calls = ~750 GitHub req/min for ~7 minutes. That burst rate is why a 403 arrives while the hourly bucket is nearly empty — the agent's own probe read
{"limit":12500,"remaining":12475,"used":25}for the very window its calls were failing in. This is GitHub's per-minute secondary limit, driven by burst rate, not hourly volume.Root cause
reconcile-repo.ts— the merge-conflict edge guards onprev.checks !== "conflicts"but itsstatusPatchwrites"failing". Nothing ever writes"conflicts", so the guard can never become false and the edge re-fires on every pass.decideFailed()routesfailedtasks with an open PR into that branch, wherecanResumeis false, so it always lands on theNEEDS_ATTENTIONtransition.FAILED → NEEDS_ATTENTIONis not a legal edge (state-machine.ts), sotransitionTaskthrows.Invalid state transitiontogether withStateRaceand returnedstale.staleoutcome after 500 ms, with no attempt cap and no backoff escalation.loadPrStatusunconditionally spends 3 GitHub calls.→ ~1 iteration/second per stuck task, indefinitely. Two production tasks were in this loop (
external-board-tools#1080,backend-nest#3451, bothfailedsince 2026-06-19 with conflicted PRs).The 885
cas_failed_pre_transitionretries are the same amplifier:casUpdategates ontasks.updatedAt, which the PR watcher bumped on every row every cycle while 4 reconciles raced for it.What changed
Breaking the loop
"conflicts", not"failing"), and preserve it inpatchPrStatusFields.NEEDS_ATTENTIONfromFAILED— addedisFailedguards to the conflicts / CI-failing / review-changes branches.invalidoutcome for illegal transitions, split fromStateRace(a genuine race, still retried).staleretries now escalate 1s→2s→4s… capped at 5 min, max 10 attempts (OPTIO_MAX_STALE_RECONCILE_RETRIES). Counter is in-process on purpose:reconcile_attemptsalready drives the world-read backoff curve, and inflating it would lengthen unrelated waits.Removing the waste it amplified
snapshot.pr. Notablyneeds_attention, which always noops awaiting user intent yet is swept into every 5-minute resync (8 such rows in prod, 3 calls each, result discarded).OPTIO_PR_WATCH_MAX_AGE_DAYS(default 5), measured onlast_activity_at—updated_atwas unusable because the watcher bumped it on every poll. Aged-outpr_openedtasks transition once toneeds_attentionso abandonment is visible rather than silent.updatedAtbump that kept losing the reconciler's CAS.status, so a 404 PR is reportedgoneand drops out of the watch set permanently. This also makes the pre-existing 401 auth-failure check functional for the first time — it could never fire before.Test plan
reconcileRepoon an unchanged conflicted-PR snapshot must not re-propose the same action, and afailedtask must never proposeNEEDS_ATTENTION.reconcile-edge-cases.test.tsasserted the buggyFAILED → NEEDS_ATTENTIONbehaviour; updated to assert the legal outcome.prReviewComments, which the resume prompt reads. The edge now persists it andapplyResumeAgentprefers the freshly-observed value. Covered by a new test.npx turbo typecheck12/12,npx turbo test12/12 (2032 api + 398 shared),prettier --check .clean.needs_attention— 11 of them pointing atfrontend-flutter#5678, which does not exist (gh pr view→Could not resolve to a PullRequest) and had been 404-polled since April.Follow-up, deliberately not in this PR
Retry-Afterorx-ratelimit-remaining. Callers still cannot distinguish "rate limited" from "PR missing" and never slow down. This PR removes the traffic that trips the limit; that change is what would make a 403 survivable rather than merely rare.fetchJson— GitHub does not charge 304s.