Skip to content

fix(codex): keep a bound thread's prompt cache and tell configured routing apart from adopted routing - #4581

Merged
lidge-jun merged 6 commits into
devfrom
codex/260914-l2-pool-routing-cache
Sep 14, 2026
Merged

lidge-jun merged 6 commits into
devfrom
codex/260914-l2-pool-routing-cache

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

Two account-pool defects where the proxy's behaviour and its reporting disagreed with what the operator believed.

#4546 — pool rotation destroyed the prompt cache of a bound conversation. Under the quota strategy a live thread binding could move to any strictly cooler eligible account once its account crossed autoSwitchThreshold. "Strictly cooler" had no floor, so once every account sat in the 80-100% band the coolest was still over the threshold and a long-running conversation was handed from account to account on consecutive turns. Codex prompt caches are account-isolated, so each hop restarted from a cold prefix: the reporter measured 7k-token turns becoming 150k-token turns, and 1.9 billion tokens across 15,607 requests in about 13 hours on five accounts.

A live binding may now move only to an account that has genuine quota headroom and is strictly cooler than the bound account. Headroom alone is not enough, because hasCodexQuotaHeadroom deliberately answers true for unknown usage — correct for an unbound pick, a guess when a warm prefix is at stake — and CODEX_UNKNOWN_USAGE_SCORE is 101, so the second bar excludes an unobserved destination without a special case. Before: with a=95, b=90, c=97 the thread moved every turn. After: it stays, and movement is bounded by the number of accounts rather than the number of turns.

This is the same bar reset-first already applied through hasCodexQuotaHeadroom; quota was the asymmetric outlier. It narrows a preference and never a refusal — a 429/402 with no success since, a failover streak, pause, cooldown, a lost generation and an unusable account all still release the binding before the rule is consulted. Unbound selection is untouched, and pool.cacheAffinity keeps its meaning and its default as the stronger sticky-until-exhaustion opt-in.

#4550ocx status presented configured routing as adopted routing. It printed routing=opencodex-local read from config on disk, so an operator read it as proof their running Codex CLI was pooled. A client that started before the route was injected keeps dialing wss://chatgpt.com/backend-api/codex/responses directly, which is exactly what the report shows: repeated usage_limit_exceeded while a healthy secondary account sat in the pool, and no proxy usage record for those turns.

Status now carries adoption evidence. A running Codex client whose start time precedes the recorded injection cannot have read the injected route, so it is named as pending-client-restart with its pid and the summary says to restart it. adopted is deliberately an inference about opportunity, not an observation of live traffic, and the module comment names every false-adopted source this evidence does not cover — a client the matcher misses, a resumed thread holding an already-open direct WebSocket, OPENAI_BASE_URL overrides, same-second starts, an empty match set, a different CODEX_HOME, and surfaces such as codex-code-mode-host and VS Code extension hosts. Anything unverifiable is unknown: a failed process walk stays distinct from an empty one, because an empty walk means "no clients running" and would read as adopted.

The Codex CLI matching rules were private and reachable only through a count, which can answer "is Codex busy" but cannot name a stale pid, so they are now one exported predicate with two callers rather than a second copy. The new routingAdoption field is optional and the routing detail string keeps its old shape as a prefix, so the GUI and server consumers of StartupHealth are untouched and a stale client never changes status, protection, rebootSafe or recommendedCommand.

structure/providers/openai-tiers.md records the new rebind invariant, as structure/manifest.json ownership of src/codex/ requires. Planning notes are in devlog/_plan/260914_l2_pool_routing_cache/.

Verification

