Skip to content

fix(combo): wait out a short transient lockout on a pinned native Codex turn - #15168

Merged
diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.52from
QuangBlue:fix/codex-pin-short-lockout
Oct 2, 2026
Merged

diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.52from
QuangBlue:fix/codex-pin-short-lockout

Conversation

@QuangBlue

Copy link
Copy Markdown
Contributor

Problem

A native Codex turn pinned to a model is terminated with 400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE when the pinned model only carries a short transient model-only lockout set by another client's failed request.

Seen live: one client's grok-cli/grok-4.6 stream failed mid-generation (Internal error during token generation), which set a 5s server_error model lockout on the (single) grok-cli connection. A Codex turn pinned to that model arrived 1.4s later, areAllPinnedTargetsModelScopedUnusable() returned true, auto-resume was rightly rejected (unsafe_provider_state — the body carries continuation state), and the turn was killed. Codex CLI cannot recover from that 400; the user has to start a new turn.

Root cause

isPinnedTargetModelScopedUnusable() treats every model lock as "model gone". The combo cooldown-wait would absorb a 5s lock, but the pre-dispatch gate (executeTargetGates.ts, "model locked by resilience") skips locked targets without recording a retry-after, so the attempt loop's cooldown-wait never engages for them.

Fix

  • A lock that the existing cooldown-wait predicate accepts (shouldWaitForComboCooldown: retryable reason such as server_error/rate_limit, remaining ≤ maxWaitMs, cooldown-wait enabled) no longer makes a pinned target unusable.
  • The pinned path waits for the shortest waitable lock before handing the pinned targets to the attempt loop (waitForCooldownAwareRetry; 499 on client abort). The wait counts as one cooldown-wait attempt and is charged to the cooldown-wait budget.
  • Auto-resume alternates are still judged without that allowance (they are dispatched without the wait), so a briefly locked alternate is skipped in favor of a healthy one.
  • Unchanged: long locks, non-transient reasons (quota_exhausted, …), cooldown-wait disabled, and every non-native-turn request.
  • The termination log now names each pinned target's lock (reason Ns failureCount=N or none) so a remaining 400 can be classified from one line.

Tests

  • New tests/unit/native-codex-turn-pin-short-lockout.test.ts (7 cases): short server_error lock keeps the turn on the pinned model (fails before the fix), second upstream failure after the wait surfaces a retryable 5xx instead of the terminal 400 (fails before the fix), quota_exhausted / lock > maxWaitMs / cooldown-wait disabled keep the 400, client abort during the wait returns 499 without dispatch, auto-resume skips a briefly locked alternate.
  • Existing suites pass: native-codex-auto-resume, native-codex-auto-resume-guards, native-codex-turn-pin-10379, native-codex-turn-pin-model-scoped-fallback, chatgpt-web-codex-turn-pin.
  • npm run typecheck:core, scripts/check/check-open-sse-typecheck.mjs, eslint (with suppressions), npm run check:cycles clean.
  • E2E: Codex CLI 0.159 through a local router + mock OpenAI-compatible upstream; a second client triggers a 502 while Codex runs a tool. Before: Codex shows NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE. After: the router logs waiting 1s before dispatch instead of terminating the turn and the turn completes.

⚠️ base-red inherited: #15100

@QuangBlue
QuangBlue force-pushed the fix/codex-pin-short-lockout branch from f44a4bc to 078ba78 Compare September 30, 2026 04:41
…ex turn

A native Codex turn pinned to a model was terminated with 400
NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE when the pinned model only carried a
short transient model-only lockout set by ANOTHER client's failed request
(live: a 5s server_error lockout after an upstream "Internal error during
token generation"; the turn arrived 1.4s later). Auto-resume was rightly
rejected (unsafe_provider_state), so the turn could not continue.

Root cause: isPinnedTargetModelScopedUnusable() treated every model lock as
"model gone". The combo cooldown-wait would have absorbed a 5s lock, but the
pre-dispatch gate skips locked targets without recording a retry-after, so
the attempt loop's wait never engages for them.

Fix: a lock that the cooldown-wait predicate accepts (retryable reason,
remaining <= maxWaitMs, cooldown-wait enabled) no longer counts as unusable;
the pinned path waits it out before dispatch (deducted from the cooldown-wait
budget, 499 on client abort). Long locks, non-transient reasons
(quota_exhausted, ...) and cooldown-wait disabled keep the old behavior. The
termination log now names each pinned target's lock (reason, remaining
seconds, failureCount) or "none". Auto-resume alternates are still judged
without the waitable-lock allowance: they are dispatched without that wait,
so a briefly locked alternate is skipped in favor of a healthy one.

Tests: tests/unit/native-codex-turn-pin-short-lockout.test.ts (7 cases; the
two short-lock cases fail before the fix, plus client abort and alternate
selection). E2E with Codex CLI through a local
router and a mock upstream reproduced the 400 and confirmed the turn now
completes after the wait.
@QuangBlue
QuangBlue force-pushed the fix/codex-pin-short-lockout branch from 078ba78 to daa37b2 Compare September 30, 2026 07:00
@diegosouzapw
diegosouzapw merged commit 77f2ac7 into diegosouzapw:release/v3.8.52 Oct 2, 2026
47 of 51 checks passed
@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks @QuangBlue — merged into release/v3.8.52; it ships in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants