Skip to content

fix(codex): let an operator account selection outrank the pool cursor - #4284

Merged
lidge-jun merged 10 commits into
devfrom
codex/manual-selection-wins
Sep 11, 2026
Merged

fix(codex): let an operator account selection outrank the pool cursor#4284
lidge-jun merged 10 commits into
devfrom
codex/manual-selection-wins

Conversation

@lidge-jun

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

Copy link
Copy Markdown
Owner

Summary

Fixes the reported defect: the pool moves the active Codex account to B, the operator then selects A through the dashboard or ocx account use, and the runtime keeps serving B.

The cause was not the write path the operator takes. GUI and CLI both issue the same PUT /api/codex-auth/active, and that handler already clears the runtime cursor. The cause is that nothing stopped the next automatic pick from immediately overwriting it again: rememberActiveCodexAccount is reached from the round-robin commit, the fill-first commit, the failover promote and priority preemption, and none of them consulted the operator's choice. The pin was only ever a priority-tier ceiling, not a selection input.

This adds an unspent one-shot preference, mirroring the manualPreference shape Anthropic already has and Codex lacked.

Three things make it safe, and each was forced by a measured failure rather than guessed:

  • It is spent by success, not by time. recordCodexUpstreamOutcome consumes it on a successful dispatch for that scope. Wiring the guard without a consume site first makes the preference permanent and freezes the automatic cursor: that shape fails 15 of 69 rotation tests.
  • The failover promote is exempt. It only runs because the account in use just failed, so it is never an automatic pick competing with the operator. Guarding it traps routing on a cooled account and fails 6 rotation tests, all of them 429 promotion cases.
  • Reads are untouched. getEffectiveActiveCodexAccountId keeps its existing contract. Overlaying the preference there breaks the callers that now treat it as the live shared cursor, including health evidence and the management snapshots.

The user-facing rule, stated plainly: an account past its switch threshold is temporarily spent, not wrong. The pool serves the request from another account, and the operator's selection stays pointed where the operator put it, so the window rolling over returns routing to it without a second manual pick.

What the last audit round changed

The first version of this PR shipped three tests that all passed with the production change reverted. They re-asserted what resetCodexRoutingForManualSelection and the deliberately exempt failover promote already did, so the guard, the preference map and the consume site had no coverage at all. Driving them red first surfaced three real defects, now fixed here:

  • Deletion never revoked the preference. Pause and exclusion route through reconcileCodexActiveAfterExclusion; delete does not. It reaches routing through clearCodexUpstreamHealthForAccount, which cleared two health maps and left the preference behind. Once the named account is gone nothing can succeed on it, so the one-shot could never be spent and every later automatic write stayed suppressed until process restart. The generation sweep in reconcileCodexRoutingHealth had the same hole.
  • The model-detour promote was unguarded, twelve lines above the preemption site that already yields. Both are automatic picks competing with the operator; only the failover promote earns the exemption.
  • The independent-scope preference entries were dead state — every write site the guard protects is already skipped for independent scopes, so those keys were seeded and consumed but never read. Removed.

Each replacement case is red against the variant that removes the piece it covers:

Case Red against
an over-threshold operator account is served around, not replaced parent branch: reads b, expected a
deleting the preferred account releases the hold pre-fix head 63217d161: reads undefined, expected b
a successful dispatch spends the one-shot so the pool may move again guard without consume: 15 of 69 rotation tests fail

Design and the full audit trail: devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md. Six audit rounds and three measured implementation passes are recorded there, including the two failing shapes above and the empty-coverage round.

Verification

  • bun x tsc --noEmit - pass
  • bun test tests/codex-integration/codex-pool-rotation.test.ts - 74 pass, 0 fail
  • Red-control: production file reverted to the parent branch, new tests kept - 1 fail (the guard case), proving the coverage is not vacuous
  • Red-control: production file reverted to 63217d161 - 1 fail (the delete case)
  • bun run privacy:scan - passed

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.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 11, 2026 14:27
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 537034b3-f5fb-4d2f-83b6-be617612a19b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 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 Completed 2026-09-11T14:32:37.716129Z 7260439 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 github-actions Bot added the bug Something isn't working label Sep 11, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 74 / 80

