diff --git a/src/index.ts b/src/index.ts index 596ea4161c..deca89fa11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { createApp } from "./api/routes"; import { RateLimiter } from "./auth/rate-limit"; -import { delayUntil, shouldWaitForGitHubRateLimit } from "./github/rate-limit"; +import { delayUntil, shouldWaitForGitHubRateLimit, MAINTENANCE_RESERVED_HEADROOM } from "./github/rate-limit"; import { processDlqBatch } from "./queue/dlq"; import { processJob } from "./queue/processors"; import { isOrbBrokerEnabled } from "./orb/broker"; @@ -60,7 +60,17 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController): // red-CI non-owner PR CLOSES promptly — reviewbot parity (its cron fired every minute). It re-fetches LIVE CI + // mergeable and only ACTS (merge/close/hold); it never re-runs the AI, so it is cheap enough for this cadence. // Previously this was gated by `isHourly`, so an approved PR could wait ~an hour for its merge pass. - const jobs: JobMessage[] = [{ type: "agent-regate-sweep", requestedBy: "schedule" }]; + // BACKPRESSURE (#6): the sweep + its per-repo/per-PR fan-out is the heaviest GitHub-budget consumer. When the + // shared REST budget is already at/below the maintenance headroom, SKIP enqueuing it this tick so the remaining + // budget is reserved for webhooks (which drive timely reviews) instead of compounding the backlog; the next + // tick (~2 min) retries, and after the bucket resets the sweep resumes. Webhooks never pre-yield. + const jobs: JobMessage[] = []; + const sweepThrottledUntil = await shouldWaitForGitHubRateLimit(env, MAINTENANCE_RESERVED_HEADROOM); + if (sweepThrottledUntil) { + console.log(JSON.stringify({ event: "regate_sweep_throttled", resetAt: sweepThrottledUntil })); + } else { + jobs.push({ type: "agent-regate-sweep", requestedBy: "schedule" }); + } // Orb relay retry: re-attempt failed forwardOrbEvent calls each sweep cycle. Only enqueued when the // broker is enabled — brokered self-hosts register relay URLs; hosted-cloud instances have no relay failures. if (isOrbBrokerEnabled(env)) jobs.push({ type: "retry-orb-relay", requestedBy: "schedule" }); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 63652f018f..4265dfa27c 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -154,6 +154,20 @@ describe("worker entrypoint", () => { expect(sent).toEqual([{ type: "agent-regate-sweep", requestedBy: "schedule" }]); }); + it("THROTTLES the sweep when the GitHub REST budget is at/below the maintenance headroom (#6 backpressure)", async () => { + const sent: Array = []; + const env = createTestEnv({ + JOBS: { async send(message: import("../../src/types").JobMessage) { sent.push(message); } } as unknown as Queue, + }); + // Seed a low REST observation (remaining 50 <= MAINTENANCE_RESERVED_HEADROOM=150) with a future reset. + await recordGitHubRateLimitObservation(env, { repoFullName: "owner/repo", resource: "rest", path: "/x", statusCode: 200, limitValue: 5000, remaining: 50, resetAt: new Date(Date.now() + 600_000).toISOString(), observedAt: new Date().toISOString() }); + const waitUntil: Promise[] = []; + await worker.scheduled(controllerFor("2026-05-25T05:14:00.000Z"), env, executionContext(waitUntil)); + await Promise.all(waitUntil); + // The sweep is NOT enqueued this tick — the shared budget is reserved for webhooks; the next tick retries. + expect(sent.find((m) => m.type === "agent-regate-sweep")).toBeUndefined(); + }); + it("enqueues hourly refreshes without full detail work outside the six-hour window", async () => { const sent: Array = []; const env = createTestEnv({