@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic-image-codec.ts +57 -0
  5. package/src/adapters/anthropic-image-normalize.ts +28 -1
  6. package/src/adapters/anthropic.ts +68 -6
  7. package/src/adapters/base.ts +8 -0
  8. package/src/adapters/coding-agent/protocol.ts +41 -16
  9. package/src/adapters/cursor/cursor-errors.ts +1 -1
  10. package/src/adapters/cursor/live-transport.ts +5 -1
  11. package/src/adapters/cursor/native-exec-fs.ts +10 -10
  12. package/src/adapters/cursor/native-exec-network.ts +2 -2
  13. package/src/adapters/cursor/native-exec-shell.ts +13 -12
  14. package/src/adapters/cursor/native-exec.ts +51 -10
  15. package/src/adapters/cursor/policy-error.ts +75 -0
  16. package/src/adapters/cursor/protobuf-request.ts +105 -1
  17. package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
  18. package/src/adapters/devin/live-models.ts +33 -2
  19. package/src/adapters/google-wire-compiler.ts +8 -0
  20. package/src/adapters/google.ts +46 -0
  21. package/src/adapters/input-media-guard.ts +45 -0
  22. package/src/adapters/kiro/adapter.ts +8 -0
  23. package/src/adapters/kiro/payload.ts +28 -6
  24. package/src/adapters/kiro-events.ts +25 -6
  25. package/src/adapters/kiro-images.ts +30 -0
  26. package/src/adapters/kiro-retry.ts +8 -0
  27. package/src/adapters/openai-chat.ts +33 -4
  28. package/src/adapters/openai-responses.ts +26 -0
  29. package/src/adapters/registry.ts +4 -0
  30. package/src/bridge.ts +163 -116
  31. package/src/chat/image-parts.ts +151 -0
  32. package/src/chat/inbound.ts +70 -33
  33. package/src/cli/connect.ts +30 -9
  34. package/src/cli/dispatch.ts +7 -3
  35. package/src/cli/index.ts +3 -0
  36. package/src/cli/runtime-api.ts +25 -0
  37. package/src/cli/status.ts +21 -19
  38. package/src/cli/system-restart-client.ts +25 -0
  39. package/src/clients/config-export.ts +14 -4
  40. package/src/codex/app-server-processes.ts +25 -0
  41. package/src/codex/auth-context.ts +8 -0
  42. package/src/codex/autostart-health.ts +36 -2
  43. package/src/codex/catalog/provider-fetch.ts +41 -0
  44. package/src/codex/catalog-auto-refresh.ts +182 -0
  45. package/src/codex/catalog-refresh-status.ts +93 -0
  46. package/src/codex/history-provider.ts +55 -0
  47. package/src/codex/model-entitlements.ts +78 -0
  48. package/src/codex/native-profile-processes.ts +114 -15
  49. package/src/codex/prompt-text-probe.ts +274 -41
  50. package/src/codex/routing-adoption.ts +189 -0
  51. package/src/codex/routing.ts +520 -48
  52. package/src/codex/runtime.ts +249 -7
  53. package/src/combos/failover.ts +45 -0
  54. package/src/config.ts +124 -4
  55. package/src/generated/compatibility-version.json +110 -74
  56. package/src/generated/model-metadata.ts +1 -0
  57. package/src/lib/request-execution-budget.ts +202 -0
  58. package/src/lib/upstream-retry.ts +95 -8
  59. package/src/lib/workflow-budget.ts +172 -0
  60. package/src/oauth/devin.ts +57 -12
  61. package/src/providers/quota.ts +37 -6
  62. package/src/providers/registry.ts +53 -6
  63. package/src/responses/input-media.ts +65 -0
  64. package/src/responses/parser-content.ts +42 -0
  65. package/src/responses/schema.ts +12 -2
  66. package/src/server/audio-live.ts +1 -2
  67. package/src/server/audio-transcriptions.ts +1 -2
  68. package/src/server/auth-cors.ts +1 -1
  69. package/src/server/background-lifecycle.ts +18 -0
  70. package/src/server/chat-completions.ts +23 -8
  71. package/src/server/chat-native.ts +17 -17
  72. package/src/server/index.ts +24 -0
  73. package/src/server/management/request-history-routes.ts +5 -0
  74. package/src/server/request-log.ts +8 -2
  75. package/src/server/responses/compact.ts +51 -3
  76. package/src/server/responses/core.ts +238 -28
  77. package/src/server/search.ts +7 -9
  78. package/src/types/config.ts +51 -6
  79. package/src/usage/log.ts +37 -0
  80. package/src/vision/eligibility.ts +37 -4
  81. package/src/vision/index.ts +1 -0
  82. package/src/vision/plan.ts +45 -10
  83. package/src/web-search/alpha-search.ts +324 -0
  84. package/src/web-search/index.ts +13 -22
  85. package/src/web-search/passthrough-bridge.ts +195 -22
  86. package/src/web-search/sidecar-providers.ts +22 -0
@@ -46,12 +46,92 @@ type ThreadAffinityEntry = {
46
46
  // Last time the bound account's quota threshold was re-evaluated for this
47
47
  // thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
48
48
  lastReevalAt: number;
49
+ // When a transient failure streak first forced this thread onto another account
50
+ // while the binding was HELD (#4546). Cleared the moment the bound account serves
51
+ // again; once it ages past CODEX_TRANSIENT_AFFINITY_HOLD_MS the binding is
52
+ // released through the ordinary path instead of detouring forever.
53
+ transientHoldSince?: number;
54
+ // Which account is serving this thread while its own is held under a transient hold.
55
+ // Remembered rather than re-picked per request: under round-robin a fresh pick each turn
56
+ // would walk the ring and start cold on every hop, which is the behaviour the hold exists
57
+ // to prevent. Cleared with transientHoldSince when the bound account serves again.
58
+ transientDetourAccountId?: string;
49
59
  };
50
60
 
51
61
  export type CodexThreadResolution =
