Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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" });
Expand Down
14 changes: 14 additions & 0 deletions test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<import("../../src/types").JobMessage> = [];
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<unknown>[] = [];
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<import("../../src/types").JobMessage> = [];
const env = createTestEnv({
Expand Down
Loading