@bitkyc08/opencodex 2.54.0 → 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.
- package/gui/dist/assets/{index-CkvITofZ.js → index-DH2PUHqr.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +57 -0
- package/src/adapters/anthropic-image-normalize.ts +28 -1
- package/src/adapters/anthropic.ts +68 -6
- package/src/adapters/base.ts +8 -0
- package/src/adapters/coding-agent/protocol.ts +41 -16
- package/src/adapters/cursor/cursor-errors.ts +1 -1
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/native-exec-fs.ts +10 -10
- package/src/adapters/cursor/native-exec-network.ts +2 -2
- package/src/adapters/cursor/native-exec-shell.ts +13 -12
- package/src/adapters/cursor/native-exec.ts +51 -10
- package/src/adapters/cursor/policy-error.ts +75 -0
- package/src/adapters/cursor/protobuf-request.ts +105 -1
- package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
- package/src/adapters/devin/live-models.ts +33 -2
- package/src/adapters/google-wire-compiler.ts +8 -0
- package/src/adapters/google.ts +46 -0
- package/src/adapters/input-media-guard.ts +45 -0
- package/src/adapters/kiro/adapter.ts +8 -0
- package/src/adapters/kiro/payload.ts +28 -6
- package/src/adapters/kiro-events.ts +25 -6
- package/src/adapters/kiro-images.ts +30 -0
- package/src/adapters/kiro-retry.ts +8 -0
- package/src/adapters/openai-chat.ts +33 -4
- package/src/adapters/openai-responses.ts +26 -0
- package/src/adapters/registry.ts +4 -0
- package/src/bridge.ts +163 -116
- package/src/chat/image-parts.ts +151 -0
- package/src/chat/inbound.ts +70 -33
- package/src/cli/connect.ts +30 -9
- package/src/cli/dispatch.ts +7 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/runtime-api.ts +25 -0
- package/src/cli/status.ts +21 -19
- package/src/cli/system-restart-client.ts +25 -0
- package/src/clients/config-export.ts +14 -4
- package/src/codex/app-server-processes.ts +25 -0
- package/src/codex/auth-context.ts +8 -0
- package/src/codex/autostart-health.ts +36 -2
- package/src/codex/catalog/provider-fetch.ts +41 -0
- package/src/codex/catalog-auto-refresh.ts +182 -0
- package/src/codex/catalog-refresh-status.ts +93 -0
- package/src/codex/history-provider.ts +55 -0
- package/src/codex/model-entitlements.ts +78 -0
- package/src/codex/native-profile-processes.ts +114 -15
- package/src/codex/prompt-text-probe.ts +274 -41
- package/src/codex/routing-adoption.ts +189 -0
- package/src/codex/routing.ts +520 -48
- package/src/codex/runtime.ts +249 -7
- package/src/combos/failover.ts +45 -0
- package/src/config.ts +124 -4
- package/src/generated/compatibility-version.json +110 -74
- package/src/generated/model-metadata.ts +1 -0
- package/src/lib/request-execution-budget.ts +202 -0
- package/src/lib/upstream-retry.ts +95 -8
- package/src/lib/workflow-budget.ts +172 -0
- package/src/oauth/devin.ts +57 -12
- package/src/providers/quota.ts +37 -6
- package/src/providers/registry.ts +53 -6
- package/src/responses/input-media.ts +65 -0
- package/src/responses/parser-content.ts +42 -0
- package/src/responses/schema.ts +12 -2
- package/src/server/audio-live.ts +1 -2
- package/src/server/audio-transcriptions.ts +1 -2
- package/src/server/auth-cors.ts +1 -1
- package/src/server/background-lifecycle.ts +18 -0
- package/src/server/chat-completions.ts +23 -8
- package/src/server/chat-native.ts +17 -17
- package/src/server/index.ts +24 -0
- package/src/server/management/request-history-routes.ts +5 -0
- package/src/server/request-log.ts +8 -2
- package/src/server/responses/compact.ts +51 -3
- package/src/server/responses/core.ts +238 -28
- package/src/server/search.ts +7 -9
- package/src/types/config.ts +51 -6
- package/src/usage/log.ts +37 -0
- package/src/vision/eligibility.ts +37 -4
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +45 -10
- package/src/web-search/alpha-search.ts +324 -0
- package/src/web-search/index.ts +13 -22
- package/src/web-search/passthrough-bridge.ts +195 -22
- package/src/web-search/sidecar-providers.ts +22 -0
package/src/codex/routing.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
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`
|
|
2230
|
-
*
|
|
2231
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
2269
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
3013
|
-
//
|
|
3014
|
-
|
|
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)
|