52
- | { status: "selected"; accountId: string }
53
- | { status: "none" }
54
- | { status: "expired"; accountId: string };
62
+ | { status: "selected"; accountId: string; affinity?: CodexAffinityDecision }
63
+ | { status: "none"; affinity?: CodexAffinityDecision }
64
+ | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision };
65
+
66
+ /** What happened to this thread's binding on this request (#4546). */
67
+ export type CodexAffinityMove =
68
+ /** Served by its own bound account, which was healthy. */
69
+ | "reused"
70
+ /** Served by its own bound account while something transient was wrong with it. */
71
+ | "held"
72
+ /** Served by another account while the binding stayed put. */
73
+ | "detour"
74
+ /** The binding was released and a different account took the thread. */
75
+ | "rebound"
76
+ /** There was no live binding; this request established one. */
77
+ | "new_bind"
78
+ /** The binding was released without a replacement on this request. */
79
+ | "cleared";
80
+
81
+ /**
82
+ * Why. A move is the expensive event -- it discards the prompt-cache prefix warmed on the old
83
+ * account -- so the operator should not have to infer it from account labels across log lines,
84
+ * which is how #4546 had to be diagnosed.
85
+ */
86
+ export type CodexAffinityReason =
87
+ | "healthy"
88
+ | "quota_headroom"
89
+ | "quota_refusal"
90
+ | "transient"
91
+ | "transient_hold_expired"
92
+ | "unusable"
93
+ | "paused"
94
+ | "plan_excluded"
95
+ | "cooldown"
96
+ | "quota_avoided"
97
+ | "generation"
98
+ | "expired"
99
+ | "model_lane";
100
+
101
+ export interface CodexAffinityDecision {
102
+ move: CodexAffinityMove;
103
+ reason: CodexAffinityReason;
104
+ }
105
+
106
+ /** The decision to report once a binding has been released and selection starts over. */
107
+ function affinityAfterRelease(
108
+ threadId: string | null,
109
+ releaseReason: CodexAffinityReason | undefined,
110
+ ): CodexAffinityDecision {
111
+ // Reported now, so it must not be reported again by the next request.
112
+ clearPendingReleaseReason(threadId);
113
+ return releaseReason === undefined
114
+ ? { move: "new_bind", reason: "healthy" }
115
+ : { move: "rebound", reason: releaseReason };
116
+ }
117
+
118
+ /**
119
+ * What to report when selection produced no account at all. The binding is gone and nothing took
120
+ * it, which is a `cleared`, and the pending reason is deliberately NOT consumed: a no-account
121
+ * result reaches no auth context and therefore no usage entry, so the next resolve that does
122
+ * produce one is the first place this release can actually be seen.
123
+ */
124
+ function affinityOnNoAccount(
125
+ threadId: string | null,
126
+ releaseReason: CodexAffinityReason | undefined,
127
+ ): CodexAffinityDecision | undefined {
128
+ if (releaseReason === undefined) return undefined;
129
+ // Hand it forward as well as reporting it. A reason derived from the entry this request just
130
+ // released lives only in a local, so without this the next resolve finds no entry and no
131
+ // pending reason and calls the rebind a fresh healthy bind.
132
+ notePendingReleaseReason(threadId, releaseReason);
133
+ return { move: "cleared", reason: releaseReason };
134
+ }
55
135
 
56
136
  /**
57
137
  * Process-local cursor for automatic RR/fill-first (and quota-429 when not
@@ -169,6 +249,23 @@ const MAX_AFFINITY_COMPONENT_BYTES = 512;
169
249
  // Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
170
250
  export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
171
251
 
252
+ /**
253
+ * How long a live binding outlives a TRANSIENT failure streak on its own account (#4546).
254
+ *
255
+ * Being unable to send right now is not the same as losing ownership of the conversation.
256
+ * A 5xx streak is frequently provider-wide rather than account-specific, and deleting the
257
+ * binding for it discards a prompt-cache prefix that the next turn then pays for again --
258
+ * the same cost the quota threshold used to impose, arriving through a different door.
259
+ * So the request detours to another account while the binding is held here.
260
+ *
261
+ * Bounded, because an unbounded hold is its own defect: an account that never recovers
262
+ * would keep a thread detouring indefinitely while the conversation's real warm prefix
263
+ * accumulates somewhere else. Ten minutes is longer than the whole soft-avoid escalation
264
+ * ladder up to its final step, so an ordinary outage resolves inside the hold and a
265
+ * genuine one converts to a real rebind instead of a permanent detour.
266
+ */
267
+ export const CODEX_TRANSIENT_AFFINITY_HOLD_MS = 10 * 60_000;
268
+
172
269
  const upstreamHealth = new Map<string, CodexUpstreamHealth>();
