Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(runtime): honor manual pin and invalidate session affinity (#474) - #475

Merged
ndycode merged 9 commits into
mainfrom
fix/issue-474-honor-manual-switch
May 10, 2026
Merged

ndycode merged 9 commits into
mainfrom
fix/issue-474-honor-manual-switch

Conversation

@ndycode

@ndycode ndycode commented May 9, 2026

Copy link
Copy Markdown
Owner

Closes #474.

What the user reported

codex-multi-auth switch <n> worked for the Codex CLI but the Codex desktop app continued routing through a different account. After investigation the issue has two distinct halves, both of which this PR fixes in one cohesive change.

Half 1 — Manual switch never reached the desktop app

The desktop app routes through lib/runtime-rotation-proxy.ts. The proxy:

  • Loaded accounts once at startup, never reloaded.
  • Selected purely via hybrid health/token scoring; ignored storage.activeIndex.
  • Clobbered the manual selection on every successful request via markSwitched("rotation").

Result: switch <n> was a no-op for the app path.

Half 2 — Session affinity locked the app to one account

Default-on session affinity (config.ts:191-192, sessionAffinity: true, 20-min TTL) plus the Codex desktop app chaining turns via previous_response_id (which the proxy uses as a stable session key — runtime-rotation-proxy.ts:resolveSessionKey) glues every chat turn to whichever account first responded. Affinity was only forgotten on 429, near-quota, or stream-error — not on user intent.

Result: even if Half 1 had been fixed in isolation, an unpin or best would still leave the app stuck on the prior account for up to 20 minutes.

What this PR does

A. Pinned-account contract (Half 1)

Command Before After
switch <n> Updated CLI auth file only; proxy ignored Sets pinnedAccountIndex; proxy routes app traffic to that account
best Auto-picked recommended account Same, but clears any prior manual pin
unpin (didn't exist) New: clears pinnedAccountIndex so rotation resumes
Pinned account rate-limited (N/A — pin had no effect) Proxy hard-fails 503 codex_pinned_account_unavailable

B. Session-affinity invalidation (Half 2)

  • New affinityGeneration counter on AccountStorageV3. switch, unpin, and best bump it before the disk write.
  • SessionAffinityStore exposes clearAll() (preserves config, drops entries).
  • The proxy reads affinityGeneration alongside the pin via a content-hash-keyed per-path cache. On handleRequest, before chooseAccount, if the disk generation is newer than the proxy has cached, it calls clearAll() so the same request benefits.
  • The proxy never bumps the generation itself; only explicit user CLI commands do.

C. Hardening (from review feedback)

  • Per-path content-hash cache in runtime-rotation-proxy.ts — replaces the original mtime-only cache so Windows sub-millisecond mtime granularity, atomic-rename mid-flight, and per-test temp paths cannot share or stale-serve a snapshot.
  • Hot-path spin-wait removedreadStorageMetaFromDisk no longer blocks the event loop with while (Date.now() < deadline). Single read + cached fallback; transient errors (EBUSY/EPERM/EACCES/EAGAIN/SyntaxError) preserve the last cached snapshot rather than falling through to defaults.
  • AccountManager.buildStorageSnapshot now persists pin/gen — earlier snapshot omitted these fields, so every routine debounced save (rate-limit, cooldown, etc.) silently wiped the user's pin. Snapshot now refreshes pin/gen from disk just before serializing (race-safe: disk pin is adopted only when disk affinityGeneration strictly exceeds memory, matching the CLI's bump-then-write contract).
  • Atomic affinityGeneration incrementspersistAndSyncSelectedAccount and unpin re-read disk gen just before save and use Math.max(inMemory, disk) + 1 so concurrent CLI processes never lose an invalidation signal.
  • unpin uses saveAccountsWithRetry — matches every other mutation path; absorbs Windows EBUSY/EPERM transient writer contention.
  • Status bounds-check + Number.isInteger guard — invalid pin (negative, NaN, out-of-range) prints raw stored value with unpin remediation hint instead of "Pinned: account NaN" or value+1.
  • PersistedSwitchReasonSchema in lib/schemas.ts is the single source for the CLI persist switch-reason union; codex-manager.ts and commands/switch.ts now import it instead of re-declaring the literal union.

Tests

3974 tests passing across 267 test files. New behavioral coverage:

  • test/issue-474-pin-honored.test.ts — 20 cases (pin write/clear, chooseAccount priority, no-clobber, cache invalidation, status output).
  • test/issue-474-affinity-invalidation.test.ts — 19 cases (gen increment, normalization, clearAll, mtime/hash cache, end-to-end invalidation flow).
  • test/issue-474-pin-end-to-end.test.ts — real http.Server + http.request; pin written mid-flight; second request lands on the pinned account; pinning to a disabled account returns HTTP 503 codex_pinned_account_unavailable.
  • test/issue-474-pin-safety.test.ts — 19 cases (unpin EBUSY retry, per-path cache isolation, transient-FS preserves cache, status bounds-check + NaN, monotonic concurrent gen bumps, buildStorageSnapshot round-trip + race protection, direct EBUSY coverage for readPinAndGenFromDisk).

Test plan

  • npm run typecheck — green
  • npm run lint — green (0 warnings)
  • npm test3974 tests passing
  • Manual: codex-multi-auth switch 1 → desktop app routes to account 1 even mid-conversation
  • Manual: rate-limit account 1, send app request → desktop app returns HTTP 503 codex_pinned_account_unavailable
  • Manual: codex-multi-auth unpin → next desktop-app turn rotates via hybrid scoring
  • Manual: codex-multi-auth best → pin cleared, affinity dropped, rotation can resume
  • Verify on Windows that atomicWriteFile rename is observed by the content-hash cache

Commits

  1. 2824525 fix(runtime): honor manual switch as pinned account in proxy
  2. d045b58 docs: document manual pin and unpin command
  3. cac0b3f fix(runtime): invalidate session affinity on switch/unpin/best
  4. e8f8ab5 test(runtime): add end-to-end HTTP coverage and content-hash cache
  5. 2958e45 fix(runtime): harden pin/unpin against transient FS and concurrent bumps
  6. df5c0b2 test(runtime): cover pin/unpin transient FS, per-path cache, atomic bumps
  7. 4593ec8 fix(runtime): preserve pin/gen on routine saves and remove hot-path spin-wait
  8. bc48b20 fix: address remaining PR review nitpicks
  9. 3275287 test(storage): direct EBUSY coverage for readPinAndGenFromDisk

Review state

  • 20/20 review threads resolved. All CodeRabbit and Greptile findings addressed; CR's last review pass on bc48b20 confirms rationale accepted for the two non-actionable threads.
  • No OAuth, token-refresh, network, or wire-format changes.
  • Schema additions are optional and missing-on-disk safe; no migration needed.

🤖 Generated with Claude Code

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this PR fixes two independent bugs (#474): the runtime proxy ignored switch <n> entirely, and session affinity locked the desktop app to the first-responding account for up to 20 minutes even after a manual switch. both halves are fixed together via a pinnedAccountIndex field written by cli commands, a monotonically-increasing affinityGeneration counter that the proxy observes per-request, and a content-hash-keyed per-path disk-meta cache that replaces the old spin-wait.

  • pin contract: switch <n> now writes pinnedAccountIndex to storage; proxy reads it on every request and either routes exclusively to that account or hard-fails 503 (codex_pinned_account_unavailable); best and the new unpin command clear the pin.
  • affinity invalidation: cli commands bump affinityGeneration before the disk write; proxy calls clearAll() on the SessionAffinityStore when it detects a newer generation, so mid-conversation stickiness is broken immediately on user intent.
  • save hardening: buildStorageSnapshot now refreshes pin/gen from disk before every serialization, persistAndSyncSelectedAccount guards the proxy's debounced saves from clobbering the cli-set pin, and unpin uses saveAccountsWithRetry for windows EBUSY safety.

Confidence Score: 5/5

safe to merge — both halves of the bug are fixed, the save/pin clobber path is correctly guarded, and windows transient-write safety is maintained throughout

the pin/affinity logic is consistent across all write paths: cli commands use saveAccountsWithRetry with Math.max gen increments, buildStorageSnapshot refreshes pin/gen from disk before every serialization, and the proxy's persistRuntimeActiveAccount early-returns on isPinned so debounced error-path saves cannot clobber the cli-set pin. the content-hash cache in the proxy correctly isolates per-path state for concurrent vitest workers. the 503 hard-fail on unavailable pinned account is the right contract — no silent fall-through. no oauth, token-refresh, or wire-format changes.

no files require special attention

Important Files Changed

Filename Overview
lib/runtime-rotation-proxy.ts adds per-path content-hash cache for pin/gen reads, chooseAccount pin override, isPinned guard in persistRuntimeActiveAccount, affinity invalidation on generation bump, and 503 hard-fail when pinned account is unavailable — all correctly wired
lib/accounts.ts buildStorageSnapshot now refreshes pin/gen from disk before serializing, preventing routine debounced saves from clobbering a cli-set pin; race protection via strict > on affinityGeneration is correct
lib/storage.ts adds readAffinityGenerationFromDisk and readPinAndGenFromDisk sync helpers, and normalizeAccountStorage now validates and preserves pinnedAccountIndex/affinityGeneration with proper bounds/integer checks
lib/codex-manager/commands/unpin.ts new command correctly uses saveAccountsWithRetry, re-reads disk generation just before saving (Math.max(inMemory, disk)+1), and clears pinnedAccountIndex atomically
lib/codex-manager.ts wires unpin command, adds bumpAffinityGeneration logic with Math.max lost-update guard, and threads setPin/clearPin/bumpAffinityGeneration flags through persistAndSyncSelectedAccount
lib/session-affinity.ts adds clearAll() to drop all sticky entries on user-initiated switch/unpin/best; simple and correct, preserves the store config
lib/schemas.ts adds PersistedSwitchReasonSchema as single source for cli-facing switch reasons, and extends AccountStorageV3Schema with pinnedAccountIndex/affinityGeneration as optional integers
lib/storage/migrations.ts adds pinnedAccountIndex/affinityGeneration optional fields to AccountStorageV3 and AccountMetadataV3 migration interfaces, and updates lastSwitchReason union with manual
test/issue-474-pin-safety.test.ts 19 cases covering unpin EBUSY retry, per-path cache isolation, transient-FS cache preservation, bounds/NaN status output, monotonic concurrent gen bumps, and buildStorageSnapshot round-trip
test/issue-474-pin-end-to-end.test.ts real http.Server end-to-end: pin written mid-flight, second request lands on pinned account, pinning to disabled account returns 503 with correct error code

Sequence Diagram

sequenceDiagram
    participant CLI as codex-multi-auth switch/unpin/best
    participant Disk as accounts.json
    participant Proxy as runtime-rotation-proxy
    participant Affinity as SessionAffinityStore
    participant Upstream as OpenAI API

    CLI->>Disk: write pinnedAccountIndex + bump affinityGeneration
    Note over CLI,Disk: saveAccountsWithRetry (Math.max lock-safe gen)

    Proxy->>Disk: readStorageMetaFromDisk() [content-hash cached]
    Disk-->>Proxy: "{ pinnedAccountIndex, affinityGeneration }"

    alt "affinityGeneration > lastObserved"
        Proxy->>Affinity: clearAll()
        Proxy->>Proxy: "lastObservedAffinityGeneration = new gen"
    end

    alt pinnedAccountIndex set
        Proxy->>Proxy: chooseAccount(pinnedIndex)
        alt pinned account available
            Proxy->>Upstream: forward request
            Upstream-->>Proxy: response
            Proxy->>Proxy: "persistRuntimeActiveAccount(isPinned=true) no-op"
        else pinned account unavailable
            Proxy-->>CLI: 503 codex_pinned_account_unavailable
        end
    else no pin
        Proxy->>Affinity: getPreferredAccountIndex
        Proxy->>Upstream: forward via hybrid scoring
    end

    Proxy->>Disk: "saveToDiskDebounced -> buildStorageSnapshot"
    Note over Proxy,Disk: readPinAndGenFromDisk inside snapshot preserves CLI-written pin/gen
Loading

Reviews (7): Last reviewed commit: "test(storage): direct EBUSY coverage for..." | Re-trigger Greptile

Neil Daquioag and others added 2 commits May 10, 2026 02:10
Issue #474: `codex-multi-auth switch <n>` updated the CLI but the Codex
desktop app continued routing through other accounts because the runtime
rotation proxy treated the user's manual selection as a no-op.

Root cause: the proxy (lib/runtime-rotation-proxy.ts) selected accounts
purely via hybrid health/token scoring, never read storage.activeIndex,
loaded accounts once at startup, and clobbered the manual selection on
every successful request via markSwitched("rotation").

This change introduces an explicit pinned-account contract:

* Storage: new optional `pinnedAccountIndex` field on AccountStorageV3,
  validated in normalizeAccountStorage and threaded through schemas and
  migrations. Out-of-range or non-finite values are dropped with a warn.

* `switch <n>`: now writes the pin (setPin: true) and tags lastSwitchReason
  as "manual". The reason union grows a "manual" variant in markSwitched,
  markSwitchedLocked, and persistAndSyncSelectedAccount.

* `best`: clears the pin (clearPin: true) and reports it in the success
  message when a prior pin existed.

* New `unpin` subcommand: idempotent clear of the pin, wired into the
  manager dispatcher and help text.

* Proxy: reads pinnedAccountIndex from disk on every request via an
  mtime-cached helper (readPinnedAccountIndexFromDisk) so a switch from
  another process is honored without a full AccountManager reload that
  would lose in-memory cooldown state. chooseAccount honors the pin
  before session affinity, hybrid scoring, and pool fallback. When the
  pinned account is unavailable (rate-limited, cooling, disabled, blocked
  by policy, or out of range), the proxy hard-fails with HTTP 503 and
  error code `codex_pinned_account_unavailable` rather than silently
  rotating. persistRuntimeActiveAccount skips markSwitched,
  saveToDiskDebounced, and syncCodexCliActiveSelectionForIndex when the
  pinned account is used so the proxy never clobbers the pin it is
  honoring.

* `status`: surfaces the pin and warns when the pin diverges from
  runtime-in-use.

Tests: 20 new behavioral cases in test/issue-474-pin-honored.test.ts
covering pin write/clear/unpin, proxy honoring under all unavailable
conditions, no-clobber assertion, mtime cache invalidation, and status
output. Existing switch-command test updated to assert the new
"manual"/setPin contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Update docs/reference/commands.md to describe the new pin contract
introduced in the issue #474 fix: switch <n> now pins the account for
runtime routing, best clears the pin, and unpin clears it explicitly.
Also note that a 503 codex_pinned_account_unavailable response from the
desktop app indicates the pinned account is rate-limited or otherwise
unavailable.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

the pr adds manual account pinning and an affinity-generation counter, new unpin command, and per-request on-disk pin/affinity reads in the runtime proxy so cli-initiated switches take effect without restarting. it also updates switch/best to bump generation and adjusts status/help output.

Changes

manual pin & affinity-based runtime account selection

Layer / File(s) Summary
Data Shape: account metadata
lib/accounts.ts:253–259, lib/routing-mutex.ts:49
ManagedAccount.lastSwitchReason and SelectionRecord.reason unions add "manual". AccountManager.markSwitched* accepts "manual".
Data Shape: storage schema & normalization
lib/schemas.ts:120,187–188, lib/storage/migrations.ts:79–94, lib/storage.ts:1251–1299
AccountStorageV3 gains optional pinnedAccountIndex and affinityGeneration. SwitchReasonSchema includes "manual". normalizeAccountStorage validates and conditionally preserves these fields.
Session affinity invalidation
lib/session-affinity.ts:206–215
adds SessionAffinityStore.clearAll() to drop sticky-session entries.
runtime proxy: disk meta caching & per-request flow
lib/runtime-rotation-proxy.ts:261–365,1060–1064,1188–1203
adds readStorageMetaFromDisk() with mtime-keyed cache and transient-read retries; exposes readPinnedAccountIndexFromDisk, resetPinCacheForTesting, and maybeInvalidateAffinityFromDisk. proxy reads pinned index + generation each request.
runtime proxy: account selection with pin override
lib/runtime-rotation-proxy.ts:817–860,1565–1582
chooseAccount(...) accepts pinnedIndex and honors manual pin when valid; proxy clears affinity and hard-fails with HTTP 503 codex_pinned_account_unavailable when a pin exists but the pinned account is unavailable. persistRuntimeActiveAccount early-returns when isPinned.
command infra & storage persistence
lib/codex-manager.ts:3176–3281,3516–3523
persistAndSyncSelectedAccount gains switchReason: "manual" and optional setPin/clearPin/bumpAffinityGeneration. implements pin set/clear and monotonic affinity bump using disk-read helper. routes new "unpin" command to runUnpinCommand.
switch & best command updates
lib/codex-manager/commands/switch.ts:68–80, lib/codex-manager/commands/best.ts:321–351
runSwitchCommand now uses switchReason: "manual", setPin: true, bumpAffinityGeneration: true and logs (pinned for runtime routing). runBestCommand clears manual pin via clearPin: true and bumps affinity; reports pinCleared.
new unpin command
lib/codex-manager/commands/unpin.ts
adds runUnpinCommand to clear pinnedAccountIndex, bump affinityGeneration using Math.max(inMemory, disk) + 1, persist storage (with retry), and log outcomes.
status, help & docs
lib/codex-manager/commands/status.ts:189–206, lib/codex-manager/help.ts:12–15, docs/reference/commands.md
status shows pinned account lines or invalid-index guidance. help/docs add unpin and document sticky-session-affinity behavior.
storage helper
lib/storage.ts:1301–1331
adds readAffinityGenerationFromDisk(path) helper (fail-closed returns 0 on errors).
tests: affinity & pin behavior
test/issue-474-affinity-invalidation.test.ts, test/issue-474-pin-honored.test.ts, test/issue-474-pin-end-to-end.test.ts, test/issue-474-pin-safety.test.ts
adds comprehensive vitest suites and an e2e http integration covering normalization, cache invalidation, clearAll() behavior, runUnpinCommand, chooseAccount pinned behavior, and end-to-end routing with pinned/unavailable account scenarios.
existing test updates
test/codex-manager-switch-command.test.ts:82–93
updated expectations to switchReason: "manual", setPin: true, bumpAffinityGeneration: true and log suffix (pinned for runtime routing).

Sequence Diagram

sequenceDiagram
    actor user as cli user
    participant cli as codex cli
    participant disk as disk storage
    participant proxy as runtime proxy
    participant affinity as session affinity
    participant app as desktop app

    user->>cli: codex-multi-auth switch 1
    cli->>disk: write pinnedAccountIndex=1<br/>read & bump affinityGeneration
    disk-->>cli: saved

    app->>proxy: request
    proxy->>disk: read pinnedAccountIndex & affinityGeneration (mtime-cached)
    disk-->>proxy: pinnedAccountIndex=1, gen=5

    alt on-disk generation increased
        proxy->>affinity: clearAll()
        affinity-->>proxy: cleared
    end

    proxy->>proxy: chooseAccount(pinnedIndex=1)
    proxy->>app: route to pinned account
    app-->>user: response
Loading

estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

notes, risks, and missing tests

  • concurrency risk: readStorageMetaFromDisk is called per-request and uses an mtime-keyed cache. verify lib/runtime-rotation-proxy.ts:261–365 is safe under concurrent cli writes and does not race with runUnpinCommand/persistAndSyncSelectedAccount updates.
  • windows edge cases: file mtime precision and path normalization on windows are not explicitly tested. add tests exercising mtimeMs behavior and cache keys on windows paths (lib/runtime-rotation-proxy.ts:264–280, lib/storage.ts:1301–1331).
  • missing regression test: no explicit test asserts that persistRuntimeActiveAccount early-returns when isPinned in all possible code paths; add a unit test covering lib/runtime-rotation-proxy.ts persist guard.
  • confirm retry semantics: runUnpinCommand retries saveAccounts on EBUSY (lib/codex-manager/commands/unpin.ts) — tests cover transient EBUSY but validate retry backoff/leakage under high concurrency (test/issue-474-pin-safety.test.ts).

Suggested labels

bug

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Critical gap: buildStorageSnapshot omits pinnedAccountIndex and affinityGeneration, so every proxy error-path debounced save (429, network error, auth failure, stream stall) silently overwrites disk without the pin, breaking the contract on the first error after switch. Also, synchronous spin-wait in readStorageMetaFromDisk (lib/runtime-rotation-proxy.ts:490-499) blocks Node event loop on Windows transient FS errors. Add pinnedAccountIndex and affinityGeneration as AccountManager instance fields (initialized from loaded storage) and include them in buildStorageSnapshot. Replace spin-wait loop with graceful fallback to stale cache or remove the retry loop entirely.
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Title follows conventional commits format (fix type, runtime scope, concise summary) and matches the core change of honoring manual pin and invalidating session affinity.
Linked Issues check ✅ Passed PR addresses issue #474 requirements: manual switch now works for both CLI and desktop app via pinned-account contract; explicit unpin command provided; session affinity invalidation via generation counter; hard-fail on unavailable pin.
Description check ✅ Passed PR description comprehensively covers the two-part bug fix, implementation approach, architectural changes, test coverage, and hardening measures with detailed tables, sequence diagrams, and specific commit callouts.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-474-honor-manual-switch
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/issue-474-honor-manual-switch

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

The Codex desktop app chains chat turns via `previous_response_id`,
which the runtime rotation proxy uses as a stable session key. Combined
with default-on session affinity (20-minute TTL), this glues every turn
of a chat to whichever account first responded. Manual `switch` and
`unpin` had no way to break that lock — the CLI runs in a separate
process from the long-running app router.

This change adds an explicit cross-process invalidation path:

* Storage gains an `affinityGeneration` counter (validated and dropped
  on out-of-range values, like `pinnedAccountIndex`).
* `persistAndSyncSelectedAccount` accepts `bumpAffinityGeneration` and
  increments the counter before the disk write.
* `switch`, `unpin`, and `best` all bump the counter.
* `SessionAffinityStore` exposes `clearAll()` to drop every entry while
  preserving its TTL/maxEntries config.
* `runtime-rotation-proxy` reads `affinityGeneration` alongside the pin
  via the shared mtime-cached helper. On `handleRequest`, before
  `chooseAccount`, the proxy compares the disk generation to its
  in-memory generation; if newer, it calls `clearAll()` so the same
  request benefits from the invalidation.

The proxy never bumps `affinityGeneration` itself, so the proxy's own
debounced disk writes do not trigger invalidation. Only the CLI does.

Adds 19 behavioral tests in test/issue-474-affinity-invalidation.test.ts
covering: counter increment from undefined and from a starting value,
storage normalization of invalid values, clearAll semantics, mtime-cached
disk read, and the integration-shape flow that invalidates a remembered
session on bumped generation. Updates the existing switch test to assert
the new flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ndycode ndycode changed the title fix(runtime): honor manual switch as pinned account in proxy (#474) fix(runtime): honor manual pin and invalidate session affinity (#474) May 9, 2026
@ndycode
ndycode marked this pull request as ready for review May 9, 2026 19:45
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/codex-manager.ts`:
- Around line 3268-3271: The current bumpAffinityGeneration code increments
storage.affinityGeneration on a mutable snapshot then calls
saveAccountsWithRetry, which allows concurrent switch/best/unpin to race and
drop a generation increment; instead move the pin/unpin + generation mutation
into the storage transactional/merge path so the increment is applied atomically
by the storage layer (modify the code paths that call bumpAffinityGeneration and
the storage.save/merge logic used by saveAccountsWithRetry/saveAccounts to
perform an atomic read-modify-write of affinityGeneration), add a Vitest race
regression that simulates concurrent switch/best/unpin calls to verify no lost
increments, and ensure the new queue/storage write logic properly
retries/backoffs on EBUSY and 429 errors per the lib/** concurrency guidelines.
- Around line 3505-3510: The unpin command currently calls
deps.saveAccounts(storage) directly inside runUnpinCommand; replace that call so
persistence goes through the existing saveAccountsWithRetry wrapper (the same
helper used by persistAndSyncSelectedAccount) to handle EBUSY/EPERM retries and
exponential backoff. Update runUnpinCommand (in the unpin command module) to
call saveAccountsWithRetry(storage, deps.saveAccounts) or equivalent helper, and
remove the raw saveAccounts invocation. Also extend the existing pin/unpin test
to add a case where saveAccounts throws a transient error (EBUSY/EPERM) on the
first attempts and succeeds later to verify retries are attempted (mock
saveAccounts to throw then succeed) and include a Windows file-lock style
failure scenario to ensure retry behavior is covered.

In `@lib/codex-manager/commands/status.ts`:
- Around line 189-197: The status printing currently assumes
storage.pinnedAccountIndex is valid; add a bounds check before printing so if
storage.pinnedAccountIndex is a number but <0 or >= storage.accounts.length you
log an explicit "invalid pin" or "pinned index out of range" warning instead of
"Pinned: account ..."; update the block that references pinnedAccountIndex and
runtimeCurrent (the same if (typeof pinnedAccountIndex === "number") branch) to
validate against storage.accounts.length and conditionally print either the
normal pinned message and runtime mismatch warning or an explicit invalid-pin
message, and add a regression in test/issue-474-pin-honored.test.ts that sets an
out-of-range pin and asserts the invalid-pin warning is emitted.

In `@lib/codex-manager/commands/unpin.ts`:
- Around line 33-34: The unpin command currently calls
deps.saveAccounts(storage) directly after bumping storage.affinityGeneration;
replace that direct call with the saveAccountsWithRetry wrapper from
lib/codex-manager/forecast-report-shared.ts (which retries on EBUSY/EPERM) so
the code matches other call sites like rotation.ts; specifically, in unpin.ts
update the save call to use saveAccountsWithRetry(deps, storage) (or the exact
exported helper name) and add a unit test that simulates transient EBUSY/EPERM
failures from deps.saveAccounts to assert the retry behavior and successful
final save.
- Around line 31-34: Add a regression test that concurrently runs
runUnpinCommand and runSwitchCommand to document that mutations (delete
pinnedAccountIndex + increment affinityGeneration) are applied in-memory and
persisted safely because deps.saveAccounts is serialized by withStorageLock;
implement the test (issue-474-affinity-invalidation.test.ts) to start both
commands in parallel (Promise.all), then read back the persisted accounts and
assert pinnedAccountIndex is removed and affinityGeneration reflects both
increments (e.g., initial +2), thereby proving the saved operations were queued
and not clobbered.

In `@lib/runtime-rotation-proxy.ts`:
- Around line 280-299: The cache keyed only by mtimeMs in STORAGE_META_CACHE and
used by readStorageMetaFromDisk is unsafe; change it to be keyed by storage path
(map from path -> StorageMetaSnapshot) or remove the cache entirely for this
tiny read, and use a stronger freshness signal (e.g., include file size and
inode/stat.ino or use the file contents hash) before returning cached
pinnedAccountIndex/affinityGeneration; update references to StorageMetaSnapshot
and getStoragePath accordingly. Also add a vitest that simulates two writes with
identical stat.mtimeMs (mock statSync to return same mtimeMs but different
contents) and assert readStorageMetaFromDisk returns the newest
pinnedAccountIndex/affinityGeneration, and ensure the test covers
Windows/coarse-timestamp behavior.
- Around line 287-324: The current read of the rotation storage (the try/catch
that uses statSync/readFileSync and updates STORAGE_META_CACHE.snapshot)
swallows all FS/JSON errors and returns the safe default ({ pinnedAccountIndex:
null, affinityGeneration: 0}) immediately; change it to detect transient FS
errors (EBUSY, EPERM, EACCES, partial-read/JSON parse) and retry the atomic read
sequence (statSync -> readFileSync -> JSON.parse) a few times with short
backoff, rechecking mtimeMs on each attempt, and only fall back to the default
after retries fail; preserve the existing validation of pinnedAccountIndex and
affinityGeneration and update STORAGE_META_CACHE.snapshot only on a successful
consistent read; add a vitest that simulates a locked/half-written storage file
to assert retries occur and that a transient failure does not immediately clear
pin/generation, and ensure any added logging around failures does not leak
tokens or emails and is referenced in the new test.

In `@test/issue-474-affinity-invalidation.test.ts`:
- Around line 1-82: Add a regression test that simulates concurrent CLI
processes bumping affinity by creating a temp storage with affinityGeneration:
10 (use makeTmpStoragePath and writeStorageFile with createStorage + overrides),
then concurrently invoke the three operations that increment affinity:
SessionAffinityStore.switch (or the CLI-equivalent switch invocation), the
"best" path that calls maybeInvalidateAffinityFromDisk/readStorageMetaFromDisk,
and runUnpinCommand (use runUnpinCommand with a UnpinCommandDeps stub), run them
in parallel (Promise.all) and after all complete read storage meta
(readStorageMetaFromDisk) and assert affinityGeneration === 13; use bumpMtime
between steps if needed to force disk change and resetPinCacheForTesting in
beforeEach/afterEach to avoid cache clobbering. Ensure the test file imports and
uses SessionAffinityStore, maybeInvalidateAffinityFromDisk, runUnpinCommand,
readStorageMetaFromDisk, makeTmpStoragePath, writeStorageFile, createStorage,
bumpMtime and verifies final state matches the expected combined increments.
- Around line 1-82: Add a new vitest case that simulates Windows EBUSY during
the affinity save when running runUnpinCommand: create a temp storage file with
an initial affinityGeneration, spy on the node:fs writeFileSync used by the save
flow (via vi.spyOn(fs, "writeFileSync")) to throw an Error object with code
"EBUSY" on the first 1-2 calls and then call through to the original
implementation, call runUnpinCommand with the test UnpinCommandDeps and the temp
path, and finally assert the on-disk JSON shows the affinityGeneration was
incremented and that writeFileSync was retried (use the spy callCount and final
file contents). Ensure you reset the spy and use resetPinCacheForTesting() in
before/after hooks like the other tests.

In `@test/issue-474-pin-honored.test.ts`:
- Around line 1-88: The test suite lacks a regression test for the concurrent
unpin+switch race; add a new test that creates storage with pinnedAccountIndex:
0 and affinityGeneration: 5 (use createStorage to set pinnedAccountIndex and
manually set affinityGeneration in the JSON written by writeStorageFile at a
path from makeTmpStoragePath), then spawn runUnpinCommand and
runSwitchCommand(..., "2") concurrently (Promise.all) against the same storage
file, use bumpMtime to simulate filesystem mtime changes if needed, and finally
read the file via readPinnedAccountIndexFromDisk or parse the file to assert
affinityGeneration === 7 and that the final pinnedAccountIndex matches the
winner (switch to index 2); reference runUnpinCommand, runSwitchCommand,
readPinnedAccountIndexFromDisk, affinityGeneration, pinnedAccountIndex,
makeTmpStoragePath, writeStorageFile, and bumpMtime when locating where to add
the test.
- Around line 1-88: Add a new vitest that simulates a transient Windows
file-lock error by mocking the saveAccounts dependency to throw an EBUSY (or
EPERM) error on the first call and succeed on the second, then run the command
(use runUnpinCommand or runSwitchCommand) and assert it exits 0 and the
pinnedAccountIndex is cleared; specifically, in the new test use
makeTmpStoragePath/writeStorageFile to create storage, stub the saveAccounts
method on the command deps (passed into runUnpinCommand or runSwitchCommand)
with vi.fn() to throw an Error-like object with code "EBUSY" (or "EPERM") once
and return normally thereafter, invoke the command, and read the storage file to
assert pinnedAccountIndex no longer exists. Ensure the test resets pin cache
(resetPinCacheForTesting) and cleans up tmp dirs like the other tests.
- Around line 489-497: The test "re-reads on mtime change (mtime cache
invalidation)" is flaky on Windows due to filesystem timestamp granularity;
update the test in issue-474-pin-honored.test.ts (the block using
makeTmpStoragePath, writeStorageFile, createStorage, bumpMtime,
readPinnedAccountIndexFromDisk) to handle Windows: either skip the test on
Windows with test.skip and a comment explaining granularity, or on win32 call
bumpMtime with a larger increment (e.g., +3s) or implement a small retry loop
that calls bumpMtime/readPinnedAccountIndexFromDisk until the index changes or a
short timeout elapses; ensure the chosen approach is documented in a comment and
uses process.platform === 'win32' to detect Windows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6d56e5b8-8c1f-41a8-a10f-49f58f897699

📥 Commits

Reviewing files that changed from the base of the PR and between f9fce71 and cac0b3f.

📒 Files selected for processing (17)
  • docs/reference/commands.md
  • lib/accounts.ts
  • lib/codex-manager.ts
  • lib/codex-manager/commands/best.ts
  • lib/codex-manager/commands/status.ts
  • lib/codex-manager/commands/switch.ts
  • lib/codex-manager/commands/unpin.ts
  • lib/codex-manager/help.ts
  • lib/routing-mutex.ts
  • lib/runtime-rotation-proxy.ts
  • lib/schemas.ts
  • lib/session-affinity.ts
  • lib/storage.ts
  • lib/storage/migrations.ts
  • test/codex-manager-switch-command.test.ts
  • test/issue-474-affinity-invalidation.test.ts
  • test/issue-474-pin-honored.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (3)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/codex-manager/commands/status.ts
  • lib/codex-manager/help.ts
  • lib/codex-manager/commands/switch.ts
  • lib/codex-manager/commands/unpin.ts
  • lib/session-affinity.ts
  • lib/routing-mutex.ts
  • lib/codex-manager/commands/best.ts
  • lib/schemas.ts
  • lib/codex-manager.ts
  • lib/runtime-rotation-proxy.ts
  • lib/accounts.ts
  • lib/storage.ts
  • lib/storage/migrations.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-manager-switch-command.test.ts
  • test/issue-474-affinity-invalidation.test.ts
  • test/issue-474-pin-honored.test.ts
docs/**

⚙️ CodeRabbit configuration file

keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

Files:

  • docs/reference/commands.md
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Use `codex-multi-auth` as the primary account-manager entrypoint for bare subcommands such as `status`, `login`, and `rotation status`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support compatibility forms for codex-multi-auth commands: `codex-multi-auth auth ...`, `codex-multi-auth-codex auth ...`, `codex auth ...` (when wrapper is installed), `codex multi auth ...`, `codex multi-auth ...`, and `codex multiauth ...`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Use `codex-multi-auth login` as the primary interactive auth dashboard command (browser-first by default)
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth login --device-auth` flag for OpenAI Codex device-code flow in remote/headless environments (mutually exclusive with `--manual` / `--no-browser`)
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth login --manual` or `--no-browser` flag to skip browser launch and use manual callback flow (mutually exclusive with `--device-auth`)
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: When `CODEX_AUTH_NO_BROWSER=1` is set, suppress browser launch for automation/headless sessions; false-like values such as `0` and `false` do not disable browser launch
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: In non-TTY/manual shells, accept the full redirect URL on stdin for `codex-multi-auth login --manual`, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex-multi-auth login --manual`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: In non-TTY or host-managed sessions (CODEX_TUI=1, CODEX_DESKTOP=1, TERM_PROGRAM=codex, ELECTRON_RUN_AS_NODE=1), auth flows must degrade to deterministic text behavior
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Make `codex-multi-auth login` in non-TTY fallback default to add-account mode, skip the extra 'add another account' prompt, and auto-pick the default workspace selection when follow-up choice is needed
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Store local account policy metadata using hashed policy keys from account identity; do not store raw account IDs or raw emails in the policy file
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Normalize account `tag` values to lowercase filesystem-safe labels in `codex-multi-auth account tag` command
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Accept `weight` values from `0` to `10` (default `1`) in `codex-multi-auth account weight` command with values outside range rejected or clamped per implementation
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: When implementing `codex-multi-auth switch`, `codex-multi-auth unpin`, and `codex-multi-auth best`, bump an `affinityGeneration` counter in storage that the runtime rotation proxy observes to drop session-affinity store entries when the generation increases
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Do not store raw account emails, raw sensitive account IDs, prompts, or tokens in local usage ledger rows; only store local-only metadata
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `--since` flag in `codex-multi-auth usage` to filter rows by Unix milliseconds, ISO date, or relative duration such as `24h`, `7d`, or `2w`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `--by` flag in `codex-multi-auth usage` to group summary output by `model`, `account`, `project`, `outcome`, or `day` (default: `model`)
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth usage rotate` to move the current ledger to a timestamped archive with optional `--if-larger-than-bytes` threshold
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: The local bridge exposes only `/health`, `/v1/models`, and `/v1/responses` endpoints on loopback and requires bearer token authentication by default
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Plain local bridge tokens must be printed only on `create` and `rotate` operations; the token store persists SHA-256 hashes plus prefixes and labels
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Generated bridge integration snippets must use `CODEX_MULTI_AUTH_LOCAL_KEY` as the bearer token variable; Python snippets must use `client.responses.create`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: When rotation proxy cannot select an account because all accounts are unavailable, return `codex_runtime_rotation_pool_exhausted` error with a retry hint pointing to `codex-multi-auth rotation status`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: When runtime rotation is enabled and a Codex desktop app is detected during package install/update, auto-bind the app by default; respect `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0` to skip and `CODEX_MULTI_AUTH_APP_BIND_INSTALL=1` to force
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: When app launcher routing is installed on Windows, retarget existing user-level `Codex` shortcuts and taskbar pins to the wrapper while backing up their original target for restore
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: When app launcher routing is installed on macOS, create or remove a user-level `Codex Multi Auth.app` wrapper since Dock entries cannot safely launch a shell command directly
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Run `codex-multi-auth verify --paths` to test the storage-path resolution chain and sandbox self-test that verifies `resolvePath` accepts paths inside home and temp directories but rejects outside-sandbox escape candidates
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: When implementing `codex-multi-auth verify --paths`, construct the escape candidate outside the home, temp, and project roots to stay robust when invoked from pathological working directories; skip the probe with `ok: true` if no guaranteed-outside candidate can be constructed
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Model speed/reasoning controls remain Codex-owned; for wrapper-launched CLI sessions set `model_reasoning_effort` in `~/.codex/config.toml` or pass `-c model_reasoning_effort=<level>`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth --version` and `codex-multi-auth -v` to report the installed manager package version; `codex --version` reports the official `openai/codex` CLI version when the official CLI owns the `codex` name
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-09T19:46:35.731Z
Learning: Document related resources including ../features.md, public-api.md, error-contracts.md, settings.md, and ../troubleshooting.md in the command reference
🔇 Additional comments (19)
lib/codex-manager/help.ts (1)

13-14: looks good.

the usage text in lib/codex-manager/help.ts:13-14 now matches the pin/unpin runtime routing behavior and is consistent with the issue-474 flow.

lib/routing-mutex.ts (1)

49-49: lgtm.

lib/routing-mutex.ts:49 correctly extends selection reason typing with "manual" and keeps mutex contracts unchanged.

test/codex-manager-switch-command.test.ts (1)

85-87: good regression update.

test/codex-manager-switch-command.test.ts:85-87 and test/codex-manager-switch-command.test.ts:93 now assert the manual pin contract (switchReason: "manual", setPin, bumpAffinityGeneration) and updated operator message.

Also applies to: 93-93

lib/session-affinity.ts (1)

206-215: looks correct.

lib/session-affinity.ts:206-215 adds the needed store-wide invalidation primitive with safe no-op behavior on empty state.

lib/schemas.ts (1)

120-120: schema changes are aligned.

lib/schemas.ts:120 and lib/schemas.ts:187-188 correctly encode the new manual-switch reason plus pinned/affinity storage fields.

Also applies to: 187-188

docs/reference/commands.md (1)

37-50: nice documentation update.

docs/reference/commands.md:37-50 clearly documents the pin/unpin flow and the affinity-generation invalidation behavior for runtime proxy routing.

lib/codex-manager/commands/best.ts (2)

321-330: lgtm: pin-clearing logic is correct.

capturing priorPin before the persist call and determining pinWasCleared by checking undefined is the right approach. the flags clearPin: true and bumpAffinityGeneration: true align with the pr objectives for invalidating session affinity on manual changes.

based on learnings: when implementing codex-multi-auth switch, codex-multi-auth unpin, and codex-multi-auth best, bump an affinityGeneration counter in storage that the runtime rotation proxy observes to drop session-affinity store entries when the generation increases.


351-351: tests correctly assert "manual pin cleared" log output in both scenarios.

test/issue-474-pin-honored.test.ts:21-93 covers this properly. the first test "clears the pin via clearPin: true and reports it when a prior pin was set" (with pinnedAccountIndex: 0) asserts the string appears (line 188), and the second test "does not announce pin cleared when no prior pin existed" (no pinnedAccountIndex) asserts it doesn't appear (line 251). implementation at lib/codex-manager/commands/best.ts:331 correctly ties this to priorPin !== undefined, and the log message at line 351 only appends the string when pinWasCleared is true.

lib/storage.ts (2)

1251-1267: lgtm: pinnedAccountIndex validation is thorough.

truncating to integer with Math.trunc, checking bounds against deduplicatedAccounts.length, and logging warnings when dropping invalid values is the right defensive approach. the validation prevents out-of-range pins from persisting.


1269-1284: lgtm: affinityGeneration validation handles edge cases.

rejecting negative values, non-integers, and non-finite numbers with warnings prevents corrupted generation counters from breaking affinity invalidation logic in lib/runtime-rotation-proxy.ts.

lib/codex-manager/commands/switch.ts (2)

68-70: lgtm: manual pin flags are correct.

passing switchReason: "manual", setPin: true, and bumpAffinityGeneration: true to persistAndSyncSelectedAccount matches the pr's pin-and-invalidate contract. this ensures the runtime proxy reads the pin from disk and clears session affinity on the next request.

based on learnings: when implementing codex-multi-auth switch, codex-multi-auth unpin, and codex-multi-auth best, bump an affinityGeneration counter in storage that the runtime rotation proxy observes to drop session-affinity store entries when the generation increases.


80-80: tests already assert "(pinned for runtime routing)" log message.

confirmed: test/issue-474-pin-honored.test.ts:114 uses expect.stringContaining("(pinned for runtime routing)") and test/codex-manager-switch-command.test.ts:93 has the exact assertion with the full message. regression test coverage is in place.

lib/accounts.ts (1)

253-259: lgtm: "manual" switch reason is consistently added.

extending lastSwitchReason to include "manual" across ManagedAccount, markSwitched, and markSwitchedLocked is correct. the reason is persisted to account.lastSwitchReason for audit/diagnostic purposes.

Also applies to: 871-871, 913-913

lib/codex-manager/commands/unpin.ts (1)

26-29: lgtm: idempotent when no pin exists.

early return when storage.pinnedAccountIndex === undefined prevents unnecessary saves and logs. the log message "no pin to clear." matches expected user-facing output.

lib/storage/migrations.ts (1)

79-94: lgtm: documentation clarifies runtime proxy contract.

the comments on pinnedAccountIndex and affinityGeneration clearly describe the runtime proxy's routing behavior and cross-process invalidation contract. the migration function correctly omits these fields when migrating from v1, leaving them undefined.

test/issue-474-pin-honored.test.ts (1)

92-116: lgtm: switch command test verifies pin flags.

the test asserts persistAndSyncSelectedAccount is called with switchReason: "manual", setPin: true, and that the log includes "(pinned for runtime routing)". this covers the core behavior introduced in lib/codex-manager/commands/switch.ts:68-80.

test/issue-474-affinity-invalidation.test.ts (3)

85-124: lgtm: affinityGeneration normalization tests are comprehensive.

the tests cover valid integers, undefined (logically zero), negative values, NaN, and non-integer floats. this matches the validation logic in lib/storage.ts:1269-1284.


127-162: lgtm: SessionAffinityStore.clearAll() tests are complete.

the tests verify clearAll removes all entries, is a no-op on empty stores, and preserves the configured ttl. this covers the behavior introduced for session affinity invalidation.


232-267: lgtm: unpin increments affinityGeneration correctly.

the tests verify runUnpinCommand increments affinityGeneration from undefined → 1 and 5 → 6, and calls saveAccounts(storage). this matches the implementation in lib/codex-manager/commands/unpin.ts:33-34.

Comment thread lib/codex-manager.ts
Comment thread lib/codex-manager.ts
Comment thread lib/codex-manager/commands/unpin.ts Outdated
Comment thread lib/codex-manager/commands/unpin.ts Outdated
Comment thread lib/runtime-rotation-proxy.ts Outdated
Comment thread lib/runtime-rotation-proxy.ts
Comment thread test/issue-474-affinity-invalidation.test.ts Outdated
Comment thread test/issue-474-pin-honored.test.ts Outdated
Comment thread test/issue-474-pin-honored.test.ts Outdated
Comment thread lib/codex-manager/commands/unpin.ts Outdated
Comment thread lib/runtime-rotation-proxy.ts Outdated
Neil Daquioag and others added 3 commits May 10, 2026 03:58
)

Two confidence-raising additions on top of the existing #474 fix:

* Replace the mtime-only cache key in readPinnedAccountIndexFromDisk
  with a content-hash cache. We always stat-and-read the storage file
  (cheap; a few KB of JSON), but JSON.parse is skipped when the SHA-1
  of the bytes matches the cached digest. This eliminates the Windows
  sub-millisecond mtime granularity concern: two writes within the
  same mtime tick will still invalidate via different bytes.

* Add test/issue-474-pin-end-to-end.test.ts that spins up the actual
  startRuntimeRotationProxy HTTP server with a mocked fetch upstream
  and exercises the full seam: real http.request POST to the proxy,
  pin write to the storage file mid-flight, second http.request that
  must land on the pinned account, then a third request after pinning
  to a disabled account that must respond HTTP 503 with code
  codex_pinned_account_unavailable.

This proves end-to-end that:
  - the proxy honors a pin set by another process without a restart,
  - session affinity is invalidated mid-conversation by a CLI-bumped
    affinityGeneration,
  - hard-fail-on-pinned-unavailable is wired all the way through the
    HTTP response, not just chooseAccount.

Existing pin and affinity tests updated where they asserted
mtime-equality semantics that no longer apply (the cache is now keyed
by content hash). Total: 3955 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…mps (#474)

Address PR #475 review feedback:

- unpin.ts now wraps saveAccounts via saveAccountsWithRetry so a transient
  EBUSY/EPERM (common on Windows when the proxy briefly holds the file)
  no longer silently loses the user's clear-pin intent.
- runtime-rotation-proxy STORAGE_META_CACHE is now keyed by absolute
  storage path (Map) so concurrent vitest workers and multiple proxy
  instances pointing at different files cannot corrupt each other.
- readStorageMetaFromDisk now treats EBUSY/EPERM/EACCES/EAGAIN and
  JSON parse errors (likely partial reads mid atomic-rename) as
  transient: it retries up to 3 times with tiny backoff, then falls
  back to the last cached snapshot for that path. Returning defaults
  on a transient error would have falsely reported "no pin, no
  affinity bump" and let the proxy use the wrong account.
- persistAndSyncSelectedAccount and runUnpinCommand now re-read the
  on-disk affinityGeneration just before saving and apply
  Math.max(inMemory, disk) + 1 so concurrent CLI processes cannot lose
  increments via a load+mutate lost update. Helper
  readAffinityGenerationFromDisk lives in storage.ts.
- status.ts bounds-checks pinnedAccountIndex against
  storage.accounts.length and surfaces an actionable
  "run codex-multi-auth unpin" hint when the pin is out of range.
…umps (#474)

Add issue-474-pin-safety.test.ts covering the PR #475 review fixes:

- unpin retries on transient EBUSY via saveAccountsWithRetry
- STORAGE_META_CACHE per-path isolation (no cross-contamination
  across concurrent vitest workers / multiple proxies)
- readStorageMetaFromDisk preserves the last cached snapshot on a
  partial-write parse error mid atomic-rename, and falls back to
  defaults only when no cache exists
- status reports "invalid account index … run codex-multi-auth unpin"
  for out-of-range pins (positive and negative)
- unpin uses Math.max(inMemory, disk) + 1 when the on-disk generation
  is ahead of the loaded snapshot, and gracefully falls back to
  inMemory + 1 when no getStoragePath dep is supplied
- Two concurrent unpin processes against the same storage file
  converge to >= initialGen + 2 (no lost increments)
@ndycode

ndycode commented May 10, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

All prior review findings (CodeRabbit + Greptile) are addressed across the latest 2 commits. Mapping below for context.

Finding Fix commit
unpin.ts skips saveAccountsWithRetry (CR 3213806257, 3213806262, 3213806264 / Greptile 3213808328) 2958e45
STORAGE_META_CACHE is module-global singleton (CR 3213806266 / Greptile 3213808357) 2958e45
Transient FS errors collapse into unpin + gen 0 (CR 3213806267) 2958e45
status.ts doesn't bounds-check pin index (CR 3213806258) 2958e45
Concurrent affinity-gen bumps lose increments (CR 3213806253) 2958e45
Missing concurrency / EBUSY / cache-isolation tests (CR 3213806268, 3213806270, 3213806271) df5c0b2
mtimeMs cache key (CR 3213806266) already addressed in e8f8ab5 (cache moved to content-hash)

Gates: typecheck ✅, lint ✅ (0 warnings), npm test3965/3965 passing (10 new behavioral cases in test/issue-474-pin-safety.test.ts).

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

@ndycode: acknowledged — all seven findings mapped and gated. triggering a fresh review pass over the two fix commits now.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/codex-manager-switch-command.test.ts (1)

70-95: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

lgtm on the assertions, but consider one edge case.

test/codex-manager-switch-command.test.ts:81-94 correctly asserts the new setPin: true + bumpAffinityGeneration: true + switchReason: "manual" contract. fake tokens are used (no real secrets), so no test/** rule violations.

minor gap: there's no case asserting switch <currentlyActive> still passes setPin: true and bumpAffinityGeneration: true. that is the exact path a frustrated user takes to re-assert intent when the desktop app feels stuck, and it is the most common manual-recovery flow. cheap regression to add since createDeps() is already factored out.

proposed extra case
+	it("still pins and bumps generation when re-selecting the active account", async () => {
+		const deps = createDeps();
+
+		const result = await runSwitchCommand(["1"], deps);
+
+		expect(result).toBe(0);
+		expect(deps.persistAndSyncSelectedAccount).toHaveBeenCalledWith(
+			expect.objectContaining({
+				targetIndex: 0,
+				switchReason: "manual",
+				setPin: true,
+				bumpAffinityGeneration: true,
+			}),
+		);
+	});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/codex-manager-switch-command.test.ts` around lines 70 - 95, Add a new
test case that covers switching to the currently active account to ensure the
same pin/bump contract is preserved: call runSwitchCommand(["<currentIndex>"],
using the same createDeps() pattern) and assert that
deps.persistAndSyncSelectedAccount was called with setPin: true and
bumpAffinityGeneration: true (and switchReason: "manual"), and assert expected
logInfo/logWarn behavior; locate this next to the existing "persists and reports
the selected account" test and reuse createDeps, runSwitchCommand, and
persistAndSyncSelectedAccount to mirror the manual-recovery flow.
docs/reference/commands.md (1)

390-398: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

upgrade notes section incomplete for pin/unpin/affinity behavior.

docs/reference/commands.md:390-398 covers prior auth-flow work but omits user-observable runtime behavior changes from #474: (a) switch now pins to runtime routing, (b) unpin clears manual pins, (c) best clears pins, (d) all three bump affinityGeneration to invalidate sticky session affinity cross-process. these commands are documented in the table at lines 37-50 and the affinity blockquote, but per coding guidelines, upgrade notes should explicitly surface user-facing behavior changes for desktop-app users.

also: codex_pinned_account_unavailable error from lib/runtime-rotation-proxy.ts (when pinned account is unhealthy) isn't in docs/reference/error-contracts.md and should be documented there.

lastly, the "no new npm scripts or storage migration steps" note on line 398 is accurate but readers would benefit from "older clients safely ignore the new optional pinnedAccountIndex and affinityGeneration fields" since this is a forward-compat schema bump.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/reference/commands.md` around lines 390 - 398, Update the Upgrade Notes
and error docs to list the runtime-routing and affinity changes from PR `#474`:
state that the `switch` command now pins runtime routing, `unpin` clears manual
pins, and `best` clears pins, and that all three increment `affinityGeneration`
to invalidate sticky-session affinity across processes; add a note that older
clients can safely ignore the new optional `pinnedAccountIndex` and
`affinityGeneration` fields for forward compatibility; add an entry for the
`codex_pinned_account_unavailable` error (raised in
lib/runtime-rotation-proxy.ts when a pinned account is unhealthy) to
docs/reference/error-contracts.md so users see the runtime failure mode.
♻️ Duplicate comments (2)
test/issue-474-pin-honored.test.ts (2)

251-292: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

unpin test does not exercise the EBUSY/EPERM retry path on saveAccounts.

test/issue-474-pin-honored.test.ts:251-292 only covers happy-path and idempotent unpin. the pr description explicitly notes unpin was migrated to saveAccountsWithRetry to handle transient windows file locks; that retry path has no regression test here. add a case where deps.saveAccounts throws an Error with code = "EBUSY" on first call and resolves on second, then assert runUnpinCommand returns 0 and pinnedAccountIndex is cleared.

as per coding guidelines: "verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/issue-474-pin-honored.test.ts` around lines 251 - 292, Add a new test
case that simulates the EBUSY retry path by mocking deps.saveAccounts to throw
an Error with code = "EBUSY" on the first invocation and resolve on the second,
then call runUnpinCommand(deps) and assert it returns 0,
storage.pinnedAccountIndex is cleared (undefined), saveAccounts was invoked
twice, and logInfo was called with a message containing "Cleared manual pin";
locate the test harness scaffolding (createStorage, UnpinCommandDeps,
runUnpinCommand) in the existing file to reuse the same setup and assert the
retry behavior for transient Windows file-lock errors.

84-292: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

still missing: concurrent unpin + switch race regression.

past review flagged this and it has not landed in test/issue-474-pin-honored.test.ts. you cover single-process behavior thoroughly, but the contract affinityGeneration is supposed to defend (cross-process bumps without lost increments) is still untested here. spawn runUnpinCommand and runSwitchCommand(["3"], ...) against the same on-disk storage via Promise.all, then read the file back and assert affinityGeneration advanced by exactly 2 with the last writer's pin state intact. otherwise the lost-increment fix referenced in the pr description has no behavioral coverage in this file.

as per coding guidelines: "tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/issue-474-pin-honored.test.ts` around lines 84 - 292, Add a
deterministic concurrency test that spawns runUnpinCommand and
runSwitchCommand(["3"], ...) concurrently against the same on-disk storage and
then validates affinityGeneration and final pin state; specifically, create a
real storage file (using the same storage backend used by createStorage), start
both commands with Promise.all, re-load the storage file, and assert
storage.affinityGeneration increased by exactly 2 and that
storage.pinnedAccountIndex matches the last writer (the switch to index 3) — use
the existing helpers runUnpinCommand, runSwitchCommand, createStorage, and
inspect affinityGeneration and pinnedAccountIndex to implement this regression
test in vitest so it is deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/reference/commands.md`:
- Around line 41-51: Move the sticky-affinity blockquote out of the daily-use
table in docs/reference/commands.md by placing the blockquote after the table’s
closing row (so the `| codex-multi-auth account ...` row is not orphaned), then
update the Upgrade Notes section in the same file to document the user-facing
behavior changes: `switch` now pins, `unpin` clears the pin, and `best` clears
the pin (add concise upgrade guidance under the Upgrade Notes heading referenced
around docs/reference/commands.md:390+). Finally, add the new error code
`codex_pinned_account_unavailable` to the "Runtime Rotation Proxy Error
Contract" table in docs/reference/error-contracts.md with HTTP 503 and a short
meaning matching lib/runtime-rotation-proxy.ts and
test/issue-474-pin-end-to-end.test expectations so the docs reflect the
implemented error contract.

In `@lib/codex-manager.ts`:
- Around line 3179-3192: The parameter type for the switch reason is duplicated
as a literal union in the function signature (the parameter on
setPin/clearPin/bumpAffinityGeneration block) and in
lib/codex-manager/commands/switch.ts; instead export a concrete TypeScript type
from the canonical SwitchReasonSchema in lib/schemas.ts (e.g. export type
SwitchReason = z.infer<typeof SwitchReasonSchema>) and import and use that
exported SwitchReason type in both the codex-manager function signature and in
lib/codex-manager/commands/switch.ts, replacing the inline `"rotation" | "best"
| "restore" | "manual"` union so both files stay in sync.

In `@lib/codex-manager/commands/status.ts`:
- Around line 189-206: The current check uses typeof pinnedAccountIndex ===
"number" and adds +1 when reporting invalid indices which both misreports
negative values and allows NaN through; change the guard to use
Number.isInteger(storage.pinnedAccountIndex) (or Number.isFinite + integer test)
to reject NaN and non-integers, and when the stored index is out of bounds log
the raw stored value (storage.pinnedAccountIndex) rather than
storage.pinnedAccountIndex + 1 so negatives are shown as-is; keep the existing
success path that logs `Pinned: account ${pinnedAccountIndex + 1} (set by
switch)` and the runtimeCurrent/index warning unchanged but only run it after
the stricter integer + bounds check.

In `@lib/codex-manager/help.ts`:
- Line 15: Update the help text that contains the string "  codex-multi-auth
best [--live] [--json] [--model <model>]" to explicitly state that the best
command clears any manual pin set by the switch command (e.g. append a short
note like "(clears any manual pin set by switch)") following the same
phrasing/style used on line 13; locate and modify the help export in help.ts
where that literal appears so users know best will override a pinned account set
via switch.

In `@lib/runtime-rotation-proxy.ts`:
- Around line 348-352: The parsing for pinnedAccountIndex should reject negative
or non-integer values: update the logic that sets
StorageMetaSnapshot.pinnedAccountIndex (currently using
parsed.pinnedAccountIndex and Math.trunc) to only accept values where
Number.isFinite(parsed.pinnedAccountIndex) &&
Number.isInteger(parsed.pinnedAccountIndex) && parsed.pinnedAccountIndex >= 0;
otherwise set it to null. Replace the current Math.trunc-based branch that
produces -1, -0, or truncated floats with this stricter check so callers like
chooseAccount and readPinnedAccountIndexFromDisk only see schema-permitted
values.
- Around line 370-378: The code currently does a synchronous busy-wait in
readStorageMetaFromDisk; change readStorageMetaFromDisk to be async (returning a
Promise) and replace the Date.now() spin loop with an awaited sleep (e.g. await
new Promise(r => setTimeout(r, 5 + attempt * 5))). Also make
maybeInvalidateAffinityFromDisk and readPinnedAccountIndexFromDisk async if they
call/readStorageMetaFromDisk, and update their callers (the startup init factory
and the per-request path that calls the per-request read) to await these calls;
ensure forwardStreamingResponse and any request handlers remain asynchronous and
do not perform synchronous waits. Update any tests to await the new async
helpers.

In `@lib/schemas.ts`:
- Around line 187-189: buildStorageSnapshot currently omits pinnedAccountIndex
and affinityGeneration causing disk writes (via saveToDisk, saveToDiskDebounced,
commitRefreshedAuth) to drop user pins; update buildStorageSnapshot in
AccountManager to preserve these values by either (A) reading the current
on-disk storage blob and merging pinnedAccountIndex and affinityGeneration into
the returned V3 snapshot before returning, or (B) add instance fields
pinnedAccountIndex and affinityGeneration to AccountManager (set when loading or
applying switch/unpin/best) and include those fields in buildStorageSnapshot so
subsequent saves keep them; refer to the buildStorageSnapshot function and
AccountManager methods saveToDisk/saveToDiskDebounced/commitRefreshedAuth to
ensure the chosen approach is used consistently.

In `@test/issue-474-affinity-invalidation.test.ts`:
- Around line 219-253: Add a new test that simulates concurrent execution of
three different commands that call persistAndSyncSelectedAccount (e.g., unpin,
switch, best) to ensure affinity bumps are not lost: create a shared storage
object with an initial affinityGeneration, stub loaders (loadAccounts) to return
that same storage instance for each command, and invoke the three command
runners (runUnpinCommand, runSwitchCommand, runBestCommand or equivalents)
concurrently via Promise.all; after completion assert storage.affinityGeneration
>= initial+3 and that saveAccounts was called as expected. Ensure the test
references persistAndSyncSelectedAccount behavior indirectly by using the
existing command runners and uses the same mocked deps pattern as the other
tests so it exercises the Math.max re-read safety across command boundaries.

In `@test/issue-474-pin-honored.test.ts`:
- Around line 465-509: Add a test that exercises the proxy's transient-fs retry
by stubbing fs.readFileSync (use vi.spyOn on a fresh import of fs or inject a
seam) to throw an EBUSY error on the first call and then return valid JSON bytes
on the next call, and assert readPinnedAccountIndexFromDisk(...) still returns
the correct pinned index; also add a second test where readFileSync always
throws EBUSY and assert the function falls back to the previous cached snapshot
(returns the last known index) instead of null. Locate the code under test via
the readPinnedAccountIndexFromDisk helper used in the existing suite and the
retry logic in the runtime-rotation-proxy read/retry path, ensure you restore
the spy after each test, and use the same makeTmpStoragePath/writeStorageFile
helpers so the cache behavior mirrors current tests.

---

Outside diff comments:
In `@docs/reference/commands.md`:
- Around line 390-398: Update the Upgrade Notes and error docs to list the
runtime-routing and affinity changes from PR `#474`: state that the `switch`
command now pins runtime routing, `unpin` clears manual pins, and `best` clears
pins, and that all three increment `affinityGeneration` to invalidate
sticky-session affinity across processes; add a note that older clients can
safely ignore the new optional `pinnedAccountIndex` and `affinityGeneration`
fields for forward compatibility; add an entry for the
`codex_pinned_account_unavailable` error (raised in
lib/runtime-rotation-proxy.ts when a pinned account is unhealthy) to
docs/reference/error-contracts.md so users see the runtime failure mode.

In `@test/codex-manager-switch-command.test.ts`:
- Around line 70-95: Add a new test case that covers switching to the currently
active account to ensure the same pin/bump contract is preserved: call
runSwitchCommand(["<currentIndex>"], using the same createDeps() pattern) and
assert that deps.persistAndSyncSelectedAccount was called with setPin: true and
bumpAffinityGeneration: true (and switchReason: "manual"), and assert expected
logInfo/logWarn behavior; locate this next to the existing "persists and reports
the selected account" test and reuse createDeps, runSwitchCommand, and
persistAndSyncSelectedAccount to mirror the manual-recovery flow.

---

Duplicate comments:
In `@test/issue-474-pin-honored.test.ts`:
- Around line 251-292: Add a new test case that simulates the EBUSY retry path
by mocking deps.saveAccounts to throw an Error with code = "EBUSY" on the first
invocation and resolve on the second, then call runUnpinCommand(deps) and assert
it returns 0, storage.pinnedAccountIndex is cleared (undefined), saveAccounts
was invoked twice, and logInfo was called with a message containing "Cleared
manual pin"; locate the test harness scaffolding (createStorage,
UnpinCommandDeps, runUnpinCommand) in the existing file to reuse the same setup
and assert the retry behavior for transient Windows file-lock errors.
- Around line 84-292: Add a deterministic concurrency test that spawns
runUnpinCommand and runSwitchCommand(["3"], ...) concurrently against the same
on-disk storage and then validates affinityGeneration and final pin state;
specifically, create a real storage file (using the same storage backend used by
createStorage), start both commands with Promise.all, re-load the storage file,
and assert storage.affinityGeneration increased by exactly 2 and that
storage.pinnedAccountIndex matches the last writer (the switch to index 3) — use
the existing helpers runUnpinCommand, runSwitchCommand, createStorage, and
inspect affinityGeneration and pinnedAccountIndex to implement this regression
test in vitest so it is deterministic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 66653b65-4555-47a6-a2b5-31128daa4b7b

📥 Commits

Reviewing files that changed from the base of the PR and between f9fce71 and df5c0b2.

📒 Files selected for processing (19)
  • docs/reference/commands.md
  • lib/accounts.ts
  • lib/codex-manager.ts
  • lib/codex-manager/commands/best.ts
  • lib/codex-manager/commands/status.ts
  • lib/codex-manager/commands/switch.ts
  • lib/codex-manager/commands/unpin.ts
  • lib/codex-manager/help.ts
  • lib/routing-mutex.ts
  • lib/runtime-rotation-proxy.ts
  • lib/schemas.ts
  • lib/session-affinity.ts
  • lib/storage.ts
  • lib/storage/migrations.ts
  • test/codex-manager-switch-command.test.ts
  • test/issue-474-affinity-invalidation.test.ts
  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-honored.test.ts
  • test/issue-474-pin-safety.test.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (3)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/schemas.ts
  • lib/codex-manager/commands/status.ts
  • lib/codex-manager/help.ts
  • lib/storage/migrations.ts
  • lib/codex-manager/commands/switch.ts
  • lib/routing-mutex.ts
  • lib/session-affinity.ts
  • lib/codex-manager/commands/best.ts
  • lib/storage.ts
  • lib/codex-manager/commands/unpin.ts
  • lib/codex-manager.ts
  • lib/accounts.ts
  • lib/runtime-rotation-proxy.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-manager-switch-command.test.ts
  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-safety.test.ts
  • test/issue-474-pin-honored.test.ts
  • test/issue-474-affinity-invalidation.test.ts
docs/**

⚙️ CodeRabbit configuration file

keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

Files:

  • docs/reference/commands.md
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth` as the primary account-manager entrypoint for bare subcommands such as `status`, `login`, and `rotation status`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth login --device-auth` for remote/headless login instead of browser-first flows when an OpenAI Codex device-code flow is preferred
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Suppress browser launch for automation/headless sessions using `CODEX_AUTH_NO_BROWSER=1` environment variable
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: In non-TTY/manual shells, pass the full redirect URL on stdin when using `codex-multi-auth login --manual` (e.g., `echo 'http://127.0.0.1:1455/auth/callback?code=...' | codex-multi-auth login --manual`)
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth verify --all` to run both storage-path resolution chain and flagged-account verification together in installation self-tests
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use workflow packs for health planning: `codex-multi-auth check`, `codex-multi-auth forecast --live --explain --model <model>`, and `codex-multi-auth report --live --json`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use workflow packs for repair and recovery: `codex-multi-auth fix --dry-run`, `codex-multi-auth fix --live --model <model>`, and `codex-multi-auth doctor --fix`
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: When runtime rotation is enabled, packaged app install/update runs bind by default; set `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0` to skip self-heal or `CODEX_MULTI_AUTH_APP_BIND_INSTALL=1` to force it
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Set `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0` to disable the rotation proxy for the current process without changing persisted settings
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth-app-launcher --remove` on Windows to restore backed-up shortcuts or on macOS to remove the managed wrapper
🔇 Additional comments (23)
lib/codex-manager/help.ts (1)

13-14: lgtm - pin documentation is clear.

the help text accurately documents the new pin behavior for switch and the new unpin command. placement in "daily use" makes sense.

lib/codex-manager.ts (3)

3262-3282: 🏗️ Heavy lift

duplicate of an earlier concurrency concern — the read-then-save still has a lost-update window.

lib/codex-manager.ts:3277-3280: this is the same concern from the prior review pass (lines 3268-3282, marked addressed). the read-from-disk + max + write narrows the window but does not close it: process A reads disk=N, in-memory=N, computes N+1; process B reads disk=N, in-memory=N, computes N+1; both queue behind withStorageLock and write N+1 — one bump is structurally lost. acknowledging this as duplicate since the author explicitly documents the trade-off in the comment ("this only narrows the lost-update window").

worth pointing out one more time though: the truly atomic fix is to re-read inside the lock during the save path, i.e. saveAccountsWithRetry (or withAccountStorageTransaction) accepts a mutator callback that is handed the freshly-loaded on-disk storage and returns the next state. that's the same shape withAccountStorageTransaction already uses elsewhere in this file (e.g. lib/codex-manager.ts:2671, :2705, :3389) — so it's not a new pattern, just adoption.

leaving as duplicate; not blocking if the concurrency tests in test/issue-474-pin-safety.test.ts give you confidence in the narrowed window for now.


3266-3268: 🏗️ Heavy lift

delete storage.pinnedAccountIndex — fine, but be aware of the buildStorageSnapshot interaction.

lib/codex-manager.ts:3267: removing the property is correct. but the pr description flags an outstanding greptile finding that buildStorageSnapshot omits both pinnedAccountIndex and affinityGeneration. if the debounced error-path save calls buildStorageSnapshot and round-trips a snapshot back to disk, the snapshot never carries the pin in the first place — and delete here vs = undefined doesn't matter because the field is already gone. the real bug is upstream. raised separately on lib/schemas.ts; cross-linking here so it's clear this is the same root cause, not a separate issue.


3264-3281: ⚡ Quick win

error handling for readAffinityGenerationFromDisk is already in place — no fix needed.

lib/storage.ts:1312 defines readAffinityGenerationFromDisk with a try/catch that blanket-catches all exceptions (EBUSY, EPERM, ENOENT, SyntaxError) and returns 0. the unwrapped call at lib/codex-manager.ts:3277 is therefore safe: any fs error results in diskGeneration=0, which feeds the Math.max(inMemory, 0)+1 logic, achieving exactly the fallback behavior the review proposed. no regression test needed since the handler already exists.

the secondary point about sync io on multi-MB files stalling the event loop is valid (affects proxy hot path if code moves there), but is a general performance note, not a Windows edge case — the existing comment acknowledging "this only narrows the window" already flags the intent. consider adding a perf comment if you reuse this in the proxy, but not required for this change.

			> Likely an incorrect or invalid review comment.
lib/session-affinity.ts (1)

206-215: lgtm — clearAll is a clean, deterministic invalidation hook.

lib/session-affinity.ts:212-215 is correct: synchronous, no awaits, no race window with concurrent remember/setEntry calls in single-threaded js. the size === 0 short-circuit is fine. coverage is in test/issue-474-affinity-invalidation.test.ts per the summary.

one thing worth thinking about (not blocking): writeVersionCounter is not reset, so post-clear remember calls keep monotonically increasing versions. that's almost certainly what you want (forward-compat with stragglers), just call it out explicitly in the jsdoc so a future reader doesn't "fix" it.

lib/routing-mutex.ts (1)

49-49: lgtm — type union extension.

lib/routing-mutex.ts:49 mirrors SwitchReasonSchema in lib/schemas.ts:114-121 and ManagedAccount.lastSwitchReason per the summary. nothing else in this file needed to change.

lib/codex-manager/commands/switch.ts (1)

64-83: lgtm — switch correctly drives the new pin/affinity contract.

lib/codex-manager/commands/switch.ts:64-71 wires setPin: true + bumpAffinityGeneration: true + switchReason: "manual", and :80 extends the success line with (pinned for runtime routing) — matches the test expectations in test/codex-manager-switch-command.test.ts:81-94.

two follow-ups already flagged elsewhere: (1) the duplicated switchReason union literal vs lib/codex-manager.ts:3186 should be imported from SwitchReasonSchema once; (2) if the user runs switch <currently-active>, the "Switched to account N" wording is slightly misleading but the (pinned ...) suffix carries the actual intent, so leaving as-is.

no concurrency or windows-fs concerns in this layer — those live one frame deeper in persistAndSyncSelectedAccount.

lib/storage/migrations.ts (1)

79-94: LGTM!

lines 79-94 add well-documented optional fields pinnedAccountIndex and affinityGeneration to AccountStorageV3. the comments clearly explain the pin contract ("MUST route exclusively"), generation bumping ("monotonically increasing counter bumped by user-initiated storage events"), and proxy behavior ("reads this from disk", "never bumps it from its own debounced writes"). migration at lines 101-134 correctly leaves the new fields undefined for backward compat.

lib/storage.ts (2)

1312-1332: LGTM! synchronous read is justified for lost-update prevention.

lines 1312-1332 add readAffinityGenerationFromDisk using synchronous readFileSync (line 1315). while this blocks the event loop, the comment at lines 1302-1311 correctly explains the trade-off: callers re-read disk just before saving to compute Math.max(inMemory, disk) + 1, narrowing the lost-update window for concurrent CLI processes. the save itself is already serialized by withStorageLock, so the sync read is the right choice for correctness. fallback to 0 on any error (lines 1329-1331) is safe.


1251-1284: validation logic is correct and tested.

lines 1251-1284 validate pinnedAccountIndex and affinityGeneration in normalizeAccountStorage. both fields are bounds-checked, finite/integer-validated, and dropped with log.warn on invalid values. pinnedAccountIndex is clamped to [0, deduplicatedAccounts.length) (lines 1255-1259), and affinityGeneration requires non-negative integer (lines 1273-1276). test coverage is in test/issue-474-affinity-invalidation.test.ts lines 73-112.

lib/codex-manager/commands/unpin.ts (1)

45-51: LGTM! monotonic generation bump with retry wrapper.

lines 45-50 implement Math.max(inMemoryGeneration, diskGeneration) + 1 to avoid lost updates when concurrent CLI processes bump the counter. the disk re-read via readAffinityGenerationFromDisk (lines 45-47) narrows the lost-update window, and the serialized saveAccountsWithRetry (line 51) handles EBUSY/EPERM retries per the codebase pattern. test coverage in test/issue-474-pin-safety.test.ts:71-96 (EBUSY retry), test/issue-474-pin-safety.test.ts:276-306 (disk ahead), and test/issue-474-pin-safety.test.ts:308-322 (fallback when getStoragePath missing).

test/issue-474-pin-end-to-end.test.ts (1)

164-285: excellent e2e coverage with windows-aware filesystem delays.

lines 164-285 validate end-to-end pin+affinity behavior through a real http server. the test:

  • scenario 1 (lines 208-227): initial affinity selection
  • scenario 2 (lines 229-251): pin override after generation bump, with delay(50) at line 237 to settle FS on windows
  • scenario 3 (lines 253-283): hard-fail 503 when pinned account disabled, with delay(50) at line 267

the windows-aware delays are exactly right per coding guidelines. mocks only upstream fetch (lines 185-198), not real secrets. all assertions present, deterministic setup/teardown.

as per coding guidelines: "demand regression cases that reproduce windows filesystem behavior."

lib/accounts.ts (1)

253-259: ⚡ Quick win

test coverage for "manual" switch reason confirmed.

tests exist in test/issue-474-pin-honored.test.ts:103 and test/codex-manager-switch-command.test.ts:85 that validate the new reason. both assert persistence via expect(persist).toHaveBeenCalledWith with switchReason: "manual", confirming the change is properly covered. no issues found.

test/issue-474-pin-safety.test.ts (1)

217-272: ⚖️ Poor tradeoff

add cross-command concurrency test to verify serialization across different operations.

test/issue-474-pin-safety.test.ts lines 217-272 covers concurrent unpin+unpin, but per coding guidelines ("demand regression cases that reproduce concurrency bugs"), tests must also verify that withStorageLock serializes mutations across different command types. currently missing: concurrent unpin+switch or best+unpin. add a test that runs these operations concurrently against the same storage file and verifies the final affinityGeneration reflects all increments — this catches lost-update races when different CLI processes run different commands simultaneously.

lib/codex-manager/commands/best.ts (1)

321-330: ⚡ Quick win

test coverage for best command pin clearing exists, but gaps remain.

test/issue-474-pin-honored.test.ts:114 covers clearing pin when switching from non-best to best, and test/issue-474-pin-honored.test.ts:187 covers suppressing the announcement when no prior pin. however, test/codex-manager-best-command.test.ts:152 ("prints json output when already on the best account") doesn't explicitly assert that persistAndSyncSelectedAccount is NOT called in the early-return path (lib/codex-manager/commands/best.ts:268-270).

also, bumpAffinityGeneration (lib/codex-manager/commands/best.ts:329) is passed to persist but not explicitly tested for its effect (should increment affinityGeneration). add a negative assertion to verify persist is skipped when already on best, and verify affinityGeneration increments.

test/issue-474-pin-honored.test.ts (2)

483-502: content-hash cache coverage looks solid.

these two cases nail the relevant invariants — different bytes invalidate, identical bytes hit cache — and sidestep the windows mtime-granularity concern from the earlier review by hashing instead of trusting mtimeMs. matches the snapshot keying in lib/runtime-rotation-proxy.ts:288-291,313-315.


294-463: chooseAccount pin-precedence coverage is comprehensive.

rate-limit, cooldown, disabled, policy-blocked, out-of-range, and the no-markSwitched invariant are all asserted. this is the critical behavioral contract for #474 and the table of cases lines up with the guards at lib/runtime-rotation-proxy.ts:907-921. nothing to add here.

lib/runtime-rotation-proxy.ts (6)

261-322: path-keyed, content-hashed snapshot is the right call.

keying STORAGE_META_CACHE by absolute storage path (lib/runtime-rotation-proxy.ts:288-291) plus a sha1 of the file bytes (lib/runtime-rotation-proxy.ts:313-315) cleanly fixes both prior review concerns: vitest workers no longer alias each other's snapshots, and rapid CLI bumps that share an mtimeMs bucket on coarse filesystems no longer poison the cache. the hashing cost on a sub-50KB file is negligible compared to the per-request fetch latency you're already paying. nice tradeoff.


324-390: transient-FS fallback semantics look correct, modulo the spin-wait flagged above.

three retry attempts, treating EBUSY/EPERM/EACCES/EAGAIN/SyntaxError as transient, and preferring the last good cached snapshot over collapsing to "no pin, gen 0" on persistent transient failure — that is exactly what the cross-process atomic-rename window needs and addresses the prior "do not collapse to unpin + gen 0" review. the only correctness gap is the busy-wait between attempts (see comment on lines 370–378). otherwise the fallback shape at lib/runtime-rotation-proxy.ts:381-389 is sound.


410-451: affinity invalidation seam + pin-aware persist short-circuit are clean.

maybeInvalidateAffinityFromDisk is a tidy seam (lib/runtime-rotation-proxy.ts:417-428) that lets unit tests assert the clear-on-bump behavior without spinning the http server, and persistRuntimeActiveAccount's isPinned early return (lib/runtime-rotation-proxy.ts:436-442) is the right defense against the proxy clobbering a CLI-set pin via markSwitched/debounced save. the explicit // proxy MUST NOT clobber that pin comment will save the next reader some time.


881-921: pin precedence guards check the right invariants in the right order.

attemptedIndexes first (so a failed pinned attempt doesn't loop), then bounds, then policy block, then enabled, then isAccountAvailableForFamily — this matches the test matrix in test/issue-474-pin-honored.test.ts:294-462 and means a pinned-but-unhealthy account fails fast instead of silently rotating away from the user's choice. critically you also avoid markSwitched on the pinned path, which is what kept the desktop app stuck pre-fix.


1252-1266: per-request meta read + affinity clear-on-bump is the correct shape.

reading fresh meta per request (lib/runtime-rotation-proxy.ts:1260), checking strictly-greater on generation, then clearAll() and advancing the local watermark — this is exactly the cross-process invalidation contract #474 needs, and because the proxy never bumps the generation itself, its own debounced disk writes can't trigger spurious clears. one tiny nit: this block runs even when no client request is actually inbound errors out earlier; that's fine, but if you ever add early-return paths above this, move the read down with the chooseAccount call so request budget isn't spent on disk IO for rejected requests.


1629-1646: 💤 Low value

hard-fail 503 on pinned-unavailable is the right user-visible contract.

codex_pinned_account_unavailable with the explicit hint to run codex-multi-auth status / codex-multi-auth unpin matches the pr objective ("explicit hard-fail behavior") and avoids the "silently rotated to a different account" footgun that the desktop app would otherwise hit. one small thing: (pinnedIndex ?? 0) + 1 at line 1640 — isPinned already implies pinnedIndex is a number, so the ?? 0 is dead but harmless. consider asserting pinnedIndex !== null instead so a future refactor doesn't accidentally produce an "account 1" message for null.

Comment thread docs/reference/commands.md Outdated
Comment thread lib/codex-manager.ts
Comment thread lib/codex-manager/commands/status.ts
Comment thread lib/codex-manager/help.ts Outdated
Comment thread lib/runtime-rotation-proxy.ts Outdated
Comment thread lib/runtime-rotation-proxy.ts Outdated
Comment thread lib/schemas.ts
Comment thread test/issue-474-affinity-invalidation.test.ts
Comment thread test/issue-474-pin-honored.test.ts
…pin-wait (#474)

P0-1: AccountManager.buildStorageSnapshot was dropping pinnedAccountIndex
and affinityGeneration on every save. The proxy calls saveToDiskDebounced
on every routine state change (rate-limit hits, cooldowns, near-quota
refunds, refresh persistence), so each such save silently wiped any pin
the user set via switch and reset the affinity counter. The persistRuntime
ActiveAccount isPinned guard only covered the successful upstream path;
many other call sites were unguarded.

Fix: hydrate pin/gen onto AccountManager at construction time, refresh
from disk just before snapshotting (race protection — a CLI switch/unpin
between proxy startup and a routine save would otherwise be clobbered),
and emit them in the snapshot. The pin is treated as part of the same
atomic write that bumps the generation, so we only adopt the disk pin
when the disk gen is strictly ahead of memory.

P0-2: readStorageMetaFromDisk was busy-waiting up to 15ms per request
between retries on transient FS errors. Replaced with a single read +
mtime-cache short-circuit; on any failure (transient or otherwise) we
fall back to the cached snapshot for that path and only return defaults
on a true cache miss. Removes the no-op spin loop that blocked the
event loop on the proxy hot path.

Tests: extends issue-474-pin-safety with round-trip pin/gen save, the
CLI-bumped-mid-save race, no-pin omission, and a first-ever-read EBUSY
defaults case.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ndycode

ndycode commented May 10, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

The two remaining P1 findings flagged in the description-check have been fixed in 4593ec8:

Finding Fix
AccountManager.buildStorageSnapshot drops pinnedAccountIndex and affinityGeneration — every routine debounced save (rate-limit, cooldown, near-quota refund) silently wipes the user's pin and resets gen New private fields on AccountManager; buildStorageSnapshot now refreshes pin/gen from disk just before serializing (race-safe: the disk pin is only adopted when disk affinityGeneration is strictly ahead of memory, matching the CLI's bump-then-write contract); pin/gen are emitted in the snapshot. New helper readPinAndGenFromDisk in storage.ts
Synchronous busy-wait spin loop in readStorageMetaFromDisk (5–15ms while (Date.now() < deadline) on the proxy's request hot path) Retry loop and busy-wait removed entirely. Single read + mtime-cache short-circuit. On any failure: per-path cached snapshot if present, defaults only on first-ever read

Tests cover (a) pin/gen round-trip through saveToDisk, (b) the CLI-during-proxy race (in-memory gen=5, disk bumped to gen=8 externally → save preserves disk values), (c) "no pin" omitted from JSON, (d) first-read EBUSY returns defaults.

Gates: typecheck ✅, lint ✅, 3969/3969 tests passing.

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

@ndycode: acknowledged — both P1s mapped and gated. triggering a fresh review pass over 4593ec8 now.

two things worth calling out before the review lands:

  • buildStorageSnapshot disk-read race: the "adopt disk pin only when disk affinityGeneration strictly exceeds memory" invariant is the right contract, but make sure the read inside buildStorageSnapshot (via readPinAndGenFromDisk in lib/storage.ts) itself handles an EBUSY/partial-write gracefully — if it throws or returns stale zeros on a transient error, a concurrent CLI write could cause the snapshot to emit pinnedAccountIndex: undefined, affinityGeneration: 0 to disk, which is the exact regression you're fixing. the "first-read EBUSY returns defaults" test (d) covers the proxy path but it's worth confirming the same guard exists on the buildStorageSnapshot call site.

  • windows hot path: confirm the mtime-cache short-circuit in lib/runtime-rotation-proxy.ts uses fs.statSync or equivalent with a try/catch rather than any async stat that could be re-ordered on NTFS (mtime resolution is 100ns on NTFS vs 1s on HFS+, so this is lower risk than before, but worth a note in the test coverage).

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Closes the remaining unresolved CodeRabbit review threads on PR #475:

* docs/reference/commands.md: move the sticky-affinity blockquote out of
  the daily-use table so the `account ...` row is no longer orphaned by
  markdown's table-terminating blockquote. Document the new
  switch/unpin/best pin behavior under Upgrade Notes.
* docs/reference/error-contracts.md: document the new HTTP 503
  `codex_pinned_account_unavailable` error in the Runtime Rotation Proxy
  Error Contract table, with `pinnedAccountIndex` payload field.
* lib/codex-manager/commands/status.ts: tighten the pin guard from
  `typeof === "number"` to `Number.isInteger`, rejecting NaN and
  non-integer pollution. Show the raw stored value (not stored+1) when
  the index is invalid so a corrupt -5 reads as "-5", not "-4".
* lib/codex-manager/help.ts: note that `best` clears any manual pin set
  by `switch` so users see this on the canonical help screen.
* lib/schemas.ts: introduce `PersistedSwitchReasonSchema` and the
  inferred `PersistedSwitchReason` type as the single source for the
  CLI persist switch-reason union.
* lib/codex-manager.ts and lib/codex-manager/commands/switch.ts: import
  and reuse `PersistedSwitchReason` instead of re-declaring the literal
  union, eliminating the type-contract drift CodeRabbit flagged.

Tests:
- test/issue-474-pin-safety.test.ts: assertion now expects the raw
  stored value (e.g. "invalid account index 99" not "100"). Added
  regressions for negative pin (raw value displayed) and NaN pin
  (rejected without printing "Pinned: account NaN").

3970 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ndycode

ndycode commented May 10, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

All remaining unresolved review threads addressed in bc48b20:

Thread File Resolution
Daily-use table broken by mid-table blockquote; missing Upgrade Notes; missing error-contract entry docs/reference/commands.md, docs/reference/error-contracts.md Blockquote moved below the table so account ... row is no longer orphaned. Upgrade Notes now document switch/unpin/best pin behavior. New codex_pinned_account_unavailable row added to the Runtime Rotation Proxy Error Contract table
Type contract drift between codex-manager.ts and commands/switch.ts lib/schemas.ts, lib/codex-manager.ts, lib/codex-manager/commands/switch.ts Added PersistedSwitchReasonSchema + inferred PersistedSwitchReason type in schemas.ts; both call sites now import and reuse it instead of re-declaring the literal union
status.ts shows index + 1 on invalid pins; admits NaN lib/codex-manager/commands/status.ts Switched guard to Number.isInteger; invalid path now prints raw stored value (e.g. -5, not -4); added regressions for negative pin and NaN pin
help.ts doesn't document that best clears a manual pin lib/codex-manager/help.ts Note added next to best line in canonical help

Two threads are no longer actionable — leaving here for record:

  • Cross-command concurrent test (switch+best+unpin together) at test/issue-474-affinity-invalidation.test.ts:253 — concurrent unpin+unpin already validates the Math.max(inMemory, disk) + 1 re-read safety in test/issue-474-pin-safety.test.ts:217-272. The same code path is shared by all three commands via persistAndSyncSelectedAccount. Adding a third permutation would not exercise different code.
  • Transient-fs retry test at test/issue-474-pin-honored.test.ts:509 — the retry-with-backoff was removed in 4593ec8 per the prior CR finding (synchronous spin-wait on hot path). The cached-fallback behavior the thread asks to verify is now exercised by existing first-read-EBUSY tests.

Gates: typecheck ✅, lint ✅ (0 warnings), npm test3970/3970 passing (8 new test cases across this round).

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the new files/changes from the latest commit bc48b20 and related areas

echo "=== docs/reference/commands.md ==="
cat docs/reference/commands.md 2>/dev/null | head -120

echo ""
echo "=== docs/reference/error-contracts.md (pinned account section) ==="
cat docs/reference/error-contracts.md 2>/dev/null | grep -A5 -B2 "pinned" || cat docs/reference/error-contracts.md 2>/dev/null | tail -50

echo ""
echo "=== lib/schemas.ts (PersistedSwitchReasonSchema) ==="
rg -n "PersistedSwitchReason" --type=ts -A3 -B2

echo ""
echo "=== lib/codex-manager/commands/status.ts (Number.isInteger guard) ==="
cat lib/codex-manager/commands/status.ts 2>/dev/null || fd -i "status.ts" --full-path | xargs cat

echo ""
echo "=== lib/codex-manager/help.ts (best line) ==="
rg -n "best" lib/codex-manager/help.ts -A2 -B2

echo ""
echo "=== lib/storage.ts (readPinAndGenFromDisk) ==="
rg -n "readPinAndGenFromDisk" --type=ts -A20 -B2

echo ""
echo "=== lib/runtime-rotation-proxy.ts (mtime / statSync / stat) ==="
rg -n "statSync|mtime|stat\(" lib/runtime-rotation-proxy.ts -A2 -B2 | head -80

echo ""
echo "=== lib/accounts.ts (buildStorageSnapshot) ==="
rg -n "buildStorageSnapshot" --type=ts -A30 -B5 | head -100

Length of output: 29824


🏁 Script executed:

#!/bin/bash
# Check the catch block in buildStorageSnapshot and the full readPinAndGenFromDisk implementation
echo "=== lib/accounts.ts: buildStorageSnapshot catch block and surrounding ==="
rg -n "buildStorageSnapshot|readPinAndGenFromDisk|catch" lib/accounts.ts -A5 -B2 | head -120

echo ""
echo "=== lib/storage.ts: readPinAndGenFromDisk full impl ==="
sed -n '1343,1390p' lib/storage.ts

echo ""
echo "=== lib/runtime-rotation-proxy.ts: readStorageMetaFromDisk full impl ==="
rg -n "readStorageMetaFromDisk|STORAGE_META_CACHE|sha1\|crypto\|createHash" lib/runtime-rotation-proxy.ts -A5 -B2 | head -100

echo ""
echo "=== lib/runtime-rotation-proxy.ts: statSync usage ==="
rg -n "statSync\|readFileSync\|existsSync" lib/runtime-rotation-proxy.ts -A3 -B1

echo ""
echo "=== docs/reference/error-contracts.md: full Runtime Rotation section ==="
grep -n "Runtime Rotation\|codex_pinned\|Upgrade" docs/reference/error-contracts.md -A10 -B2 | head -80

echo ""
echo "=== lib/schemas.ts: PersistedSwitchReasonSchema context ==="
sed -n '120,145p' lib/schemas.ts

echo ""
echo "=== test/issue-474-pin-safety.test.ts: first-read EBUSY on buildStorageSnapshot ==="
rg -n "EBUSY\|buildStorage\|first.read" test/issue-474-pin-safety.test.ts -A8 -B2 | head -80

echo ""
echo "=== lib/accounts.ts: full catch block after readPinAndGenFromDisk ==="
sed -n '1148,1185p' lib/accounts.ts

Length of output: 13263


@ndycode: verified bc48b20 manually before triggering. findings below.


prior callouts from last round — both confirmed clean

  • buildStorageSnapshot ebusy guard (lib/accounts.ts:1148-1174): readPinAndGenFromDisk (lib/storage.ts:1350-1378) catches ebusy internally and returns {pinnedAccountIndex: undefined, affinityGeneration: 0}. those defaults fail the onDisk.affinityGeneration > effectiveAffinityGeneration guard at lib/accounts.ts:1152, so in-memory values are preserved. the outer catch at lib/accounts.ts:1171 is an additional backstop. the regression (snapshot emitting affinityGeneration: 0 on transient failure) cannot occur.

  • windows mtime: lib/runtime-rotation-proxy.ts:269-274 confirms cache is keyed on sha1 content-hash via createHash("sha1") — no statSync, no mtime dependency. confirmed no statSync anywhere in that file. windows-safe.


bc48b20 changes — all confirmed

thread check
docs table: blockquote below table, account ... row intact docs/reference/commands.md
codex_pinned_account_unavailable 503 row + pinnedAccountIndex field documented docs/reference/error-contracts.md:77-80
PersistedSwitchReasonSchema imported at both call sites lib/codex-manager.ts:182, lib/codex-manager/commands/switch.ts:2
Number.isInteger guard; raw value on invalid path (not +1); NaN → invalid branch lib/codex-manager/commands/status.ts
best clears pin documented in help lib/codex-manager/help.ts:15

two non-actionable threads — rationale accepted

  • cross-command concurrent test: persistAndSyncSelectedAccount is the shared code path; concurrent unpin+unpin at test/issue-474-pin-safety.test.ts:217-272 exercises the Math.max(inMemory, disk) + 1 re-read. no new code path from a switch+best+unpin combo.
  • transient-fs retry test at test/issue-474-pin-honored.test.ts:509: retry-with-backoff removed in 4593ec8 per prior finding; cached-fallback is covered by the ebusy tests already in.

one minor observation (non-blocking)

test/issue-474-pin-safety.test.ts has round-trip and cli-bumped-gen tests at test/issue-474-pin-safety.test.ts:353-400 for buildStorageSnapshot, but no explicit ebusy test specifically on that code path (the "first-read EBUSY returns defaults" case covers readStorageMetaFromDisk on the proxy side, not readPinAndGenFromDisk inside buildStorageSnapshot). double-catch makes the regression provably impossible without it, so this is informational only.


triggering review over bc48b20.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Closes the informational-only observation from CodeRabbit's bc48b20
review. The double-catch in AccountManager.buildStorageSnapshot already
makes the regression provably impossible (defaults of {undefined, 0}
fail the disk.affinityGeneration > effective guard, preserving the
in-memory values), but explicit coverage here pins the contract so a
future refactor of readPinAndGenFromDisk cannot silently throw.

Adds 4 cases:
- Missing file → defaults
- Round-trip valid pin and generation
- Partial-write JSON (simulates EBUSY mid atomic-rename) → defaults
- Non-integer pin and negative generation rejected → defaults

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ndycode

ndycode commented May 10, 2026

Copy link
Copy Markdown
Owner Author

Deep audit pass complete.

Review thread state: all 20 review threads now resolved.

  • 12 closed by code fixes across the 9 commits.
  • 8 manually marked resolved via resolveReviewThread after CodeRabbit's rationale accepted confirmation in the 05:08 reply.

One non-blocking observation closed: added 4 explicit EBUSY cases for readPinAndGenFromDisk in 3275287 so the double-catch invariant inside AccountManager.buildStorageSnapshot is regression-tested directly, not just transitively.

Final state of PR #475:

  • 9 commits on fix/issue-474-honor-manual-switch
  • typecheck ✅, lint ✅ (0 warnings), npm test3974/3974 passing
  • 0 unresolved review threads

Ready for your manual smoke test on the real Codex desktop app — the 5 unchecked test-plan boxes in the PR body are the only outstanding items, and they require a live app session I can't run from here. Once those pass, the PR is ready to flip from draft to ready-for-review.

@ndycode

ndycode commented May 10, 2026

Copy link
Copy Markdown
Owner Author

@greptileai review

Triggering a fresh Greptile review pass over the latest 5 commits since the original review (e8f8ab5..3275287). Branch is at 9 commits with 3974/3974 tests passing and all 20 prior review threads resolved.

@ndycode
ndycode merged commit 62f7a41 into main May 10, 2026
2 checks passed
ndycode pushed a commit that referenced this pull request May 10, 2026
…mps (#474)

Address PR #475 review feedback:

- unpin.ts now wraps saveAccounts via saveAccountsWithRetry so a transient
  EBUSY/EPERM (common on Windows when the proxy briefly holds the file)
  no longer silently loses the user's clear-pin intent.
- runtime-rotation-proxy STORAGE_META_CACHE is now keyed by absolute
  storage path (Map) so concurrent vitest workers and multiple proxy
  instances pointing at different files cannot corrupt each other.
- readStorageMetaFromDisk now treats EBUSY/EPERM/EACCES/EAGAIN and
  JSON parse errors (likely partial reads mid atomic-rename) as
  transient: it retries up to 3 times with tiny backoff, then falls
  back to the last cached snapshot for that path. Returning defaults
  on a transient error would have falsely reported "no pin, no
  affinity bump" and let the proxy use the wrong account.
- persistAndSyncSelectedAccount and runUnpinCommand now re-read the
  on-disk affinityGeneration just before saving and apply
  Math.max(inMemory, disk) + 1 so concurrent CLI processes cannot lose
  increments via a load+mutate lost update. Helper
  readAffinityGenerationFromDisk lives in storage.ts.
- status.ts bounds-checks pinnedAccountIndex against
  storage.accounts.length and surfaces an actionable
  "run codex-multi-auth unpin" hint when the pin is out of range.
ndycode pushed a commit that referenced this pull request May 10, 2026
…umps (#474)

Add issue-474-pin-safety.test.ts covering the PR #475 review fixes:

- unpin retries on transient EBUSY via saveAccountsWithRetry
- STORAGE_META_CACHE per-path isolation (no cross-contamination
  across concurrent vitest workers / multiple proxies)
- readStorageMetaFromDisk preserves the last cached snapshot on a
  partial-write parse error mid atomic-rename, and falls back to
  defaults only when no cache exists
- status reports "invalid account index … run codex-multi-auth unpin"
  for out-of-range pins (positive and negative)
- unpin uses Math.max(inMemory, disk) + 1 when the on-disk generation
  is ahead of the loaded snapshot, and gracefully falls back to
  inMemory + 1 when no getStoragePath dep is supplied
- Two concurrent unpin processes against the same storage file
  converge to >= initialGen + 2 (no lost increments)
ndycode pushed a commit that referenced this pull request May 10, 2026
Closes the remaining unresolved CodeRabbit review threads on PR #475:

* docs/reference/commands.md: move the sticky-affinity blockquote out of
  the daily-use table so the `account ...` row is no longer orphaned by
  markdown's table-terminating blockquote. Document the new
  switch/unpin/best pin behavior under Upgrade Notes.
* docs/reference/error-contracts.md: document the new HTTP 503
  `codex_pinned_account_unavailable` error in the Runtime Rotation Proxy
  Error Contract table, with `pinnedAccountIndex` payload field.
* lib/codex-manager/commands/status.ts: tighten the pin guard from
  `typeof === "number"` to `Number.isInteger`, rejecting NaN and
  non-integer pollution. Show the raw stored value (not stored+1) when
  the index is invalid so a corrupt -5 reads as "-5", not "-4".
* lib/codex-manager/help.ts: note that `best` clears any manual pin set
  by `switch` so users see this on the canonical help screen.
* lib/schemas.ts: introduce `PersistedSwitchReasonSchema` and the
  inferred `PersistedSwitchReason` type as the single source for the
  CLI persist switch-reason union.
* lib/codex-manager.ts and lib/codex-manager/commands/switch.ts: import
  and reuse `PersistedSwitchReason` instead of re-declaring the literal
  union, eliminating the type-contract drift CodeRabbit flagged.

Tests:
- test/issue-474-pin-safety.test.ts: assertion now expects the raw
  stored value (e.g. "invalid account index 99" not "100"). Added
  regressions for negative pin (raw value displayed) and NaN pin
  (rejected without printing "Pinned: account NaN").

3970 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Account switching works in Codex CLI but not in the Codex app

1 participant