mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The ACPX engine runs Claude agents. A watchdog ends a turn when a pending tool call gets no update for 30 minutes. > - #16688 added a field to stalled runs. The field records if the stalled call's permission request reached Paperclip. > - Recent stalled runs show `acpStalledToolPermissionRequested: false`. We can read this as "the call stopped before its permission request". That reading is correct only if calls of the same kind usually send a request. > - We have no data that shows if finished calls of that kind sent requests. Thus we cannot tell if the missing request is a real signal. > - This pull request records a positive control: the number of finished calls of the stalled call's kind, and how many of them sent a permission request. > - The benefit: the next stall event tells us if the missing request locates the stall, or if it gives no information. ## Linked Issues or Issue Description Refs #16688 **What happened?** Runs stop with the stalled-tool watchdog error. The diagnostics show that the stalled call sent no permission request. The diagnostics do not show if finished calls of the same kind sent requests. **Expected behavior** The stalled-run diagnostics contain enough data to show if a missing permission request is a real signal. **Steps to reproduce** A pending tool call gets no ACP update for 30 minutes. The watchdog ends the turn. The run result contains the stalled-tool fields. ## What Changed - `packages/adapter-utils/src/acpx-engine/execute.ts`: when the watchdog fires, count the finished calls of the stalled call's kind, and count how many of them sent a permission request. Write the counts to the run result as `acpSettledSameKindToolCount` and `acpSettledSameKindToolPermissionRequestedCount`. - `server/src/services/run-failure-diagnostics.ts`: allow the two new fields as non-negative safe integers. Other values are dropped. - `doc/observability.md`: explain how to read the two fields with the existing permission-request field. - Tests: extend the existing stalled-tool tests in `execute.test.ts` and `run-failure-diagnostics.test.ts`. ## Verification - `pnpm exec vitest run src/acpx-engine/execute.test.ts` in `packages/adapter-utils`: 244/244 pass. - `pnpm exec vitest run src/services/__tests__/run-failure-diagnostics.test.ts` in `server`: 115/115 pass. - Mutation check: remove the kind filter, or count every finished call as "requested". Each mutation makes the stalled-tool test fail. - `tsc --noEmit` passes in `packages/adapter-utils` and in `server`. Note: the full `server` `typecheck` script did not run locally, because the runner vendor build step failed in this sandbox. I ran `tsc --noEmit` directly. ## Risks - Low risk. The change only adds two counts to the run result after the watchdog fires. It does not change the decision to stop the turn, or any permission decision. - The fields hold only counts. They contain no tool titles, inputs, or identifiers. This is the run-log and run-result path. It is not Telemetry. The fields reach Sentry through the existing allowlist in `run-failure-diagnostics.ts`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, test runs). ## 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>
469 lines
25 KiB
TypeScript
469 lines
25 KiB
TypeScript
import { readWorkspaceBaseRefDiagnostic } from "./workspace-base-ref-diagnostics.js";
|
|
import { getEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
|
|
import type { heartbeatRuns } from "@paperclipai/db";
|
|
import { readControllerLeaseLoss, readControllerLeaseLossCause, readRunCancellation } from "./run-cancellation.js";
|
|
import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js";
|
|
import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared";
|
|
import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction";
|
|
import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
|
|
import { ACP_TOOL_KINDS } from "@paperclipai/adapter-utils/acpx-engine/constants";
|
|
import { redactCurrentUserText } from "../log-redaction.js";
|
|
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
|
|
import { readNativeModelRejectionDiagnostic, readNativeTurnFailureDiagnostic } from "./native-runtime/native-provider-failure.js";
|
|
import { readRemoteRunnerRecoveryDiagnostic, readRemoteRunnerRecoveryError } from "./native-runtime/remote-runner-recovery-diagnostics.js";
|
|
import { MANAGED_GIT_WORKTREE_REASON_CODES, PERSISTED_WORKSPACE_SOURCE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js";
|
|
|
|
type Run = typeof heartbeatRuns.$inferSelect;
|
|
type Context = Record<string, string | number | boolean>;
|
|
|
|
export interface RunFailureReportOptions {
|
|
/** The caught exception, before callers flatten it to a message. */
|
|
error?: unknown;
|
|
/** Structured adapter error metadata; only known diagnostic keys are read. */
|
|
adapterErrorMeta?: unknown;
|
|
phase?: "setup" | "execute";
|
|
/** Runtime-only values to redact; never included in the captured event. */
|
|
secretValues?: readonly string[];
|
|
/**
|
|
* The caught native session error. Only an unrecoverable-close settlement is
|
|
* read, as closed labels and counts; its message and stack are never copied.
|
|
*/
|
|
nativeCloseFailure?: unknown;
|
|
}
|
|
|
|
export interface RunFailureException {
|
|
name?: string;
|
|
message?: string;
|
|
stack?: string;
|
|
code?: string;
|
|
status?: number;
|
|
requestId?: string;
|
|
}
|
|
|
|
export interface RunFailureDiagnostics {
|
|
execution: Context;
|
|
adapter: Context;
|
|
provider: Context;
|
|
exceptions: RunFailureException[];
|
|
truncatedFields: string[];
|
|
}
|
|
|
|
const ACP_PENDING_TOOL_KIND_LABELS: ReadonlySet<string> = new Set([...ACP_TOOL_KINDS, "unknown"]);
|
|
|
|
/** Closed ACP tool-kind labels only; any other content drops the whole field. */
|
|
function readAcpPendingToolKinds(value: unknown): string | null {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > 200) return null;
|
|
const kinds = value.split(",");
|
|
return kinds.every((kind) => ACP_PENDING_TOOL_KIND_LABELS.has(kind)) ? value : null;
|
|
}
|
|
|
|
/** One closed ACP tool-kind label, or nothing. */
|
|
function readAcpToolKind(value: unknown): string | null {
|
|
return typeof value === "string" && ACP_PENDING_TOOL_KIND_LABELS.has(value) ? value : null;
|
|
}
|
|
|
|
function read(value: unknown, key: string): unknown {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
try {
|
|
return (value as Record<string, unknown>)[key];
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const NATIVE_CLOSE_COMMAND_STATUSES: ReadonlySet<unknown> = new Set(["pending", "completed", "failed", "rejected", "indeterminate"]);
|
|
const NATIVE_CLOSE_RUNNER_LIFECYCLES: ReadonlySet<unknown> = new Set(["ready", "suspended", "closed", "recoverable_failure"]);
|
|
|
|
function boundedCount(value: unknown): number | undefined {
|
|
if (Array.isArray(value)) return value.length;
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
}
|
|
|
|
/**
|
|
* Which close-barrier condition failed. Tool identities, digests and provider
|
|
* turn ids stay in the run log; only booleans, fixed labels and counts leave.
|
|
*/
|
|
function readNativeCloseSettlement(error: unknown): Context | null {
|
|
let candidate = error;
|
|
for (let depth = 0; depth < 4 && candidate && typeof candidate === "object"; depth++) {
|
|
if (read(candidate, "code") === "native_session_close_unrecoverable") break;
|
|
candidate = read(candidate, "cause");
|
|
}
|
|
if (read(candidate, "code") !== "native_session_close_unrecoverable") return null;
|
|
const settlement = read(candidate, "settlement");
|
|
const result: Context = {};
|
|
for (const [field, key] of [["runnerSuspended", "closeRunnerSuspended"], ["providerDrained", "closeProviderDrained"]] as const) {
|
|
const value = read(settlement, field);
|
|
if (typeof value === "boolean") result[key] = value;
|
|
}
|
|
const suspension = read(settlement, "suspensionState");
|
|
const commandStatus = read(suspension, "commandStatus");
|
|
if (NATIVE_CLOSE_COMMAND_STATUSES.has(commandStatus)) result.closeSuspendCommandStatus = commandStatus as string;
|
|
else if (suspension && commandStatus === null) result.closeSuspendCommandStatus = "not_queued";
|
|
const lifecycle = read(suspension, "runnerLifecycle");
|
|
if (NATIVE_CLOSE_RUNNER_LIFECYCLES.has(lifecycle)) result.closeRunnerLifecycle = lifecycle as string;
|
|
else if (suspension && lifecycle === null) result.closeRunnerLifecycle = "unobserved";
|
|
const identityMatches = read(suspension, "runnerIdentityMatches");
|
|
if (typeof identityMatches === "boolean") result.closeRunnerIdentityMatches = identityMatches;
|
|
const provider = read(settlement, "finalProviderState");
|
|
if (provider === null) result.closeProviderState = "not_local";
|
|
else if (provider === "unreadable") result.closeProviderState = "unreadable";
|
|
else if (provider && typeof provider === "object") {
|
|
const settled = read(provider, "providerSettled");
|
|
if (typeof settled === "boolean") result.closeProviderState = settled ? "settled" : "unsettled";
|
|
const pending = boundedCount(read(provider, "pendingEventCount"));
|
|
if (pending !== undefined) result.closeProviderPendingEventCount = pending;
|
|
const activeTurn = read(provider, "activeProviderTurnId");
|
|
if (activeTurn === null || typeof activeTurn === "string") result.closeProviderTurnActive = activeTurn !== null;
|
|
}
|
|
const tools = read(settlement, "semanticTools");
|
|
const persistenceFailed = read(tools, "persistenceFailed");
|
|
if (typeof persistenceFailed === "boolean") result.closeSemanticResultPersistenceFailed = persistenceFailed;
|
|
for (const [field, key] of [
|
|
["pending", "closeSemanticToolsPending"],
|
|
["deliveries", "closeSemanticToolDeliveriesOpen"],
|
|
["failures", "closeSemanticResultFailures"],
|
|
] as const) {
|
|
const count = boundedCount(read(tools, field));
|
|
if (count !== undefined) result[key] = count;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function scalars(value: unknown, fields: readonly string[]): Context {
|
|
const result: Context = {};
|
|
for (const field of fields) {
|
|
const entry = read(value, field);
|
|
if (
|
|
typeof entry === "string" || typeof entry === "boolean" ||
|
|
(typeof entry === "number" && Number.isFinite(entry))
|
|
) result[field] = entry;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Unknown inherited values may contain credentials under arbitrary names.
|
|
// Explicitly configured values remain private, including overrides of these keys.
|
|
const PUBLIC_ENV_KEYS = new Set([
|
|
"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "HOME", "USERPROFILE",
|
|
"HOMEDRIVE", "HOMEPATH", "USER", "USERNAME", "LOGNAME", "SHELL", "LANG",
|
|
"LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "NODE_ENV",
|
|
"XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME",
|
|
"PWD", "OLDPWD", "INIT_CWD", "SHLVL", "_", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORTERM",
|
|
"NPM_LIFECYCLE_EVENT", "NPM_COMMAND", "NPM_EXEC_PATH", "NPM_EXECPATH", "NPM_NODE_EXECPATH",
|
|
"NPM_PACKAGE_NAME", "NPM_PACKAGE_VERSION", "NPM_PACKAGE_JSON", "NPM_CONFIG_USER_AGENT",
|
|
"XPC_SERVICE_NAME", "VITEST_POOL_ID", "VITEST_WORKER_ID",
|
|
"PAPERCLIP_AGENT_ID", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_RUN_ID", "PAPERCLIP_TASK_ID",
|
|
]);
|
|
const PUBLIC_BOOLEAN_ENV_KEYS = new Set([
|
|
"OPENCODE_ALLOW_ALL_MODELS", "CLAUDE_CODE_USE_BEDROCK", "GOOGLE_GENAI_USE_GCA",
|
|
"CI", "NO_COLOR", "FORCE_COLOR",
|
|
"VITEST", "MALLOCNANOZONE", "CODEX_CI", "CODEX_SHELL", "CODEX_SAGE_BACKFILL_TRACKER_TAB_REUSE",
|
|
"DEV", "PROD", "SSR", "PAPERCLIP_REQUIRE_SENTRY_TEST_SDK",
|
|
"PAPERCLIP_DB_BACKUP_ENABLED", "PAPERCLIP_WORKSPACE_COMPACTION_ENABLED", "PAPERCLIP_AUTH_DISABLE_SIGN_UP", "PAPERCLIP_ENABLE_COMPANY_DELETION",
|
|
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "PAPERCLIP_SECRETS_STRICT_MODE",
|
|
"SERVE_UI", "PAPERCLIP_UI_DEV_MIDDLEWARE", "HEARTBEAT_SCHEDULER_ENABLED", "PAPERCLIP_ANNOUNCEMENTS_ENABLED",
|
|
"GITHUB_ACTIONS", "GITHUB_REF_PROTECTED", "RUNNER_DEBUG", "ACTIONS_RUNNER_DEBUG", "ACTIONS_STEP_DEBUG",
|
|
]);
|
|
const PUBLIC_NUMBER_ENV_KEYS = new Set([
|
|
"PORT", "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", "PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
|
|
"PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "PAPERCLIP_WORKSPACE_COMPACTION_DELAY_HOURS",
|
|
"HEARTBEAT_SCHEDULER_INTERVAL_MS",
|
|
"GITHUB_RUN_ATTEMPT", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ID", "GITHUB_RETENTION_DAYS",
|
|
]);
|
|
|
|
/** Include declared bindings and unknown values, not just credential-like keys. */
|
|
export function collectRunFailureSecretValues(
|
|
env: unknown, secretKeys: Iterable<string> = [], inherited = false,
|
|
): string[] {
|
|
if (!env || typeof env !== "object") return [];
|
|
const strings = Object.fromEntries(Object.entries(env).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
));
|
|
const declared = new Set(secretKeys);
|
|
const values: string[] = [];
|
|
for (const [key, value] of Object.entries(strings)) {
|
|
if (!value) continue;
|
|
const publicSetting = (inherited && (
|
|
PUBLIC_ENV_KEYS.has(key.toUpperCase()) ||
|
|
(PUBLIC_NUMBER_ENV_KEYS.has(key.toUpperCase()) && /^\d+$/.test(value)) ||
|
|
(key === "BASE_URL" && value === "/") ||
|
|
(key === "MODE" && /^(?:test|development|production)$/.test(value)) ||
|
|
(key === "PAPERCLIP_SENTRY_RUN_FAILURES" && /^(?:off|unexpected|all)$/.test(value))
|
|
)) ||
|
|
(PUBLIC_BOOLEAN_ENV_KEYS.has(key.toUpperCase()) && /^(?:0|1|true|false)$/i.test(value));
|
|
if (declared.has(key) || !publicSetting) values.push(value);
|
|
try {
|
|
const password = new URL(value).password;
|
|
if (password) values.push(value, password, decodeURIComponent(password));
|
|
} catch { /* ordinary environment values are not URLs */ }
|
|
}
|
|
return [...new Set(values)].sort((a, b) => b.length - a.length);
|
|
}
|
|
|
|
/** Redact literal, JSON-escaped and URI-encoded forms without rewriting markers. */
|
|
export function redactRunFailureSecretValues<T>(input: T, values: readonly string[]): T {
|
|
const forms = [...new Set(values.filter(Boolean).flatMap((value) => {
|
|
const forms = [value, JSON.stringify(value).slice(1, -1)];
|
|
try { forms.push(encodeURIComponent(value)); } catch { /* malformed Unicode */ }
|
|
return forms;
|
|
}))].sort((a, b) => b.length - a.length);
|
|
if (forms.length === 0) return input;
|
|
const pattern = new RegExp(forms.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gu");
|
|
const redact = (value: unknown): unknown => {
|
|
if (typeof value === "string") return value.replace(pattern, () => REDACTED_EVENT_VALUE);
|
|
if (Array.isArray(value)) return value.map(redact);
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redact(entry)]));
|
|
}
|
|
return value;
|
|
};
|
|
return redact(input) as T;
|
|
}
|
|
|
|
/** Snapshot only diagnostics. Never walk a request, response, config or prompt. */
|
|
export function collectRunFailureDiagnostics(run: Run, options: RunFailureReportOptions): RunFailureDiagnostics {
|
|
const execution = scalars(run, [
|
|
"runtimeMode", "executionStage", "nativePhase", "driverKind", "driverVersion",
|
|
]);
|
|
if (options.phase) execution.failurePhase = options.phase;
|
|
const providerConfig = read(read(run.runnerProfileJson, "nativeExecutionInput"), "provider");
|
|
const providerKind = read(providerConfig, "kind");
|
|
const model = read(providerConfig, "model");
|
|
if (typeof providerKind === "string") execution.providerKind = providerKind;
|
|
if (typeof model === "string") execution.model = model;
|
|
for (const field of ["startedAt", "finishedAt"] as const) {
|
|
const value = run[field];
|
|
if (value instanceof Date && Number.isFinite(value.getTime())) execution[field] = value.toISOString();
|
|
}
|
|
const originalFailureCode = read(run.resultJson, "originalFailureCode");
|
|
if (typeof originalFailureCode === "string" && /^[a-z][a-z0-9_]{1,99}$/.test(originalFailureCode)) {
|
|
execution.originalFailureCode = originalFailureCode;
|
|
}
|
|
if (run.startedAt && run.finishedAt) {
|
|
const durationMs = run.finishedAt.getTime() - run.startedAt.getTime();
|
|
if (Number.isFinite(durationMs) && durationMs >= 0) execution.durationMs = durationMs;
|
|
}
|
|
const result = run.resultJson;
|
|
// Restart reattachment can fail before the native coordinator saves its
|
|
// result. The thrown error then reaches the generic run failure path, so
|
|
// read the same closed labels from the error.
|
|
const remoteRunnerRecovery = readRemoteRunnerRecoveryDiagnostic(read(result, "remoteRunnerRecovery")) ??
|
|
readRemoteRunnerRecoveryError(options.error);
|
|
if (remoteRunnerRecovery) {
|
|
execution.remoteRunnerRecoveryStage = remoteRunnerRecovery.stage;
|
|
execution.remoteRunnerRecoveryReason = remoteRunnerRecovery.reason;
|
|
}
|
|
const configuration = read(result, "configurationIncomplete");
|
|
if (run.errorCode === "configuration_incomplete" && read(configuration, "reason") === "workspace_base_ref_unresolved") {
|
|
const diagnostic = readWorkspaceBaseRefDiagnostic(read(configuration, "baseRefDiagnostic"));
|
|
if (diagnostic) {
|
|
for (const [key, value] of Object.entries(diagnostic)) {
|
|
if (key !== "schemaVersion") execution[`workspaceBaseRef${key[0]!.toUpperCase()}${key.slice(1)}`] = value;
|
|
}
|
|
}
|
|
}
|
|
const workspaceValidation = read(result, "workspaceValidation");
|
|
if (run.errorCode === "workspace_validation_failed" && read(workspaceValidation, "reason") === "git_worktree_not_reusable") {
|
|
execution.workspaceValidationReason = "git_worktree_not_reusable";
|
|
const reasonCode = MANAGED_GIT_WORKTREE_REASON_CODES.find(code => code === read(workspaceValidation, "reasonCode"));
|
|
if (reasonCode) execution.workspaceValidationReasonCode = reasonCode;
|
|
const diagnostic = reasonCode === "git_inspection_failed"
|
|
? readManagedGitInspectionDiagnostic(read(workspaceValidation, "inspectionDiagnostic")) : null;
|
|
if (diagnostic) {
|
|
execution.workspaceValidationInspectionCommand = diagnostic.command;
|
|
execution.workspaceValidationInspectionFailure = diagnostic.failure;
|
|
if (diagnostic.errorCode) execution.workspaceValidationInspectionErrorCode = diagnostic.errorCode;
|
|
if (diagnostic.exitCode !== undefined) execution.workspaceValidationInspectionExitCode = diagnostic.exitCode;
|
|
}
|
|
}
|
|
if (run.errorCode === "workspace_validation_failed" && read(workspaceValidation, "reason") === "persisted_workspace_source_conflict") {
|
|
execution.workspaceValidationReason = "persisted_workspace_source_conflict";
|
|
const reasonCode = PERSISTED_WORKSPACE_SOURCE_REASON_CODES.find(code => code === read(workspaceValidation, "reasonCode"));
|
|
if (reasonCode) execution.workspaceValidationReasonCode = reasonCode;
|
|
}
|
|
if (run.errorCode === "process_lost") {
|
|
const diagnostic = readProcessLossDiagnostic(read(result, "processLossDiagnostic"));
|
|
for (const [field, value] of Object.entries(diagnostic)) {
|
|
execution[`processLoss${field[0]!.toUpperCase()}${field.slice(1)}`] = value;
|
|
}
|
|
}
|
|
const cancellation = readRunCancellation(result);
|
|
if (cancellation) {
|
|
execution.cancellationSource = cancellation.source;
|
|
execution.cancellationExpected = cancellation.expected;
|
|
execution.cancellationInitiatorType = cancellation.initiator.type;
|
|
const leaseLossCause = readControllerLeaseLossCause(cancellation);
|
|
if (leaseLossCause) execution.controllerLeaseLossCause = leaseLossCause;
|
|
} else if (run.status === "cancelled") {
|
|
execution.cancellationSource = "unknown";
|
|
execution.cancellationExpected = false;
|
|
}
|
|
const leaseLoss = readControllerLeaseLoss(read(result, "controllerLeaseLoss"));
|
|
if (leaseLoss) {
|
|
execution.controllerLeaseLossCause = leaseLoss.cause;
|
|
if (leaseLoss.sinceRenewalMs !== undefined) execution.controllerLeaseSinceRenewalMs = leaseLoss.sinceRenewalMs;
|
|
if (leaseLoss.renewalPendingMs !== undefined) execution.controllerLeaseRenewalPendingMs = leaseLoss.renewalPendingMs;
|
|
if (leaseLoss.deadlineLateMs !== undefined) execution.controllerLeaseDeadlineLateMs = leaseLoss.deadlineLateMs;
|
|
if (leaseLoss.renewalCount !== undefined) execution.controllerLeaseRenewalCount = leaseLoss.renewalCount;
|
|
}
|
|
Object.assign(execution, scalars(result, [
|
|
"mode", "stopReason", "timeoutFired", "timeoutSource", "timeoutConfigured",
|
|
"effectiveTimeoutSec", "errorFamily",
|
|
]));
|
|
for (const field of [
|
|
"acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount",
|
|
"acpLastEventAgeAtStopMs", "acpOldestPendingToolAgeAtStopMs", "acpStalledToolSilentMs",
|
|
"acpPendingToolPermissionRequestedCount", "acpSettledSameKindToolCount",
|
|
"acpSettledSameKindToolPermissionRequestedCount",
|
|
]) {
|
|
const value = read(result, field);
|
|
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) execution[field] = value;
|
|
}
|
|
const closeSettlement = readNativeCloseSettlement(options.nativeCloseFailure);
|
|
if (closeSettlement) Object.assign(execution, closeSettlement);
|
|
const pendingToolKinds = readAcpPendingToolKinds(read(result, "acpPendingToolKinds"));
|
|
if (pendingToolKinds) execution.acpPendingToolKinds = pendingToolKinds;
|
|
const stalledToolKind = readAcpToolKind(read(result, "acpStalledToolKind"));
|
|
if (stalledToolKind) execution.acpStalledToolKind = stalledToolKind;
|
|
const stalledToolPermissionRequested = read(result, "acpStalledToolPermissionRequested");
|
|
if (typeof stalledToolPermissionRequested === "boolean") {
|
|
execution.acpStalledToolPermissionRequested = stalledToolPermissionRequested;
|
|
}
|
|
const toolInventoryComplete = read(result, "acpToolInventoryComplete");
|
|
if (typeof toolInventoryComplete === "boolean") execution.acpToolInventoryComplete = toolInventoryComplete;
|
|
const restoreFailure = read(result, "workspaceRestoreFailure");
|
|
const restoreCode = WORKSPACE_RESTORE_FAILURE_CODES.find((code) => code === restoreFailure);
|
|
if (restoreCode) {
|
|
execution.workspaceRestoreFailure = restoreCode;
|
|
const diagnostic = sanitizeWorkspaceRestoreDiagnostic(read(result, "workspaceRestoreDiagnostic"));
|
|
if (diagnostic) {
|
|
execution.workspaceRestorePhase = diagnostic.phase;
|
|
execution.workspaceRestoreErrorCode = diagnostic.errorCode;
|
|
if (diagnostic.step) execution.workspaceRestoreStep = diagnostic.step;
|
|
if (diagnostic.httpStatus !== undefined) execution.workspaceRestoreHttpStatus = diagnostic.httpStatus;
|
|
if (diagnostic.exitCode !== undefined) execution.workspaceRestoreExitCode = diagnostic.exitCode;
|
|
if (diagnostic.transferStep) execution.workspaceRestoreTransferStep = diagnostic.transferStep;
|
|
if (diagnostic.transferFailureKind) execution.workspaceRestoreTransferFailureKind = diagnostic.transferFailureKind;
|
|
if (diagnostic.rpcCode !== undefined) execution.workspaceRestoreRpcCode = diagnostic.rpcCode;
|
|
if (diagnostic.gitCommand) execution.workspaceRestoreGitCommand = diagnostic.gitCommand;
|
|
if (diagnostic.gitFailureKind) execution.workspaceRestoreGitFailureKind = diagnostic.gitFailureKind;
|
|
}
|
|
}
|
|
const adapter = scalars(options.adapterErrorMeta, [
|
|
"category", "phase", "errorName", "acpCode", "causeMessage", "retryable",
|
|
"stackPreview", "status", "statusCode", "requestId",
|
|
]);
|
|
const provider = scalars(read(result, "terminalSessionFailure"), ["category", "title", "details"]);
|
|
const nativeProviderFailure = readNativeModelRejectionDiagnostic(read(result, "nativeProviderFailure"));
|
|
if (nativeProviderFailure) Object.assign(provider, nativeProviderFailure);
|
|
const nativeTurnFailure = readNativeTurnFailureDiagnostic(read(result, "nativeTurnFailure"));
|
|
if (nativeTurnFailure) Object.assign(provider, nativeTurnFailure);
|
|
const truncatedFields: string[] = [];
|
|
const providerTruncation = read(read(result, "terminalSessionFailure"), "truncatedFields");
|
|
if (Array.isArray(providerTruncation)) {
|
|
for (const field of ["title", "details"]) {
|
|
if (providerTruncation.includes(field)) truncatedFields.push(`provider.${field}`);
|
|
}
|
|
}
|
|
const exceptions: RunFailureException[] = [];
|
|
const seen = new Set<unknown>();
|
|
let error = options.error;
|
|
while (error !== undefined && error !== null && exceptions.length < 4) {
|
|
if (seen.has(error)) {
|
|
truncatedFields.push("exceptions.cycle");
|
|
break;
|
|
}
|
|
seen.add(error);
|
|
if (typeof error === "string") {
|
|
exceptions.push({ message: error });
|
|
error = undefined;
|
|
break;
|
|
}
|
|
if (typeof error !== "object") break;
|
|
const acquisition = options.phase === "setup" && run.errorCode === "setup_failed"
|
|
? getEnvironmentAcquisitionDiagnostic(error, run) : null;
|
|
if (acquisition && execution.environmentAcquisitionPhase === undefined) {
|
|
execution.environmentAcquisitionPhase = acquisition.phase;
|
|
execution.environmentAcquisitionElapsedMs = acquisition.elapsedMs;
|
|
execution.environmentAcquisitionBudgetMs = acquisition.budgetMs;
|
|
if (acquisition.providerState) execution.environmentAcquisitionProviderState = acquisition.providerState;
|
|
}
|
|
if (execution.restoreLockOwnerState === undefined && read(error, "code") === "ERR_WORKSPACE_RESTORE_LOCK_TIMEOUT") {
|
|
const lock = read(error, "workspaceRestoreLock");
|
|
const operation = read(lock, "operation");
|
|
if (["agent_directory_prepare", "agent_directory_release", "agent_directory_collect", "agent_directory_checkpoint", "agent_directory_handoff"].some(value => value === operation)) {
|
|
execution.restoreLockOperation = operation as string;
|
|
}
|
|
const ownerState = read(lock, "ownerState");
|
|
if (["alive", "dead", "unknown", "missing", "invalid"].some(value => value === ownerState)) {
|
|
execution.restoreLockOwnerState = ownerState as string;
|
|
}
|
|
for (const field of ["knownLocalHolder", "ownerSameProcess", "ownerPredatesProcess"]) {
|
|
const value = read(lock, field);
|
|
if (typeof value === "boolean") execution[`restoreLock${field[0]!.toUpperCase()}${field.slice(1)}`] = value;
|
|
}
|
|
for (const field of ["ownerAgeMs", "waitMs"]) {
|
|
const value = read(lock, field);
|
|
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 7 * 24 * 60 * 60 * 1000) {
|
|
execution[`restoreLock${field[0]!.toUpperCase()}${field.slice(1)}`] = value;
|
|
}
|
|
}
|
|
}
|
|
const entry: RunFailureException = {};
|
|
for (const field of ["name", "message", "stack", "code"] as const) {
|
|
const value = read(error, field);
|
|
if (typeof value === "string") entry[field] = value;
|
|
else if (field === "code" && typeof value === "number" && Number.isFinite(value)) entry.code = String(value);
|
|
}
|
|
if (entry.code === undefined) {
|
|
const reasonCode = read(error, "reasonCode");
|
|
if (typeof reasonCode === "string") entry.code = reasonCode;
|
|
}
|
|
const status = read(error, "status") ?? read(error, "statusCode");
|
|
if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599) entry.status = status;
|
|
const requestId = read(error, "requestId") ?? read(error, "request_id");
|
|
if (typeof requestId === "string") entry.requestId = requestId;
|
|
if (Object.keys(entry).length > 0) exceptions.push(entry);
|
|
else break;
|
|
error = read(error, "cause");
|
|
}
|
|
if (error !== undefined && error !== null && exceptions.length === 4) truncatedFields.push("exceptions.depth");
|
|
return { execution, adapter, provider, exceptions, truncatedFields };
|
|
}
|
|
|
|
/** Redact complete values before cutting, including credentials across a cut. */
|
|
export function sanitizeRunFailureText(input: string, limit: number): string {
|
|
const normalized = input
|
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
.replace(/[\ud800-\udfff]/gu, "\ufffd");
|
|
const clean = redactSensitiveText(redactDiagnosticText(redactCurrentUserText(normalized)));
|
|
if (clean.length <= limit) return clean;
|
|
const suffix = "\n[truncated]";
|
|
let end = limit - suffix.length;
|
|
if ((clean.codePointAt(end - 1) ?? 0) > 0xffff) end--;
|
|
return clean.slice(0, end) + suffix;
|
|
}
|
|
|
|
/** Called after the run's registered secret values have also been removed. */
|
|
export function sanitizeRunFailureDiagnostics(raw: RunFailureDiagnostics): RunFailureDiagnostics {
|
|
const truncatedFields = [...raw.truncatedFields];
|
|
const text = (value: string, path: string, limit: number) => {
|
|
const sanitized = sanitizeRunFailureText(value, limit);
|
|
if (sanitized.endsWith("\n[truncated]")) truncatedFields.push(path);
|
|
return sanitized;
|
|
};
|
|
const context = (value: Context, prefix: string, limits: Record<string, number> = {}): Context =>
|
|
Object.fromEntries(Object.entries(value).map(([key, value]) => [key,
|
|
typeof value === "string" ? text(value, `${prefix}.${key}`, limits[key] ?? 200) : value,
|
|
]));
|
|
return {
|
|
execution: context(raw.execution, "execution"),
|
|
adapter: context(raw.adapter, "adapter", { causeMessage: 2048, stackPreview: 8192 }),
|
|
provider: context(raw.provider, "provider", { title: 4096, details: 12288 }),
|
|
exceptions: raw.exceptions.map((entry, i) => context(entry as Context, `exceptions.${i}`, { message: 2048, stack: 8192 })),
|
|
truncatedFields: [...new Set(truncatedFields)],
|
|
};
|
|
}
|