mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The server sends unexpected run failures to Sentry through
`reportRunFailure`
> - A Claude ACP run can fail because the provider rejects the stored
credential ("Invalid API key"). The ACP agent then sends a typed
`access` terminal failure.
> - The Claude adapter already classifies this failure as
`claude_auth_required`. The run is blocked, and the responsible user
gets a reconnect request.
> - Sentry also records each of these runs as "ACP agent reported a
terminal access failure.". This duplicates a signal that the user
already gets, and it hides real defects.
> - This pull request adds one narrow exclusion for this proven provider
auth state, next to the existing quota and Codex capacity exclusions.
> - The benefit is less Sentry noise, and all other access-shaped
failures stay visible.
## Linked Issues or Issue Description
Refs #14886 (related: it routes ACP access failures in recovery, not in
Sentry reporting).
**What happened?**
Staging Sentry recorded a run failure "ACP agent reported a terminal
access failure." for 8 events. All events had
`agent_adapter=claude_local`, `error_code=claude_auth_required`, and a
typed provider access failure ("Invalid API key · Fix external API
key"). The failures stopped when the provider accepted the credential
again.
**Expected behavior**
A provider auth failure that the adapter types and classifies is a user
action state. The product already shows it to the user. It must not go
to Sentry in `unexpected` mode.
**Steps to reproduce**
1. Configure a `claude_local` agent with an ACP runtime and a credential
that the provider rejects.
2. Start a run.
3. The ACP agent ends the turn with `category: "access"`. The run fails
with `claude_auth_required`.
4. See a new Sentry run-failure event.
**Agent adapter(s) involved**
Claude Code (`claude_local`, ACP lane).
## What Changed
- `server/src/services/run-failure-report.ts`: add
`isAcpProviderAccessFailure`. It skips a report only when all of these
are true: the reporting mode is `unexpected`, the run status is
`failed`, the error code is `claude_auth_required` or
`acpx_auth_required`, `resultJson.terminalSessionFailure.category` is
`access`, the phase is not `setup`, and the run has no signal.
- `server/src/services/__tests__/run-failure-report.test.ts`: add
regression tests for the staging shape, eight near-miss shapes that must
still report, and the setup and `all` mode paths.
## Verification
- Red: without the predicate, the two new "keeps ... local" tests fail
(`2 failed | 266 passed`).
- Green: `cd server && pnpm exec vitest run
src/services/__tests__/run-failure-report.test.ts
src/services/__tests__/run-failure-diagnostics.test.ts` gives `383
passed`.
- `pnpm exec tsc --noEmit -p server` shows no errors in the changed
files.
## Risks
- Low risk. The change only removes Sentry events. It does not change
run status, recovery, or user notices.
- A real defect that the ACP agent wrongly types as `access` with a
Claude auth code is not reported in `unexpected` mode. Set
`PAPERCLIP_SENTRY_RUN_FAILURES=all` to see these events again.
- Codex and other ACP adapters do not map `access` to an auth code, so
their access failures still report.
> 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`), run through Claude Code
with tool use (shell, file edit, Sentry and GitHub connectors).
## 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: Devin Foley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
19 KiB
TypeScript
391 lines
19 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";
|
|
import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "./workspace-source-restriction.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";
|
|
}
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
/** A retained shared checkout denied by task privacy before dispatch; the task notice names the owner action. */
|
|
function isRestrictedWorkspaceSourceBlocker(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);
|
|
return configuration?.reason === RESTRICTED_WORKSPACE_SOURCE_REASON &&
|
|
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;
|
|
}
|
|
|
|
function isCodexAcpModelCapacityFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
|
|
const result = asRecord(run.resultJson);
|
|
return run.status === "failed" && run.errorCode === "codex_transient_upstream" &&
|
|
result?.errorFamily === "transient_upstream" &&
|
|
asRecord(result.terminalSessionFailure)?.category === "service" &&
|
|
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
|
|
* 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, 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
|
|
* `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();
|
|
// 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) ||
|
|
isLocalPathWorkspaceConfigurationBlocker(run, options) ||
|
|
isRestrictedWorkspaceSourceBlocker(run, options) ||
|
|
isGitConnectionFailure(run, options) ||
|
|
isProviderQuotaFailure(run, options) ||
|
|
isCodexAcpModelCapacityFailure(run, options) ||
|
|
isAcpProviderAccessFailure(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?.();
|
|
}),
|
|
]);
|
|
}
|