From cb5fddbb1567bb8ffe6d247a4fa0c286902e71aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 04:13:35 +0000 Subject: [PATCH 1/4] feat: add new anthropic baseline models --- .../src/agent-options/agent-options.test.ts | 12 ++++++++---- packages/shared/src/agent-options/anthropic.ts | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/agent-options/agent-options.test.ts b/packages/shared/src/agent-options/agent-options.test.ts index 2fe8a2b9..e085abcb 100644 --- a/packages/shared/src/agent-options/agent-options.test.ts +++ b/packages/shared/src/agent-options/agent-options.test.ts @@ -63,7 +63,7 @@ describe("PROVIDER_CATALOGS", () => { describe("resolveModelId", () => { it("resolves the opus alias to the latest dated id", () => { - expect(resolveModelId("anthropic", "opus")).toBe("claude-opus-4-7"); + expect(resolveModelId("anthropic", "opus")).toBe("claude-opus-4-8"); }); it("resolves the sonnet alias", () => { @@ -74,6 +74,10 @@ describe("resolveModelId", () => { expect(resolveModelId("anthropic", "haiku")).toBe("claude-haiku-4-5-20251001"); }); + it("resolves the fable alias", () => { + expect(resolveModelId("anthropic", "fable")).toBe("claude-fable-5"); + }); + it("returns an exact-match dated id unchanged", () => { expect(resolveModelId("anthropic", "claude-sonnet-4-5")).toBe("claude-sonnet-4-5"); }); @@ -87,7 +91,7 @@ describe("resolveModelId", () => { it("returns the latest-marked model when no input is provided", () => { // Pick a provider with an explicit `latest` flag. - expect(resolveModelId("anthropic", undefined)).toBe("claude-opus-4-7"); + expect(resolveModelId("anthropic", undefined)).toBe("claude-opus-4-8"); }); it("returns undefined for free-text providers with no baseline models", () => { @@ -102,7 +106,7 @@ describe("resolveModelId", () => { }); it("treats an empty string like undefined", () => { - expect(resolveModelId("anthropic", "")).toBe("claude-opus-4-7"); + expect(resolveModelId("anthropic", "")).toBe("claude-opus-4-8"); }); it("resolves gemini-pro alias", () => { @@ -160,7 +164,7 @@ describe("groupModelsByFamily", () => { it("groups anthropic models by family", () => { const groups = groupModelsByFamily(ANTHROPIC_CATALOG); const families = groups.map((g) => g.family).sort(); - expect(families).toEqual(["haiku", "opus", "sonnet"]); + expect(families).toEqual(["fable", "haiku", "opus", "sonnet"]); }); it("falls back to the model id when there is no family", () => { diff --git a/packages/shared/src/agent-options/anthropic.ts b/packages/shared/src/agent-options/anthropic.ts index 74bfccbd..34db5c1b 100644 --- a/packages/shared/src/agent-options/anthropic.ts +++ b/packages/shared/src/agent-options/anthropic.ts @@ -10,11 +10,17 @@ export const ANTHROPIC_CATALOG: ProviderCatalog = { label: "Claude Code", modelField: "claudeModel", models: [ + { + id: "claude-opus-4-8", + label: "Opus 4.8", + family: "opus", + latest: true, + source: "baseline", + }, { id: "claude-opus-4-7", label: "Opus 4.7", family: "opus", - latest: true, source: "baseline", }, { @@ -43,11 +49,19 @@ export const ANTHROPIC_CATALOG: ProviderCatalog = { latest: true, source: "baseline", }, + { + id: "claude-fable-5", + label: "Fable 5", + family: "fable", + latest: true, + source: "baseline", + }, ], aliases: { - opus: "claude-opus-4-7", + opus: "claude-opus-4-8", sonnet: "claude-sonnet-4-6", haiku: "claude-haiku-4-5-20251001", + fable: "claude-fable-5", }, options: [ { From 8be629a81f595c8546dfb206eab2b5cd46ec1cd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 04:16:29 +0000 Subject: [PATCH 2/4] feat: add new anthropic baseline models --- packages/shared/src/agent-options/agent-options.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/agent-options/agent-options.test.ts b/packages/shared/src/agent-options/agent-options.test.ts index e085abcb..1cfb0ba0 100644 --- a/packages/shared/src/agent-options/agent-options.test.ts +++ b/packages/shared/src/agent-options/agent-options.test.ts @@ -127,9 +127,9 @@ describe("mergeLiveModels", () => { }); it("preserves baseline metadata when a live id matches", () => { - const opus = ANTHROPIC_CATALOG.models.find((m) => m.id === "claude-opus-4-7")!; - const merged = mergeLiveModels(ANTHROPIC_CATALOG, ["claude-opus-4-7"]); - const mergedOpus = merged.models.find((m) => m.id === "claude-opus-4-7")!; + const opus = ANTHROPIC_CATALOG.models.find((m) => m.id === "claude-opus-4-8")!; + const merged = mergeLiveModels(ANTHROPIC_CATALOG, ["claude-opus-4-8"]); + const mergedOpus = merged.models.find((m) => m.id === "claude-opus-4-8")!; expect(mergedOpus.label).toBe(opus.label); expect(mergedOpus.latest).toBe(true); expect(mergedOpus.source).toBe("baseline"); From 1eef8ddecbb462216ed617bdd4c6ac97e660100e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:29:21 +0000 Subject: [PATCH 3/4] fix: remove latest flag from fable 5 --- packages/shared/src/agent-options/anthropic.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/agent-options/anthropic.ts b/packages/shared/src/agent-options/anthropic.ts index 34db5c1b..e130e13b 100644 --- a/packages/shared/src/agent-options/anthropic.ts +++ b/packages/shared/src/agent-options/anthropic.ts @@ -53,7 +53,6 @@ export const ANTHROPIC_CATALOG: ProviderCatalog = { id: "claude-fable-5", label: "Fable 5", family: "fable", - latest: true, source: "baseline", }, ], From dcfa97e750ad6871d4aa437b65b7f006d0d20664 Mon Sep 17 00:00:00 2001 From: Jayson Jacobs Date: Sat, 13 Jun 2026 22:18:23 -0600 Subject: [PATCH 4/4] fix: respect github rate limiting Persist a reconcile cooldown on secondary/primary/auth GitHub write failures so no reconcile producer re-issues a blocked write before the deadline. --- .../src/services/git-platform/github.test.ts | 79 ++++++++++- apps/api/src/services/git-platform/github.ts | 111 +++++++++++++++- .../src/services/reconcile-executor.test.ts | 78 ++++++++++- apps/api/src/services/reconcile-executor.ts | 123 ++++++++++++++++++ apps/api/src/workers/reconcile-worker.ts | 5 +- .../src/app/jobs/[id]/runs/[runId]/page.tsx | 88 +++++++------ apps/web/src/app/tasks/[id]/page.tsx | 13 +- packages/shared/src/error-classifier.test.ts | 47 +++++++ packages/shared/src/error-classifier.ts | 59 +++++++++ 9 files changed, 558 insertions(+), 45 deletions(-) diff --git a/apps/api/src/services/git-platform/github.test.ts b/apps/api/src/services/git-platform/github.test.ts index cc88190b..b28ac405 100644 --- a/apps/api/src/services/git-platform/github.test.ts +++ b/apps/api/src/services/git-platform/github.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { GitHubPlatform } from "./github.js"; +import { GitHubPlatform, GitHubApiError, classifyGitHubFailure } from "./github.js"; import type { RepoIdentifier } from "@optio/shared"; const ri: RepoIdentifier = { @@ -239,5 +239,82 @@ describe("GitHubPlatform", () => { await expect(platform.getPullRequest(ri, 999)).rejects.toThrow("GitHub API error 404"); }); + + it("throws a GitHubApiError carrying status, retry-after, and secondary-limit flag", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + headers: new Headers({ "retry-after": "120" }), + json: () => Promise.resolve({}), + text: () => Promise.resolve("You have exceeded a secondary rate limit"), + }); + + await expect(platform.getPullRequest(ri, 1)).rejects.toMatchObject({ + name: "GitHubApiError", + status: 403, + isSecondaryRateLimit: true, + retryAfterMs: 120000, + }); + }); + + it("flags a primary rate limit from x-ratelimit headers", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + headers: new Headers({ "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1900000000" }), + json: () => Promise.resolve({}), + text: () => Promise.resolve("API rate limit exceeded"), + }); + + await expect(platform.getPullRequest(ri, 2)).rejects.toMatchObject({ + name: "GitHubApiError", + status: 403, + isPrimaryRateLimit: true, + isSecondaryRateLimit: false, + rateLimitRemaining: 0, + rateLimitResetMs: 1900000000000, + }); + }); + }); +}); + +describe("classifyGitHubFailure", () => { + it("classifies a secondary rate limit (body marker), carrying Retry-After", () => { + const f = classifyGitHubFailure( + new GitHubApiError(403, "You have exceeded a secondary rate limit", { retryAfterMs: 120000 }), + ); + expect(f).toMatchObject({ kind: "secondary_rate_limit", retryAfterMs: 120000 }); + }); + + it("classifies a primary rate limit (remaining 0) with the reset time", () => { + const f = classifyGitHubFailure( + new GitHubApiError(403, "API rate limit exceeded", { + rateLimitRemaining: 0, + rateLimitResetMs: 1_900_000_000_000, + }), + ); + expect(f).toMatchObject({ kind: "primary_rate_limit", resetAtMs: 1_900_000_000_000 }); + }); + + it("treats a bare 429 as a secondary rate limit", () => { + expect(classifyGitHubFailure(new GitHubApiError(429, "Too Many Requests"))?.kind).toBe( + "secondary_rate_limit", + ); + }); + + it("classifies 401 as auth and a non-rate-limit 403 as permission", () => { + expect(classifyGitHubFailure(new GitHubApiError(401, "Bad credentials"))?.kind).toBe("auth"); + expect( + classifyGitHubFailure(new GitHubApiError(403, "Resource not accessible by integration")) + ?.kind, + ).toBe("permission"); + }); + + it("returns null for transient 5xx, 4xx logic errors, and non-GitHub errors", () => { + expect(classifyGitHubFailure(new GitHubApiError(500, "Server Error"))).toBeNull(); + expect(classifyGitHubFailure(new GitHubApiError(404, "Not Found"))).toBeNull(); + expect(classifyGitHubFailure(new GitHubApiError(422, "Unprocessable"))).toBeNull(); + expect(classifyGitHubFailure(new Error("db hiccup"))).toBeNull(); + expect(classifyGitHubFailure(undefined)).toBeNull(); }); }); diff --git a/apps/api/src/services/git-platform/github.ts b/apps/api/src/services/git-platform/github.ts index e6ae54b0..37e644a1 100644 --- a/apps/api/src/services/git-platform/github.ts +++ b/apps/api/src/services/git-platform/github.ts @@ -11,6 +11,113 @@ import type { RepoContent, } from "@optio/shared"; +/** + * Error thrown by GitHubPlatform for any non-2xx GitHub REST response. Carries + * the verified response metadata callers need to choose the correct recovery: + * HTTP status, `Retry-After`, the `x-ratelimit-*` primary-quota signals, and + * whether the body indicates a *secondary* rate limit. The `.message` is kept + * identical to the previous `GitHub API error : ` string so + * existing message-based logging/classification is unaffected. + */ +export class GitHubApiError extends Error { + readonly status: number; + readonly retryAfterMs: number | null; + readonly rateLimitRemaining: number | null; + readonly rateLimitResetMs: number | null; + readonly isSecondaryRateLimit: boolean; + readonly isPrimaryRateLimit: boolean; + + constructor( + status: number, + body: string, + meta: { + retryAfterMs?: number | null; + rateLimitRemaining?: number | null; + rateLimitResetMs?: number | null; + } = {}, + ) { + super(`GitHub API error ${status}: ${body}`); + this.name = "GitHubApiError"; + this.status = status; + this.retryAfterMs = meta.retryAfterMs ?? null; + this.rateLimitRemaining = meta.rateLimitRemaining ?? null; + this.rateLimitResetMs = meta.rateLimitResetMs ?? null; + this.isSecondaryRateLimit = + (status === 403 || status === 429) && + /secondary rate limit|exceeded a secondary rate limit|abuse detection/i.test(body); + // A primary-quota exhaustion is a 403/429 with the remaining counter at 0 + // and no secondary marker — it must wait for the reset, not the 60s floor. + this.isPrimaryRateLimit = + (status === 403 || status === 429) && + this.rateLimitRemaining === 0 && + !this.isSecondaryRateLimit; + // Preserve the prototype chain so `instanceof GitHubApiError` holds even + // under down-level transpilation targets. + Object.setPrototypeOf(this, GitHubApiError.prototype); + } + + static fromResponse(res: Response, body: string): GitHubApiError { + return new GitHubApiError(res.status, body, { + retryAfterMs: parseRetryAfterMs(res.headers), + rateLimitRemaining: parseIntHeader(res.headers, "x-ratelimit-remaining"), + rateLimitResetMs: parseEpochSecondsToMs(res.headers, "x-ratelimit-reset"), + }); + } +} + +function parseRetryAfterMs(headers: Headers | undefined): number | null { + const raw = headers?.get("retry-after"); + if (!raw) return null; + const seconds = Number(raw); + return Number.isFinite(seconds) ? Math.max(0, seconds) * 1000 : null; +} + +function parseIntHeader(headers: Headers | undefined, name: string): number | null { + const raw = headers?.get(name); + if (raw == null || raw === "") return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +function parseEpochSecondsToMs(headers: Headers | undefined, name: string): number | null { + const seconds = parseIntHeader(headers, name); + return seconds == null ? null : seconds * 1000; +} + +/** Distinct GitHub failure classes, each of which needs a distinct retry policy. */ +export type GitHubFailureKind = + | "secondary_rate_limit" + | "primary_rate_limit" + | "auth" + | "permission"; + +export interface GitHubFailure { + kind: GitHubFailureKind; + /** Server-directed wait (`Retry-After`), if present. */ + retryAfterMs: number | null; + /** Primary-quota reset time (`x-ratelimit-reset`), if present. */ + resetAtMs: number | null; +} + +/** + * Classify a thrown error into the GitHub failure class that determines retry + * policy, or `null` when it is NOT a GitHub block (transient 5xx, 404, 422, + * network, or any non-GitHub error) — in which case callers keep their normal + * fast-retry behavior. The deadline math (how long to wait) is the caller's + * responsibility, since it depends on persisted attempt history. + */ +export function classifyGitHubFailure(err: unknown): GitHubFailure | null { + if (!(err instanceof GitHubApiError)) return null; + const { retryAfterMs, rateLimitResetMs: resetAtMs } = err; + if (err.isSecondaryRateLimit) return { kind: "secondary_rate_limit", retryAfterMs, resetAtMs }; + if (err.isPrimaryRateLimit) return { kind: "primary_rate_limit", retryAfterMs, resetAtMs }; + // A 429 with no secondary marker / remaining counter is still a rate limit. + if (err.status === 429) return { kind: "secondary_rate_limit", retryAfterMs, resetAtMs }; + if (err.status === 401) return { kind: "auth", retryAfterMs: null, resetAtMs: null }; + if (err.status === 403) return { kind: "permission", retryAfterMs: null, resetAtMs: null }; + return null; +} + export class GitHubPlatform implements GitPlatform { readonly type = "github" as const; private readonly token: string; @@ -37,7 +144,7 @@ export class GitHubPlatform implements GitPlatform { const res = await fetch(url, init); if (!res.ok) { const body = await res.text().catch(() => ""); - throw new Error(`GitHub API error ${res.status}: ${body}`); + throw GitHubApiError.fromResponse(res, body); } return (await res.json()) as T; } @@ -196,7 +303,7 @@ export class GitHubPlatform implements GitPlatform { }); if (!res.ok && res.status !== 422) { const body = await res.text().catch(() => ""); - throw new Error(`GitHub API error ${res.status}: ${body}`); + throw GitHubApiError.fromResponse(res, body); } } diff --git a/apps/api/src/services/reconcile-executor.test.ts b/apps/api/src/services/reconcile-executor.test.ts index 81bf7fa0..5e6ca2d4 100644 --- a/apps/api/src/services/reconcile-executor.test.ts +++ b/apps/api/src/services/reconcile-executor.test.ts @@ -32,8 +32,21 @@ vi.mock("../db/client.js", () => ({ })); vi.mock("../db/schema.js", () => ({ - tasks: { id: "id", updatedAt: "updated_at" }, + tasks: { + id: "id", + updatedAt: "updated_at", + reconcileBackoffUntil: "reconcile_backoff_until", + reconcileAttempts: "reconcile_attempts", + errorMessage: "error_message", + }, workflowRuns: { id: "id", updatedAt: "updated_at", state: "state" }, + prReviews: { + id: "id", + updatedAt: "updated_at", + reconcileBackoffUntil: "reconcile_backoff_until", + reconcileAttempts: "reconcile_attempts", + errorMessage: "error_message", + }, })); vi.mock("./task-service.js", () => ({ @@ -89,6 +102,7 @@ vi.mock("../logger.js", () => ({ // Import AFTER mocks import { executeAction } from "./reconcile-executor.js"; +import { GitHubApiError } from "./git-platform/github.js"; // ─── Fixtures ─── @@ -537,6 +551,68 @@ describe("reconcile-executor", () => { expect(outcome.status).toBe("error"); expect(mockTransitionTask).not.toHaveBeenCalled(); }); + + it("persists a cooldown and does not transition on a secondary rate limit", async () => { + mockGetPlatform.mockResolvedValue({ + platform: { mergePullRequest: mockMergePR }, + ri: { owner: "acme", repo: "repo" }, + }); + mockMergePR.mockRejectedValue( + new GitHubApiError(403, "You have exceeded a secondary rate limit", { + retryAfterMs: 120000, + }), + ); + const chain = chainable([{ id: "task-1" }]); + mockDbUpdate.mockReturnValue(chain); + + const snap = repoSnapshot(); + if (snap.run.kind !== "repo") throw new Error("fixture mismatch"); + snap.run.status.prUrl = "https://github.com/acme/repo/pull/42"; + snap.run.status.state = TaskState.PR_OPENED; + + const outcome = await executeAction( + { kind: "autoMergePr", reason: "auto_merge_ready" }, + snap, + ); + + // Applied (not error) so the worker won't fast-retry; run NOT advanced. + expect(outcome.status).toBe("applied"); + expect(outcome.reason).toContain("github_cooldown"); + expect(mockTransitionTask).not.toHaveBeenCalled(); + expect(chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + reconcileBackoffUntil: expect.anything(), + errorMessage: expect.stringContaining("secondary rate limit"), + }), + ); + // Single delayed wake honoring Retry-After (>=120s). + const enqueueArgs = mockEnqueueReconcile.mock.calls.at(-1)?.[1] as { delayMs: number }; + expect(enqueueArgs.delayMs).toBeGreaterThanOrEqual(120000); + }); + + it("persists a long cooldown for an auth (bad credentials) failure", async () => { + mockGetPlatform.mockResolvedValue({ + platform: { mergePullRequest: mockMergePR }, + ri: { owner: "acme", repo: "repo" }, + }); + mockMergePR.mockRejectedValue(new GitHubApiError(401, '{"message":"Bad credentials"}')); + mockDbUpdate.mockReturnValue(chainable([{ id: "task-1" }])); + + const snap = repoSnapshot(); + if (snap.run.kind !== "repo") throw new Error("fixture mismatch"); + snap.run.status.prUrl = "https://github.com/acme/repo/pull/42"; + snap.run.status.state = TaskState.PR_OPENED; + + const outcome = await executeAction( + { kind: "autoMergePr", reason: "auto_merge_ready" }, + snap, + ); + expect(outcome.status).toBe("applied"); + expect(outcome.reason).toContain("github_cooldown:auth"); + expect(mockTransitionTask).not.toHaveBeenCalled(); + const enqueueArgs = mockEnqueueReconcile.mock.calls.at(-1)?.[1] as { delayMs: number }; + expect(enqueueArgs.delayMs).toBeGreaterThanOrEqual(60_000); + }); }); describe("resumeAgent", () => { diff --git a/apps/api/src/services/reconcile-executor.ts b/apps/api/src/services/reconcile-executor.ts index b6113e5a..a15d3671 100644 --- a/apps/api/src/services/reconcile-executor.ts +++ b/apps/api/src/services/reconcile-executor.ts @@ -15,9 +15,11 @@ import { PrReviewState, PersistentAgentState, parsePrUrl, + parseIntEnv, } from "@optio/shared"; import * as taskService from "./task-service.js"; import { enqueueReconcile } from "./reconcile-queue.js"; +import { classifyGitHubFailure, type GitHubFailure } from "./git-platform/github.js"; import { logger } from "../logger.js"; /** @@ -475,6 +477,8 @@ async function applyAutoMergePr(snapshot: WorldSnapshot): Promise 0 ? "applied" : "stale"; } +// ── GitHub write cooldown ─────────────────────────────────────────────────── +// When a content-creating GitHub write (auto-merge, auto-submit-review) is +// blocked by a rate limit or an auth/permission failure, we must stop EVERY +// reconcile producer (event-driven, pr-watcher, resync) from re-issuing that +// write until a deadline — not just the one delayed retry job. We do that by +// persisting `reconcileBackoffUntil` on the run: the pure decision functions +// already short-circuit to a noop while it is in the future, so every producer +// becomes harmless. A single delayed reconcile is scheduled for the deadline. + +const GITHUB_RATE_LIMIT_MIN_BACKOFF_MS = 60_000; // GitHub requires >=60s when no Retry-After +const GITHUB_RATE_LIMIT_MAX_BACKOFF_MS = 30 * 60_000; // cap exponential growth + +function githubCooldownDeadlineMs( + failure: GitHubFailure, + attempts: number, + nowMs: number, +): { untilMs: number; incrementAttempts: boolean } { + switch (failure.kind) { + case "primary_rate_limit": + // Primary quota: wait until the reset, not the generic 60s floor. + return { + untilMs: failure.resetAtMs ?? nowMs + GITHUB_RATE_LIMIT_MIN_BACKOFF_MS, + incrementAttempts: false, + }; + case "secondary_rate_limit": { + if (failure.retryAfterMs != null) { + return { + untilMs: nowMs + Math.max(failure.retryAfterMs, GITHUB_RATE_LIMIT_MIN_BACKOFF_MS), + incrementAttempts: false, + }; + } + // No Retry-After: bounded exponential from 60s, escalating with attempts. + const backoff = Math.min( + GITHUB_RATE_LIMIT_MAX_BACKOFF_MS, + GITHUB_RATE_LIMIT_MIN_BACKOFF_MS * 2 ** Math.min(attempts, 6), + ); + return { + untilMs: nowMs + backoff + Math.floor(Math.random() * 5_000), + incrementAttempts: true, + }; + } + case "auth": + case "permission": + // Retrying the same credential/permission is futile — hold for a long + // window rather than looping. The classified errorMessage drives the + // GitHub credential/scope recovery UI. + return { + untilMs: nowMs + parseIntEnv("OPTIO_GITHUB_AUTH_COOLDOWN_MS", 60 * 60_000), + incrementAttempts: false, + }; + } +} + +/** + * Persist a monotonic (never-shortening) GitHub cooldown on a repo task or + * pr-review run, bumping updated_at so any in-flight CAS write becomes stale and + * cannot perform a concurrent GitHub write. `GREATEST` guarantees two concurrent + * failures keep the later deadline. + */ +async function persistGithubCooldown( + kind: "repo" | "pr-review", + id: string, + untilMs: number, + opts: { incrementAttempts: boolean; errorMessage: string }, +): Promise { + const until = new Date(untilMs); + if (kind === "repo") { + const set: Record = { + reconcileBackoffUntil: sql`GREATEST(COALESCE(${tasks.reconcileBackoffUntil}, ${until}), ${until})`, + errorMessage: opts.errorMessage, + updatedAt: new Date(), + }; + if (opts.incrementAttempts) set.reconcileAttempts = sql`${tasks.reconcileAttempts} + 1`; + await db.update(tasks).set(set).where(eq(tasks.id, id)); + return; + } + const set: Record = { + reconcileBackoffUntil: sql`GREATEST(COALESCE(${prReviews.reconcileBackoffUntil}, ${until}), ${until})`, + errorMessage: opts.errorMessage, + updatedAt: new Date(), + }; + if (opts.incrementAttempts) set.reconcileAttempts = sql`${prReviews.reconcileAttempts} + 1`; + await db.update(prReviews).set(set).where(eq(prReviews.id, id)); +} + +/** + * If `err` is a GitHub write block (rate limit / auth / permission), persist a + * cooldown, schedule a single delayed wake, and return an `applied` outcome so + * the worker does NOT additionally fast-retry. Returns null for transient or + * non-GitHub errors so the caller falls through to its normal error handling. + */ +async function handleGithubWriteBlock( + snapshot: WorldSnapshot, + err: unknown, +): Promise { + const failure = classifyGitHubFailure(err); + if (!failure) return null; + const run = snapshot.run; + if (run.kind !== "repo" && run.kind !== "pr-review") return null; + const nowMs = Date.now(); + const attempts = run.status.reconcileAttempts ?? 0; + const { untilMs, incrementAttempts } = githubCooldownDeadlineMs(failure, attempts, nowMs); + await persistGithubCooldown(run.kind, run.ref.id, untilMs, { + incrementAttempts, + errorMessage: err instanceof Error ? err.message : String(err), + }); + await enqueueReconcile(run.ref, { + reason: `github_cooldown:${failure.kind}`, + delayMs: Math.max(0, untilMs - nowMs), + }); + logger.warn( + { runId: run.ref.id, kind: failure.kind, untilMs }, + "GitHub write blocked — persisted reconcile cooldown", + ); + return { status: "applied", reason: `github_cooldown:${failure.kind}` }; +} + // ── PR Review applicators ────────────────────────────────────────────────── async function applyPrReviewTransition( @@ -741,6 +862,8 @@ async function applySubmitReview(snapshot: WorldSnapshot): Promise - {/* Auth banner — same recovery surface as task/review pages */} - {classifiedError?.category === "auth" && ( + {/* GitHub credential recovery — distinct from the Claude token banner */} + {classifiedError?.recovery === "github-token" && (
- +
)} - {/* Classified error banner — matches /tasks/[id] and /reviews/[id] */} - {classifiedError && classifiedError.category !== "auth" && ( -
+ {/* Claude auth banner — only for non-GitHub auth (Claude/OpenAI) errors */} + {classifiedError?.category === "auth" && !classifiedError.recovery && ( +
-
- -
-
-

{classifiedError.title}

-

{classifiedError.description}

-
- {classifiedError.remedy && ( -
-
- Suggested fix -
-
-                      {classifiedError.remedy}
-                    
+ +
+
+ )} + + {/* Classified error panel — rate-limit, github-permission, and all + non-auth errors (no token form). */} + {classifiedError && + classifiedError.recovery !== "github-token" && + !(classifiedError.category === "auth" && !classifiedError.recovery) && ( +
+
+
+ +
+
+

{classifiedError.title}

+

{classifiedError.description}

- )} -
- {classifiedError.retryable && canRetry && ( - + {classifiedError.remedy && ( +
+
+ Suggested fix +
+
+                        {classifiedError.remedy}
+                      
+
)} - - {classifiedError.category} - +
+ {classifiedError.retryable && canRetry && ( + + )} + + {classifiedError.category} + +
-
- )} + )} {/* Main content: log column + timeline sidebar — mirrors /tasks/[id] */}
diff --git a/apps/web/src/app/tasks/[id]/page.tsx b/apps/web/src/app/tasks/[id]/page.tsx index 0254f0a8..5d54e2d0 100644 --- a/apps/web/src/app/tasks/[id]/page.tsx +++ b/apps/web/src/app/tasks/[id]/page.tsx @@ -12,7 +12,7 @@ import { DetailHeader } from "@/components/detail-header"; import { PrStatusBar } from "@/components/pr-status-bar"; import { ChatComposer } from "@/components/chat-box"; import { StateBadge } from "@/components/state-badge"; -import { TokenRefreshBanner } from "@/components/token-refresh-banner"; +import { TokenRefreshBanner, GitHubTokenBanner } from "@/components/token-refresh-banner"; import { api } from "@/lib/api-client"; import { ErrorBoundary } from "@/components/error-boundary"; import { classifyError } from "@optio/shared"; @@ -502,7 +502,16 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin (isTerminal || task.state === "needs_attention") && (() => { const classified = classifyError(task.errorMessage); - if (classified.category === "auth") { + if (classified.recovery === "github-token") { + return ( +
+
+ +
+
+ ); + } + if (classified.category === "auth" && !classified.recovery) { return (
diff --git a/packages/shared/src/error-classifier.test.ts b/packages/shared/src/error-classifier.test.ts index dfc847f2..298739d0 100644 --- a/packages/shared/src/error-classifier.test.ts +++ b/packages/shared/src/error-classifier.test.ts @@ -157,4 +157,51 @@ describe("classifyError", () => { expect(result.category).toBe("auth"); expect(result.title).toBe("Authentication token expired"); }); + + it("classifies GitHub secondary rate limit as non-retryable with rate-limit recovery", () => { + const result = classifyError( + "GitHub API error 403: You have exceeded a secondary rate limit and have been temporarily blocked from content creation.", + ); + expect(result.category).toBe("auth"); + expect(result.title).toBe("GitHub secondary rate limit"); + expect(result.retryable).toBe(false); + expect(result.recovery).toBe("rate-limit"); + }); + + it("classifies GitHub bad credentials as non-retryable with github-token recovery", () => { + const result = classifyError('GitHub API error 401: {"message":"Bad credentials"}'); + expect(result.title).toBe("GitHub credentials invalid"); + expect(result.retryable).toBe(false); + expect(result.recovery).toBe("github-token"); + }); + + it("classifies GitHub permission error as non-retryable with github-permission recovery", () => { + const result = classifyError( + 'GitHub API error 403: {"message":"Resource not accessible by integration"}', + ); + expect(result.title).toBe("GitHub permission denied"); + expect(result.retryable).toBe(false); + expect(result.recovery).toBe("github-permission"); + }); + + it("does NOT classify unrelated 'bad credentials' (no GitHub wrapper) as a GitHub error", () => { + const result = classifyError("Internal connector rejected the request: bad credentials"); + expect(result.title).not.toBe("GitHub credentials invalid"); + expect(result.recovery).toBeUndefined(); + expect(result.category).toBe("unknown"); + expect(result.retryable).toBe(true); + }); + + it("does NOT classify an unrelated 'resource not accessible' string as a GitHub error", () => { + const result = classifyError("resource not accessible by integration"); + expect(result.title).not.toBe("GitHub permission denied"); + expect(result.recovery).toBeUndefined(); + }); + + it("still treats a non-GitHub provider rate limit as retryable (generic rule)", () => { + const result = classifyError("API returned 429 too many requests"); + expect(result.title).toBe("API rate limit exceeded"); + expect(result.retryable).toBe(true); + expect(result.recovery).toBeUndefined(); + }); }); diff --git a/packages/shared/src/error-classifier.ts b/packages/shared/src/error-classifier.ts index 4a8041e8..79e4c8da 100644 --- a/packages/shared/src/error-classifier.ts +++ b/packages/shared/src/error-classifier.ts @@ -4,12 +4,71 @@ export interface ClassifiedError { description: string; remedy: string; retryable: boolean; + /** + * Stable discriminator for which provider-specific recovery surface the UI + * should render. Absent for generic errors. Set explicitly by classifiers so + * renderers never have to infer the provider from display-title text. + */ + recovery?: "claude-token" | "github-token" | "github-permission" | "rate-limit"; } const ERROR_PATTERNS: Array<{ pattern: RegExp; classify: (match: RegExpMatchArray) => ClassifiedError; }> = [ + // ── GitHub write-path failures ────────────────────────────────────────── + // Must precede the generic rate-limit rule and the LLM auth rule. Scoped to + // the verified `GitHub API error : ` wrapper that GitHubPlatform + // produces, so an unrelated provider/service emitting e.g. "bad credentials" + // is NOT misclassified as a permanent GitHub failure. `recovery` tells the UI + // which provider-specific recovery surface to render. + { + pattern: /github api error \d+:.*(secondary rate limit|abuse detection)/is, + classify: () => ({ + category: "auth", + title: "GitHub secondary rate limit", + description: + "GitHub temporarily blocked writes (PRs, comments, reviews, merges) because too many " + + "content-creating requests were sent too quickly. This is a secondary rate limit, separate " + + "from the primary hourly quota.", + remedy: + "Stop retrying immediately. Wait at least 60 seconds (honor the Retry-After header if present) " + + "before further GitHub writes, and reduce concurrent agents/auto-resumes if it recurs.", + retryable: false, + recovery: "rate-limit", + }), + }, + { + pattern: /github api error \d+:.*bad credentials/is, + classify: () => ({ + category: "auth", + title: "GitHub credentials invalid", + description: + "GitHub rejected the token (HTTP 401 Bad credentials). The configured GitHub token is missing, " + + "expired, or revoked. Retrying with the same token will keep failing.", + remedy: + "Reconnect the GitHub App or refresh the GITHUB_TOKEN secret. Do not retry until the credential " + + "is replaced.", + retryable: false, + recovery: "github-token", + }), + }, + { + pattern: + /github api error \d+:.*resource not accessible by (integration|personal access token|fine-grained personal access token)/is, + classify: () => ({ + category: "auth", + title: "GitHub permission denied", + description: + "The GitHub token authenticated but lacks permission for this operation (HTTP 403 Resource not " + + "accessible). The App installation or token is missing a required scope/permission.", + remedy: + "Grant the missing permission to the GitHub App installation (or PAT scopes) — e.g. Pull requests: " + + "write, Issues: write, Contents: write — then retry.", + retryable: false, + recovery: "github-permission", + }), + }, { pattern: /ErrImageNeverPull|InvalidImageName/i, classify: (match) => {