fix(quota): stop the backfill reverting fresh data and bleeding across accounts - #52
Conversation
…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.
手动测试清单(本地 .app 构建后
|
There was a problem hiding this comment.
💡 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".
| .and_then(|value| DateTime::parse_from_rfc3339(value.trim()).ok()) | ||
| .and_then(|value| u64::try_from(value.timestamp_millis()).ok()); |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!activeQuotaSilentRefreshInFlight) { | ||
| activeQuotaSilentRefreshInFlight = refreshActiveQuotaSilentlyInner().finally(() => { | ||
| activeQuotaSilentRefreshInFlight = null; | ||
| }); |
There was a problem hiding this comment.
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 👍 / 👎.
| if let Some(activated_at_ms) = | ||
| profile_activated_at_ms(profile, &get_backup_root(Some(codex_home))) | ||
| { |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
The quota backfill had four compounding defects (full review in this session's audit):
~/.codex/profile.jsoncopy 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_profilemarker; macOS had nothing.~/.codex/sessionsis 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.win/runtime/profiles_index.rswas a drifted near-copy of the shared implementation (missing the unmanaged-account guard, the display-side remnant of the cross-contamination bug).Changes (5 commits + a fmt pre-pass)
fadd25f—switch_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.91766f5— dedupe the display path (Windows re-exports shared) and scope live sessions to the profile's activation (activated_atfrom the.active_profilemarker).04b202e— scope the write-back fold the same way, drop the Windows prime, kick an immediate silent API refresh after each switch.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); legacycodex-switch.shstops copying rootprofile.jsonback.a0fb615— cargo-machete false-positive ignore list (module tree lives outsidesrc/).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 --noEmitclean;bash -nclean;cargo machetecleanquota_updated_at_ms=1782878450168) vs profile copy refreshed Jul 11 — one launch away from a 10-day rollbackKnown 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