Local suite runs were NOT RUN for this unit, by explicit instruction. No bun run test, no bare bun test, no bun run typecheck, no bun install, no bun run build:gui — not locally, and not by any delegated agent. The proof for this pull request is hosted CI at the exact final head SHA and nothing else; a green run at an earlier head is not evidence for a later one.

  • Hosted CI run: Cross-platform CI 34806827740 at head 4b22b25f61c254cd29a823ed125ece5233b9289a — conclusion success, all 24 checks green across Linux, macOS and Windows. That run is the only proof for this head.
  • Regression coverage added instead of local execution, per AGENTS.md for src/ behaviour changes:
    • tests/codex-integration/codex-pool-rotation.test.ts — four cases beside the existing cache-affinity tests. The first is the [Bug]: Account pool routing destroys prompt cache and triggers 10x-50x token burn death-spiral above 80% usage threshold #4546 death spiral and fails without the fix: with a=95, b=90, c=97 the pre-fix rule handed the thread to whichever account was one point cooler on every resolve. The scores are deliberately unequal, since equal scores would not move even before the fix and the case would pass for the wrong reason. The other three keep the change a narrowing rather than a pin — a thread still moves onto an account with genuine headroom, a 429 still releases a binding the preference rule would have kept, and a fully spent account still yields to a sibling with headroom. Preview is asserted beside every resolve because both call sites share one helper.
    • tests/service/autostart-health.test.ts — adoption derivation through its pure and injectable seams: a pre-injection client is pending-client-restart with its pid, a post-injection client is adopted, a same-second start is not stale, enumeration failure and unreadable start times are unknown, a stale client outranks an unreadable one, non-opencodex-local routing is not-applicable, the detail string stays a prefix, and a stale client leaves the restart-safety classification unchanged.
  • Reviewed by two independent reviewer rounds before and after implementation. Round 1 returned FAIL on three blocking findings: the planned Codex-client process set could not see a CLI process at all, adopted was not sound as written, and the write scope omitted the structure/ doc that owns src/codex/. All three were fixed. Round 2 returned NEAR-PASS and verified the committed pickCacheSafeQuotaReplacement against every call site, finding no case that returns the wrong account and no weakened release path.
  • Existing bound-rebind assertions were audited by hand rather than executed: every one of them moves to a destination with real headroom (b at 5%), so the narrowing keeps them green.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Docs: the autoSwitchThreshold, accountPoolStrategy, pool-routing prose and strategy table now state the headroom bar, in the English reference and in all seven translated locales. No credential, token or request content is read or logged; the adoption check reads process ids and start times plus the journal's own timestamp, and it never mutates the journal — it parses JOURNAL_PATH directly rather than calling readJournal, which can delete a corrupt file.

Closes #4546
Closes #4550

…option unit

Locks the two fixes for #4546 and #4550 before implementation.
Under the quota account-pool strategy a live thread binding could be moved to
any strictly-cooler eligible account once its account crossed
autoSwitchThreshold. "Strictly cooler" had no floor, so once every account sat
in the 80-100% band the coolest was still over the threshold and a long-running
conversation was handed from account to account on consecutive turns. Codex
prompt caches are account-isolated, so each hop restarted from a cold prefix.

A live binding may now only move to an account that clears the same bar
reset-first already applied through hasCodexQuotaHeadroom, and that is also
strictly cooler than the bound account. Headroom alone is not enough, because
that predicate answers true for unknown usage, which is the right default for
an unbound pick and a guess when a warm prefix is at stake.

Movement is now bounded by the number of accounts rather than the number of
turns, and every release path is untouched: a 429/402 refusal, failover,
exhaustion, pause and generation checks all still drop the binding before the
preference rule is consulted.

Refs #4546
structure/manifest.json lists providers/openai-tiers.md as a doc for
src/codex/, so narrowing the quota-strategy live-rebind rule obliges the same
change here. Records why both bars are load-bearing, what the rule deliberately
does not change, and that the rule is written twice on purpose.

Also folds an independent roadmap review into devlog/_plan: the Codex-client
process set must not be the app-server lister, adopted stays an opportunity
inference, and tests extend existing subsystem files rather than adding new ones.

Refs #4546
Four cases next to the existing cache-affinity tests. The first is the #4546
death spiral and fails without the fix: with a=95, b=90, c=97 the pre-fix rule
handed the thread to whichever account was one point cooler on every resolve.
The scores are deliberately unequal, since equal scores would not move even
before the fix and would pass for the wrong reason.

The other three keep the change a narrowing rather than a pin: a thread still
moves onto an account with genuine headroom, a 429 still releases a binding the
preference rule would have kept, and a fully spent account still yields to a
sibling with headroom. Preview is asserted beside every resolve because the rule
is written twice.

Refs #4546
ocx status printed routing=opencodex-local straight from config on disk, so an
operator read it as proof that their running Codex CLI was pooled. A client that
started before the route was injected keeps dialing chatgpt.com directly, which
is what #4550 reports: repeated usage_limit_exceeded while a healthy secondary
account sat in the pool, and no proxy usage record for those turns.

Status now carries adoption evidence. A running Codex client whose start time
precedes the recorded injection cannot have read the injected route, so it is
named as pending-client-restart with its pid, and the summary says to restart it.
Anything unverifiable stays unknown rather than claiming a clean bill of health:
a failed process walk is distinct from an empty one, because an empty walk means
"no clients running" and would read as adopted.

adopted is deliberately an inference about opportunity, not an observation of
live traffic, and the module comment names every false-adopted source this
evidence does not cover, including same-second starts, an empty match set, a
different CODEX_HOME, and Codex surfaces the CLI predicate never matches.

The Codex CLI matching rules were private and reachable only through a count, so
they are now one exported predicate with two callers rather than a second copy.
The adoption field is optional and the routing detail string keeps its old shape
as a prefix, so the GUI and server consumers of StartupHealth are untouched, and
a stale client never changes status, protection, rebootSafe or recommendedCommand.

Refs #4550
Twelve cases through the pure derivation and the injected lister and start-time
seams, so nothing reads a real process table or the journal. A pre-injection
client is named with its pid, a post-injection client is adopted, a start in the
same wall-clock second is not stale, and enumeration failure, a missing injection
time and an unreadable start time each resolve to unknown rather than to a clean
bill of health. A stale client outranks an unreadable one.

The detail string is asserted byte-identical to the old
routing=..., service=..., shim=... form when nothing is pending, which is why the
existing assertions in this file still hold, and a stale client is asserted not to
move status, protection, rebootSafe or recommendedCommand.

Refs #4550
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 04:38
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 9 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 003dc1b3-4bcf-4e5c-9115-3503f2cef910

📥 Commits

Reviewing files that changed from the base of the PR and between c66709f and 4b22b25.

📒 Files selected for processing (20)
  • devlog/_plan/260914_l2_pool_routing_cache/000_unit.md
  • devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md
  • devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md
  • devlog/_plan/260914_l2_pool_routing_cache/030_delivery.md
  • docs-site/src/content/docs/fr/reference/configuration/providers.md
  • docs-site/src/content/docs/ja/reference/configuration/providers.md
  • docs-site/src/content/docs/ko/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/configuration/providers.md
  • docs-site/src/content/docs/ru/reference/configuration/providers.md
  • docs-site/src/content/docs/tr/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
  • src/codex/app-server-processes.ts
  • src/codex/autostart-health.ts
  • src/codex/native-profile-processes.ts
  • src/codex/routing-adoption.ts
  • src/codex/routing.ts
  • structure/providers/openai-tiers.md
  • tests/codex-integration/codex-pool-rotation.test.ts
  • tests/service/autostart-health.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-09-14T04:39:28.457456Z 4b22b25 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 14, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 64 / 80

지금 dev(HEAD c66709f31)에서 #4546의 핵심은 그대로입니다. quota 전략이 바인딩된 스레드를 autoSwitchThreshold 넘김 + pickLowerUsageAccount(조금만 더 시원하면 OK)로 옮기고, pool.cacheAffinity는 여전히 !== true라 기본 OFF입니다. 같은 시각에 열린 #4580은 그 사고를 cache-first 기본 + transient hold + headroom floor로 막으려 하고, 이 PR(codex/260914-l2-pool-routing-cache)은 L2 레인으로 문턱 eviction은 유지한 채 목적지 headroom만 좁히는 쪽입니다. #4580 계획 문서가 이 레인의 010_cache_safe_rebind.md를 흡수한다고 써 둔 그 분석입니다.

이 PR은 두 덩어리입니다. (1) src/codex/routing.tspickCacheSafeQuotaReplacement을 넣고 previewReusableAffinityAccount / reevaluateAffinityQuotahasCodexQuotaHeadroom + 엄격히 더 낮은 usage인 곳으로만 옮기게 합니다. 풀이 전부 80–99%면 아무도 안 옮겨서 #4546 ping-pong의 절반(목적지 하한)은 막힙니다. 하지만 mayRebindAffinityForQuota는 여전히 cacheAffinity !== true면 문턱만으로 재바인딩을 허용하고, 기본값을 ON으로 뒤집지도 않으며, transient streak가 바인딩을 지우는 구멍도 이 레인에 없습니다. 그래서 「플래그를 모르는 설치」는 85%→5%로 워밍된 prefix를 버릴 수 있습니다 — #4580이 1순위로 막는 바로 그 케이스입니다. (2) 새 파일 src/codex/routing-adoption.ts + autostart-health / native-profile-processes / app-server-processes 연동은 #4550용입니다. 주입 시각 이후 시작된 클라이언트만 adopted로 추론하고, 같은 초·열거 실패·미매칭 표면은 unknown / pending-client-restart로 남깁니다. 「adopted = 라이브 트래픽 관측」이 아니라고 주석에 못 박은 점이 좋습니다. types/config 스플릿에 무효화되는 PR은 아닙니다.

라인 - pickCacheSafeQuotaReplacement과 #4580의 boundThreadReplacementsrc/codex/routing.ts 같은 자리를 고칩니다. 둘 다 머지하면 충돌·이중 정책이 납니다. #4580이 랜딩하면 이 파일의 cache-rebind 절반은 superseded입니다.
라인 - #4546을 Closes로 걸기엔 이 레인만으로는 부족합니다. 기본 cacheAffinity OFF + transient eviction 미처리라서, 제목에 나온 「keep a bound thread's prompt cache」는 headroom floor만으로는 끝까지 못 지킵니다. 이슈 closes는 #4580에 맡기는 편이 맞습니다.
경로/심볼 - routing-adoption.tsadopted는 빈 매칭 집합을 vacuously adopted로 둡니다(주석에 명시). 운영 UI가 그걸 「전부 OK」로 보여 주면 #4550 오해가 다시 납니다 — 표시 문구에 observedClients=0 / unknown 가능성을 같이 실어야 합니다.
경로/심볼 - 두 관심사(풀 재바인딩 비용 + 라우팅 채택 보고)가 한 PR에 묶여 있습니다. cache 절반이 #4580에 흡수되면 adoption 절반(#4550)만 남은 채 브랜치가 무거워질 수 있습니다.

메인테이너의 판단이 필요한 지점

너의 추천
#4546은 #4580을 우선 머지(CI·스크린샷 게이트 정리 후). 이 PR(#4581)은 cache-rebind 커밋을 superseded로 정리하고, routing-adoption.ts + health/process 연동만 dev 최신 위에 새 브랜치/PR로 분리해 #4550을 다루는 쪽을 추천합니다. 지금 상태에서 둘 다 머지하지 말 것. rebase로 #4580과 싸우기보다 close-don't-rebase가 유지보수 비용이 덜합니다.

이 댓글은 grok-bot이 작성했습니다

@lidge-jun

Copy link
Copy Markdown
Owner Author

Merging into dev under the single-maintainer dev integration clause in MAINTAINERS.md.

Exact-head evidence at 4b22b25: 30 successes, 2 skips. The two cancelled entries are label and enforce-target runs that later runs at the same SHA superseded with successes.

Reviewed independently before merge, and the review is worth recording because it is narrower than the pull request title suggests.

src/codex/routing.ts routes live quota rebinds through a cache-safe replacement check at both the affinity re-evaluation and the reusable-account preview, so a bound thread can move only to an account with real quota headroom that is also strictly cooler. That is what stops the all-hot ping-pong that was discarding the account-isolated prompt-cache prefix, and the new cases pin stay-put, a single move onto real headroom, release on 429, and exhaustion with a sibling. The spiral is genuinely prevented rather than made less likely. What is not done: a below-threshold sibling still takes the thread once, so the cache is lost once before it goes sticky, and unbound subagent cascading is untouched.

The pooled-status half is honest reporting, not transport interception. A pre-injection matched CLI is now named as pending-client-restart with its pid, adopted stays an inference, and the routing string is byte-identical for the adopted and unknown cases. So the printed claim is accurate when a stale CLI is found, and it is still not proof about live traffic when the matcher misses a process or a resumed WebSocket stays direct.

Because of those residuals I am leaving both issues open with the landed scope recorded on them rather than closing them against this merge.

Local suite runs: NOT RUN. Hosted CI at the exact head is the proof of record.

@lidge-jun
lidge-jun merged commit 1de9184 into dev Sep 14, 2026
32 of 34 checks passed
@lidge-jun
lidge-jun deleted the codex/260914-l2-pool-routing-cache branch September 14, 2026 04:58
lidge-jun added a commit that referenced this pull request Sep 14, 2026
The L2 lane unit shipped its headroom floor while this unit was in flight. Both quota call sites use pickCacheSafeQuotaReplacement unchanged; this unit covers what that PR left open.
lidge-jun added a commit that referenced this pull request Sep 14, 2026
The #4581 conflict boundary cut through "a fully spent bound account still moves to a sibling with headroom", so the merged file lost its closing brace and the whole file failed to parse.
lidge-jun added a commit that referenced this pull request Sep 14, 2026
… case

