# Plan: make local account state authoritative ## Problem Current `accounts.json` state is not acting like a real source of truth for `/token`: - `active_account_id` is persisted but not actually used to drive selection - `last_known_usage` is mostly treated as a sort hint, then immediately replaced by a live refresh - `/token` live-refreshes every candidate it tries, so local limits are not truly trusted - the persisted snapshot contains duplicate derived fields (`used_percent`, `remaining_percent`, `exhausted`) in addition to canonical window and flag data - as a result, the file is hard to reason about: some fields look authoritative but are only decorative or transient ## Desired behavior 1. `accounts.json` is the source of truth for `/token` selection. 2. If `active_account_id` exists and local state says the active account is usable, `/token` must try that account first. 3. Only if local state says the active account is blocked or unusable should `/token` fall back to choosing another account. 4. Fallback selection can keep the current ranking approach: most-used eligible account first. 5. `/usage` should continue to refresh all accounts live. 6. Persisted usage snapshots should store canonical data only, without duplicated derived fields. ## Recommended approach ### 1. Split `/token` selection into local selection and live validation In `src/gibby/manager.py`: - Add `_find_active_account(state)` to resolve `state.active_account_id` to an `AccountRecord` - Add `_is_locally_blocked(account, current)` to decide from local file state only whether an account can be tried - Add `_build_selection_order(state, current)` that: - returns the active account first if it exists and is not locally blocked - otherwise falls back to the remaining eligible accounts sorted by current saved-usage ranking - never duplicates the active account in the fallback list `_is_locally_blocked()` should use only persisted local state: - blocked if `cooldown_until > now` - blocked if `last_known_usage` exists and local usage indicates exhaustion - otherwise not blocked This gives the exact behavior the user requested: - active account is mandatory first choice when nothing local blocks it - local file decides whether active is allowed before any network call - live refresh remains only a validation step for the chosen candidate ### 2. Keep live refresh only for accounts actually attempted In `src/gibby/manager.py`: - keep the current live refresh path (`refresh_account_usage`) once an account has been selected for an attempt - if active account passes local checks but fails live validation, persist the updated state and continue to the next candidate - if active account is locally blocked, skip live refresh for it during that `/token` call `/usage` stays as-is and continues refreshing all accounts live. ### 3. Clean up the persisted usage snapshot schema In `src/gibby/models.py` and `src/gibby/store.py`: - stop persisting derived snapshot fields: - `used_percent` - `remaining_percent` - `exhausted` - keep only canonical persisted snapshot data: - `checked_at` - `primary_window` - `secondary_window` - `limit_reached` - `allowed` Implementation direction: - keep `UsageSnapshot` as the in-memory model for now, but derive: - `used_percent` - `remaining_percent` - `exhausted` from canonical fields when loading/parsing - update `store._snapshot_to_dict()` to write only canonical fields - update `store._snapshot_from_dict()` to reconstruct the full in-memory `UsageSnapshot` from canonical persisted fields This keeps code churn smaller than a full model rewrite while making the file itself cleaner and more honest. ### 4. Keep cooldown as the persisted local block, but make local exhaustion matter too Local selection should not depend on a fresh API round-trip. For `/token`: - `cooldown_until` remains the strongest persisted block - if cooldown is clear but local `last_known_usage` still says exhausted, treat the account as locally blocked too - only accounts that pass local checks are eligible to be attempted live This changes current behavior in an important way: - today, an account with expired or missing cooldown can still be live-refreshed even if local snapshot says exhausted - after the change, local state truly gates the initial decision ### 5. Preserve existing fallback ranking for non-active accounts After active account is rejected locally, keep the current fallback sort in `manager.py`: - primary window used percent descending - secondary window used percent descending That avoids a larger policy change in this pass and isolates the refactor to "trust local state first". ## Files to modify - `/home/wzray/AI/gibby/src/gibby/manager.py` - respect `active_account_id` - add local-only eligibility predicate - change selection order to active-first-when-locally-usable - `/home/wzray/AI/gibby/src/gibby/models.py` - keep canonical usage derivation helpers centralized - support reconstructing derived values from canonical fields - `/home/wzray/AI/gibby/src/gibby/store.py` - write canonical snapshot shape only - read canonical snapshot shape into full in-memory model - `/home/wzray/AI/gibby/src/gibby/account_ops.py` - keep refresh path aligned with canonical snapshot handling - reuse a local exhaustion predicate if helpful instead of duplicating logic - `/home/wzray/AI/gibby/tests/test_core.py` - add and update selection behavior tests - `/home/wzray/AI/gibby/tests/test_account_ops.py` - update snapshot persistence assumptions if needed - `/home/wzray/AI/gibby/tests/test_app.py` - adjust fixture shapes only if response expectations change - `/home/wzray/AI/gibby/tests/test_refresh_limits.py` - ensure live refresh still rewrites canonical local state correctly - `/home/wzray/AI/gibby/tests/test_oauth_helper.py` - ensure oauth helper stores canonical snapshot shape correctly ## Test plan ### Selection behavior Add or update tests in `tests/test_core.py` for: - active account is used first when locally allowed, even if another account has higher saved usage - active account is skipped without live refresh when `cooldown_until` is still active - active account is skipped without live refresh when local snapshot says exhausted - active account passes local checks but fails live refresh, then fallback account is tried - missing or stale `active_account_id` falls back cleanly to non-active selection logic - fallback ordering still prefers higher saved primary usage and uses secondary as tie-breaker ### Snapshot/file behavior Add or update tests to verify: - `accounts.json` no longer writes `used_percent`, `remaining_percent`, or `exhausted` - loading canonical persisted snapshots still reconstructs full in-memory `UsageSnapshot` - `/usage`, `refresh_limits.py`, and `oauth_helper.py` still persist refreshed canonical state correctly ## Verification - `uv run pytest -q tests/test_core.py tests/test_account_ops.py tests/test_app.py tests/test_refresh_limits.py tests/test_oauth_helper.py` - inspect a real `accounts.json` after `/usage` or `refresh_limits.py` and confirm snapshot entries contain only canonical fields - manually test `/token` with: - a locally usable active account - a locally blocked active account - a dangling `active_account_id` - verify that active account is not live-refreshed when local file state already blocks it