mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native runs use the Paperclip runner to drive a provider session, for example the Codex app server > - When a Codex turn fails, two classifiers look for known failures: a model rejection and a server capacity failure > - When no classifier matches, the run fails with the generic message `Native session failed` and the error code `adapter_failed` > - The run failure report to Sentry then has no data about the cause, because the server drops the Codex `codexErrorInfo` value > - This pull request records a closed-vocabulary diagnostic for that failed turn and sends it to the Sentry `provider_failure` context > - The benefit is that operators can see which Codex failure kind causes these generic failures, and then write a targeted fix ## Linked Issues or Issue Description **What happened?** Hosted staging reports many run failures with the title `Error: Native session failed`. The events have `agent_adapter=paperclip_runner`, `error_code=adapter_failed`, `driverKind=codex_app_server`, and `exitCode=1`. The events do not show why the Codex turn failed. **Expected behavior** A run failure report for a failed Codex turn shows the failure kind, so that an operator can find the cause. The report must not contain provider response text, prompts, or model names. **Steps to reproduce** 1. Start a native Codex run. 2. Make the Codex app server end the turn with `status: "failed"` and an error that is not a model rejection or `serverOverloaded`. For example, use `codexErrorInfo: "contextWindowExceeded"`. 3. Look at the Sentry event for the run failure. Before this change, the `provider_failure` context is empty. **Agent adapter(s) involved** Codex (native runner, `codex_app_server` driver). ## What Changed - `native-provider-failure.ts`: The provider failure observation now records a diagnostic for each failed Codex turn. The diagnostic has only closed values: `provider: "codex"`, `category: "turn_failed"`, a known `CodexErrorInfo` kind (or `unrecognized` / `absent`), an HTTP status from 100 to 599 for the HTTP variants, and a runner failure code from an explicit allowlist of the codes the runner itself writes on a failed Codex turn (any other string becomes `unrecognized`, because a provider error object can carry its own `code`). It never copies the provider message. - New `unclassifiedTurnFailure()` gives this diagnostic only for a failed terminal turn that `forTerminal()` does not classify. `forTerminal()`, error codes, error messages, retries, and finalizer decisions do not change. - New `readNativeTurnFailureDiagnostic()` rebuilds a persisted value from the closed vocabulary and rejects all other values. - `native-session-executor.ts`: The adapter result keeps the generic message. It adds `resultJson.nativeTurnFailure` when the turn failure is unclassified. - `run-failure-diagnostics.ts`: The run failure report copies the validated diagnostic into the `provider` context. Sentry sends this as `provider_failure`. - Tests: unit tests for the vocabulary, HTTP status bounds, unknown kinds, runner codes, and privacy; a diagnostics test; and an update to the end-to-end executor test for the `unknown-error` scenario. ## Verification - `pnpm exec vitest run src/services/native-runtime/native-provider-failure src/services/__tests__/run-failure-diagnostics.test.ts src/services/__tests__/run-failure-report*.test.ts src/__tests__/sentry*.test.ts` (in `server/`): 6 files, 432 passed, 5 skipped. - `pnpm exec vitest run src/services/native-runtime/native-session-executor.test.ts` (in `server/`): 698 passed. - I could not run the full `tsc --noEmit` in my sandbox: the memory limit stopped the process, and the runner vendor build needs `cargo`. CI runs the full typecheck. - To check by hand: make a Codex turn fail with an unclassified error. Then make sure that the Sentry event `provider_failure` context shows `category: turn_failed` and the `codexErrorInfo` kind, and no provider text. ## Risks - Low risk. The change adds diagnostic data only. Run status, error code, error message, retry, and finalizer logic do not change. - Privacy: the diagnostic uses an allowlist of Codex enum names, a bounded integer, and an allowlist of runner-issued failure codes. Tests make sure that provider text does not get through. - If Codex adds a new `CodexErrorInfo` kind, the diagnostic shows `unrecognized` until the allowlist gets the new kind. - This change does not fix the root cause. It gives the data that is necessary to find the cause. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edit, 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 Related: #15347 (capacity classifier), #15304 (model rejection diagnostics). 🤖 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>
169 lines
7.8 KiB
TypeScript
169 lines
7.8 KiB
TypeScript
import type { PrpEvent, PrpTerminalState } from "../../vendor/paperclip-runner/index.js";
|
|
|
|
export const NATIVE_MODEL_REJECTION_MESSAGE =
|
|
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.";
|
|
|
|
export const NATIVE_PROVIDER_OVERLOADED_MESSAGE =
|
|
"Selected model is at capacity. Please try a different model.";
|
|
export const NATIVE_PROVIDER_OVERLOADED_CODE = "native_provider_overloaded";
|
|
export const NATIVE_PROVIDER_CAPACITY_MAX_RETRIES = 2;
|
|
export const NATIVE_PROVIDER_OVERLOADED_DIAGNOSTIC = {
|
|
provider: "codex",
|
|
category: "server_overloaded",
|
|
} as const;
|
|
|
|
export const NATIVE_MODEL_REJECTION_DIAGNOSTIC = {
|
|
provider: "codex",
|
|
category: "model_auth_incompatible",
|
|
status: 400,
|
|
authMode: "chatgpt",
|
|
} as const;
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
/** Reconstruct only the closed diagnostic vocabulary, never provider text. */
|
|
export function readNativeModelRejectionDiagnostic(value: unknown) {
|
|
try {
|
|
const entry = record(value);
|
|
return Object.entries(NATIVE_MODEL_REJECTION_DIAGNOSTIC).every(([key, expected]) => entry[key] === expected)
|
|
? { ...NATIVE_MODEL_REJECTION_DIAGNOSTIC }
|
|
: null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
/**
|
|
* Codex `CodexErrorInfo` variants. Unit variants arrive as strings; HTTP
|
|
* variants arrive as one-key objects that can carry `httpStatusCode`.
|
|
*/
|
|
const CODEX_ERROR_INFO_KINDS: ReadonlySet<string> = new Set([
|
|
"contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", "httpConnectionFailed",
|
|
"responseStreamConnectionFailed", "internalServerError", "unauthorized", "badRequest",
|
|
"threadRollbackFailed", "sandboxError", "responseStreamDisconnected",
|
|
"responseTooManyFailedAttempts", "other",
|
|
]);
|
|
/**
|
|
* `error.code` values the runner itself writes on a failed Codex turn: the
|
|
* Codex driver's `failProtocol` callers and runnerd's Codex backend. A provider
|
|
* error object can also carry `code`, so any other value is `unrecognized`.
|
|
*/
|
|
const NATIVE_TURN_FAILURE_RUNNER_CODES: ReadonlySet<string> = new Set([
|
|
"thread_binding_mismatch", "turn_binding_mismatch", "turn_start_mismatch", "tool_binding_mismatch",
|
|
"runtime_request_binding_mismatch", "runtime_request_duplicate", "runtime_request_recovery_failed",
|
|
"notification_transport_failed", "provider_event_type_invalid", "invalid_semantic_result",
|
|
"conflicting_semantic_result", "conflicting_turn_terminal", "invalid_spawn_lineage",
|
|
"provider_descendant_capacity_exhausted", "provider_terminal_error_missing", "provider_turn_lost_on_restore",
|
|
]);
|
|
|
|
/**
|
|
* Closed vocabulary for a failed Codex turn that no classifier above claims.
|
|
* It explains generic `Native session failed` reports without provider text.
|
|
*/
|
|
export interface NativeTurnFailureDiagnostic {
|
|
provider: "codex";
|
|
category: "turn_failed";
|
|
/** A known `CodexErrorInfo` kind, `unrecognized`, or `absent`. */
|
|
codexErrorInfo: string;
|
|
httpStatus?: number;
|
|
/** A known runner-issued failure code, or `unrecognized` for any other code. */
|
|
runnerFailureCode?: string;
|
|
}
|
|
|
|
function httpStatusField(value: unknown) {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
|
|
? { httpStatus: value } : {};
|
|
}
|
|
|
|
function runnerFailureCodeField(value: unknown) {
|
|
if (typeof value !== "string") return {};
|
|
return { runnerFailureCode: NATIVE_TURN_FAILURE_RUNNER_CODES.has(value) ? value : "unrecognized" };
|
|
}
|
|
|
|
function codexErrorInfoFields(value: unknown): Pick<NativeTurnFailureDiagnostic, "codexErrorInfo" | "httpStatus"> {
|
|
if (value === undefined || value === null) return { codexErrorInfo: "absent" };
|
|
if (typeof value === "string") return { codexErrorInfo: CODEX_ERROR_INFO_KINDS.has(value) ? value : "unrecognized" };
|
|
const entry = record(value);
|
|
const keys = Object.keys(entry);
|
|
if (keys.length !== 1 || !CODEX_ERROR_INFO_KINDS.has(keys[0]!)) return { codexErrorInfo: "unrecognized" };
|
|
return { codexErrorInfo: keys[0]!, ...httpStatusField(record(entry[keys[0]!]).httpStatusCode) };
|
|
}
|
|
|
|
/** Rebuild a persisted turn-failure diagnostic from its closed vocabulary only. */
|
|
export function readNativeTurnFailureDiagnostic(value: unknown): NativeTurnFailureDiagnostic | null {
|
|
try {
|
|
const entry = record(value);
|
|
const kind = entry.codexErrorInfo;
|
|
if (entry.provider !== "codex" || entry.category !== "turn_failed" || typeof kind !== "string" ||
|
|
(kind !== "absent" && kind !== "unrecognized" && !CODEX_ERROR_INFO_KINDS.has(kind))) return null;
|
|
return {
|
|
provider: "codex",
|
|
category: "turn_failed",
|
|
codexErrorInfo: kind,
|
|
...httpStatusField(entry.httpStatus),
|
|
...runnerFailureCodeField(entry.runnerFailureCode),
|
|
};
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** Observe only events accepted by the bound control-plane persistence port. */
|
|
export function createNativeProviderFailureObservation(provider: { kind: string; model?: string | null }) {
|
|
let rejectedTurnId: string | null = null;
|
|
let overloadedTurnId: string | null = null;
|
|
const failedTurns = new Map<string, NativeTurnFailureDiagnostic>();
|
|
return {
|
|
observe(event: PrpEvent) {
|
|
if (provider.kind !== "codex" ||
|
|
event.sourceKind !== "runner" || event.eventType !== "turn.failed" || !event.turnId) return;
|
|
const payload = record(event.payload);
|
|
const failure = record(payload.error);
|
|
if (payload.status === "failed") {
|
|
failedTurns.set(event.turnId, {
|
|
provider: "codex",
|
|
category: "turn_failed",
|
|
...codexErrorInfoFields(failure.codexErrorInfo),
|
|
...runnerFailureCodeField(failure.code),
|
|
});
|
|
}
|
|
if (payload.status === "failed" && failure.codexErrorInfo === "serverOverloaded") {
|
|
overloadedTurnId = event.turnId;
|
|
return;
|
|
}
|
|
if (!provider.model || payload.status !== "failed" || payload.recoverable === true || failure.recoverable === true ||
|
|
typeof failure.message !== "string" || failure.message.length > 4_096) return;
|
|
let response: Record<string, unknown>;
|
|
try { response = record(JSON.parse(failure.message)); } catch { return; }
|
|
const error = record(response.error);
|
|
// This is a provider terminal envelope, not text emitted by a tool or an
|
|
// agent. Bind the exact rejection to the configured model without storing
|
|
// its name or the response. Unknown provider errors retain their behavior.
|
|
if (response.type !== "error" || response.status !== 400 || error.type !== "invalid_request_error" ||
|
|
error.message !== `The '${provider.model}' model is not supported when using Codex with a ChatGPT account.`) return;
|
|
rejectedTurnId = event.turnId;
|
|
},
|
|
forTerminal(turnId: string | null | undefined, terminal: PrpTerminalState) {
|
|
if (!turnId || terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed") return null;
|
|
if (turnId === overloadedTurnId) return {
|
|
errorCode: NATIVE_PROVIDER_OVERLOADED_CODE,
|
|
errorMessage: NATIVE_PROVIDER_OVERLOADED_MESSAGE,
|
|
diagnostic: { ...NATIVE_PROVIDER_OVERLOADED_DIAGNOSTIC },
|
|
};
|
|
if (turnId !== rejectedTurnId) return null;
|
|
return {
|
|
errorCode: "native_provider_model_rejected" as const,
|
|
errorMessage: NATIVE_MODEL_REJECTION_MESSAGE,
|
|
diagnostic: { ...NATIVE_MODEL_REJECTION_DIAGNOSTIC },
|
|
};
|
|
},
|
|
/** Diagnostics only: the failed turn that `forTerminal` did not classify. */
|
|
unclassifiedTurnFailure(turnId: string | null | undefined, terminal: PrpTerminalState) {
|
|
if (!turnId || terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed" ||
|
|
turnId === overloadedTurnId || turnId === rejectedTurnId) return null;
|
|
const diagnostic = failedTurns.get(turnId);
|
|
return diagnostic ? { ...diagnostic } : null;
|
|
},
|
|
};
|
|
}
|