173
270
  /**
174
271
  * Reset-derived 429s can describe a quota owned by one native model family,
@@ -333,17 +430,56 @@ export function clearThreadAccountMap(): void {
333
430
  threadAffinityEntryTotal = 0;
334
431
  }
335
432
 
336
- export function clearThreadAccountMapForAccount(accountId: string): void {
433
+ export function clearThreadAccountMapForAccount(
434
+ accountId: string,
435
+ reason: CodexAffinityReason = "unusable",
436
+ ): void {
337
437
  for (const [threadId, affinities] of threadAccountMap) {
338
438
  for (const [scope, entry] of affinities) {
339
439
  if (entry.accountId === accountId && affinities.delete(scope)) {
340
440
  threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
441
+ notePendingReleaseReason(threadId, reason);
341
442
  }
342
443
  }
343
444
  if (affinities.size === 0) threadAccountMap.delete(threadId);
344
445
  }
345
446
  }
346
447
 
448
+ /**
449
+ * Why a binding was released, held until that thread's next resolve can report it (#4546).
450
+ *
451
+ * A release and the request that pays for it are two different moments: a 429 clears the pin
452
+ * inside the outcome recorder, and the next request arrives with nothing left to explain why it
453
+ * is starting cold. Bounded, because it is a diagnostic and must not become a leak.
454
+ */
455
+ const pendingReleaseReasons = new Map<string, CodexAffinityReason>();
456
+ const MAX_PENDING_RELEASE_REASONS = 4096;
457
+
458
+ function notePendingReleaseReason(threadId: string | null, reason: CodexAffinityReason): void {
459
+ if (threadId === null) return;
460
+ if (!pendingReleaseReasons.has(threadId) && pendingReleaseReasons.size >= MAX_PENDING_RELEASE_REASONS) {
461
+ const oldest = pendingReleaseReasons.keys().next();
462
+ if (!oldest.done) pendingReleaseReasons.delete(oldest.value);
463
+ }
464
+ pendingReleaseReasons.set(threadId, reason);
465
+ }
466
+
467
+ function peekPendingReleaseReason(threadId: string | null): CodexAffinityReason | undefined {
468
+ if (threadId === null) return undefined;
469
+ return pendingReleaseReasons.get(threadId);
470
+ }
471
+
472
+ /**
473
+ * Forget a release only once it has actually been reported.
474
+ *
475
+ * Consuming it at derivation time lost it whenever selection then failed to produce an account:
476
+ * a no-account return carries no payload, so the release went unrecorded and the next successful
477
+ * resolve claimed a fresh healthy bind (#4598). A release survives until some resolve reports it.
478
+ */
479
+ function clearPendingReleaseReason(threadId: string | null): void {
480
+ if (threadId !== null) pendingReleaseReasons.delete(threadId);
481
+ }
482
+
347
483
  export function clearCodexUpstreamHealth(): void {
348
484
  // Operator preferences are routing state, not health, but they live and die with the same
349
485
  // reset points. Leaving them behind lets a selection from one context suppress the
@@ -1202,6 +1338,32 @@ function isCodexAccountSelectable(
1202
1338
  && isCodexAccountUsable(config, accountId, selectionOptions);
1203
1339
  }
1204
1340
 
1341
+ /**
1342
+ * Which guard in {@link isCodexAccountSelectable} refused this account, if any.
1343
+ *
1344
+ * Deliberately the same predicates in the same order as that function, because the point is to
1345
+ * REPORT the guard that actually fired rather than to re-derive a plausible-looking cause. An
1346
+ * earlier version of the release reason checked only a subset and let a paused, plan-excluded,
1347
+ * cooled-down or quota-avoided release fall through to a quota fallback, which named something
1348
+ * routing never used -- a diagnostic that is confidently wrong in exactly the cases an operator
1349
+ * would consult it for (#4598).
1350
+ */
1351
+ function codexAccountBlockReason(
1352
+ config: OcxConfig,
1353
+ accountId: string,
1354
+ now: number,
1355
+ quotaScope?: CodexQuotaScope,
1356
+ selectionOptions?: CodexAccountUsabilityOptions,
1357
+ ): CodexAffinityReason | undefined {
1358
+ if (isCodexAccountPaused(config, accountId)) return "paused";
1359
+ if (isCodexAccountPlanExcluded(config, accountId)) return "plan_excluded";
1360
+ if (getCodexQuotaHealthSnapshot(accountId, quotaScope, now) !== null) return "cooldown";
1361
+ if (isCodexQuotaAvoided(accountId, quotaScope, now)) return "quota_avoided";
1362
+ if (isCodexAccountSoftAvoided(accountId, now)) return "transient";
1363
+ if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable";
1364
+ return undefined;
1365
+ }
1366
+
1205
1367
  function threadAffinityScope(quotaScope?: CodexQuotaScope): BaseThreadAffinityScope {
1206
1368
  return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
1207
1369
  }
@@ -1516,6 +1678,112 @@ function hasCodexQuotaHeadroom(
1516
1678
  return usage < threshold;
1517
1679
  }
1518
1680
 
1681
+ /**
1682
+ * Is a live binding held for its prompt cache?
1683
+ *
1684
+ * Unset means yes. Cache affinity shipped as an opt-in flag (#4292) and then #4546 measured
1685
+ * what the default costs: a pool whose accounts all sit in the 80-99% band hands a bound
1686
+ * conversation from account to account, and because provider prompt caches are account-isolated
1687
+ * every hop re-sends the entire prefix. An install that has never heard of this flag is exactly
1688
+ * the install that gets hurt by it, so the protection cannot be something you have to find.
1689
+ *
1690
+ * `false` restores capacity-first routing byte-for-byte. It is a real choice -- a pinned thread
1691
+ * on a busy account pays latency -- and it stays available; it is just no longer the default.
1692
+ */
1693
+ function isCacheAffinityEnabled(config: OcxConfig): boolean {
1694
+ return config.pool?.cacheAffinity !== false;
1695
+ }
1696
+
1697
+ /**
1698
+ * Is a transient failure streak the ONLY thing standing between this thread and its account?
1699
+ *
1700
+ * The point is the word "only". A binding must still be released for every cause that means
1701
+ * the account cannot serve this conversation at all -- a quota refusal it already answered,
1702
+ * an operator pause, a plan exclusion, an unusable or superseded credential, a hard cooldown,
1703
+ * an avoided quota window. What is left after those is a 5xx streak and the escalating
1704
+ * soft-avoid window it writes, and that is a statement about right now, not about ownership.
1705
+ *
1706
+ * #4269 is the cautionary case: a retryable 503 whose human-readable body happened to contain
1707
+ * the word "reauthentication" was classified as an auth failure. A failure's blast radius has
1708
+ * to come from the scope it was recorded at, which is what this predicate reads.
1709
+ *
1710
+ * Deliberately NOT gated on `pool.cacheAffinity`. That flag chooses between cache-first and
1711
+ * capacity-first QUOTA routing; it says nothing about how a failure should be attributed, and
1712
+ * an operator who prefers capacity-first has not asked for three 503s to cost them a prefix.
1713
+ */
1714
+ function isTransientOnlyAffinityBlock(
1715
+ config: OcxConfig,
1716
+ entry: ThreadAffinityEntry,
1717
+ now: number,
1718
+ quotaScope?: CodexQuotaScope,
1719
+ selectionOptions?: CodexAccountUsabilityOptions,
1720
+ ): boolean {
1721
+ if (!isThreadAffinityGenerationLive(entry)) return false;
1722
+ if (hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope)) return false;
1723
+ if (isCodexAccountPaused(config, entry.accountId)) return false;
1724
+ if (isCodexAccountPlanExcluded(config, entry.accountId)) return false;
1725
+ if (!isCodexAccountUsable(config, entry.accountId, selectionOptions)) return false;
1726
+ if (getCodexQuotaHealthSnapshot(entry.accountId, quotaScope, now) !== null) return false;
1727
+ if (isCodexQuotaAvoided(entry.accountId, quotaScope, now)) return false;
1728
+ return shouldFailover(config, entry.accountId, now) || isCodexAccountSoftAvoided(entry.accountId, now);
1729
+ }
1730
+
1731
+ /** Has a held binding waited longer than a transient failure can reasonably explain? */
1732
+ function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean {
1733
+ return entry.transientHoldSince !== undefined
1734
+ && now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS;
1735
+ }
1736
+
1737
+ /**
1738
+ * Is every pin this thread holds on the failing account past its hold window?
1739
+ *
1740
+ * A thread that has never detoured has no hold to spend, so it answers false: the resolve path
1741
+ * has not yet had the chance to route around the failure, and deleting the pin here would take
1742
+ * that chance away.
1743
+ */
1744
+ function isTransientHoldSpentForAccount(threadId: string, accountId: string, now: number): boolean {
1745
+ const affinities = threadAccountMap.get(threadId);
1746
+ if (!affinities) return false;
1747
+ let matched = false;
1748
+ for (const entry of affinities.values()) {
1749
+ if (entry.accountId !== accountId) continue;
1750
+ matched = true;
1751
+ if (!isTransientHoldExpired(entry, now)) return false;
1752
+ }
1753
+ return matched;
1754
+ }
1755
+
1756
+ /**
1757
+ * Who serves this thread while its own account is held. Prefers the account already doing so,
1758
+ * because a detour that moves every turn is just the original defect wearing a different name.
1759
+ */
1760
+ function transientDetourAccount(
1761
+ config: OcxConfig,
1762
+ entry: ThreadAffinityEntry,
1763
+ now: number,
1764
+ quotaScope?: CodexQuotaScope,
1765
+ selectionOptions?: CodexAccountUsabilityOptions,
1766
+ mode: "commit" | "peek" = "commit",
1767
+ ): string | null {
1768
+ const held = entry.transientDetourAccountId;
1769
+ if (
1770
+ held !== undefined
1771
+ && held !== entry.accountId
1772
+ && isCodexAccountSelectable(config, held, now, quotaScope, selectionOptions)
1773
+ && !hasUnrecoveredCodexQuotaRefusal(held, quotaScope)
1774
+ && !shouldFailover(config, held, now)
1775
+ && !isCodexAccountSoftAvoided(held, now)
1776
+ ) {
1777
+ return held;
1778
+ }
1779
+ // Preview must name the same account resolve would, including before any detour has been
1780
+ // recorded -- but without advancing the round-robin ring, which is the one side effect in
1781
+ // the selection path.
1782
+ return mode === "peek"
1783
+ ? peekAlternateCodexAccount(config, entry.accountId, now, quotaScope, selectionOptions)
1784
+ : pickAlternateCodexAccount(config, entry.accountId, now, quotaScope, selectionOptions);
1785
+ }
1786
+
1519
1787
  /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