The destination rule is only reachable when a threshold crossing can move a bound thread, which is now what pool.cacheAffinity: false selects.
lidge-jun added a commit that referenced this pull request Sep 14, 2026
#4580)

* docs(devlog): roadmap the #4546 cost-guard stabilization program

Eight-phase plan: hold a live binding for cache, release it only on real evidence, bound retry amplification and fan-out spend, group credentials by observed quota and cache domain, reinforce the Codex prompt cache, and report cache state honestly.

* fix(codex): hold a bound thread's account for its prompt cache (#4546)

Pooled routing moved a live conversation whenever its account crossed autoSwitchThreshold, and moved it to whichever account was strictly cooler with no floor under the destination. Provider prompt caches are account-isolated, so every hop re-sent the whole prefix; once a pool sat in the 80-99% band the coolest account was still over the threshold and the thread was handed on again every turn. The same predicate short-circuits the 60s re-score interval, so in that band the thread was re-scored per request.

pool.cacheAffinity shipped as the opt-in cure (#4292) but defaulted off, so the install that gets hurt was exactly the install that had never heard of it. Three changes:

- pool.cacheAffinity now defaults ON. A bound thread keeps its account until that account genuinely cannot serve. An explicit false restores capacity-first routing.
- A bound thread may only move to a destination with genuine quota headroom and strictly lower usage, under either setting. CODEX_UNKNOWN_USAGE_SCORE is 101, so an unmeasured account is excluded without a special case. When the whole pool is hot, nobody moves.
- A transient failure streak no longer deletes a live binding. The request detours to a remembered alternate while the thread keeps its home, and returns as soon as that account serves again; a hold outliving 10 minutes releases normally. This is independent of cacheAffinity, because attributing a 5xx is not a quota preference. The model-detour lane gets the same hold.

Quota refusals (429/402), pauses, plan exclusion, credential invalidation, generation bumps and TTL expiry still release a binding, unchanged. The account-wide clear on a transient streak is gone: each pinned thread reaches the same detour on its own next request, and wiping the map retired bindings for quota scopes the failure never described.

Preview never starts a hold or picks a fresh detour, because pickRoundRobinAccount commits and advances the ring and preview is contractually read-only; it reports the detour the request path already chose.

Six existing tests encoded the old default and now state it explicitly with pool.cacheAffinity: false.

* docs: align pool routing docs with cache-first bound-thread policy (#4546)

Every locale of the provider reference, the accounts CLI page and the dashboard guide documented pool.cacheAffinity as opt-in and off, and described the auto-switch threshold as the bar for moving a bound task. Both became wrong when the default flipped, and a default documented in eight languages is wrong in eight languages.

Also corrects the transient-failure wording: a 5xx streak no longer releases a live binding, independently of cacheAffinity. structure/providers/openai-tiers.md and the GUI strategy hints carried the same two claims.

The auth-context regression now asserts the session comes home to its held account once health clears, which is what proves a late failure did not disturb the binding; under the old rule there was a newer binding to protect instead.

* test(gui): update pool copy assertions to the cache-first wording (#4546)

Four dashboard tests pinned the sentences that said the usage threshold rebinds a bound task. Those strings changed with the default, so the assertions moved to the claims the copy now makes: bound tasks keep affinity by default, and the threshold rebind is what an explicit pool.cacheAffinity: false restores.

* docs(devlog): record that the destination rule landed as #4581

The L2 lane unit shipped its headroom floor while this unit was in flight. Both quota call sites use pickCacheSafeQuotaReplacement unchanged; this unit covers what that PR left open.

* fix(test): restore a test closer dropped by the rebase splice

The #4581 conflict boundary cut through "a fully spent bound account still moves to a sibling with headroom", so the merged file lost its closing brace and the whole file failed to parse.

* test(codex): state capacity-first explicitly in the #4581 destination case

The destination rule is only reachable when a threshold crossing can move a bound thread, which is now what pool.cacheAffinity: false selects.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant