Skip to content

fix: scope session-equivalent forecast history by account - #276

Open
zaszlo wants to merge 1 commit into
nesszer:mainfrom
zaszlo:fix/forecast-history-account-scope
Open

fix: scope session-equivalent forecast history by account#276
zaszlo wants to merge 1 commit into
nesszer:mainfrom
zaszlo:fix/forecast-history-account-scope

Conversation

@zaszlo

@zaszlo zaszlo commented Aug 10, 2026

Copy link
Copy Markdown

Problem

Session-equivalent forecast history is keyed by provider_id alone, so it is shared across every account on that provider.

SessionEquivalentHistoryStore and LastFullSessionEstimateStore are both HashMap<String, _> keyed by provider, and bridge.rs passes id.cli_name() — the literal string "codex" or "claude" — with no account discriminator:

codexbar::core::record_provider_windows(provider_id, session, Some(weekly), now);

Fetch itself is single-account, so nothing mixes concurrently. The problem is switching: switch_active_account() changes which account is fetched, while the ring buffer — sized sample_limit * 8, minimum 24 entries — still holds the previous account's observations.

After a switch the burn median is computed from a mixture of two accounts' samples until the old entries age out. Moving between plans of different sizes produces a silently wrong "estimated windows to exhaust" for the following several windows, with nothing in the UI signalling the number is unreliable.

The existing source comment already anticipates the correct key:

ponytail: history is process-local and lost on restart; upgrade path = disk cache keyed by provider+account once forecast quality justifies persistence.

This is the same class of fix as #203, which scoped quota notifications by account identity. That change never reached forecast history.

Change

Introduce ForecastScope { provider_id, account_key } and key both stores by it.

  • SessionEquivalentHistoryStore.by_providerby_scope
  • LastFullSessionEstimateStore.by_providerby_scope
  • record_provider_windows(), forecast_for_provider(), remember_full_session_estimate(), record(), histories() take the account discriminator

Sourcing the discriminator

Claude publishes account_email, but Codex publishes neither email nor organization — under the ADR 0003 ambient/managed lane split its only stable discriminator is the managed token-account id. Keying off the email alone would have left this bug live for Codex, which is the provider with first-class multi-account support.

So the identity is resolved where it actually lives and threaded down:

spawn_provider_refreshes (has inputs.token_accounts) → refresh_providerfetch_provider_snapshotfrom_fetch_result

A new forecast_account_key() deliberately mirrors quota_notification_account_identity precedence — token-account:{uuid} → email → org:{org}. It is a separate function only because that one consumes an already-built ProviderUsageSnapshot while the forecast needs the key while the snapshot is being built.

Behaviour for single-account users is unchanged: one account means one scope.

Notes

  • The account key is in-memory only, and the email is already resident in UsageSnapshot. A comment marks that it should be hashed rather than written in the clear if this history is later persisted to disk.
  • An account with no discoverable identity gets account_key: None, which is its own bucket rather than a catch-all shared with identified accounts.
  • No change to the forecast math, the snapshot shape, or the TS bridge contract.

Tests

  • history_is_isolated_per_account — two accounts on one provider; asserts neither account's samples appear in the other's history and that the None bucket stays empty.
  • forecast_account_key_matches_notification_identity — pins forecast_account_key to quota_notification_account_identity across all 8 combinations of email/org/token, so the two subsystems can never see one account as two identities. If they diverged, an account's burn history would split and silently halve the sample count behind every forecast.
  • history_ring_retains_latest_samples updated for the new signature.

Verification

cargo fmt --all --check                                              # clean
cargo clippy -p codexbar --all-targets -- -D warnings                # exit 0
cargo clippy -p codexbar-desktop-tauri --all-targets -- -D warnings  # exit 0
cargo test -p codexbar --lib                                         # 1207 passed, 0 failed
cargo test -p codexbar-desktop-tauri                                 # 342 passed, 0 failed

Note on cli::tty_runner::tests::test_run_sends_script_through_pty: it failed on one
full-suite run here and passed on others. I checked it against untouched main
(116af24b), where it also fails under full-suite parallel load, so it is pre-existing
flakiness in this environment and unrelated to this change.

No CUA Driver proof included: this changes no UI, tray, or Settings surface. The forecast line in MenuCardDetails renders exactly as before for a single-account user.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@Finesssee

Copy link
Copy Markdown
Collaborator

Thanks for the PR, I will review it ASAP.

@Finesssee Finesssee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes: for the provider this PR is about, the account discriminator is structurally always None, so the headline bug remains.

Trace of the blocker, all at head 98e397b:

  • spawn_provider_refreshes (apps/desktop-tauri/src-tauri/src/commands/providers.rs:327) sources the discriminator from ProviderRefreshInputs::load()'s TokenAccountStore read (token-accounts.json).
  • TokenAccountSupport::for_provider explicitly excludes Codex (rust/src/core/token_accounts.rs; pinned by assert!(!TokenAccountSupport::is_supported(ProviderId::Codex))), and the normal writers (codexbar account add/list/active, the usage env-override path) all reject unsupported providers — so a Codex entry can never exist in that store through any supported flow.
  • Codex accounts live in the separate codex_accounts AccountStore (accounts.json under the app-support dir, the ADR 0003 lanes), which this refresh path never consults.
  • The ambient Codex provider publishes neither account_email nor account_organization (nothing in rust/src/providers/codex sets them) — the new comment in from_fetch_result itself concedes this.

Net effect: token_account_id = None and forecast_account_key = None on every real Codex refresh, so record_provider_windows / forecast_for_provider keep keying every Codex sample as (codex, None). Switching accounts still blends burn history into one series — the exact bug this PR claims to fix.

Required before merge:

  1. Derive the forecast identity from the same resolved CODEX_HOME/auth.json the fetch path uses (rust/src/providers/codex/api.rs), and have the Codex provider publish it: email first, falling back to provider_account_id / auth_subject from the auth.json identity. The codex_accounts manager already parses exactly those fields via load_identity, so the parsing precedent exists in-repo.
  2. A real refresh-boundary regression test proving two Codex accounts on one machine yield two distinct history keys end-to-end. The current targeted tests are insufficient: they inject synthetic ids (token-account:aaaaaaaa-…) directly into from_fetch_result, so they cannot observe the TokenAccountStore gap.
  3. One canonical account-identity helper instead of duplicated precedence: forecast_account_key deliberately mirrors quota_notification_account_identity's token-account → email → organization ordering (its own doc comment says so). Collapse both into a single shared helper so the notification and forecast subsystems cannot drift apart again.

Explicit guidance on the identity source: do not substitute CodexAccount.id as the discriminator. Undiscovered ambient/managed accounts receive fresh Uuid::new_v4() ids — candidate_account and build_discovered_account in rust/src/codex_accounts/account_manager.rs fall back to Uuid::new_v4() whenever identity matching finds no existing account — so one real account would keep bifurcating into new history keys across discoveries.

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.

3 participants