Skip to content

fix(quota): stop the backfill reverting fresh data and bleeding across accounts - #52

Merged
Cmochance merged 6 commits into
mainfrom
fix/quota-backfill
Jul 11, 2026
Merged

fix(quota): stop the backfill reverting fresh data and bleeding across accounts#52
Cmochance merged 6 commits into
mainfrom
fix/quota-backfill

Conversation

@Cmochance

Copy link
Copy Markdown
Owner

Summary

The quota backfill had four compounding defects (full review in this session's audit):

  1. Stale-root clobber — the root ~/.codex/profile.json copy is only written at switch-in, but every write-back (switch / app launch / window close / current-profile login) copied that frozen snapshot back over the profile card, reverting the quota/plan updates recorded while it was active. Permanent for API-key profiles (no API refresh path repairs them). Windows had patched the switch path only — keyed off the possibly-stale .current_profile marker; macOS had nothing.
  2. Cross-account live bleed~/.codex/sessions is not swapped on switch, so the previous account's newer JSONL entries won the freshness race and displayed (and, on Windows, persisted) as the current profile's quota.
  3. Duplicated display pathwin/runtime/profiles_index.rs was a drifted near-copy of the shared implementation (missing the unmanaged-account guard, the display-side remnant of the cross-contamination bug).
  4. Prime side-effect — Windows primed the switch target's stored timestamp to "now", mislabeling stale data as fresh and suppressing the post-switch API refresh behind the 5-min gate.

Changes (5 commits + a fmt pre-pass)

  1. fadd25fswitch_core::sync_live_quota_and_refresh_root: fold the freshest live-session usage into the identity-verified target's card and rewrite the root copy, immediately before every write-back, both platforms.
  2. 91766f5 — dedupe the display path (Windows re-exports shared) and scope live sessions to the profile's activation (activated_at from the .active_profile marker).
  3. 04b202e — scope the write-back fold the same way, drop the Windows prime, kick an immediate silent API refresh after each switch.
  4. 27b7f49 — pre-push multi-agent review round (1 HIGH / 3 MEDIUM / 4 LOW): strict metadata load so a transient read failure can't blank a card; fold requires the profile's own parseable marker (drift targets skip instead of misattributing); the login write-back path covered; marker self-heal + one-time corruption warning; downgrade-to-free clear no longer overridden by older sessions; filtered scans keep the fast-path cache anchor; window-close sync failures logged; in-flight dedup for the silent refresh (refresh_token race); legacy codex-switch.sh stops copying root profile.json back.
  5. a0fb615 — cargo-machete false-positive ignore list (module tree lives outside src/).

Verification

  • cargo test --lib: 137 passed / 0 failed (8 new: 2 stale-root regressions, drift-skip fold, activation-scoped display ×3, moved win fold tests ×2)
  • tsc --noEmit clean; bash -n clean; cargo machete clean
  • Real-machine evidence for the bug (pre-fix): root copy frozen at Jul 1 (quota_updated_at_ms=1782878450168) vs profile copy refreshed Jul 11 — one launch away from a 10-day rollback

Known residual (documented in CHANGELOG)

A codex CLI session started under the previous account that survives the switch keeps appending to a session file whose mtime passes the activation cutoff — its usage can be misattributed until that session exits. Fixing that needs per-account session attribution (follow-up).

🤖 Generated with Claude Code

…the live-quota fold

The root ~/.codex/profile.json copy is only ever written by the
switch-in overlay, so it freezes at switch-in time while the profile
copy keeps moving via the 5-min ticker / bulk plan refresh / manual
refresh. Every backup_root_state_to_profile write-back (switch, launch
bootstrap, window close) then copied that frozen snapshot over the
profile copy, silently reverting the quota / plan updates recorded
while the profile was active — permanently for API-key profiles, which
no API refresh path repairs. Windows had patched the switch path only
(and keyed the fold off the .current_profile marker); macOS had
nothing.

switch_core now folds the freshest live-JSONL usage into the
write-back target's card and rewrites the root copy from the merged
result, immediately before every write-back on both platforms. The
fold targets the identity-verified backup target (same as the auth
write-back), not the marker. The Windows-only wrapper keeps just the
target-prime step; its live-quota tests moved to switch_core alongside
two new stale-root regression tests.
…tivation

win/runtime/profiles_index.rs was a near-copy of the shared
load_current_live_quota / select_current_quota that had already
drifted twice: it lacked the unmanaged-live-account guard (so Windows
kept showing the drifted-away card's numbers — the display-side
remnant of the cross-contamination bug), and it lacked the
stored-empty clause. Windows now re-exports the shared module like
macOS does.

The shared display path additionally scopes the live session JSONL to
entries written since the current profile's activation
(activated_at= from the .active_profile marker, parsed by the new
profiles::profile_activated_at_ms). ~/.codex/sessions is not swapped
on switch, so newer entries from the previously-live account used to
win the freshness race and show the previous account's usage as the
current profile's quota for the first minutes after a switch. Markers
without a parseable activated_at fall back to the unfiltered legacy
race.
…on switch

- the write-back fold now only considers session JSONL written since
  the target profile's activation (activated_at from the active
  marker) — entries older than that belong to the previously-live
  account and were being folded into the wrong card when it had no
  fresher stored data
- the Windows-only target-prime step is removed and win/switch.rs
  becomes the same thin switch_core wrapper macOS uses: activation
  scoping closes the cross-account display bleed prime guarded
  against, while priming the stored timestamp to "now" mislabeled
  stale data as fresh and suppressed the post-switch API refresh
  behind the 5-min staleness gate
- the frontend kicks the silent quota refresh immediately after a
  successful switch, so the new card shows live numbers in seconds
  instead of waiting out the next 5-min tick