이 PR은 Codex 계정 풀이 이미 활성 계정을 B로 옮긴 뒤에, 운영자가 대시보드나 ocx account use로 A를 골라도 런타임이 계속 B를 쓰는 버그를 고칩니다. CURRENT dev HEAD는 c1ce2560e(패키지 2.52.0, tip #4282 DeepSeek V4.1 레지스트리 전환)이고, 이 결함의 무대인 src/codex/routing.ts는 그 tip에도 그대로 있습니다. 베이스는 dev가 아니라 codex/pool-kernel(열린 #4279)이고, 헤드는 codex/manual-selection-wins입니다. 스택은 대략 #4275(로드맵 문서) → #4277(키 풀) → #4279(커널 이동) → 이 PR 순서입니다.

원인을 짧게 말하면 이렇습니다. GUI와 CLI는 둘 다 같은 PUT /api/codex-auth/active를 타고, 그 핸들러는 이미 resetCodexRoutingForManualSelection으로 런타임 커서를 지웁니다. 문제는 그 다음입니다. 라운드로빈 커밋, 필스트 커밋, 페일오버 promote, 우선순위 preemption이 모두 rememberActiveCodexAccount로 커서를 다시 씁니다. 예전의 핀은 우선순위 천장일 뿐이라, 운영자 선택을 입력값으로 보지 않았습니다. 그래서 풀이 한 번 옮기면 운영자가 A를 골라도 다음 자동 픽이 바로 커서를 덮어썼습니다.

고치는 모양은 Anthropic 쪽에 이미 있는 manualPreference를 Codex에 맞춘 일회성(one-shot) 선호입니다. resetCodexRoutingForManualSelection이 공유 풀 키와 독립 쿼터 스코프에 같은 계정 id를 심고, pickUnboundStrategyAccount의 remember 두 곳과 preemption remember만 그 선호가 다른 계정을 가리킬 때 막습니다. 성공한 디스패치에서만 recordCodexUpstreamOutcome의 success 분기가 consumeManualPreference로 소모합니다. 페일오버 promote는 의도적으로 가드 밖입니다. 쿨다운 계정에 가두면 429 승격 테스트 6개가 깨진다는 측정이 문서에 남아 있습니다. getEffectiveActiveCodexAccountId는 건드리지 않습니다. 여기 선호를 얹으면 헬스·관리 스냅샷이 라이브 커서로 읽는 계약이 깨집니다.

문서 쪽도 같이 옵니다. devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md에 구현 진입 감사, consume를 먼저 안 넣으면 69개 중 15개가 깨지는 측정, promote 가드가 429에서 6개를 깨는 두 번째 측정이 적혀 있습니다. 테스트는 tests/codex-integration/codex-pool-rotation.test.ts에 세 개를 더합니다. 풀이 옮긴 뒤 수동 픽이 이기고, 성공이 one-shot을 쓰며, 선호 계정 429는 여전히 승격합니다. 본문 검증은 tsc, 풀 로테이션 72통과, 라우팅·auth-api 509통과, privacy:scan 통과입니다. CI는 이 리뷰 시점에 아직 돌아가는 중이라 merge 상태는 UNSTABLE입니다.

지금 dev가 최적화하는 축(#4282 레지스트리 정리, #4281 커서 체크포인트, 그 아래 풀·쿼터 열차)과 겹칩니다. 계정 풀 통일 유닛(#4275)의 phase 1 결함 수정이라 방향은 맞고, 사용자에게 바로 보이는 운영자 선택 무시 버그라 점수도 높습니다. 다만 이 PR 자체는 dev에 바로 머지할 수 없고, 아래 스택이 먼저 열려야 합니다.

src/codex/routing.ts / 베이스 브랜치 - 베이스가 dev가 아니라 codex/pool-kernel(#4279)이라, #4275#4277→#4279가 안 닫히면 이 픽스도 dev tip에 못 올라갑니다. 실제 코드 변경은 routing.ts와 테스트·phase1 문서뿐이라, 급하면 dev로 체리픽·리타겟을 검토할 여지가 있습니다.
라인 pickUnboundStrategyAccount remember 가드 - 가드는 rememberActiveCodexAccount만 막고, 함수가 돌려주는 picked 자체는 막지 않습니다. RR/fill-first 경로에서는 strategyPick이 있으면 그 값이 곧 응답 계정이 됩니다. 이번 테스트가 통과하는 이유는 reset이 링을 시드해서 다음 픽이 고른 계정이 되기 때문이지, 선호가 resolve 결과를 강제해서가 아닙니다. sticky가 넘어가거나 동시 unbound가 있으면 공유 커서는 A인데 요청은 B로 나가는 틈이 남을 수 있습니다.
경로 applyQuotaAutoSwitch / setActiveCodexAccount - 쿼터 전략의 persist 경로는 선호 맵을 보지 않고 config.activeCodexAccountId를 바꿉니다. 보고된 결함은 RR 쪽이지만, 쿼터 모드에서 운영자 선택 직후 자동전환이 선호를 우회할 수 있는지 한 번 더 확인할 필요가 있습니다.
경로 isEffectiveCodexAccountPinned - phase1 문서 감사 6번(핀 판정이 persisted+runtime만 볼지, GET active가 수동 sticky로 남을지)이 코드/주석으로 닫히지 않았습니다. 동작은 바뀔 수 있으니 문서에 결정만 적어도 됩니다.
경로 clearCodexUpstreamHealthForAccount - 전체 clear는 manualPreference.clear()를 하지만, 계정 단위 clear는 선호를 지우지 않습니다. 계정이 풀에 남아 있는 한 보통은 괜찮고, exclusion/pause는 forgetManualPreference로 처리합니다. 의도인지 한 줄만 확인하면 됩니다.

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

  • 스택 유지(refactor(oauth): move the pool rotation kernel out of the Codex namespace #4279 뒤) vs 사용자 보이는 버그라 dev에 체리픽·리타겟해서 먼저 넣을지
  • strategyPick이 선호와 다를 때 return 값까지 막을지, 지금처럼 remember만 막을지(시드+커서 가드에 맡길지)
  • 쿼터 setActiveCodexAccount에도 같은 선호 규칙을 얹을지, RR/fill-first 범위로 끝낼지
  • isEffectiveCodexAccountPinned / GET active의 수동 sticky 해석을 문서에 어떻게 남길지

너의 추천
CI(크로스플랫폼 test/gates) 초록을 보고, 스택이 그대로면 #4279 머지 직후 이 PR을 이어서 머지하세요. 운영자 선택 무시가 지금 현장 이슈면 routing.ts+테스트+phase1 문서만 dev로 체리픽하는 쪽을 먼저 검토하세요. 라벨은 바꾸지 않았습니다.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7260439d35

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/codex/routing.ts
Comment on lines 1481 to +1484
if (commitSharedActive) {
if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
if (!isIndependentCodexQuotaScope(quotaScope)
&& !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
rememberActiveCodexAccount(config, picked);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the one-shot in the round-robin selection itself

When a round-robin dispatch on the preferred account fails, the preference remains unspent, but this guard only suppresses rememberActiveCodexAccount; it does not change the picked account returned to the caller. Because notePoolRotationSuccess advances the ring when the account is selected rather than when its upstream outcome succeeds, later unbound requests can route to another account while the preference is still live, contradicting the consume-on-success behavior. Select the live preferred account before consulting the ring while it remains eligible, and mirror that choice in preview, instead of merely hiding the automatic pick from the effective cursor.

Useful? React with 👍 / 👎.

Comment thread src/codex/routing.ts
//
// Seeding happens ONLY here. A pool-driven promote must never create or move a preference,
// or the pool would manufacture an operator intent nobody expressed.
manualPreference.set(POOL_KEY_CODEX, accountId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip one-shot seeding when the active selection is cleared

The active-account API calls this function with MAIN_CODEX_ACCOUNT_ID even when the request body contains accountId: null, which explicitly clears rather than creates a selection. This unconditional insertion therefore manufactures a main-account preference; if the Desktop/main account is unavailable and a pool account serves subsequent requests, no success can consume that entry and manualPreferenceBlocks suppresses automatic cursor updates indefinitely, leaving GET /api/codex-auth/active null or stale. Pass the clear operation separately or allow this reset to skip preference creation.

Useful? React with 👍 / 👎.

Comment thread src/codex/routing.ts
Comment on lines +1671 to +1673
function forgetManualPreference(accountId: string): void {
for (const [poolKey, preferred] of manualPreference) {
if (preferred === accountId) manualPreference.delete(poolKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forget one-shots when their account is deleted

This cleanup is only invoked by reconcileCodexActiveAfterExclusion, while deleteCodexAccount instead calls purgeCodexAccountRuntimeState and clearCodexUpstreamHealthForAccount, neither of which removes the new preference. Deleting a manually selected account before its successful dispatch therefore leaves an impossible-to-consume entry that continues blocking every replacement account from becoming the runtime cursor. Invoke this cleanup from per-account purge/reconciliation as well as the pause path.

Useful? React with 👍 / 👎.

@lidge-jun
lidge-jun force-pushed the codex/manual-selection-wins branch from 7260439 to 63217d1 Compare September 11, 2026 15:14
lidge-jun and others added 6 commits September 12, 2026 00:18
The audit drove the first version of these tests red on the parent branch: all three passed with the production change reverted, because they only re-asserted what resetCodexRoutingForManualSelection and the deliberately exempt failover promote already did.

Three defects fall out of that. Deletion reaches routing through clearCodexUpstreamHealthForAccount, which did not revoke the preference, so a preference could outlive its account and suppress every later write. The generation sweep had the same hole. The model-detour promote wrote over the operator's selection while preemption next to it yielded. The independent-scope preference entries were written and consumed but never read by any guard.

Co-authored-by: Heisenberg <audit@localhost>
Guarding it failed 8 cases in codex-routing.test.ts. A model detour runs because the operator account cannot serve the model at all, and under a rotating strategy the promote moves only the process-local cursor, never the persisted selection.
reconcileCodexRoutingHealth had no test at all, so the preference sweep added for the delete-path blocker was verified by reading rather than by running. Both halves are covered now: an account the generation no longer lists loses its preference, and one that is still listed keeps it. Red control: removing the four sweep lines makes the first case read undefined.
…ards

clearCodexUpstreamHealth dropped every health map but kept lastReconciledGeneration and liveHealthAccountIds, so after a full reset recordCodexUpstreamOutcome would still discard a writer whose generation predated the watermark for any account missing from the equally stale live set. Found because the watermark leaked between test files: the new generation-sweep coverage failed two unrelated cases in codex-routing.test.ts when both files ran in one process.
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.

2 participants