diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index 0c1d83de7b7..111be4faf60 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -301,6 +301,39 @@ describeEmbeddedPostgres("reportRunFailure", () => { expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" })); }); + function providerQuotaRun(overrides: Partial = {}) { + return buildRun({ + error: "ACP agent reported a terminal limit failure.\nYou've hit your session limit · resets 2am (UTC)", + errorCode: "provider_quota", executionStage: "dispatching", exitCode: 1, signal: null, + resultJson: { errorFamily: "provider_quota", stopReason: "adapter_failed" }, ...overrides, + }); + } + + it.each(["claude_local", "codex_local"])("keeps a classified %s provider quota wait local", async (adapterType) => { + await seedCompanyAndAgent({ adapterType }); + const [run] = await db.insert(heartbeatRuns).values(providerQuotaRun()).returning(); + const before = structuredClone(run); + for (const phase of [undefined, "execute"] as const) await reportRunFailure(db, run!, { phase }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(await db.select().from(heartbeatRuns)).toEqual([before]); + }); + + it.each([ + { status: "timed_out" }, { status: "cancelled", startedAt: new Date(0) }, { signal: "SIGTERM" }, + { errorCode: "adapter_failed" }, { errorCode: "claude_transient_upstream" }, + { resultJson: {} }, { resultJson: null }, { resultJson: { errorFamily: "transient_upstream" } }, + ])("reports a provider quota outcome without matching adapter evidence: %j", async (overrides) => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerQuotaRun(overrides), { phase: "execute" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it("keeps a provider quota code reportable during setup", async () => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerQuotaRun(), { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + function aiSelectionRun(overrides: Partial = {}) { return buildRun({ errorCode: "configuration_incomplete", diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index a059bf523d0..60e6309e541 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -164,6 +164,16 @@ function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportO (connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error"); } +/** + * The adapter proved the user's provider account is out of quota, and recovery + * waits for its reset. Both fields come from the adapter's own classifier. + */ +function isProviderQuotaFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + return run.status === "failed" && run.errorCode === "provider_quota" && + asRecord(run.resultJson)?.errorFamily === "provider_quota" && + options.phase !== "setup" && run.signal == null; +} + /** * Report a terminal run failure to Sentry. Returns at once for any status * other than failures and unexpected started cancellations. Never throws — a Sentry failure or a @@ -182,6 +192,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve(); if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve(); if (isGitConnectionFailure(run, options)) return Promise.resolve(); + if (isProviderQuotaFailure(run, options)) return Promise.resolve(); const runStatus = run.status; const report = captureTerminalRunFailure(db, run, runStatus, options); pendingRunFailureReports.add(report);