Findings from the pre-push adaptive-review pass (1 HIGH, 3 MEDIUM, 4 LOW):

- a present-but-unreadable profile.json now aborts the write-back
  (load_profile_metadata_strict) instead of silently loading a default
  card that the root refresh + write-back would then copy over the
  stored data — a transient read failure must not blank a card
- the live fold now requires the profile's own parseable activation
  marker: an identity-drift target (marker still on another profile)
  or a hand-removed marker means sessions cannot be attributed, so the
  fold is skipped while the root refresh still runs; the bootstrap
  fold also moved before the marker heal, which used to re-stamp
  activated_at first and silently filter every session out
- login_current_profile (mac + win) was a fourth write-back call site
  without the root refresh — same clobber, now covered
- the marker heal also re-stamps when activated_at is missing/corrupt,
  and a corrupt-but-present marker logs a one-time warning instead of
  being indistinguishable from a legitimately missing one
- select_current_quota drops the stored-empty override: an empty card
  with a newer timestamp is the deliberate downgrade-to-free clear and
  must not be overridden by older live sessions
- a filtered session scan no longer clears the unfiltered fast-path
  cache anchor; window-close sync failures are logged instead of
  swallowed; the post-switch silent refresh gained in-flight dedup so
  it cannot race a ticker fire into a refresh_token reuse; the legacy
  codex-switch.sh stops copying the root profile.json back entirely

Known residual (documented in CHANGELOG): a CLI session started under
the previous account that survives the switch keeps passing the
mtime-based activation cutoff until it exits.
The crate's module tree lives outside src/ (shared/, mac/, win/ are
mounted via #[path] in src/lib.rs), so cargo-machete's conventional
file discovery never sees the real use sites. Every ignored entry has
verified active call sites in those trees.
@Cmochance

Copy link
Copy Markdown
Owner Author

手动测试清单(本地 .app 构建后 open dist/codex_switch.app)

核心验证:额度回填不再回滚 / 不再串显

  1. 回滚回归(主修复):打开 app 让 5-min ticker 刷新当前 profile 额度(或点手动 Refresh)→ 记住数字 → 切换到另一 profile 再切回来 → 卡片额度应保持刷新后的数字,不回退到旧值(修复前:回到 switch-in 时刻的快照)
  2. 重启不回滚:刷新额度后退出 app 重开 → 卡片数字不回退(launch bootstrap 路径)
  3. 关窗不回滚:刷新后关窗(不退出进程的话跳过此项)→ 重开验证同上
  4. 切换后立即刷新:切到一个久未使用的 profile → 数秒内额度自动更新(不再等最多 5 分钟)
  5. 跨账号串显回归:在 A 账号跑一段 codex 会话 → 切到 B → B 卡片不应显示 A 的用量(修复前最长 5 分钟串显)
  6. Login 路径:当前 profile 点 Login 重新登录 → 完成后卡片额度不回退
  7. (可选)~/.codex/profile.json 的 mtime 在每次切换/启动后应被刷新为当前时间(修复前冻结在 switch-in 时刻)

:已知残留(CHANGELOG 有记):切换时 A 账号仍在终端跑着的 codex CLI 会话,退出前其用量仍可能计入新 profile。

@Cmochance
Cmochance merged commit 841dd0f into main Jul 11, 2026
3 checks passed
@Cmochance
Cmochance deleted the fix/quota-backfill branch July 11, 2026 15:09

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0fb61558f

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +77 to +78
.and_then(|value| DateTime::parse_from_rfc3339(value.trim()).ok())
.and_then(|value| u64::try_from(value.timestamp_millis()).ok());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve sub-second activation times for quota scoping

When a switch happens in the same second that the previous account's session JSONL was last modified, this cutoff is rounded down to the start of that second because set_active_marker writes utc_timestamp() without fractional seconds. The new session filter compares millisecond mtimes against this parsed value, so a previous-account file touched at e.g. 12:00:00.800 before an activation marker of 12:00:00Z is still treated as after activation and can be displayed/persisted as the new profile's quota. Storing/parsing millisecond precision, or otherwise using a non-rounded activation cutoff, is needed for the cross-account bleed fix to hold in rapid switches.

Useful? React with 👍 / 👎.

Comment on lines +197 to +200
if (!activeQuotaSilentRefreshInFlight) {
activeQuotaSilentRefreshInFlight = refreshActiveQuotaSilentlyInner().finally(() => {
activeQuotaSilentRefreshInFlight = null;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Key silent refresh dedupe by active profile

If the 5-minute ticker has already started a silent refresh for profile A and the user switches to profile B before it finishes, this global in-flight promise makes the post-switch kick return the A refresh instead of starting one for B. The A response is then ignored by applyCurrentQuota once the snapshot points at B, so the switched-to card misses the immediate API refresh this change added and waits until the next 5-minute tick; track the in-flight profile or queue a refresh for the new current profile after the old one completes.

Useful? React with 👍 / 👎.

Comment on lines +61 to +63
if let Some(activated_at_ms) =
profile_activated_at_ms(profile, &get_backup_root(Some(codex_home)))
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Backfill legacy markers before skipping live quota folds

For upgraded profiles whose .active_profile marker exists but lacks a parseable activated_at, this branch skips the live JSONL fold entirely during the first launch/window-close/switch write-back. The display path explicitly falls back to the old unfiltered quota read for those legacy markers, but the persistence path now refreshes root profile.json only from the stale stored card before writing back, so the latest session usage is not saved (and API-key profiles have no API refresh path to repair it). Treat the marked current profile as a legacy fallback or restamp after preserving its current live quota.

Useful? React with 👍 / 👎.

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.

1 participant