Skip to content
Merged
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
53 changes: 53 additions & 0 deletions devlog/_plan/260920_round2_followups/040_r4_retry_rework.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# R4 — #4942 and #4989 as one ambiguous-resend gate

Status: OPEN. Branch `codex/260920-r4-retry-rework`, cut from `origin/dev` at `d6d87440b7`.

## Why the two pull requests are one change

#4942 (FredAmartey) replays a native Responses send whose connection died before any response
head, behind a per-provider opt-in. #4989 (lidge-jun) replaces a native Responses SSE stream that
died after the head while the body had carried only control events. Written apart they read as two
features. Against the stage table #5266 landed in `src/lib/request-failure-model.ts` they are one
row: a stage whose `stageCommitment` is `nothing-observed`, with a cause whose
`causeEvidence` is `unknown`. `resendPermission` answers `refused-ambiguous` for both, and
the module already names the only thing that may override it — "a narrowly scoped, explicitly
opted-in recovery that a maintainer reasoned about and bounded".

Two overrides is one too many. #4942 spreads `replayResets: 2` into every dispatch leg of the
request and #4989 takes `Math.min(1, remaining)` of the transient budget at the stream boundary,
so one logical request that reset before the head and again after it would buy a replacement on
each. The rework gives the override a single per-request allowance and makes both stages claim
from it.

## Shape

- `src/lib/request-resend-gate.ts` — the one gate. Pure table lookup for the stages the caller
already observed something at, plus the operator override for the ambiguous row. It never
restates the table: stage, cause, permission and send class all come from
`request-failure-model.ts`, and the cause comes from the `AttemptRecoveryKind` that will be
recorded, so the reason in the log and the send it authorised cannot disagree.
- `src/lib/request-execution-budget.ts` — the allowance lives on the shared send ledger, which is
what a combo child inherits through `deriveRequestExecutionBudget`. Parent and child therefore
cannot each hold one.
- `src/server/responses/reset-replay.ts` — the provider opt-in and the body judgment from #4942,
plus the per-request authority both call sites use.
- `src/lib/upstream-retry.ts` — the pre-header claim, as a callback rather than a number.
- `src/server/responses/combo-stream-preflight.ts` — the preflight reports the stage it observed
instead of a boolean, so the gate rather than the preflight decides.

## Stage classification at the stream boundary

#4989 gated on `responseCreated && !outputCommitted && !terminal`. That is `protocol-prelude`.
A read error before any parsed event is `headers-only`, which the table gives the same
commitment and therefore the same answer; the rework admits it rather than refusing a row the
table permits. Everything else the preflight can see is `semantic-output` or `terminal`, and
those refuse regardless of cause.

## In scope from the remainders

#4191 and #5180 only to the extent the resend gate reaches them. Recorded in 050.

## Verification

Static review plus exact-head hosted CI. Local suites, individual tests, typecheck, build,
install and live `ocx` execution are NOT RUN by lane policy.
59 changes: 59 additions & 0 deletions devlog/_plan/260920_round2_followups/050_r4_remainders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# R4 — what the remainders reach, and what they do not

The lane brief put #4191 and #5180 in R4 "as far as the rework reaches". This records where that
line actually fell, with the evidence, so the next lane starts from a finding rather than a
re-investigation.

## #4191 — reached: nothing. Found: a wrong stage in the shared vocabulary

The resend gate does not consult the WebSocket projection. The post-header path excludes a
`isCodexWsUpstreamResponse` body on purpose: the WS transport settles its own ambiguous
failures and marks them non-replayable, and a second reader of one exchange is a defect, not a
recovery. So the durable threading the round-2 plan names is untouched here.

The investigation did surface a real defect in the projection itself.
`classifyCodexWsFailure` in `src/server/responses/codex-ws-wire.ts` returns
`after-response-started` — which `CODEX_WS_FAILURE_PROJECTION` maps to `semantic-output` —
as soon as `relayedEvents > 0`. But `src/server/responses/codex-ws-exchange.ts` increments
`relayedEvents` for every non-metadata Responses event, and `response.created` is one:
`controlFrame` is set only when the metadata channel consumes the frame, not for lifecycle
events. `src/lib/request-failure-model.ts` puts `response.created` in `protocol-prelude`
and requires an output-bearing event for `semantic-output`. A WS failure carrying only a
created event therefore projects as committed output today.

