Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions src/lib/request-execution-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export interface RequestExecutionBudget extends TransientSendBudget {
* that cannot reach it has no operator override, which is the fail-closed answer.
*/
claimAmbiguousResend?(limit: number): boolean;
/** True once any scope has claimed a replacement for this logical request. */
readonly ambiguousResendSpent?: boolean;
}

const RESERVE_FUNDED_CLASSES: ReadonlySet<SendClass> = new Set<SendClass>([
Expand Down Expand Up @@ -224,6 +226,7 @@ interface SharedSendLedger {
* it in -- it has to ask whoever holds the request's grant.
*/
claimAmbiguousResend(limit: number): boolean;
readonly ambiguousResendSpent: boolean;
readonly observer?: RequestSendObserver;
}

Expand All @@ -240,20 +243,23 @@ const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>(
* already spent the one replacement a strict row granted buy another as soon as a more
* permissive row asked, which is a second duplicate inference of one turn.
*/
function createAmbiguousResendGrant(): (limit: number) => boolean {
function createAmbiguousResendGrant(): Pick<SharedSendLedger, "claimAmbiguousResend" | "ambiguousResendSpent"> {
let claimed = 0;
let ceiling: number | undefined;
return (limit: number): boolean => {
const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0;
// A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a
// caller that cannot state a grant, not an operator narrowing this request: a leg with no
// policy is refused before it ever claims, so binding the request to a malformed number
// would only let such a caller cancel a grant an opted-in row really made.
if (presented <= 0) return false;
ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented);
if (claimed >= ceiling) return false;
claimed += 1;
return true;
return {
get ambiguousResendSpent(): boolean { return claimed > 0; },
claimAmbiguousResend(limit: number): boolean {
const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0;
// A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a
// caller that cannot state a grant, not an operator narrowing this request: a leg with no
// policy is refused before it ever claims, so binding the request to a malformed number
// would only let such a caller cancel a grant an opted-in row really made.
if (presented <= 0) return false;
ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented);
if (claimed >= ceiling) return false;
claimed += 1;
return true;
},
};
}

Expand Down Expand Up @@ -302,6 +308,7 @@ function createRequestExecutionBudgetWithLedger(
claimAmbiguousResend(limit: number): boolean {
return counter.claimAmbiguousResend(limit);
},
get ambiguousResendSpent(): boolean { return counter.ambiguousResendSpent; },
reserveDispatch(intent: DispatchIntent): DispatchDecision {
if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" };
if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
Expand Down Expand Up @@ -396,10 +403,12 @@ export function createRequestExecutionBudget(
logicalRequestId?: string,
observer?: RequestSendObserver,
): RequestExecutionBudget {
const grant = createAmbiguousResendGrant();
return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
spent: 0,
pendingExternalSends: 0,
claimAmbiguousResend: createAmbiguousResendGrant(),
claimAmbiguousResend: grant.claimAmbiguousResend,
get ambiguousResendSpent(): boolean { return grant.ambiguousResendSpent; },
...(observer ? { observer } : {}),
});
}
Expand Down Expand Up @@ -445,8 +454,11 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
// observer genuinely cannot cross this boundary because they are private to the factory,
// but the grant can -- `claimAmbiguousResend` is public on the parent. A parent that does
// not implement it grants nothing, which is the fail-closed answer for a send whose
// upstream state is unknown.
claimAmbiguousResend: (limit: number): boolean => parent.claimAmbiguousResend?.(limit) === true,
// upstream state is unknown. So does a parent that cannot report the grant as spent: a claim
// nobody can read back would let combo failover hop after the replacement went out.
claimAmbiguousResend: (limit: number): boolean =>
typeof parent.ambiguousResendSpent === "boolean" && parent.claimAmbiguousResend?.(limit) === true,
get ambiguousResendSpent(): boolean { return parent.ambiguousResendSpent === true; },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

Expand Down
32 changes: 20 additions & 12 deletions src/lib/upstream-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,25 @@ function invitesResendAfterReplacement(status: number): boolean {
|| status === 307 || status === 308 || status === 413 || status >= 500;
}

/**
* The answer a request keeps once its one operator replacement has gone out.
*
* A status that invites another send settles as the refusal. Any other answer keeps its real
* status: no client retries it, and the caller needs the evidence (a 400 names the request
* defect). The marker still stops this process from using it as a recovery trigger, such as the
* opaque-blob rebuild of a 400 or a combo hop on a context overflow, because each of those checks
* it before sending again.
*/
export function settleOperatorReplacement(response: Response): Response {
if (response.ok) return response;
if (invitesResendAfterReplacement(response.status)) {
cancelResponseBodyBestEffort(response);
return replayRefusalResponse();
}
markResponseNonReplayable(response);
return response;
}

export async function fetchWithAttemptDeadline(
url: string,
init: RequestInit,
Expand Down Expand Up @@ -624,18 +643,7 @@ export async function fetchWithResetRetry(
opts.onSendsConsumed?.(1);
try {
const response = await doFetch(attempt === 0 ? firstRecovery : "connection-reset");
if (spentOperatorReplacement && !response.ok) {
if (invitesResendAfterReplacement(response.status)) {
cancelResponseBodyBestEffort(response);
return replayRefusalResponse();
}
// Any other answer keeps its real status: no client retries it, and the caller needs the
// evidence (a 400 names the request defect). The marker still stops this process from
// using it as a recovery trigger, such as the opaque-blob rebuild of a 400 or a combo hop
// on a context overflow, because each of those checks it before sending again.
markResponseNonReplayable(response);
}
return response;
return spentOperatorReplacement ? settleOperatorReplacement(response) : response;
} catch (err) {
if (opts.abortSignal?.aborted) throw err;
if (!isConnectionResetError(err)) {
Expand Down
14 changes: 13 additions & 1 deletion src/server/responses/core-combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
import { preflightComboStreamResponse } from "./combo-stream-preflight";
import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
import { settleOperatorReplacement } from "../../lib/upstream-retry";

/**
* Sends one combo target may run on its own before the ladder moves on. A target is a whole
Expand Down Expand Up @@ -678,9 +679,20 @@ export async function executeComboResponses(
attemptRetained = true;
lastFailure = failure.response;
lastFailedChildLog = childLog;
// A replacement that answers 200 is unmarked, and its zero-output failure only exists once
// preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the
// client would resend becomes the refusal, and anything else reaches the client as it is.
const spentReplacement = !failure.nonReplayable && comboSendScope?.ambiguousResendSpent === true;
if (spentReplacement) {
const settled = settleOperatorReplacement(failure.response);
if (settled !== failure.response) {
adoptFailedChildLog(childLog);
return settled;
}
}
// A non-replayable failure (the answer to a spent ambiguous-reset replacement) may follow a
// send that already ran the turn, so no later target may receive it, whatever its status says.
const failureDecision = failure.nonReplayable
const failureDecision = failure.nonReplayable || spentReplacement
? "stop"
: comboFailureDecision(failure.response.status, failure.classificationText, {
code: failure.upstreamCode,
Expand Down
5 changes: 3 additions & 2 deletions src/server/responses/core-opaque-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,14 +311,15 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
* Peek the upstream error body for the reasoning-effort downgrade. Only 400/403 are considered
* and the body must be complete and display-safe, the same contract the other rejection peeks
* use. The match is deliberately narrow: the upstream has to name reasoning effort, so an
* unrelated 400 never triggers a replay.
* unrelated 400 never triggers a replay. A non-replayable answer, such as one to a spent operator
* replacement, is never read: the first send may already have run the turn.
*/
export async function reasoningEffortRejectionText(
response: Response,
alreadyAttempted: boolean,
signal: AbortSignal,
): Promise<string | undefined> {
if (alreadyAttempted) return undefined;
if (alreadyAttempted || isNonReplayableResponse(response)) return undefined;
if (response.status !== 400 && response.status !== 403) return undefined;
try {
const body = await readBoundedResponseBody(response.clone(), { signal });
Expand Down
7 changes: 6 additions & 1 deletion src/server/responses/passthrough-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
fetchWithTransientRetry,
applyUpstreamRecoveryInit,
isNonReplayableResponse,
settleOperatorReplacement,
refetchAfterProtocolSafeReset,
prepareSameTarget429Wait,
sleepWithAbort,
Expand Down Expand Up @@ -212,6 +213,7 @@ export async function preparePassthroughExchange(
| "recoveryClassFor"
| "sendBudgetExhausted"
| "claimAmbiguousResend"
| "ambiguousResendSpent"
| "reserveCredentialHop"
| "pendingHopPermit"
| "workflowRootId"
Expand Down Expand Up @@ -1554,7 +1556,8 @@ export async function preparePassthroughExchange(
if (options.abortSignal?.aborted) return transportFailureResponse(options.abortSignal.reason);
upstreamResponse = preflight.response;
if (preflight.kind === "failed") {
if (!configuredTransientSendBudgetExhausted()) {
// A zero-output failure does not undo an ambiguous replacement already sent.
if (!configuredTransientSendBudgetExhausted() && !sendBudgetState.ambiguousResendSpent) {
const streamedOpaqueRecovery = await attemptOpaqueBlobRecovery({
response: upstreamResponse,
outboundBody: request.body,
Expand All @@ -1574,6 +1577,8 @@ export async function preparePassthroughExchange(
logCtx.terminalHttpStatus = preflightLog.terminalHttpStatus;
logCtx.terminalErrorCode = preflightLog.terminalErrorCode;
logCtx.terminalIncompleteReason = preflightLog.terminalIncompleteReason;
// The projected failure must not invite another send after the replacement was spent.
if (sendBudgetState.ambiguousResendSpent) upstreamResponse = settleOperatorReplacement(upstreamResponse);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
// Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds
Expand Down
3 changes: 3 additions & 0 deletions src/server/responses/policy-fallback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { comboFailureDecision } from "../../combos/failover";
import { readBoundedResponseBody } from "../../lib/bounded-body";
import { isNonReplayableResponse } from "../../lib/upstream-retry";
import { finishRequestAttempt, type RequestLogContext } from "../request-log";
import { linkRequestSessionLane } from "../request-log-conversation";
import type { OcxConfig } from "../../types";
Expand Down Expand Up @@ -84,6 +85,8 @@ function errorCodeFromText(text: string): string | undefined {

async function shouldHopPolicyCandidate(response: Response, signal?: AbortSignal): Promise<boolean> {
if (response.status < 400 || signal?.aborted) return false;
// A response that must not be sent again cannot open a policy-candidate retry either.
if (isNonReplayableResponse(response)) return false;
try {
const inspected = await readBoundedResponseBody(response.clone(), { signal });
const text = inspected.displaySafe ? inspected.text : "";
Expand Down
10 changes: 8 additions & 2 deletions src/server/responses/request-send-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ export function createResponsesSendBudget(
* upstream state is unknown.
*/
const claimAmbiguousResend = (limit: number): boolean =>
isRequestExecutionBudget(sendBudget) && sendBudget.claimAmbiguousResend?.(limit) === true;
isRequestExecutionBudget(sendBudget) && typeof sendBudget.ambiguousResendSpent === "boolean"
&& sendBudget.claimAmbiguousResend?.(limit) === true;
/**
* A credential hop reserves the send its own replay will make, and that replay is a recovery
* leg. The leg must SPEND the hop's reservation instead of taking a second one: the
Expand Down Expand Up @@ -274,6 +275,9 @@ export function createResponsesSendBudget(
noteAdapterRecoveryWithheld,
sendBudgetExhausted,
claimAmbiguousResend,
get ambiguousResendSpent(): boolean {
return isRequestExecutionBudget(sendBudget) && sendBudget.ambiguousResendSpent === true;
},
get pendingHopPermit(): SingleUseDispatchPermit | undefined {
return pendingHopPermit;
},
Expand Down Expand Up @@ -312,7 +316,9 @@ function adapterDispatchBudgetView(
get targetTransitions(): number { return budget.targetTransitions; },
get lastTargetKey(): string | undefined { return budget.lastTargetKey; },
remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap),
claimAmbiguousResend: (limit: number): boolean => budget.claimAmbiguousResend?.(limit) === true,
claimAmbiguousResend: (limit: number): boolean =>
typeof budget.ambiguousResendSpent === "boolean" && budget.claimAmbiguousResend?.(limit) === true,
get ambiguousResendSpent(): boolean { return budget.ambiguousResendSpent === true; },
reserveDispatch(intent: DispatchIntent): DispatchDecision {
// A dispatch whose upstream state is unknown is refused on its own merits. A hop that
// already paid does not make an unsafe replay safe, so that check stays with the budget.
Expand Down
7 changes: 7 additions & 0 deletions structure/transports/responses-failover.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ response, so `consumeComboFailure` records `nonReplayable` and the combo stops r
on, say, a context overflow. The cost is that a real 401, 402 or 429 on a replacement send is not
recorded against its credential on that request.

A 2xx replacement carries no marker, and its stream can still fail before any output. Preflight
then rebuilds that failure as a fresh Response, so the request execution budget's
`ambiguousResendSpent` makes the combo stop: a status the client would resend becomes the refusal,
and anything else keeps its status and the non-replayable marker. The direct path skips the
streamed opaque-blob rebuild and settles the preflight's projected failure by the same rule.
Policy fallback does not hop on a marked answer.

**An upstream reset observed mid-stream or after a terminal keeps its existing behaviour.**
The passthrough read path still settles a genuine upstream reset as a synthetic 502, and the
Codex WebSocket transport still settles `upstream_closed_before_response` (socket closed
Expand Down
Loading
Loading