Files
PaperClipAI/server/src/services/native-runtime/native-run-projection.ts
T
35d086213e fix(native): explain failed accepted results on the run (#19091)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native run ends when the server projects the accepted result onto
the run row.
> - Operators read `errorCode` and `error` on a failed run to learn why
it failed.
> - Three native writers can set a run to `failed` without these fields.
#19063 repairs the finalizer retry-exhaustion writer.
> - The two result-projection writers still set `failed` with no cause
when the provider failure is not classified.
> - This pull request makes both projection writers record a cause when
they project `failed`.
> - The benefit is that, with #19063, no native writer can leave a
failed run unexplained.

## Linked Issues or Issue Description

Refs #19063.

**What happened?**

A staging soak found 11 failed native runs with `errorCode` null and
`error` null. In each run the provider turn succeeded. Workspace
copy-back then failed three times, and the finalizer retry-exhaustion
path set the run to `failed`. #19063 fixes that writer. An audit of
every `heartbeat_runs` status writer found two more writers with the
same defect. `finalizeNativeRun` and `projectCommittedRun` project an
accepted result with `runTerminalState: "failed"`. They write the error
fields only when a classified provider failure exists.

**Expected behavior**

A run with status `failed` always has an `errorCode` and an `error`. A
cause that is already recorded stays.

**Steps to reproduce**

1. Accept a native result with `runTerminalState: "failed"` and no
`turn.failed` event.
2. Call `finalizeNativeRun` with `projectRunStatus: true`, or project
the committed run through `projectCommittedRun`.
3. Read the run. On master its status is `failed`, and its `errorCode`
and `error` are null.

## What Changed

- Add `explainFailedRun` in
`server/src/services/native-runtime/native-run-failure-explanation.ts`.
It builds SQL SET expressions. They keep a non-empty existing
`errorCode` or `error`. Otherwise they write `native_run_failed` and a
readable message. By default the expressions leave a run that is already
`cancelled` unchanged, as the status writers do.
- `finalizeNativeRun` uses the helper when it projects `failed`. The
existing provider-failure and recovered-failure values remain the
preferred causes.
- `projectCommittedRun` uses the helper for a `failed` accepted result.
This writer sets status unconditionally, so it does not use the
cancelled branch.
- Writer audit: all legacy and heartbeat callers of `setRunStatus*` that
pass `failed` already set both fields. The other direct native writers
also set both fields.

## Verification

- Red, with the source change stashed: 3 new cases failed with
`errorCode: null`. These were the finalizer projection, recovery
projection with no cause, and recovery projection with an empty error.
The control case with a recorded cause passed.
- Green: `npx vitest run
src/__tests__/native-run-projection-work.test.ts
src/services/native-runtime/native-run-finalizer-telemetry.test.ts
src/__tests__/native-status-arbiter-corpus.test.ts` (225 passed).
- Wider run: all `server/src/__tests__/native-*.test.ts` and
`server/src/services/native-runtime/*.test.ts` (7416 passed). Two
failures come from the local environment. `plugin-runner-probe` needs
the Rust runner source. `native-ingress-bootstrap-retry` needs the
controller-host OpenCode proxy.
- This change was not exercised on staging. It changes only fields that
are written at terminal projection.

## Risks

- Low risk. The change affects only rows that are projected to `failed`.
A recorded cause is never replaced. Succeeded and cancelled projections
are unchanged.
- The staging failures need #19063 too. This PR alone does not change
their writer.
- The cause behind the staging copy-back failures is still unknown. The
sandbox command reports the redacted
`remote_workspace_operation_failed`, which also appears in a separate
signature. This PR does not change that diagnostic.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edits, browser reads of staging run records).

## 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: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:40:05 -06:00

219 lines
12 KiB
TypeScript

