Files
PaperClipAI/server/src/services/native-runtime/native-provider-overload.integration.test.ts
T
Devin FoleyandClaude Opus 5.5 2a221e8e80 fix(sentry): keep scheduled native model capacity retries out of run-failure reports (#18295)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The run-failure reporter sends terminal run failures to Sentry when
an operator sets `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected`
> - In `unexpected` mode, the reporter skips failures that a bounded
automatic retry already owns. The Codex ACP model capacity failure
(`codex_transient_upstream`) is one of these
> - The native runner has the same capacity failure: Codex
`serverOverloaded` ("Selected model is at capacity"). Native
finalization schedules a bounded automatic retry for it (#15347)
> - The reporter has no matching exclusion for the native path. Each
scheduled capacity retry also sends a Sentry error, even when the retry
recovers the task
> - This pull request skips the report only when the native failure has
typed capacity evidence, a committed retry decision, and the retry run
that the decision created
> - The benefit is that Sentry shows capacity failures only when
automatic recovery stops (retries are exhausted, held, or not scheduled)

## Linked Issues or Issue Description

Refs #15347 (native capacity retry). Refs #16407 (Codex ACP capacity
exclusion).

**What happened?**
A native Codex run failed with "Selected model is at capacity. Please
try a different model." (`native_provider_overloaded`). Native
finalization scheduled the automatic capacity retry. The retry run
succeeded and the task completed. The run-failure reporter still sent a
Sentry error event for the first run.

**Expected behavior**
When the automatic capacity retry owns recovery, the run failure stays
in the local run log and does not go to Sentry. This is the same as the
Codex ACP capacity path. When retries are exhausted or held, or when no
retry is scheduled, the failure goes to Sentry.

**Steps to reproduce**
1. Set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` with a backend Sentry
DSN.
2. Run a native Codex agent turn that ends with a Codex
`serverOverloaded` terminal error.
3. See that native finalization creates a `scheduled_retry` run with
`scheduledRetryReason=native_provider_overloaded`.
4. See that Sentry also receives a run-failure event for the failed run.

## What Changed

- `server/src/services/run-failure-report.ts`: add
`isScheduledNativeCapacityRetry`. In `unexpected` mode it skips the
report only when all of these are true:
- the run is native, `failed`, has
`errorCode=native_provider_overloaded`, and has a native task
(`nativeIssueId`);
- `resultJson.nativeProviderFailure` is the closed
`codex`/`server_overloaded` diagnostic;
- `resultJson.finalizationPhase` is `committed` and
`finalizationReasonCode` is `native_provider_overloaded`;
  - the report is not from setup, and the run has no signal;
- a successor run for the same task exists with `retryOfRunId=run.id`
and `scheduledRetryReason=native_provider_overloaded`. The same status
transaction creates this run.
- The successor lookup filters on `company_id`, `context_snapshot ->>
'issueId'` and `created_at >= run.createdAt`, so the existing
`heartbeat_runs_company_ctx_issue_created_idx` bounds it to that task's
runs since the failure. No migration is needed.
- A failed read of the retry evidence logs a warning and reports the
failure.
- `doc/observability.md`: document the new exclusion in the run failure
context section.
- Unit tests: the exclusion, and twelve cases that must still report
(exhausted, held, no retry run, another retry lane, successor for
another task, run with no native task, finalization not committed, wrong
or missing diagnostic, legacy run, signal, timeout). Also setup phase,
`all` mode, and an unreadable retry record.
- Integration test (real native finalizer): no Sentry event for retries
1 and 2, one event when retries are exhausted, and one event when
reassignment prevents a retry.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/services/__tests__/run-failure-report.test.ts
src/services/native-runtime/native-provider-overload.integration.test.ts
src/services/native-runtime/native-provider-failure.test.ts
src/services/__tests__/status-arbiter.test.ts`: 359 tests pass.
- Red/green: before the fix, the new unit test captured 2 events, and
the integration test captured an event on the first scheduled retry.
After the fix, both pass.
- `EXPLAIN` on the migrated schema: `Index Scan using
heartbeat_runs_company_ctx_issue_created_idx` with company, context
issue and created time in `Index Cond`.
- CI provides the full workspace typecheck.

## Risks

- Low risk. The change applies only in `unexpected` mode and only to
native runs with typed capacity evidence and an existing retry run.
`all` mode still reports every failure.
- The check adds one index-bounded read of `heartbeat_runs` for runs
that match the in-memory capacity checks. Other runs do not do this
read.
- If a scheduled retry later fails, that retry run reports its own
outcome. When retries are exhausted, the final failure reports.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic. Agentic coding with
tool use (shell, file edits, test execution) and extended thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 05:42:43 -07:00

196 lines
17 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, agentWakeupRequests, companies, completionContracts, createDb, environmentLeases, heartbeatRuns, issueComments, issueRecoveryActions, issues, nativeRunFinalizations, statusDecisionEffects, statusDecisions } from "@paperclipai/db";
import type { ControlPlanePort, NativeExecutionInputV1, PrpEvent, PrpTerminalState } from "@paperclipai/paperclip-runner";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { CONTROL_PLANE_CONFORMANCE_RESULT } from "../../vendor/paperclip-runner/testing.js";
const provider = vi.hoisted(() => ({ execute: vi.fn() }));
const sentry = vi.hoisted(() => ({ captureRunFailure: vi.fn() }));
vi.mock("../../sentry.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../sentry.js")>()), captureRunFailure: sentry.captureRunFailure,
}));
vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../vendor/paperclip-runner/index.js")>()), executeNativeSession: provider.execute,
}));
import { executePaperclipNativeSession } from "./native-session-executor.js";
import { buildNativeCompletionContract } from "./completion-contracts.js";
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
import { finalizeNativeRun } from "./native-run-finalizer.js";
import { readPersistedNativeProviderFailure } from "./native-provider-failure-evidence.js";
import { NATIVE_PROVIDER_OVERLOADED_MESSAGE } from "./native-provider-failure.js";
import { heartbeatService } from "../heartbeat.js";
import { createPostgresWakeQueueAdapter } from "../../modules/wake-queue/adapters/postgres.js";
import { createReleaseIssueExecution } from "../../modules/wake-queue/application/use-cases.js";
import { buildExecutionContinuation } from "../execution-continuation.js";
import { waitForPendingRunFailureReports } from "../run-failure-report.js";
describe("durable native provider capacity retry", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
const companyId = randomUUID(), agentId = randomUUID(), otherAgentId = randomUUID();
beforeAll(async () => {
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
temporary = await startEmbeddedPostgresTestDatabase("native-capacity-retry-");
db = createDb(temporary.connectionString);
await db.insert(companies).values({ id: companyId, name: "Capacity regression", issuePrefix: "CAP" });
await db.insert(agents).values([
{ id: agentId, companyId, name: "Capacity worker", status: "active", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "test-model" } },
{ id: otherAgentId, companyId, name: "New owner", status: "active", adapterType: "paperclip_runner" },
]);
});
afterAll(async () => { vi.unstubAllEnvs(); await temporary?.cleanup(); });
async function acceptCapacityFailure(runId: string, issueId: string, mutateOwner = false) {
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
const [run] = await db.update(heartbeatRuns).set({ status: "running", runtimeMode: "native", nativeIssueId: issueId, error: null, errorCode: null }).where(eq(heartbeatRuns.id, runId)).returning();
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const native = await prepareNativeHeartbeatRun({ db, run, issue, environmentLeaseId: randomUUID() });
const [contract] = await db.select().from(completionContracts).where(eq(completionContracts.issueId, issueId));
const contractInput = buildNativeCompletionContract(issue, { revision: contract.revision });
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "observed" });
const execution: NativeExecutionInputV1 = {
schema: "paperclip.native-execution-input.v1", binding: { companyId, runId, issueId, agentId, executionWorkspaceId: runId },
provider: { kind: "codex", model: "test-model" },
task: { identifier: issueId, title: issue.title, description: null, prompt: "Continue the task using its history.", workMode: "standard" },
workspace: { cwd: "/tmp", repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: native.normalizedSessionId, driverKind: "codex_app_server", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
completionContract: { id: contract.id, sha256: contract.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1", contract: contractInput },
interactionResponses: [], credentialBindings: [],
};
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: execution } }).where(eq(heartbeatRuns.id, runId));
const event: PrpEvent = {
schema: "paperclip.prp.event.v1", runId, normalizedSessionId: native.normalizedSessionId, turnId: native.turnId,
sourceInstanceId: native.runnerInstanceId, sourceEventId: "overload", sourceSeq: 1, sourceKind: "runner", eventType: "turn.failed", schemaVersion: 1, priority: 0, emittedAt: new Date().toISOString(),
payload: { status: "failed", error: { codexErrorInfo: "serverOverloaded", message: NATIVE_PROVIDER_OVERLOADED_MESSAGE } },
};
const terminal: PrpTerminalState = { schema: "paperclip.prp.terminal.v1", turnTerminalState: "failed", runTerminalState: "failed", reportedWorkDisposition: "needs_review" };
const result = { ...CONTROL_PLANE_CONFORMANCE_RESULT, reportedWorkDisposition: "needs_review" as const,
summary: "I started investigating the task.", completionClaim: { ...CONTROL_PLANE_CONFORMANCE_RESULT.completionClaim, contractRevision: contractInput.revision } };
provider.execute.mockReset().mockImplementation(async (options: { controlPlane: ControlPlanePort }) => {
await options.controlPlane.openRun({ identity: { companyId, issueId, runId, agentId, sessionId: native.normalizedSessionId }, backendKind: "mock", sourceInstanceId: native.runnerInstanceId });
await options.controlPlane.appendEvent(event);
await options.controlPlane.completeRun({ result, terminal, turnId: native.turnId });
return { result, terminal, turnId: native.turnId, normalizedSessionId: native.normalizedSessionId, providerSessionId: "fixture-session", driverKind: "codex_app_server", driverVersion: "fixture", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
});
const unexpected = async () => { throw new Error("Unexpected provider call"); };
const adapter = await executePaperclipNativeSession({ db, execution, runnerInstanceId: native.runnerInstanceId, backend: { descriptor: unexpected, openSession: unexpected } });
expect(adapter).toMatchObject({ errorCode: "native_provider_overloaded", errorMessage: NATIVE_PROVIDER_OVERLOADED_MESSAGE });
const [persisted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(persisted.errorCode).toBeNull(); // Restart before adapter metadata is saved.
expect(await readPersistedNativeProviderFailure(db, persisted, native.turnId, terminal)).toMatchObject({ errorCode: "native_provider_overloaded" });
expect(await readPersistedNativeProviderFailure(db, { ...persisted, runnerProfileJson: null }, native.turnId, terminal)).toBeNull();
expect(await readPersistedNativeProviderFailure(db, persisted, "different-turn", terminal)).toBeNull();
if (mutateOwner) await db.update(issues).set({ assigneeAgentId: otherAgentId, executionRunId: null }).where(eq(issues.id, issueId));
for (let replay = 0; replay < 2; replay++) await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
expect(provider.execute).toHaveBeenCalledTimes(1);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]).toMatchObject({ status: "failed", error: NATIVE_PROVIDER_OVERLOADED_MESSAGE, errorCode: "native_provider_overloaded" });
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, runId))).toHaveLength(1);
}
it("retries after one and two minutes across restart/replay, then stops", async () => {
const issueId = randomUUID(), originalRunId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Capacity task", status: "in_progress", assigneeAgentId: agentId, responsibleUserId: "capacity-owner" });
await db.insert(heartbeatRuns).values({ id: originalRunId, companyId, agentId, responsibleUserId: "capacity-owner", contextSnapshot: { issueId, wakeCommentId: "original-message" } });
let runId: string = originalRunId;
const unexpected = vi.fn(async () => { throw new Error("Unexpected recovery"); });
const release = createReleaseIssueExecution({
issueLock: createPostgresWakeQueueAdapter(db, { resolveResponsibleUserId: unexpected, getRoutineEnv: unexpected, resolveSessionBeforeForWakeup: unexpected }),
recovery: { escalateStrandedAssignedIssue: unexpected, escalateStrandedRecoveryIssueInPlace: unexpected },
});
for (let failure = 0; failure <= 2; failure++) {
const before = Date.now();
sentry.captureRunFailure.mockClear();
await acceptCapacityFailure(runId, issueId);
await waitForPendingRunFailureReports();
const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.retryOfRunId, runId)));
if (failure === 2) {
expect(successors).toHaveLength(0);
// Exhaustion ends automatic recovery, so Sentry receives the failure.
expect(sentry.captureRunFailure).toHaveBeenCalledOnce();
expect(sentry.captureRunFailure.mock.calls[0]![0]).toMatchObject({ runId, runStatus: "failed" });
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ status: "blocked", unblockDescriptor: { action: expect.stringContaining("Automatic retries exhausted") } });
break;
}
expect(successors).toHaveLength(1);
const retry = successors[0];
expect(retry).toMatchObject({ status: "scheduled_retry", scheduledRetryAttempt: failure + 1, scheduledRetryReason: "native_provider_overloaded", error: NATIVE_PROVIDER_OVERLOADED_MESSAGE,
contextSnapshot: { issueId, forceFreshSession: true, executionRetryAccounting: { version: 1, failureRetries: failure + 1, maxTurnContinuations: 0 } } });
expect(retry.contextSnapshot).not.toHaveProperty("wakeCommentId");
// The scheduled retry owns recovery; this failure stays out of Sentry.
expect(sentry.captureRunFailure).not.toHaveBeenCalled();
expect(retry.scheduledRetryAt!.getTime() - before).toBeGreaterThanOrEqual(60_000 * 2 ** failure);
expect(retry.scheduledRetryAt!.getTime() - Date.now()).toBeLessThanOrEqual(60_000 * 2 ** failure);
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.runId, retry.id));
expect(wakes).toHaveLength(1);
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, runId));
expect(await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.decisionId, decision.id))).toMatchObject([{ effectKind: "schedule_retry", targetType: "heartbeat_run", targetId: retry.id }]);
expect((await release({ companyId, runId, now: new Date() })).outcome.kind).toBe("released");
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0);
const schedulerAfterRestart = heartbeatService(db, { runtimeEnv: {} });
expect(await schedulerAfterRestart.promoteDueScheduledRetries(new Date())).toMatchObject({ promoted: 0 });
expect(await schedulerAfterRestart.promoteDueScheduledRetries(retry.scheduledRetryAt!)).toMatchObject({ promoted: 1, runIds: [retry.id] });
runId = retry.id;
}
expect(unexpected).not.toHaveBeenCalled();
});
it("uses failed-run history without lending a resumed task's consumed continuation receipt", async () => {
const issueId = randomUUID(), runId = randomUUID(), olderRunId = randomUUID(), commentId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Resumed capacity task", status: "in_progress", assigneeAgentId: agentId, responsibleUserId: "capacity-owner" });
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: "capacity-owner", authorType: "user", body: "Continue the investigation." });
const consumedContext = { issueId, wakeCommentId: commentId, wakeCommentIds: [commentId],
explicitUserContinuation: { previousRunId: olderRunId, commentId },
previousRunId: olderRunId, resumeIntent: "continue", paperclipTurnContext: "Previously delivered context" };
await db.insert(heartbeatRuns).values([
{ id: olderRunId, companyId, agentId, status: "failed", contextSnapshot: { issueId } },
{ id: runId, companyId, agentId, responsibleUserId: "capacity-owner", contextSnapshot: consumedContext },
]);
await acceptCapacityFailure(runId, issueId);
const [retry] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId));
expect(retry.contextSnapshot).not.toHaveProperty("explicitUserContinuation");
expect(retry.contextSnapshot).not.toHaveProperty("wakeCommentId");
expect(retry.contextSnapshot).not.toHaveProperty("resumeIntent");
expect(retry.contextSnapshot).not.toHaveProperty("paperclipTurnContext");
const continuation = await buildExecutionContinuation({ db, companyId, issueId, agentId,
runId: retry.id, context: retry.contextSnapshot!, summary: null, exposeLowTrustRaw: false });
expect(continuation).toMatchObject({ trigger: { sourceRunId: runId }, objective: "Continue the investigation.",
messages: [expect.objectContaining({ id: commentId, body: "Continue the investigation." })] });
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0].contextSnapshot)
.toEqual(consumedContext);
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, retry.id));
});
it("does not claim a capacity retry before predecessor cleanup settles", async () => {
const issueId = randomUUID(), runId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Cleanup capacity task", status: "in_progress", assigneeAgentId: agentId, responsibleUserId: "capacity-owner" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId: "capacity-owner", contextSnapshot: { issueId } });
await acceptCapacityFailure(runId, issueId);
const [retry] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId));
await db.update(issues).set({ executionRunId: null, checkoutRunId: null }).where(eq(issues.id, issueId));
const scheduler = heartbeatService(db, { runtimeEnv: {} });
expect(await scheduler.promoteDueScheduledRetries(retry.scheduledRetryAt!)).toMatchObject({ promoted: 1 });
await db.insert(environmentLeases).values({ companyId, heartbeatRunId: runId, provider: "local", status: "pending_cleanup", cleanupStatus: "failed" });
await scheduler.resumeQueuedRuns();
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, retry.id)))[0].status).toBe("queued");
expect(provider.execute).toHaveBeenCalledTimes(1);
// Do not leave a runnable fixture behind for the next case.
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, retry.id));
});
it("does not retry or change the task after reassignment", async () => {
const issueId = randomUUID(), runId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Reassigned capacity task", status: "in_progress", assigneeAgentId: agentId, responsibleUserId: "capacity-owner" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId: "capacity-owner", contextSnapshot: { issueId } });
sentry.captureRunFailure.mockClear();
await acceptCapacityFailure(runId, issueId, true);
await waitForPendingRunFailureReports();
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
// No retry owns this failure, so it remains reportable.
expect(sentry.captureRunFailure).toHaveBeenCalledOnce();
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ status: "in_progress", assigneeAgentId: otherAgentId });
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).filter(wake => wake.payload?.issueId === issueId)).toHaveLength(0);
});
});