Files
PaperClipAI/server/src/services/native-runtime/native-provider-failure.ts
T
DottaandClaude Opus 5.5 2d89486c57 fix(native): record OpenCode's rejected model as a model rejection (#19865)
## Thinking Path

- Paperclip runs agents through native providers; when a provider
rejects the selected model, the person needs to see "Model unavailable"
and choose another model.
- #19548 made the OpenCode driver report OpenRouter's invalid model ID
as a closed `model_unavailable` provider failure. The task banner now
shows it.
- The server's terminal classifier, however, recognized a model
rejection only from Codex's exact ChatGPT error envelope. OpenCode's
structured failure was stored for the banner but the run was recorded as
`adapter_failed`.
- So chat showed a generic failure, and the task was not blocked for a
model choice. The `runs-adapters.inspect-failed-run` chat eval caught
this.
- This change maps an action-required `model_unavailable` failure to
`native_provider_model_rejected` for any provider.

## Linked Issues or Issue Description

Refs #19548. An OpenCode run whose model OpenRouter rejects (`... is not
a valid model ID`, HTTP 400) finishes with `errorCode: adapter_failed`.
Expected: `native_provider_model_rejected`, as for Codex.

## What Changed

- `native-provider-failure.ts`: `forTerminal` returns
`native_provider_model_rejected` when the failed turn's provider failure
has category `model_unavailable` and retryability `requires_action`.
Transient or unknown retryability still returns null. The diagnostic is
the closed turn diagnostic; no provider text or model name is copied.
- `NATIVE_MODEL_REJECTION_MESSAGE` no longer names a ChatGPT connection,
since it is now the blocker text for any provider.
- Tests for both providers, plus negative cases for transient/unknown
retryability.
- `wake-queue/adapters/postgres.ts`: a native run that failed for a
rejected model or incomplete configuration, on a task the arbiter
already blocked, is released without opening a
`native_continuation_requires_reconciliation` incident. That incident
put the run in `recovery_needed`, which hid the run page's Retry, though
the provider refused the request and nothing ran to reconcile. Queued
messages stay deferred.

## Verification

- `pnpm exec vitest run
src/services/native-runtime/native-provider-failure.test.ts
src/services/native-runtime/status-arbiter.test.ts
src/services/native-runtime/native-provider-failure.integration.test.ts`
(server): 98 passed.
- `pnpm exec vitest run src/modules/wake-queue` (server): passes,
including a new test that fails on the old code (it opened the
incident).
- Server `tsc --noEmit`: clean.
- End to end: the `runs-adapters.inspect-failed-run` chat eval will be
rerun on the chat-eval candidate with this change.

## Risks

- Codex model-rejection blocker wording changes from "current ChatGPT
connection" to "current AI connection".
- Any provider driver that reports `model_unavailable` with
`requires_action` now blocks the task for a model choice instead of the
generic failure path, which disables automatic adapter-failure retries
for that run. That is the intended behavior for a rejected model.
- Configuration-failure native runs on a blocked task no longer get a
board reconciliation incident. If such a run had already produced side
effects in earlier turns, they are not flagged for reconciliation; a
provider model rejection happens before the turn does work.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code, with
tool use (shell, file editing).

## 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: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:28:07 -06:00

301 lines
15 KiB
TypeScript

import type { PrpEvent, PrpTerminalState } from "../../vendor/paperclip-runner/index.js";
import { readProviderFailure, type ProviderFailure } from "@paperclipai/shared";
// Shared by every provider whose selected model is rejected, so it names no specific connection.
export const NATIVE_MODEL_REJECTION_MESSAGE =
"The selected model is not available through the current AI 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 NativeCodexTurnFailureDiagnostic {
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;
}
/**
* `error.code` values the OpenCode driver writes when it fails a turn itself.
* Other OpenCode failures carry provider error objects and stay `unrecognized`.
*/
const OPENCODE_TURN_FAILURE_RUNNER_CODES: ReadonlySet<string> = new Set([
"opencode_server_exited", "opencode_event_stream_lost", "provider_terminal_error_missing",
"opencode_empty_response", "opencode_output_limit_reached",
]);
const PROVIDER_FAILURE_CATEGORIES: ReadonlySet<string> = new Set<ProviderFailure["category"]>([
"rate_limit", "quota_exhausted", "authentication", "permission", "model_unavailable", "invalid_request",
"overloaded", "timeout", "unknown",
]);
const PROVIDER_FAILURE_RETRYABILITY: ReadonlySet<string> = new Set<ProviderFailure["retryability"]>([
"transient", "requires_action", "unknown",
]);
const OPENCODE_STREAM_FAILURE_KINDS: ReadonlySet<string> = new Set([
"server_exited", "connection_failed", "http_error", "stream_closed", "stream_error",
]);
const OPENCODE_CONNECTION_ERRORS: ReadonlySet<string> = new Set([
"ECONNREFUSED", "ECONNRESET", "EPIPE", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH",
"UND_ERR_SOCKET", "UND_ERR_CLOSED", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT", "unrecognized",
]);
/** POSIX, Linux and macOS signal names; any other `signal` value is dropped. */
const OPENCODE_EXIT_SIGNALS: ReadonlySet<string> = new Set([
"SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGTRAP", "SIGABRT", "SIGIOT", "SIGBUS", "SIGEMT", "SIGFPE",
"SIGKILL", "SIGUSR1", "SIGSEGV", "SIGUSR2", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGSTKFLT", "SIGCHLD",
"SIGCONT", "SIGSTOP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGURG", "SIGXCPU", "SIGXFSZ", "SIGVTALRM",
"SIGPROF", "SIGWINCH", "SIGIO", "SIGPOLL", "SIGPWR", "SIGINFO", "SIGLOST", "SIGSYS", "SIGUNUSED",
]);
/** Closed vocabulary for a failed OpenCode turn, including a lost `opencode serve`. */
export interface NativeOpenCodeTurnFailureDiagnostic {
provider: "opencode";
category: "turn_failed";
/** A known runner-issued failure code, or `unrecognized` for any other code. */
runnerFailureCode?: string;
/** How the driver lost its event stream, when it failed the turn itself. */
streamFailure?: string;
exitCode?: number;
signal?: string;
connectionError?: string;
/** The stream failure's HTTP status, or else the provider error's status. */
httpStatus?: number;
/** The provider error's category and retryability, when the turn carried one. */
providerFailureCategory?: ProviderFailure["category"];
providerFailureRetryability?: ProviderFailure["retryability"];
}
export type NativeTurnFailureDiagnostic = NativeCodexTurnFailureDiagnostic | NativeOpenCodeTurnFailureDiagnostic;
function httpStatusField(value: unknown) {
return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
? { httpStatus: value } : {};
}
function runnerFailureCodeField(value: unknown, codes: ReadonlySet<string> = NATIVE_TURN_FAILURE_RUNNER_CODES) {
if (typeof value !== "string") return {};
return { runnerFailureCode: codes.has(value) ? value : "unrecognized" };
}
function openCodeStreamFailureFields(value: unknown): Omit<NativeOpenCodeTurnFailureDiagnostic, "provider" | "category" | "runnerFailureCode"> {
const entry = record(value);
const kind = entry.kind ?? entry.streamFailure;
if (typeof kind !== "string" || !OPENCODE_STREAM_FAILURE_KINDS.has(kind)) return {};
const { exitCode, signal, connectionError } = entry;
return {
streamFailure: kind,
...(typeof exitCode === "number" && Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255 ? { exitCode } : {}),
...(typeof signal === "string" && OPENCODE_EXIT_SIGNALS.has(signal) ? { signal } : {}),
...(typeof connectionError === "string" && OPENCODE_CONNECTION_ERRORS.has(connectionError) ? { connectionError } : {}),
...httpStatusField(entry.httpStatus),
};
}
/** Only the provider error's closed fields; its message and code stay out of diagnostics. */
function openCodeProviderFailureFields(
category: unknown, retryability: unknown, httpStatus: unknown,
): Pick<NativeOpenCodeTurnFailureDiagnostic, "providerFailureCategory" | "providerFailureRetryability" | "httpStatus"> {
if (typeof category !== "string" || !PROVIDER_FAILURE_CATEGORIES.has(category)) return {};
return {
providerFailureCategory: category as ProviderFailure["category"],
...(typeof retryability === "string" && PROVIDER_FAILURE_RETRYABILITY.has(retryability)
? { providerFailureRetryability: retryability as ProviderFailure["retryability"] } : {}),
...httpStatusField(httpStatus),
};
}
function openCodeTurnFailure(failure: Record<string, unknown>): NativeOpenCodeTurnFailureDiagnostic {
const providerFailure = readProviderFailure(failure.providerFailure);
return {
provider: "opencode",
category: "turn_failed",
...runnerFailureCodeField(failure.code, OPENCODE_TURN_FAILURE_RUNNER_CODES),
...(providerFailure ? openCodeProviderFailureFields(
providerFailure.category, providerFailure.retryability, providerFailure.httpStatus) : {}),
...openCodeStreamFailureFields(failure.opencodeFailure),
};
}
function codexErrorInfoFields(value: unknown): Pick<NativeCodexTurnFailureDiagnostic, "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);
if (entry.provider === "opencode") {
if (entry.category !== "turn_failed") return null;
return {
provider: "opencode",
category: "turn_failed",
...runnerFailureCodeField(entry.runnerFailureCode, OPENCODE_TURN_FAILURE_RUNNER_CODES),
...openCodeProviderFailureFields(entry.providerFailureCategory, entry.providerFailureRetryability, entry.httpStatus),
...openCodeStreamFailureFields(entry),
};
}
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>();
const providerFailures = new Map<string, ProviderFailure>();
/** A driver-classified, action-required model rejection; `forTerminal` classifies it, so it is not "unclassified". */
const modelUnavailableTurn = (turnId: string) => {
const failure = providerFailures.get(turnId);
return failure?.category === "model_unavailable" && failure.retryability === "requires_action";
};
return {
observe(event: PrpEvent) {
if (event.sourceKind === "runner" && event.eventType === "turn.failed" && event.turnId
&& event.payload.status === "failed") {
const failure = readProviderFailure(record(event.payload.error).providerFailure);
if (failure && !providerFailures.has(event.turnId)) providerFailures.set(event.turnId, failure);
}
if ((provider.kind !== "codex" && provider.kind !== "opencode") ||
event.sourceKind !== "runner" || event.eventType !== "turn.failed" || !event.turnId) return;
const payload = record(event.payload);
const failure = record(payload.error);
if (provider.kind === "opencode") {
// OpenCode has no classifier above, so every failed turn is diagnosed.
if (payload.status === "failed") failedTurns.set(event.turnId, openCodeTurnFailure(failure));
return;
}
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;
},
providerFailureForTerminal(turnId: string | null | undefined, terminal: PrpTerminalState) {
if (!turnId || terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed") return null;
return providerFailures.get(turnId) ?? null;
},
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) {
// A driver's closed category marks a rejected model for any provider. Only an
// action-required rejection qualifies; a transient one may succeed on retry.
if (!modelUnavailableTurn(turnId)) return null;
const failure = providerFailures.get(turnId)!;
return {
errorCode: "native_provider_model_rejected" as const,
errorMessage: NATIVE_MODEL_REJECTION_MESSAGE,
diagnostic: failedTurns.get(turnId) ?? { provider: failure.provider, category: failure.category },
};
}
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 || modelUnavailableTurn(turnId)) return null;
const diagnostic = failedTurns.get(turnId);
return diagnostic ? { ...diagnostic } : null;
},
};
}