import { notifyExternalConversationSettlement } from "../external-conversation-settlement-notifications.js";
import { notifyStrandedAgentStatus } from "../live-run-recovery-notifications.js";
import { notifyNativeEnvironmentSettlement } from "../native-environment-settlement-notifications.js";
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
import { notifyNativeRecoveryNotices } from "../native-recovery-notice-notifications.js";
import { pendingNativeStatusEffectsCondition } from "./native-pending-effect-work.js";
import { notifyNativeRunProjection } from "../native-run-projection-notifications.js";
import { randomUUID } from "node:crypto";
import { and, eq, exists, inArray, isNotNull, isNull, not, or, sql, type SQL } from "drizzle-orm";
import { agentWakeupRequests, heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { notifyRetainedNativeSessionCleanup } from "../retained-native-session-cleanup-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyUnsafeWorkspaceExportRunChange } from "../unsafe-workspace-export-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js";
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
import { nativeRunnerOwnershipNotHeldCondition } from "./native-runner-ownership.js";
import { explainFailedRun, NATIVE_RUN_FAILED_UNEXPLAINED } from "./native-run-failure-explanation.js";
export function projectNativeTerminalRunStatus(
terminalState: "succeeded" | "failed" | "cancelled" | "active",
) {
return terminalState === "active" ? ("running" as const) : terminalState;
}
function claimedNativeWakeNeedsSettlement() {
return sql`exists (select 1 from ${agentWakeupRequests} where
${agentWakeupRequests.id} = ${heartbeatRuns.wakeupRequestId}
and ${agentWakeupRequests.companyId} = ${heartbeatRuns.companyId}
and ${agentWakeupRequests.agentId} = ${heartbeatRuns.agentId}
and ${agentWakeupRequests.runId} = ${heartbeatRuns.id}
and ${agentWakeupRequests.status} = 'claimed')`;
}
/** Settle only the exact persisted source claim inside terminal projection. */
export async function settleNativeTerminalWake(tx: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
"id" | "companyId" | "agentId" | "wakeupRequestId" | "runtimeMode" | "status" | "finishedAt" | "error">) {
if (run.runtimeMode !== "native" || !run.wakeupRequestId || !run.finishedAt
|| !["succeeded", "failed", "cancelled"].includes(run.status)) return;
const claim = and(eq(agentWakeupRequests.id, run.wakeupRequestId),
eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId),
eq(agentWakeupRequests.runId, run.id), eq(agentWakeupRequests.status, "claimed"));
const [current] = await tx.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests)
.where(claim).for("update").limit(1);
if (!current) return;
await notifyExternalConversationSettlement(tx, { companyId: run.companyId });
await notifyStrandedAgentStatus(tx, { companyId: run.companyId, agentId: run.agentId });
await tx.update(agentWakeupRequests).set({
status: run.status === "succeeded" ? "completed" : run.status,
finishedAt: run.finishedAt, error: run.error, updatedAt: new Date(),
}).where(claim);
}
export function nativeRunProjectionNeedsRepair(projectedStatus: "succeeded" | "failed" | "cancelled" | SQL) {
return and(
eq(heartbeatRuns.runtimeMode, "native"),
or(
inArray(heartbeatRuns.status, ["queued", "running", "failed"]),
and(eq(heartbeatRuns.status, projectedStatus),
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled"]), claimedNativeWakeNeedsSettlement()),
and(
eq(heartbeatRuns.status, "succeeded"),
or(
isNotNull(heartbeatRuns.error),
isNotNull(heartbeatRuns.errorCode),
isNull(heartbeatRuns.finishedAt),
sql`${heartbeatRuns.nativePhase} is distinct from 'committed'`,
sql`${heartbeatRuns.resultJson}->>'finalizationPhase' is distinct from 'committed'`,
sql`${heartbeatRuns.resultJson}->>'nextAttemptAt' is not null`,
sql`${heartbeatRuns.resultJson}->>'failureCode' is not null`,
sql`${heartbeatRuns.resultJson}->>'originalFailureCode' is not null`,
),
),
),
or(
sql`${heartbeatRuns.status} is distinct from ${projectedStatus}`,
claimedNativeWakeNeedsSettlement(),
isNull(heartbeatRuns.finishedAt),
sql`${heartbeatRuns.nativePhase} is distinct from 'committed'`,
sql`${heartbeatRuns.resultJson}->>'finalizationPhase' is distinct from 'committed'`,
sql`${heartbeatRuns.resultJson}->>'nextAttemptAt' is not null`,
sql`${heartbeatRuns.resultJson}->>'failureCode' is not null`,
sql`${heartbeatRuns.resultJson}->>'originalFailureCode' is not null`,
and(sql`${projectedStatus} = 'succeeded'`, or(isNotNull(heartbeatRuns.error), isNotNull(heartbeatRuns.errorCode))),
),
nativeRunnerOwnershipNotHeldCondition(),
);
}
export function pendingNativeRunProjectionCondition(db: Db) {
const status = sql<string>`${nativeRunResults.resultJson}->'terminal'->>'runTerminalState'`;
return exists(db.select({ id: nativeRunResults.id }).from(nativeRunResults).where(and(
eq(nativeRunResults.id, nativeRunFinalizations.resultId),
eq(nativeRunResults.runId, nativeRunFinalizations.runId),
eq(nativeRunResults.companyId, nativeRunFinalizations.companyId),
eq(nativeRunResults.issueId, nativeRunFinalizations.issueId),
eq(nativeRunResults.schemaStatus, "accepted"),
inArray(status, ["succeeded", "failed", "cancelled"]),
nativeRunProjectionNeedsRepair(status),
)));
}
export function recoveredExecutionFailureMetadata() {
return sql`case when ${heartbeatRuns.error} is not null or ${heartbeatRuns.errorCode} is not null
then jsonb_build_object('recoveredExecutionFailure', jsonb_build_object(
'schema', 'paperclip.recovered_execution_failure.v1',
'errorCode', ${heartbeatRuns.errorCode}, 'error', ${heartbeatRuns.error},
'observedAt', ${heartbeatRuns.updatedAt}
)) else '{}'::jsonb end`;
}
export async function projectCommittedRun(input: {
db: Db;
onlyWhenEffectsSettled?: boolean;
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "status" | "finishedAt" | "runtimeMode" | "nativePhase" | "nativeIssueId" | "contextSnapshot">;
coordinator: Pick<typeof nativeRunFinalizations.$inferSelect, "resultId" | "issueId">;
}) {
if (!input.coordinator.resultId) return;
const resultRow = await input.db
.select({ terminalState: sql<string>`${nativeRunResults.resultJson}->'terminal'->>'runTerminalState'` })
.from(nativeRunResults)
.where(
and(
eq(nativeRunResults.id, input.coordinator.resultId),
eq(nativeRunResults.runId, input.run.id),
eq(nativeRunResults.companyId, input.run.companyId),
eq(nativeRunResults.issueId, input.coordinator.issueId),
eq(nativeRunResults.schemaStatus, "accepted"),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
const terminalState = resultRow?.terminalState;
if (!["succeeded", "failed", "cancelled"].includes(String(terminalState))) {
throw new Error("native_finalization_invalid");
}
const projectedStatus = terminalState as "succeeded" | "failed" | "cancelled";
const needsRepair = and(eq(heartbeatRuns.id, input.run.id), eq(heartbeatRuns.companyId, input.run.companyId),
eq(heartbeatRuns.nativeIssueId, input.coordinator.issueId), nativeRunProjectionNeedsRepair(projectedStatus),
exists(input.db.select({ runId: nativeRunFinalizations.runId }).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.runId, input.run.id), eq(nativeRunFinalizations.companyId, input.run.companyId),
eq(nativeRunFinalizations.issueId, input.coordinator.issueId), eq(nativeRunFinalizations.resultId, input.coordinator.resultId),
eq(nativeRunFinalizations.phase, "committed"),
input.onlyWhenEffectsSettled ? not(pendingNativeStatusEffectsCondition(input.db)) : undefined,
))));
const now = new Date();
const projected = await input.db.transaction(async transaction => {
const tx = transaction as unknown as Db;
const [before] = await tx.select({ status: heartbeatRuns.status, runtimeMode: heartbeatRuns.runtimeMode,
nativePhase: heartbeatRuns.nativePhase, finishedAt: heartbeatRuns.finishedAt })
.from(heartbeatRuns).where(needsRepair).for("update").limit(1);
if (!before) return null;
await notifyExternalAgentSettlement(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyNativeRecoveryNotices(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyNativeRunProjection(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyNativeEnvironmentSettlement(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyRetainedNativeSessionCleanup(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyInstructionCollection(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, input.run);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, input.run.companyId);
await notifyExecutionRunWork(tx, { companyId: input.run.companyId });
await notifyChatRunMilestones(tx, { companyId: input.run.companyId, issueId: input.coordinator.issueId });
const [after] = await tx
.update(heartbeatRuns)
.set({
executionStatusDeliveryId: randomUUID(),
status: projectedStatus,
finishedAt: before.finishedAt ?? now,
nativePhase: "committed",
nativePhaseUpdatedAt: now,
...(terminalState === "succeeded"
? {
error: null,
errorCode: null,
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb)
|| ${recoveredExecutionFailureMetadata()}
|| jsonb_build_object('finalizationPhase', 'committed', 'failureCode', null, 'originalFailureCode', null, 'nextAttemptAt', null)`,
}
: {
// A failed accepted result keeps any recorded cause, and otherwise
// names the unexplained failure.
...(terminalState === "failed" ? explainFailedRun(NATIVE_RUN_FAILED_UNEXPLAINED, { preserveCancelled: false }) : {}),
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || jsonb_build_object('finalizationPhase', 'committed', 'failureCode', null, 'originalFailureCode', null, 'nextAttemptAt', null)`,
}),
updatedAt: now,
})
.where(needsRepair)
.returning();
if (after) await settleNativeTerminalWake(tx, after);
return after ? { before, after } : null;
});
if (projected && projected.after.status !== projected.before.status) {
await emitAgentTaskRun(input.db, projected.after);
void reportRunFailure(input.db, projected.after);
} else if (projected && isNewTerminalRunFailure(projected.before, projected.after)) {
void reportRunFailure(input.db, projected.after);
}
}