1520
1788
  function pickResetFirstCodexAccount(
1521
1789
  config: OcxConfig,
@@ -1811,6 +2079,32 @@ export function pickAlternateCodexAccount(
1811
2079
  return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
1812
2080
  }
1813
2081
 
2082
+ /**
2083
+ * The account {@link pickAlternateCodexAccount} WOULD return, without returning it.
2084
+ *
2085
+ * Only the round-robin branch has a side effect -- `pickRoundRobinAccount` commits the pick and
2086
+ * advances the ring -- so every other strategy delegates rather than growing a second copy of
2087
+ * the selection rule that could drift from it.
2088
+ *
2089
+ * This exists because preview and resolve have to agree on the FIRST transient detour, not just
2090
+ * on later ones. Preview feeds subagent model-availability scoring, so a preview that reported
2091
+ * the bound account while resolve was about to serve from a cool sibling could retire a model
2092
+ * over usage the request would never have touched.
2093
+ */
2094
+ function peekAlternateCodexAccount(
2095
+ config: OcxConfig,
2096
+ excludeId: string,
2097
+ now: number,
2098
+ quotaScope?: CodexQuotaScope,
2099
+ selectionOptions?: CodexAccountUsabilityOptions,
2100
+ ): string | null {
2101
+ if (accountPoolStrategyForScope(config, quotaScope) === "round-robin") {
2102
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
2103
+ return peekRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
2104
+ }
2105
+ return pickAlternateCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
2106
+ }
2107
+
1814
2108
  /** Effective active: automatic runtime cursor, else operator/persisted selection. */
1815
2109
  /**
1816
2110
  * Unspent operator selections, keyed by pool scope.
@@ -2182,11 +2476,27 @@ function previewReusableAffinityAccount(
2182
2476
  if (
2183
2477
  !entry
2184
2478
  || isThreadAffinityExpired(entry, now)
2185
- || !isThreadAffinityGenerationLive(entry)
2479
+ ) {
2480
+ return null;
2481
+ }
2482
+ if (
2483
+ !isThreadAffinityGenerationLive(entry)
2186
2484
  || !isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
2187
2485
  || hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope)
2188
2486
  || shouldFailover(config, entry.accountId, now)
2189
2487
  ) {
2488
+ // Preview must reach the same answer as resolve, including the transient detour, or the
2489
+ // subagent fallback decides against a binding the next real request would have held.
2490
+ // Read-only by contract: no hold is started and no detour is recorded here.
2491
+ if (
2492
+ !isTransientHoldExpired(entry, now)
2493
+ && isTransientOnlyAffinityBlock(config, entry, now, quotaScope, selectionOptions)
2494
+ ) {
2495
+ const detour = transientDetourAccount(config, entry, now, quotaScope, selectionOptions, "peek");
2496
+ if (detour !== null && detour !== entry.accountId) return detour;
2497
+ // Nowhere to detour still means the thread keeps its account, so preview says so too.
2498
+ return entry.accountId;
2499
+ }
2190
2500
  return null;
2191
2501
  }
2192
2502
  if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") {
@@ -2205,16 +2515,15 @@ function previewReusableAffinityAccount(
2205
2515
  // Preview must agree with resolve: this is the second copy of the same rule, and the
2206
2516
  // suite asserts the two answer identically.
2207
2517
  if (mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) {
2208
- const best = pickLowerUsageAccount(
2518
+ const best = pickCacheSafeQuotaReplacement(
2209
2519
  config,
2210
2520
  entry.accountId,
2211
2521
  usage,
2212
2522
  now,
2213
2523
  quotaScope,
2214
2524
  selectionOptions,
2215
- true,
2216
2525
  );
2217
- if (best !== entry.accountId) return best;
2526
+ if (best) return best;
2218
2527
  }
2219
2528
  }
2220
2529
  }
@@ -2224,13 +2533,15 @@ function previewReusableAffinityAccount(
2224
2533
  /**
2225
2534
  * May a LIVE binding be moved for quota reasons?
2226
2535
  *
2227
- * Default: yes once usage crosses `autoSwitchThreshold`, which is the historical rule.
2536
+ * Default: no. The bar is genuine exhaustion, because moving a bound conversation discards
2537
+ * the prompt cache warmed on its account and a threshold crossing is a hint that the account
2538
+ * is getting busy rather than evidence it cannot serve (#4546). Deliberately NOT
2539
+ * `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the
2540
+ * old rule under a new name.
2228
2541
  *
2229
- * With `pool.cacheAffinity` on, the bar becomes genuine exhaustion. Moving a bound
2230
- * conversation discards the prompt cache warmed on its account, so a threshold crossing -- a
2231
- * hint that the account is getting busy -- does not justify paying that cost; the account has
2232
- * to be unable to serve. Deliberately NOT `hasCodexQuotaHeadroom`, which reads
2233
- * `usage < autoSwitchThreshold` and would reproduce the old rule under a new name.
2542
+ * With `pool.cacheAffinity: false` the historical rule comes back: a crossing of
2543
+ * `autoSwitchThreshold` is enough. That is capacity-first routing, and an operator who wants
2544
+ * it keeps it -- but it is no longer what an install gets by never having heard of the flag.
2234
2545
  */
2235
2546
  function mayRebindAffinityForQuota(
2236
2547
  config: OcxConfig,
@@ -2240,7 +2551,7 @@ function mayRebindAffinityForQuota(
2240
2551
  selectionOptions?: CodexAccountUsabilityOptions,
2241
2552
  ): boolean {
2242
2553
  const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
2243
- if (config.pool?.cacheAffinity !== true) return overThreshold;
2554
+ if (!isCacheAffinityEnabled(config)) return overThreshold;
2244
2555
  // The usable half is already guaranteed by both callers, which gate on
2245
2556
  // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
2246
2557
  return !isCodexAccountUsable(config, accountId, selectionOptions)
@@ -2260,13 +2571,74 @@ function resetFirstAffinityReplacement(
2260
2571
  const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now);
2261
2572
  if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null;
2262
2573
  const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true)
2263
- .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
2574
+ // Headroom alone answers true for an UNMEASURED account, which is the right default for an
2575
+ // unbound request and the wrong bet for a bound one. The quota strategy already excludes
2576
+ // those through the strictly-cooler compare; reset ordering has no such compare, so it has
2577
+ // to say it. Moving a warm conversation onto an account nobody has a reading for is a
2578
+ // guess, not an improvement.
2579
+ .filter(id => {
2580
+ if (!hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return false;
2581
+ return !isUnknownUsage(computeCodexUsageScore(
2582
+ getAccountQuota(id),
2583
+ getPoolAccountPlanForSelection(config, id, selectionOptions),
2584
+ now,
2585
+ ));
2586
+ });
2264
2587
  return pickResetFirstCodexAccount(config, candidates, now, selectionOptions);
2265
2588
  }
2266
2589
 
2267
2590
  /**
2268
- * Re-evaluate an affined account under the quota strategy. Returns a strictly
2269
- * cooler replacement, or null when the current binding should remain.
2591
+ * Quota-strategy replacement for a LIVE binding (#4546).
2592
+ *
2593
+ * "Strictly cooler by any margin" — what {@link pickLowerUsageAccount} answers — is the
2594
+ * right rule for an unbound request and the wrong one for a bound thread. Once every
2595
+ * account sits in the threshold band the coolest is still over it, so a long-running
2596
+ * conversation was handed from account to account on consecutive turns. Codex prompt
2597
+ * caches are account-isolated, so each hop restarted from a cold prefix; the reporter
2598
+ * measured 7k-token turns becoming 150k-token turns.
2599
+ *
2600
+ * The destination must clear the same bar {@link resetFirstAffinityReplacement} already
2601
+ * applies — genuine headroom via {@link hasCodexQuotaHeadroom} — AND be strictly cooler
2602
+ * than the bound account. Headroom alone is not sufficient: that predicate deliberately
2603
+ * answers true for unknown usage, which is the right default for an unbound pick but a
2604
+ * guess when a warm prefix is at stake. `CODEX_UNKNOWN_USAGE_SCORE` is 101, so an
2605
+ * unobserved account can never be strictly cooler than a known over-threshold score and
2606
+ * the second bar excludes it without a special case.
2607
+ *
2608
+ * This narrows a preference, never a refusal: callers release the binding on a 429/402,
2609
+ * failover, or exhaustion before this helper is consulted, so a thread cannot be wedged
2610
+ * on an account that cannot serve.
2611
+ */
2612
+ function pickCacheSafeQuotaReplacement(
2613
+ config: OcxConfig,
2614
+ boundAccountId: string,
2615
+ boundUsage: number,
2616
+ now: number,
2617
+ quotaScope?: CodexQuotaScope,
2618
+ selectionOptions?: CodexAccountUsabilityOptions,
2619
+ ): string | null {
2620
+ const candidates = getEligiblePoolAccounts(
2621
+ config,
2622
+ boundAccountId,
2623
+ now,
2624
+ quotaScope,
2625
+ selectionOptions,
2626
+ true,
2627
+ ).filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
2628
+ const best = pickLowestUsageAmong(config, candidates, selectionOptions, now);
2629
+ if (best === null || best === boundAccountId) return null;
2630
+ const bestUsage = computeCodexUsageScore(
2631
+ getAccountQuota(best),
2632
+ getPoolAccountPlanForSelection(config, best, selectionOptions),
2633
+ now,
2634
+ );
2635
+ return bestUsage < boundUsage ? best : null;
2636
+ }
2637
+
2638
+ /**
2639
+ * Re-evaluate an affined account under the quota strategy. Returns a replacement
2640
+ * that has genuine quota headroom and is strictly cooler than the bound account,
2641
+ * or null when the current binding should remain (#4546).
2270
2642
  */
2271
2643
  function reevaluateAffinityQuota(
2272
2644
  entry: ThreadAffinityEntry,
@@ -2302,16 +2674,14 @@ function reevaluateAffinityQuota(
2302
2674
  }
2303
2675
  entry.lastReevalAt = now;
2304
2676
  if (!mayRebind) return null;
2305
- const best = pickLowerUsageAccount(
2677
+ return pickCacheSafeQuotaReplacement(
2306
2678
  config,
2307
2679
  entry.accountId,
2308
2680
  usage,
2309
2681
  now,
2310
2682
  quotaScope,
2311
2683
  selectionOptions,
2312
- true,
2313
2684
  );
2314
- return best === entry.accountId ? null : best;
2315
2685
  }
2316
2686
 
2317
2687
  /**
@@ -2449,6 +2819,11 @@ export function resolveCodexAccountForThreadDetailed(
2449
2819
  && !shouldFailover(config, detourEntry.accountId, now);
2450
2820
  if (detourReusable) {
2451
2821
  detourEntry.lastUsedAt = now;
2822
+ // Same as the ordinary lane: serving again ends the hold. Without this the marker
2823
+ // survives recovery, and a later streak reads a hold that started before the account
2824
+ // ever came back -- which is the pin drop this whole branch exists to prevent.
2825
+ if (detourEntry.transientHoldSince !== undefined) delete detourEntry.transientHoldSince;
2826
+ if (detourEntry.transientDetourAccountId !== undefined) delete detourEntry.transientDetourAccountId;
2452
2827
  // Model detours follow the same affinity policy as ordinary bindings:
2453
2828
  // RR/fill-first stay sticky, while quota strategy may re-evaluate an
2454
2829
  // over-threshold account without changing the ordinary lane.
@@ -2461,9 +2836,29 @@ export function resolveCodexAccountForThreadDetailed(
2461
2836
  );
2462
2837
  if (cooler) {
2463
2838
  bindModelDetourAffinity(threadId, cooler, now, modelId, quotaScope);
2464
- return { status: "selected", accountId: cooler };
2839
+ return { status: "selected", accountId: cooler, affinity: { move: "rebound", reason: "model_lane" } };
2465
2840
  }
2466
- return { status: "selected", accountId: detourEntry.accountId };
2841
+ return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "reused", reason: "model_lane" } };
2842
+ }
2843
+ // The model lane gets the same transient hold as the ordinary one. Without it a
2844
+ // model-scoped request drops its detour pin on three 503s and falls back to an ordinary
2845
+ // home account that may not even be entitled to this model.
2846
+ if (
2847
+ !isTransientHoldExpired(detourEntry, now)
2848
+ && isTransientOnlyAffinityBlock(config, detourEntry, now, quotaScope, selectionOptions)
2849
+ ) {
2850
+ const lane = transientDetourAccount(config, detourEntry, now, quotaScope, selectionOptions);
2851
+ detourEntry.transientHoldSince ??= now;
2852
+ detourEntry.lastUsedAt = now;
2853
+ if (lane !== null && lane !== detourEntry.accountId) {
2854
+ detourEntry.transientDetourAccountId = lane;
2855
+ return { status: "selected", accountId: lane, affinity: { move: "detour", reason: "transient" } };
2856
+ }
2857
+ // A provider-wide outage soft-avoids every sibling, so there is nowhere to detour.
2858
+ // That is a statement about where this request can go, not about who owns the
2859
+ // conversation: dropping the pin here would rebuild the cold prefix elsewhere for
2860
+ // exactly the failure mode the hold exists to survive.
2861
+ return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "held", reason: "transient" } };
2467
2862
  }
2468
2863
  // Detour expiry or invalidation must not expire the ordinary task. Drop only
2469
2864
  // this model lane and select from ordinary/shared state below.
@@ -2471,11 +2866,14 @@ export function resolveCodexAccountForThreadDetailed(
2471
2866
  }
2472
2867
  }
2473
2868
 
2869
+ // Why the binding went away, when it did. Carried to the selection below so the request that
2870
+ // pays for a cold prefix can say what it paid for.
2871
+ let releaseReason: CodexAffinityReason | undefined;
2474
2872
  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
2475
2873
  if (threadId && entry) {
2476
2874
  if (isThreadAffinityExpired(entry, now)) {
2477
2875
  deleteThreadAffinity(threadId, quotaScope);
2478
- return { status: "expired", accountId: entry.accountId };
2876
+ return { status: "expired", accountId: entry.accountId, affinity: { move: "cleared", reason: "expired" } };
2479
2877
  }
2480
2878
  const generationLive = isThreadAffinityGenerationLive(entry);
2481
2879
  const selectableForSharedState = generationLive
@@ -2498,8 +2896,15 @@ export function resolveCodexAccountForThreadDetailed(
2498
2896
  && !failoverReady
2499
2897
  ) {
2500
2898
  entry.lastUsedAt = now;
2899
+ // Serving again ends any transient hold: the thread is home, so the detour it was
2900
+ // parked on is no longer the answer to anything.
2901
+ if (entry.transientHoldSince !== undefined) delete entry.transientHoldSince;
2902
+ if (entry.transientDetourAccountId !== undefined) delete entry.transientDetourAccountId;
2501
2903
  // Periodic quota re-eval: a long-lived bound thread must still switch when
2502
- // it crosses autoSwitchThreshold and a strictly-cooler account exists.
2904
+ // it crosses autoSwitchThreshold, but only onto an account that has genuine
2905
+ // quota headroom AND is strictly cooler — moving to a destination still over
2906
+ // the threshold just trades the warmed prompt-cache prefix for an equally hot
2907
+ // account, which is the #4546 ping-pong.
2503
2908
  // Without this the reuse branch returns before applyQuotaAutoSwitch and the
2504
2909
  // thread stays pinned for the full idle TTL (the WSL "never switches" report).
2505
2910
  // Over-threshold pins re-eval immediately so a depleted primary does not keep
@@ -2512,18 +2917,77 @@ export function resolveCodexAccountForThreadDetailed(
2512
2917
  promoteActiveCodexAccount(config, cooler);
2513
2918
  }
2514
2919
  bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks
2515
- return { status: "selected", accountId: cooler };
2920
+ return { status: "selected", accountId: cooler, affinity: { move: "rebound", reason: "quota_headroom" } };
2516
2921
  }
2517
- return { status: "selected", accountId: entry.accountId };
2922
+ return { status: "selected", accountId: entry.accountId, affinity: { move: "reused", reason: "healthy" } };
2923
+ }
2924
+ // Transient trouble on the bound account is a reason to send elsewhere, not a reason to
2925
+ // give up the conversation. Detour this request and KEEP the binding, so recovery is free
2926
+ // instead of costing another cold prefix (#4546). Bounded: once the hold outlives what a
2927
+ // transient failure can explain, fall through and release it like any other dead account.
2928
+ if (
2929
+ !isTransientHoldExpired(entry, now)
2930
+ && isTransientOnlyAffinityBlock(config, entry, now, quotaScope, selectionOptions)
2931
+ ) {
2932
+ const detour = transientDetourAccount(config, entry, now, quotaScope, selectionOptions);
2933
+ entry.transientHoldSince ??= now;
2934
+ entry.lastUsedAt = now;
2935
+ if (detour !== null && detour !== entry.accountId) {
2936
+ entry.transientDetourAccountId = detour;
2937
+ // Deliberately no promoteActiveCodexAccount and no rebind: this is one request routing
2938
+ // around a blip, not the pool deciding where the conversation now lives.
2939
+ return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } };
2940
+ }
2941
+ // No sibling can take it either -- the usual shape of a provider-wide 503. The binding
2942
+ // survives: "cannot send right now" and "forget which account owns this conversation"
2943
+ // are different answers, and conflating them is what the hold was added to stop.
2944
+ return { status: "selected", accountId: entry.accountId, affinity: { move: "held", reason: "transient" } };
2518
2945
  }
2519
2946
  // A model-only exclusion does not invalidate the shared task binding. Health,
2520
2947
  // generation, pause, cooldown, and failure evidence still retire it normally.
2521
2948
  if (!modelScopedSelection || !healthyForSharedAffinity) {
2949
+ // A hold that outlived its window is not the same as a conversation with nowhere to go.
2950
+ // If the account that has actually been serving this thread is still healthy, promote it
2951
+ // instead of deleting the entry and re-picking cold: releasing here threw away the one
2952
+ // piece of evidence the request had -- that B works -- and handed the thread back to a
2953
+ // fresh strategy choice, which is the cold-prefix cost #4546 is about. A timer expiring
2954
+ // restores the right to re-decide; it is not itself a recovery.
2955
+ const expiredDetour = entry.transientDetourAccountId;
2956
+ if (
2957
+ isTransientHoldExpired(entry, now)
2958
+ && generationLive
2959
+ && !quotaRefused
2960
+ && expiredDetour !== undefined
2961
+ && expiredDetour !== entry.accountId
2962
+ && isCodexAccountSelectable(config, expiredDetour, now, quotaScope, selectionOptions)
2963
+ && !hasUnrecoveredCodexQuotaRefusal(expiredDetour, quotaScope)
2964
+ && !shouldFailover(config, expiredDetour, now)
2965
+ && !isCodexAccountSoftAvoided(expiredDetour, now)
2966
+ ) {
2967
+ if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, expiredDetour);
2968
+ bindThreadAffinity(threadId, expiredDetour, now, quotaScope);
2969
+ return {
2970
+ status: "selected",
2971
+ accountId: expiredDetour,
2972
+ affinity: { move: "rebound", reason: "transient_hold_expired" },
2973
+ };
2974
+ }
2975
+ releaseReason = !generationLive
2976
+ ? "generation"
2977
+ : quotaRefused
2978
+ ? "quota_refusal"
2979
+ : isTransientHoldExpired(entry, now)
2980
+ ? "transient_hold_expired"
2981
+ : codexAccountBlockReason(config, entry.accountId, now, quotaScope, selectionOptions)
2982
+ ?? "quota_headroom";
2522
2983
  deleteThreadAffinity(threadId, quotaScope);
2523
2984
  } else {
2524
2985
  preserveExistingModelScopedAffinity = true;
2525
2986
  }
2526
2987
  }
2988
+ // A release recorded by the outcome path (a 429 clears the pin before the next request even
2989
+ // arrives) is the reason this request is starting cold, so it outranks having found nothing.
2990
+ releaseReason ??= peekPendingReleaseReason(threadId);
2527
2991
 
2528
2992
  // A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return
2529
2993
  // before the quota/failover helpers below, so prefer only shared-healthy roster members here;
@@ -2564,7 +3028,7 @@ export function resolveCodexAccountForThreadDetailed(
2564
3028
  // the thing the preference exists to protect.
2565
3029
  promoteActiveCodexAccount(config, strategyPick);
2566
3030
  }
2567
- return { status: "selected", accountId: strategyPick };
3031
+ return { status: "selected", accountId: strategyPick, affinity: affinityAfterRelease(threadId, releaseReason) };
2568
3032
  }
2569
3033
 
2570
3034
  let active = getEffectiveActiveCodexAccountId(config);
@@ -2575,9 +3039,9 @@ export function resolveCodexAccountForThreadDetailed(
2575
3039
  selectionOptions?.nativeMainSelectionOnly === true
2576
3040
  && selectionOptions.modelEligibleAccountIds !== undefined
2577
3041
  ) {
2578
- return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID };
3042
+ return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, affinity: affinityAfterRelease(threadId, releaseReason) };
2579
3043
  }
2580
- return { status: "none" };
3044
+ return { status: "none", affinity: affinityOnNoAccount(threadId, releaseReason) };
2581
3045
  }
2582
3046
  if (!isIndependentCodexQuotaScope(quotaScope) && !modelScopedSelection) {
2583
3047
  setActiveCodexAccount(config, selected);
@@ -2613,15 +3077,15 @@ export function resolveCodexAccountForThreadDetailed(
2613
3077
  // return main only as a non-mutating sentinel so the caller's atomic claim can
2614
3078
  // classify maintenance. Do not fall through to the configured-but-ineligible
2615
3079
  // active account or persist/bind this synthetic selection.
2616
- return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID };
3080
+ return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, affinity: affinityAfterRelease(threadId, releaseReason) };
2617
3081
  } else if (
2618
3082
  hasConfiguredPoolAccount(config, active, selectionOptions)
2619
3083
  && !isCodexAccountPaused(config, active)
2620
3084
  && !isCodexAccountPlanExcluded(config, active)
2621
3085
  ) {
2622
- return { status: "selected", accountId: active };
3086
+ return { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) };
2623
3087
  } else {
2624
- return { status: "none" };
3088
+ return { status: "none", affinity: affinityOnNoAccount(threadId, releaseReason) };
2625
3089
  }
2626
3090
  }
2627
3091
  // Before applyQuotaAutoSwitch: its sync disk write would otherwise persist a
@@ -2661,14 +3125,14 @@ export function resolveCodexAccountForThreadDetailed(
2661
3125
  );
2662
3126
  if (!isCodexAccountUsable(config, active, selectionOptions)) {
2663
3127
  return hasConfiguredPoolAccount(config, active, selectionOptions)
2664
- ? { status: "selected", accountId: active }
2665
- : { status: "none" };
3128
+ ? { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) }
3129
+ : { status: "none", affinity: affinityOnNoAccount(threadId, releaseReason) };
2666
3130
  }
2667
- if (isCodexAccountPaused(config, active)) return { status: "none" };
3131
+ if (isCodexAccountPaused(config, active)) return { status: "none", affinity: affinityOnNoAccount(threadId, releaseReason) };
2668
3132
  if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
2669
3133
  return hasConfiguredPoolAccount(config, active, selectionOptions)
2670
- ? { status: "selected", accountId: active }
2671
- : { status: "none" };
3134
+ ? { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) }
3135
+ : { status: "none", affinity: affinityOnNoAccount(threadId, releaseReason) };
2672
3136
  }
2673
3137
  if (threadId) {
2674
3138
  if (preserveExistingModelScopedAffinity) {
@@ -2677,7 +3141,7 @@ export function resolveCodexAccountForThreadDetailed(
2677
3141
  bindThreadAffinity(threadId, active, now, quotaScope);
2678
3142
  }
2679
3143
  }
2680
- return { status: "selected", accountId: active };
3144
+ return { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) };
2681
3145
  }
2682
3146
 
2683
3147
  export function recordCodexUpstreamOutcome(
@@ -2864,7 +3328,7 @@ export function recordCodexUpstreamOutcome(
2864
3328
  // The reauth flag carries the same provenance, so a replacement landing after this call cannot
2865
3329
  // inherit a quarantine that was never about it.
2866
3330
  markAccountNeedsReauth(accountId, writerGeneration, meta.credentialGeneration);
2867
- clearThreadAccountMapForAccount(accountId);
3331
+ clearThreadAccountMapForAccount(accountId, "quota_refusal");
2868
3332
  return;
2869
3333
  }
2870
3334
 
@@ -2899,7 +3363,7 @@ export function recordCodexUpstreamOutcome(
2899
3363
  // threads must leave it and new requests should prefer an eligible account.
2900
3364
  // Reserve remains isolated so a same-account Terra/Luna combo fallback can run.
2901
3365
  if (quotaScope === "shared" && !meta.fixedAccount) {
2902
- clearThreadAccountMapForAccount(accountId);
3366
+ clearThreadAccountMapForAccount(accountId, "quota_refusal");
2903
3367
  notePoolRotationFailure(POOL_KEY_CODEX, accountId);
2904
3368
  if (getEffectiveActiveCodexAccountId(config) === accountId) {
2905
3369
  // Same-request 429 retry already picked via excludeAccountId — reuse it so
@@ -2945,7 +3409,7 @@ export function recordCodexUpstreamOutcome(
2945
3409
  }),
2946
3410
  });
2947
3411
  if (!meta.fixedAccount) {
2948
- clearThreadAccountMapForAccount(accountId);
3412
+ clearThreadAccountMapForAccount(accountId, "quota_refusal");
2949
3413
  // An independent native quota request may discover an account-wide throttle,
2950
3414
  // but it still must not advance the shared RR ring or active cursor. The next
2951
3415
  // shared request observes the cooldown and chooses its own fallback.
@@ -3006,14 +3470,22 @@ export function recordCodexUpstreamOutcome(
3006
3470
  // thread is still pinned to the FAILING account — a late failure from account A
3007
3471
  // must not delete a newer healthy binding to account B (race: T→A, A fails,
3008
3472
  // T→B, late A failure must not delete B's mapping).
3009
- if (!meta.fixedAccount && failoverReady && meta.threadId) {
3473
+ // A transient streak no longer surrenders the conversation: the resolve path detours this
3474
+ // thread onto a remembered alternate and KEEPS the binding, so recovering costs nothing
3475
+ // (#4546). The pin is dropped only once the hold has outlived what a transient failure can
3476
+ // explain, the same bound the resolve path applies -- recorded here so a thread that simply
3477
+ // stops sending cannot leave a dead pin behind.
3478
+ if (
3479
+ !meta.fixedAccount
3480
+ && failoverReady
3481
+ && meta.threadId
3482
+ && isTransientHoldSpentForAccount(meta.threadId, accountId, now)
3483
+ ) {
3010
3484
  deleteThreadAffinitiesForAccount(meta.threadId, accountId);
3011
3485
  }
3012
- // Once the account is past the failover streak, clear every thread still pinned
3013
- // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
3014
- if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
3015
- clearThreadAccountMapForAccount(accountId);
3016
- }
3486
+ // No account-wide clear for a transient streak. Every pinned thread reaches the same detour
3487
+ // on its own next request, and wiping the map would retire bindings for quota scopes the
3488
+ // failure never described -- a spent Terra window must not evict the same thread's Spark pin.
3017
3489
  if (
3018
3490
  !meta.fixedAccount
3019
3491
  && !isIndependentCodexQuotaScope(quotaScope)