diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index f2c833557d1..2ce0d6891fe 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -430,6 +430,42 @@ describeEmbeddedPostgres("reportRunFailure", () => { expect(mockCaptureRunFailure).toHaveBeenCalledTimes(2); }); + function providerAccessRun(overrides: Partial = {}) { + return buildRun({ + error: "ACP agent reported a terminal access failure.\nInvalid API key · Fix external API key", + errorCode: "claude_auth_required", executionStage: "dispatching", exitCode: 1, signal: null, + resultJson: { + stopReason: "adapter_failed", + terminalSessionFailure: { category: "access", title: "Invalid API key · Fix external API key" }, + }, + ...overrides, + }); + } + + it.each(["claude_auth_required", "acpx_auth_required"])("keeps a typed ACP provider access failure (%s) local", async (errorCode) => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + for (const phase of [undefined, "execute"] as const) await reportRunFailure(db, providerAccessRun({ errorCode }), { phase }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + }); + + it.each([ + { status: "timed_out" }, { signal: "SIGTERM" }, { errorCode: "acpx_turn_failed" }, { errorCode: "adapter_failed" }, + { resultJson: {} }, { resultJson: null }, { resultJson: { terminalSessionFailure: { category: "service" } } }, + { resultJson: { terminalSessionFailure: "access" } }, + ])("reports a provider auth outcome without typed ACP access evidence: %j", async (overrides) => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerAccessRun(overrides), { phase: "execute" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it("reports a typed ACP provider access failure during setup or in all mode", async () => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerAccessRun(), { phase: "setup" }); + vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "all"); + await reportRunFailure(db, providerAccessRun(), { phase: "execute" }); + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(2); + }); + function restartInterruptedSetupRun(overrides: Partial = {}) { return buildRun({ error: "Process lost -- server may have restarted; retrying once", diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index 46f3c090d5c..c912b19a0d3 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -236,6 +236,19 @@ function isCodexAcpModelCapacityFailure(run: HeartbeatRun, options: RunFailureRe options.phase !== "setup" && run.signal == null; } +const providerAuthErrorCodes = new Set(["claude_auth_required", "acpx_auth_required"]); + +/** + * The ACP agent typed its terminal failure as `access` (provider auth_required), + * and the adapter kept it on the sign-in recovery path. The run blocks and the + * user gets a reconnect request, so Sentry adds no signal. + */ +function isAcpProviderAccessFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + return run.status === "failed" && run.errorCode != null && providerAuthErrorCodes.has(run.errorCode) && + asRecord(asRecord(run.resultJson)?.terminalSessionFailure)?.category === "access" && + options.phase !== "setup" && run.signal == null; +} + /** * The Grok endpoint refused every work wake with an HTTP 4xx, so the offer * expired unaccepted. Durable delivery receipts prove the external cause. A @@ -284,6 +297,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR isGitConnectionFailure(run, options) || isProviderQuotaFailure(run, options) || isCodexAcpModelCapacityFailure(run, options) || + isAcpProviderAccessFailure(run, options) || isRestartInterruptedSetup(run) )) return Promise.resolve(); const runStatus = run.status;