It is left here rather than fixed because the fix needs a counter the classifier does not have,
and `CodexWsStageRecord` is derived from `CodexWsFailureStage` by `Omit`, so adding one
lands in a persisted record whose read-back whitelist in `src/usage/log.ts` would reject every
row written before it. Adding the counter and `Omit`-ing it from the durable twin avoids that,
but the output-bearing predicate lives in `combo-stream-preflight.ts` and restating it in the
exchange is the class of duplication this round already paid for three times. It belongs with
the lane that threads `failureStage` / `failureCause` into the record, where both halves can
be written once.

The SSE fallback the issue asks for stays out regardless. After `ws.send()` returns, a
fallback is a second physical send on another transport, which is a transport decision with its
own duplicate-inference policy — not a retry-gate change.

## #5180 — reached: nothing. The symptom is upstream of this gate

The reported failure is a key-auth `openai-chat` provider answering a bare 429. Traced on
current `dev`: `rateLimitRetryPolicyFor` returns null for every provider except the
OpenCode Go destination, so the same-target wait never runs; key rotation needs a pool of at
least two; and `fetchWithResetRetry` returns the first received HTTP response without
consulting its status. One send, 429 returned, which is exactly what the reporter saw.
`Retry-After` is forwarded to the client — synthesized as `2` for a bare retryable 429 by
`src/lib/retry-after.ts` — but the proxy never waits on it itself.

None of that is an ambiguous-resend question: a received 429 is `headers-only` with cause
`rate-limit`, which the stage table already answers `permitted` and funds from the
`transient` class. It needs no grant and no override. What it needs is a policy default and a
process-wide cooldown that a single-key provider can write, and `keyCooldowns` cannot be
reused unchanged because both its identity and its write path require a multi-key pool.

One adjacent accounting gap is worth recording for whoever takes it. On the generic adapter
path, `prepareAdapterExchange` passes `attempts` and `onSendsConsumed` to its retry helper
only when `transientRetryOn5xx` is configured. An unconfigured provider's initial send is
therefore recorded in the attempt log but never charged to the request-wide send counter. It is
bounded today — without `replaySafe` the reset helper makes exactly one send — so it is an
under-count rather than an amplification, and widening it without a suite to run is not a change
worth making blind.
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix
| `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. |
| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. |
| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` et `openai-responses` authentifiés par clé uniquement. Les fournisseurs `authMode: "forward"` (le pool de comptes ChatGPT) ne lisent jamais cette option et conservent l'échelle par défaut. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. |
| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs uniquement, `authMode: "forward"` compris. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. |
| `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. |
| `preserveReasoningContentModels?` | `string[]` | Modèles nécessitant un assistant préalable `reasoning_content` dans l'historique des discussions. |
| `reasoningDetailsModels?` | `string[]` | Modèles dont le point de terminaison renvoie la réflexion sous forme de tableau structuré `reasoning_details` (MiniMax série M avec `reasoning_split`) ; les deltas de flux sont des instantanés cumulatifs comparés par préfixe, et la réflexion conservée est rejouée sous forme de tableau `reasoning_details` plutôt que de chaîne `reasoning_content`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ account を削除しても mapping は保持され、同じ id を再追加す
| `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 |
| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 |
| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` および `openai-responses` プロバイダーのみ。`authMode: "forward"` のプロバイダー(ChatGPT アカウントプール)はこのオプションを読まず、既定の再試行段数を維持します。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 |
| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー専用で、`authMode: "forward"` も含みます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 |
| `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 |
| `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。 |
| `reasoningDetailsModels?` | `string[]` | thinking を構造化された `reasoning_details` 配列で返すモデル(`reasoning_split` 使用の MiniMax M シリーズ)。ストリーム差分は累積スナップショットとして prefix-diff され、保持された reasoning は `reasoning_content` 文字列ではなく `reasoning_details` 配列としてリプレイされます。 |
Expand Down
Loading
Loading