fix(sentry): report a native retry failure only when the retry ends (#16865)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server sends a Sentry `run_failure` event when an agent run
reaches a terminal failure.
> - A native run that waits for a same-run retry is stored as `failed`
with `nativePhase: "retryable_failure"`. That state is not terminal.
> - `reportRunFailure` reported that pending state. Runs that later
recovered, for example after a workspace copy-back retry, still sent a
run failure event.
> - These events make one catch-all Sentry group noisy and hide the real
terminal failures in it.
> - This pull request skips the pending retry state and reports once
when the retry ends as a terminal failure.
> - The benefit is that run failure events match runs that really
failed.

## Linked Issues or Issue Description

**What happened?**

Two staging runs had a workspace copy-back failure during settlement
(`Daytona syncOut tar command failed ... No space left on device`). The
executor stored each run as `failed` with `nativePhase:
"retryable_failure"` and `errorCode: native_session_interrupted`. Sentry
received a `run_failure` event for each run. About two minutes later,
the copy-back retry succeeded and both runs finished as `succeeded` with
`workspaceFinalizeStatus: "succeeded"`.

**Expected behavior**

A run that waits for a same-run retry must not send a terminal run
failure event. If the retry ends as a terminal failure, the server must
send one event at that time.

**Steps to reproduce**

1. Set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected`.
2. Start a native run that fails retryably. For example, make the
workspace copy-back fail once.
3. See the `run_failure` event in Sentry, and then see the run finish as
`succeeded` after the retry.

## What Changed

- `reportRunFailure` returns without a report for a native run that is
`failed` with `nativePhase: "retryable_failure"`.
- New `isNewTerminalRunFailure(before, after)`: a status change counts
as before. A failed run that leaves the pending retry state but stays
`failed` also counts.
- Three terminal writers can keep the status `failed` → `failed`. They
now use `isNewTerminalRunFailure`, so they still report once:
  - the resume reconciler's ambiguous-state write;
  - the restart recovery `blocked` write;
  - the committed-result projection (`projectCommittedRun`).
- Result-less retries already reset the run to `running` before they
start, so their terminal write still reports through the existing
status-change guard.
- `doc/observability.md` describes the new rule.

## Verification

- `pnpm exec vitest run
src/services/__tests__/run-failure-report.test.ts
src/__tests__/run-failure-sentry.test.ts
src/__tests__/native-session-resumption.test.ts
src/services/native-runtime/native-run-finalizer-telemetry.test.ts
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
src/__tests__/heartbeat-status-preserving-telemetry.test.ts
src/services/execution-control-reconciliation.test.ts` (from `server/`):
7 files, 363 passed, 9 skipped.
- New tests:
- `run-failure-report.test.ts`: a pending native retry does not report,
and a `terminal_failure` run does. Unit cases cover
`isNewTerminalRunFailure`.
- `native-session-resumption.test.ts`: a sweep that ends an unreported
pending retry sends one event.
- `native-run-finalizer-telemetry.test.ts`: a committed failure for a
pending-retry row sends one event, and a replay sends none.
- `native-runner-restart-recovery.integration.test.ts`: a restart
recovery `blocked` write for a pending-retry row sends one event.
- I removed each of the three writer changes one at a time. The matching
new test failed each time (`expected [] to have a length of 1` / `to
deeply equal [ [ ObjectContaining{…} ] ]`).
- I did not run the full server `tsc --noEmit` locally. The local
container has a 4 GiB memory limit, and the process was killed (exit
137). CI runs the typecheck.

## Risks

- A run that stays in the pending retry state and never resumes no
longer sends a run failure event. The run stays visible on the board as
failed with a scheduled retry. A later terminal write still reports it.
- The finalizer's `failed` + `retryable_failure` write for a failed
provider result also stops reporting. The terminal write that ends that
retry reports instead: retry exhaustion in `recordRetryableFailure`
reports unconditionally, and commit goes through `finalizeNativeRun` or
`projectCommittedRun`.
- A run that was reported in the pending state before this change can
report once more when it becomes terminal after deploy. This affects
only runs in flight during the upgrade.
- There are no schema, migration, or API changes. Run status, retries,
and recovery do not change.

> 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

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, code search, test runs) and a read-only code
exploration subagent. Context window: the standard window for that
model.

## 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
- [ ] 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: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 09:29:50 -07:00
1 parent 2cbca4ceb0
commit 36e4c6657f
9 files changed
+155 -10

No files matched your search

+3 -1
View File
@@ -715,7 +715,9 @@ backend Sentry DSN and the audited SDK. Set `PAPERCLIP_SENTRY_RUN_FAILURES` to:
Missing, blank, or unrecognized values keep run reporting off. Expected operator
stops, shutdown interruptions, successful runs, and queued work do not report in
either enabled mode. The mode does not change run status, retries, recovery,
either enabled mode. A native run that is `failed` while it waits for a same-run
retry (`nativePhase` `retryable_failure`) does not report either. It reports once
if a later write ends the retry as a terminal failure. The mode does not change run status, retries, recovery,
Telemetry, or OpenTelemetry export. It is process-wide, so an operator can enable
it on one staging instance without enabling it on other deployments. Restart the
server after changing deployment environment variables.
@@ -622,6 +622,46 @@ describe("P6-25 pre-result native session recovery", () => {
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(0);
});
it("sends one Sentry event when a sweep ends an unreported pending retry", async () => {
const freshRunId = "79000000-0000-4000-8000-000000000103";
await db.insert(issues).values({ id: freshRunId, companyId, title: "Independent failure report",
status: "in_progress", assigneeAgentId: agentId });
// The executor stores a scheduled same-run retry as failed + retryable_failure,
// and reportRunFailure skips that write. The sweep's terminal write reports it.
await db.insert(heartbeatRuns).values({
id: freshRunId,
companyId,
agentId,
nativeIssueId: freshRunId,
status: "failed",
nativePhase: "retryable_failure",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: freshRunId },
},
},
contextSnapshot: { issueId: freshRunId },
});
await db.insert(nativeRunFinalizations).values({
runId: freshRunId,
companyId,
issueId: freshRunId,
phase: "retryable_failure",
attempt: 1,
});
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
await claimNativeSessionResumptions({ db, runnerInstanceId: "reaper", runIds: [freshRunId] });
await waitForPendingRunFailureReports();
const newCaptures = mockCaptureRunFailure.mock.calls.slice(captureCallsBefore);
expect(newCaptures).toHaveLength(1);
expect(newCaptures[0]?.[0]).toMatchObject({ runId: freshRunId, runStatus: "failed" });
});
});
describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
@@ -29,7 +29,7 @@ vi.mock("../../log-redaction.js", async (importOriginal) => {
return { ...actual, redactCurrentUserText: mockRedactCurrentUserText };
});
import { reportRunFailure, waitForPendingRunFailureReports } from "../run-failure-report.js";
import { isNewTerminalRunFailure, reportRunFailure, waitForPendingRunFailureReports } from "../run-failure-report.js";
import { controllerLeaseLossCancellation } from "../run-cancellation.js";
import { REDACTED_EVENT_VALUE } from "../../redaction.js";
@@ -981,6 +981,26 @@ describeEmbeddedPostgres("reportRunFailure", () => {
.toBe(`the provider process exited with code ${REDACTED_EVENT_VALUE}`);
});
it("waits for the terminal write when a native run is failed pending a same-run retry", async () => {
await seedCompanyAndAgent();
await reportRunFailure(db, buildRun({ runtimeMode: "native", nativePhase: "retryable_failure" }));
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
await reportRunFailure(db, buildRun({ runtimeMode: "native", nativePhase: "terminal_failure" }));
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
});
it("treats leaving a pending native retry as a new terminal failure", () => {
const pending = { status: "failed", runtimeMode: "native", nativePhase: "retryable_failure" } as const;
expect(isNewTerminalRunFailure({ ...pending, status: "running", nativePhase: "observed" }, pending)).toBe(true);
expect(isNewTerminalRunFailure(pending, { ...pending, nativePhase: "terminal_failure" })).toBe(true);
expect(isNewTerminalRunFailure(pending, { ...pending, nativePhase: "committed" })).toBe(true);
expect(isNewTerminalRunFailure(pending, pending)).toBe(false);
expect(isNewTerminalRunFailure({ ...pending, nativePhase: null }, { ...pending, nativePhase: "terminal_failure" })).toBe(false);
expect(isNewTerminalRunFailure({ ...pending, runtimeMode: "legacy" }, { ...pending, runtimeMode: "legacy", nativePhase: null }))
.toBe(false);
});
it("captures nothing for succeeded, cancelled, and interrupted", async () => {
await seedCompanyAndAgent();
for (const status of ["succeeded", "cancelled", "interrupted"] as const) {
@@ -35,7 +35,7 @@ import {
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { issueService } from "../issues.js";
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
import { reportRunFailure } from "../run-failure-report.js";
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
import { resumeNativeWorkspaceFinalization } from "./native-workspace-finalizer.js";
import { dismissObsoleteNativePolicyReviews } from "./obsolete-policy-reviews.js";
import {
@@ -519,9 +519,10 @@ export async function claimNativeSessionResumptions(input: {
}).where(eq(heartbeatRuns.id, row.run.id)).returning();
// Only a genuine transition into "failed" is a new terminal failure.
// A candidate that is already "failed" (the filter above admits
// both "running" and "failed") must not send a second Sentry event.
// both "running" and "failed") must not send a second Sentry event,
// unless that earlier write was an unreported pending retry.
terminalRunToEmit =
updatedRun && updatedRun.status !== row.run.status ? updatedRun : null;
updatedRun && isNewTerminalRunFailure(row.run, updatedRun) ? updatedRun : null;
await issueService(tx as unknown as Db).update(
row.coordinator.issueId,
{ status: "blocked" },
@@ -19,7 +19,7 @@ import { readProcessStartedAt } from "../hot-restart.js";
import { getServerInfoSnapshot } from "../../server-info.js";
import { redactSensitiveText } from "../../redaction.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { reportRunFailure } from "../run-failure-report.js";
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
import { isNativeRunnerOwnershipHeld } from "./native-runner-ownership.js";
import { nativeRestartInterruptedTurnId, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
@@ -895,7 +895,7 @@ export async function claimNativeRestartRecoveries(input: {
})
.where(eq(heartbeatRuns.id, row.run.id))
.returning();
if (updatedRun && updatedRun.status !== row.run.status) {
if (updatedRun && isNewTerminalRunFailure(row.run, updatedRun)) {
terminalRunToReport = updatedRun;
}
await tx
@@ -58,6 +58,7 @@ import { restoreNativeWorkspaceBestEffort } from "./native-workspace-best-effort
import { reconcileNativeFinalizations } from "./native-finalization-reconciler.js";
import { recoverLegacyUnsafeWorkspaceExports } from "./native-workspace-export-recovery.js";
import { finalizeNativeRun, recordNativeFinalizationFailure } from "./native-run-finalizer.js";
import { waitForPendingRunFailureReports } from "../run-failure-report.js";
import { deliverExecutionStatuses } from "../execution-status-delivery.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "./status-arbiter.js";
@@ -555,6 +556,34 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
expect(replayed).toEqual(repaired);
});
it("reports a committed failure once when the run row was an unreported pending retry", async () => {
const fixture = await seedNativeRun();
await driveToCompleteResult(fixture, {
...CONTROL_PLANE_CONFORMANCE_TERMINAL,
turnTerminalState: "failed",
runTerminalState: "failed",
});
const finalize = () => finalizeNativeRun({ db, runId: fixture.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
await finalize();
// The executor stores a scheduled same-run retry as failed + retryable_failure,
// and reportRunFailure skips that write.
await db.update(heartbeatRuns).set({
nativePhase: "retryable_failure",
}).where(eq(heartbeatRuns.id, fixture.runId));
await waitForPendingRunFailureReports();
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
await finalize();
await waitForPendingRunFailureReports();
expect(captureRunFailureCallsFrom(captureCallsBefore)).toEqual([
[expect.objectContaining({ runId: fixture.runId, runStatus: "failed" })],
]);
await finalize();
await waitForPendingRunFailureReports();
expect(captureRunFailureCallsFrom(captureCallsBefore)).toHaveLength(1);
});
it("retires this run's scheduled finalization retry after successful commit", async () => {
const fixture = await seedNativeRun();
await driveToCompleteResult(fixture, CONTROL_PLANE_CONFORMANCE_TERMINAL);
@@ -54,7 +54,7 @@ import {
readNativeBoardResponseWaitSource,
} from "./native-board-response-wait.js";
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
import { reportRunFailure } from "../run-failure-report.js";
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
import { resolveExternalChatResponseWaitAuthorization } from "./chat-attachment-reuse.js";
import {
authorizeNativeChatReviewPresentation,
@@ -720,10 +720,13 @@ async function projectCommittedRun(input: {
)
.returning();
// Metadata repairs can preserve the terminal status. Only a genuine status
// transition should emit another terminal event.
// transition should emit another terminal event. A failed run that leaves a
// pending retry still needs its first failure report.
if (updatedRun && updatedRun.status !== input.run.status) {
await emitAgentTaskRun(input.db, updatedRun);
void reportRunFailure(input.db, updatedRun);
} else if (updatedRun && isNewTerminalRunFailure(input.run, updatedRun)) {
void reportRunFailure(input.db, updatedRun);
}
}
@@ -50,6 +50,7 @@ import { materializeAsset } from "./runtime-context.js";
import { createRunnerTestTlsTunnel, dockerCommandRunner } from "../../__tests__/helpers/docker-command-runner.js";
import { createIsolatedTestHome } from "../../__tests__/helpers/isolated-test-home.js";
import { finalizeNativeRun } from "./native-run-finalizer.js";
import { waitForPendingRunFailureReports } from "../run-failure-report.js";
import { closeIdleWarmNativeSessionsForRestart, executePaperclipNativeSession, NativeControllerDetachedForRestartError } from "./native-session-executor.js";
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
@@ -1455,6 +1456,31 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
expect(await fixture.db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, fixture.issueId))).toHaveLength(1);
});
it("reports one event when it blocks a failed run that was an unreported pending retry", async () => {
const fixture = await seedRun("PENDING-RETRY-BLOCKED");
// The executor stores a scheduled same-run retry as failed + retryable_failure,
// and reportRunFailure skips that write. The blocked write reports it.
await fixture.db.update(heartbeatRuns).set({
status: "failed",
nativePhase: "retryable_failure",
runnerProfileJson: { sessionCheckpoint: {
terminal: { runTerminalState: "failed", turnTerminalState: "failed" },
providerSessionId: "unusable-provider-session",
} },
}).where(eq(heartbeatRuns.id, fixture.runId));
await fixture.db.update(nativeRunFinalizations).set({ attempt: 3 }).where(eq(nativeRunFinalizations.runId, fixture.runId));
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
expect(await claimNativeRestartRecoveries({
db: fixture.db, controller: successor, restartKind: "hard", runIds: [fixture.runId],
})).toEqual([{ kind: "blocked", runId: fixture.runId, reason: "provider_checkpoint_permanently_failed" }]);
await waitForPendingRunFailureReports();
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toEqual([
[expect.objectContaining({ runId: fixture.runId, runStatus: "failed", errorCode: "native_restart_recovery_blocked" })],
]);
});
it("classifies every requested recovery candidate without an implicit 100-run cap", async () => {
const db = createDb(temporary.connectionString);
const fixtures: Array<Awaited<ReturnType<typeof seedRun>>> = [];
+25 -1
View File
@@ -38,6 +38,27 @@ function isRunFailureStatus(status: string): status is RunFailureStatus {
return status === "failed" || status === "timed_out" || status === "cancelled";
}
type RunFailureTransitionState = Pick<HeartbeatRun, "status" | "runtimeMode" | "nativePhase">;
/**
* A native run that waits for a same-run retry is stored as `failed` with
* `nativePhase: "retryable_failure"`. That is not terminal: the retry or the
* workspace copy-back retry can still finish the run as succeeded.
*/
function isPendingNativeRetry(run: RunFailureTransitionState): boolean {
return run.status === "failed" && run.runtimeMode === "native" && run.nativePhase === "retryable_failure";
}
/**
* Whether a status write is a new terminal failure to pass to `reportRunFailure`.
* A status change counts. A run that leaves a pending native retry but stays
* `failed` also counts, because `reportRunFailure` skipped its earlier write.
*/
export function isNewTerminalRunFailure(before: RunFailureTransitionState, after: RunFailureTransitionState): boolean {
if (after.status !== before.status) return true;
return isPendingNativeRetry(before) && !isPendingNativeRetry(after);
}
function readTaskId(run: HeartbeatRun): string | null {
if (run.issueId) return run.issueId;
if (run.nativeIssueId) return run.nativeIssueId;
@@ -238,7 +259,8 @@ async function isRejectedExternalBotWake(db: Db, run: HeartbeatRun): Promise<boo
/**
* 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
* other than failures and unexpected started cancellations, and for a native
* run that still waits for a same-run retry. Never throws — a Sentry failure or a
* database read failure must not change the caller's control flow.
*
* Call this beside the caller's own terminal-status write, with
@@ -252,6 +274,8 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
if (mode === "off") return Promise.resolve();
if (!isRunFailureStatus(run.status)) return Promise.resolve();
if (run.status === "cancelled" && !isUnexpectedRunCancellation(run)) return Promise.resolve();
// The terminal write that ends the retry reports instead (`isNewTerminalRunFailure`).
if (isPendingNativeRetry(run)) return Promise.resolve();
if (mode === "unexpected" && (
isMissingSecretConfigurationBlocker(run, options) ||
isAiConnectionSelectionBlocker(run, options) ||