Files
PaperClipAI/server/src/services/run-failure-report.ts
T
Devin FoleyandClaude Opus 5.5 5e8956c207 fix(heartbeat): retry legacy runs a restart stopped before dispatch (#16355)
A hard server restart can stop a legacy run after it is claimed but before the adapter is dispatched. The next boot reaped it as failed/process_lost with no retry and held it for operator reconciliation, so the task wake was lost.

The legacy controller only writes executionStage "dispatching" in the lease renewal that admits the adapter. The orphan reaper now revokes the expired foreign-boot controller first, and uses the stage and process identifiers returned by that same UPDATE ... RETURNING. If the run is still "preparing" with no process identifiers, it records bootstrap evidence (providerWorkStarted: false) and uses the existing one-time process-loss retry. In "unexpected" mode, run-failure reporting does not send this retried restart interruption to Sentry. Runs that reached dispatching, native runs, and runs owned by the current boot are unchanged.

Verified exact head 91f1352097: full CI green, Greptile 5/5, no unresolved review threads, no conflicts. The tests were not run locally (sandbox memory limit); CI is the test run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:27:11 -07:00

329 lines
16 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import { agents, dotRunnerAssignments, grokBotDispatches, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { captureRunFailure, type RunFailureStatus } from "../sentry.js";
import {
collectRunFailureDiagnostics,
collectRunFailureSecretValues,
redactRunFailureSecretValues,
sanitizeRunFailureDiagnostics,
sanitizeRunFailureText,
type RunFailureReportOptions,
} from "./run-failure-diagnostics.js";
import { logger } from "../middleware/logger.js";
import { isUnexpectedRunCancellation } from "./run-cancellation.js";
import { readConnectionFailure, type ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure";
import { isAiConnectionConfigurationReason } from "./ai-connection-configuration-failure.js";
import { readRunFailureReportingMode, type RunFailureReportingMode } from "../sentry-run-failure-policy.js";
type HeartbeatRun = typeof heartbeatRuns.$inferSelect;
const UNKNOWN_ADAPTER = "unknown";
/** Sentry rejects an oversized event with HTTP 413. Bound the error message. */
const MAX_ERROR_MESSAGE_LENGTH = 4096;
/** The error code is a short label. Bound it well under the message limit. */
const MAX_ERROR_CODE_LENGTH = 200;
/**
* Every report that `reportRunFailure` started and has not yet settled.
* Shutdown must wait for this set to drain — see `waitForPendingRunFailureReports`.
*/
const pendingRunFailureReports = new Set<Promise<void>>();
/** Bounds the shutdown wait, so one stuck report cannot hang the process exit. */
const PENDING_REPORT_DRAIN_TIMEOUT_MS = 5_000;
function isRunFailureStatus(status: string): status is RunFailureStatus {
return status === "failed" || status === "timed_out" || status === "cancelled";
}
function readTaskId(run: HeartbeatRun): string | null {
if (run.issueId) return run.issueId;
if (run.nativeIssueId) return run.nativeIssueId;
const contextIssueId = run.contextSnapshot?.issueId;
return typeof contextIssueId === "string" && contextIssueId.length > 0 ? contextIssueId : null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
/** An owned AI selection rejection before dispatch, not its broad catch wrapper. */
function isAiConnectionSelectionBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
// Resumed native input may already have provider effects, even during preparation.
if (run.runtimeMode === "native" && asRecord(run.runnerProfileJson)?.nativeExecutionInput !== undefined) return false;
if (
run.status !== "failed" || run.errorCode !== "configuration_incomplete" ||
run.executionStage !== "preparing" || options.phase !== "setup" ||
run.exitCode != null || run.signal != null
) return false;
const configuration = asRecord(run.resultJson?.configurationIncomplete);
const recovery = asRecord(run.resultJson?.executionRecovery);
return configuration?.reason === "ai_connection_unavailable" &&
isAiConnectionConfigurationReason(configuration.selectionFailure) &&
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
/** A known owner action before dispatch, not a secret-provider or runtime failure. */
function isMissingSecretConfigurationBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
if (
run.status !== "failed" ||
run.errorCode !== "configuration_incomplete" ||
run.executionStage !== "preparing" ||
options.phase !== "setup" ||
run.exitCode != null ||
run.signal != null
) return false;
const configuration = asRecord(run.resultJson?.configurationIncomplete);
const recovery = asRecord(run.resultJson?.executionRecovery);
if (
configuration?.reason !== "secret_binding_missing" ||
recovery?.kind !== "bootstrap" ||
recovery.providerWorkStarted !== false ||
!Array.isArray(configuration.missingBindings) ||
configuration.missingBindings.length === 0
) return false;
return configuration.missingBindings.every((value: unknown) => {
const binding = asRecord(value);
if (!binding) return false;
if (binding.bindingType === "secret_ref") {
return binding.errorCode == null || binding.errorCode === "binding_missing";
}
if (binding.bindingType !== "user_secret_ref") return false;
// Missing-definition resolution can also catch a database error. Keep that
// ambiguous case, unknown codes, and provider errors visible in Sentry.
return binding.errorCode === "binding_missing" ||
binding.errorCode === "responsible_user_missing" ||
binding.errorCode === "user_secret_missing" ||
binding.errorCode === "secret_inactive" ||
binding.errorCode === "user_secret_definition_inactive";
});
}
/** The workspace resolver proved an explicit local-path/worktree policy mismatch. */
function isLocalPathWorkspaceConfigurationBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
if (
run.status !== "failed" || run.errorCode !== "workspace_validation_failed" ||
run.executionStage !== "preparing" || options.phase !== "setup" ||
run.exitCode != null || run.signal != null
) return false;
const validation = asRecord(run.resultJson?.workspaceValidation);
const recovery = asRecord(run.resultJson?.executionRecovery);
return validation?.reason === "git_worktree_base_not_git_checkout" &&
validation.configurationReason === "local_path_requires_git_checkout" &&
validation.resolvedWorkspaceSource === "project_primary" &&
validation.workspaceStrategyType === "git_worktree" &&
(validation.requestedExecutionWorkspaceMode === "isolated_workspace" ||
validation.requestedExecutionWorkspaceMode === "operator_branch") &&
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
/** Only the managed-clone producer can attach this bounded pre-dispatch evidence. */
function isGitConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
const connection = readConnectionFailure(run.resultJson?.connectionFailure);
if (connection?.provider !== "git" || run.status !== "failed" || run.executionStage !== "preparing" ||
options.phase !== "setup" || run.exitCode != null || run.signal != null) return false;
const recovery = asRecord(run.resultJson?.executionRecovery);
if (recovery?.kind !== "bootstrap" || recovery.providerWorkStarted !== false) return false;
const validation = asRecord(run.resultJson?.workspaceValidation);
if (run.errorCode === "setup_failed") return !validation;
if (run.errorCode !== "workspace_validation_failed" || validation?.reason !== "git_worktree_base_materialization_failed") return false;
const failures = validation.materializationFailures;
return Array.isArray(failures) && failures.length > 0 && failures.every((failure) => {
const marker = readConnectionFailure(asRecord(failure)?.connectionFailure);
return marker?.provider === "git";
}) && readConnectionFailure(asRecord(failures[0])?.connectionFailure)?.reason === connection.reason;
}
const hermesTransportReasons = new Set([
"connection_refused", "dns_failure", "network_unreachable", "connection_reset", "connection_timeout", "tls_failure",
]);
/** An adapter result remains failed; this only excludes its proven external cause. */
function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions, adapterType: string | undefined): boolean {
if (adapterType !== "hermes_gateway" || run.status !== "failed" || run.exitCode !== 1 || run.signal != null || options.phase === "setup") return false;
const connection = readConnectionFailure(run.resultJson?.connectionFailure);
if (connection?.provider !== "hermes_gateway") return false;
const code = run.errorCode;
if (connection.operation === "configuration") {
const codes: Record<Extract<ConnectionFailure, { provider: "hermes_gateway"; operation: "configuration" }>["reason"], string> = {
endpoint_missing: "hermes_gateway_api_base_url_missing",
endpoint_invalid: "hermes_gateway_api_base_url_invalid",
insecure_transport: "hermes_gateway_plain_http_remote_denied",
credentials_missing: "hermes_gateway_api_key_missing",
};
return code === codes[connection.reason];
}
if (hermesTransportReasons.has(connection.reason)) return code === "hermes_gateway_connect_failed" || code === "hermes_gateway_protocol_error";
return (connection.reason === "authentication_failed" && code === "hermes_gateway_auth_failed") ||
(connection.reason === "endpoint_not_found" && code === "hermes_gateway_runs_unsupported") ||
(connection.reason === "rate_limited" && code === "hermes_gateway_rate_limited") ||
(connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error");
}
/**
* The orphan reaper proved a server restart stopped legacy setup before
* dispatch and made the run eligible for its bounded retry. Only that reaper
* branch pairs process loss with bootstrap evidence.
*/
function isRestartInterruptedSetup(run: HeartbeatRun): boolean {
if (
run.status !== "failed" || run.errorCode !== "process_lost" || run.runtimeMode !== "legacy" ||
run.executionStage !== "preparing" || run.processPid != null || run.processGroupId != null
) return false;
const recovery = asRecord(run.resultJson?.executionRecovery);
const processLoss = asRecord(run.resultJson?.processLossDiagnostic);
return recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false &&
processLoss?.retryEligible === true && processLoss.runPredatesObserver === true &&
processLoss.localCheck === "no_identifiers";
}
/**
* 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;
}
/**
* The Grok endpoint refused every work wake with an HTTP 4xx, so the offer
* expired unaccepted. Durable delivery receipts prove the external cause. A
* local rejection (no HTTP status), any delivered wake, or accepted work stays
* reportable.
*/
async function isRejectedExternalBotWake(db: Db, run: HeartbeatRun): Promise<boolean> {
if (run.status !== "failed" || run.runtimeMode !== "native" || run.errorCode !== "native_provider_terminal_failed") return false;
const [finalization] = await db.select({ failureDetail: nativeRunFinalizations.failureDetail }).from(nativeRunFinalizations)
.where(and(eq(nativeRunFinalizations.runId, run.id), eq(nativeRunFinalizations.companyId, run.companyId)));
if (finalization?.failureDetail?.originalFailureCode !== "dot_assignment_expired") return false;
const offers = await db.select({ id: dotRunnerAssignments.id, acceptedAt: dotRunnerAssignments.acceptedAt }).from(dotRunnerAssignments)
.where(and(eq(dotRunnerAssignments.runId, run.id), eq(dotRunnerAssignments.companyId, run.companyId)));
if (offers.length !== 1 || offers[0]!.acceptedAt) return false;
const wakes = await db.select({ status: grokBotDispatches.status, httpStatus: grokBotDispatches.httpStatus }).from(grokBotDispatches)
.where(and(eq(grokBotDispatches.assignmentId, offers[0]!.id), eq(grokBotDispatches.companyId, run.companyId),
eq(grokBotDispatches.kind, "mailbox_updated")));
return wakes.length > 0 && wakes.every((wake) =>
wake.status === "rejected" && wake.httpStatus != null && wake.httpStatus >= 400 && wake.httpStatus < 500);
}
/**
* 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
* database read failure must not change the caller's control flow.
*
* Call this beside the caller's own terminal-status write, with
* `void reportRunFailure(db, run)`. Do not await it — a Sentry read must
* not delay the caller's own required lifecycle work. The function tracks
* its own in-flight promise, so a caller that does not await it still lets
* shutdown find and wait for the report — see `waitForPendingRunFailureReports`.
*/
export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureReportOptions = {}): Promise<void> {
const mode = readRunFailureReportingMode();
if (mode === "off") return Promise.resolve();
if (!isRunFailureStatus(run.status)) return Promise.resolve();
if (run.status === "cancelled" && !isUnexpectedRunCancellation(run)) return Promise.resolve();
if (mode === "unexpected" && (
isMissingSecretConfigurationBlocker(run, options) ||
isAiConnectionSelectionBlocker(run, options) ||
isLocalPathWorkspaceConfigurationBlocker(run, options) ||
isGitConnectionFailure(run, options) ||
isProviderQuotaFailure(run, options) ||
isRestartInterruptedSetup(run)
)) return Promise.resolve();
const runStatus = run.status;
const report = captureTerminalRunFailure(db, run, runStatus, options, mode);
pendingRunFailureReports.add(report);
void report.finally(() => pendingRunFailureReports.delete(report));
return report;
}
async function captureTerminalRunFailure(
db: Db,
run: HeartbeatRun,
runStatus: RunFailureStatus,
options: RunFailureReportOptions,
mode: RunFailureReportingMode,
): Promise<void> {
try {
const snapshot = redactRunFailureSecretValues({
errorMessage: run.error ?? "",
errorCode: run.errorCode ?? null,
diagnostics: collectRunFailureDiagnostics(run, options),
}, [...new Set([
...collectRunFailureSecretValues(process.env, [], true),
...(options.secretValues ?? []),
])].sort((a, b) => b.length - a.length));
const agent = await db
.select({ adapterType: agents.adapterType })
.from(agents)
.where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)))
.then((rows) => rows[0] ?? null);
if (mode === "unexpected") {
if (isHermesConnectionFailure(run, options, agent?.adapterType)) return;
const rejectedExternally = await isRejectedExternalBotWake(db, run).catch((err) => {
logger.warn({ err, runId: run.id }, "failed to read bot wake receipts for run failure report");
return false;
});
if (rejectedExternally) return;
}
const taskId = readTaskId(run);
// Resolve registered values before truncation. A failed resolution must
// not send an incompletely redacted report.
let redacted = snapshot;
if (Array.isArray(run.contextSnapshot?.paperclipSecretRedactions)) {
const { createRunSecretRedactionRegistry } = await import("./run-secret-redaction.js");
redacted = await createRunSecretRedactionRegistry(db).redactForRun(run.companyId, run.id, snapshot);
}
captureRunFailure({
companyId: run.companyId,
agentId: run.agentId,
taskId,
runId: run.id,
errorMessage: sanitizeRunFailureText(redacted.errorMessage, MAX_ERROR_MESSAGE_LENGTH),
errorCode:
redacted.errorCode === null
? null
: sanitizeRunFailureText(redacted.errorCode, MAX_ERROR_CODE_LENGTH),
agentAdapter: agent?.adapterType ?? UNKNOWN_ADAPTER,
runStatus,
exitCode: run.exitCode,
signal: run.signal,
diagnostics: sanitizeRunFailureDiagnostics(redacted.diagnostics),
});
} catch (err) {
logger.warn({ err, runId: run.id }, "failed to report run failure to Sentry");
}
}
/**
* Wait for every run-failure report that is still in flight, up to
* `timeoutMs`. Call this during server shutdown, before the database pool
* ends and before Sentry flushes — `reportRunFailure` reads the database and
* then calls Sentry, so a report started just before shutdown can otherwise
* lose its database read, its Sentry call, or both. Never throws.
*/
export async function waitForPendingRunFailureReports(
timeoutMs = PENDING_REPORT_DRAIN_TIMEOUT_MS,
): Promise<void> {
if (pendingRunFailureReports.size === 0) return;
const drained = Promise.allSettled(Array.from(pendingRunFailureReports));
await Promise.race([
drained,
new Promise<void>((resolve) => {
const timer = setTimeout(resolve, timeoutMs);
timer.unref?.();
}),
]);
}