Commit graph

1 commit

Author SHA1 Message Date
chopratejas
27adb1da57 fix: PR #281 — synthesize 5h subscription window after Anthropic reset
The /subscription-window endpoint cached the last `tracker.state` snapshot
verbatim. The tracker polls Anthropic's /api/oauth/usage every 5 minutes
(aggressive polling risks 429s / OAuth-token flagging). When the user's
5-hour window rolls over between two polls, the cached `utilization_pct`
described the OLD window — the dashboard rendered e.g. 44% while Claude
Code itself showed 0%. (Issue #281)

Fix: display-time synthesis. We already have all the data needed locally:
  - snapshot.five_hour.resets_at — the API-reported reset boundary.
  - session_tracking.compute_window_tokens — transcript-derived token
    counts we can scope to [resets_at, now] to estimate usage in the new
    window.

When `now >= resets_at`, render_state() synthesizes:
  - used = local transcript tokens since resets_at (capped at limit; we
    undercount tokens spent on Claude Code outside this proxy and must
    never report >100%).
  - utilization_pct = used / limit * 100.
  - resets_at = next_reset (advanced by window_duration; marked
    `resets_at_estimated=True`).
  - synthesized=True.

When `now < resets_at`, the cached snapshot is returned verbatim with
`synthesized=False`. Backward compatible: every existing tracker.state
key is preserved; only new keys (synthesized, resets_at_estimated,
optional render_warning) are added per window dict.

Also adds maybe_poll_on_demand(): a 60s-floored singleton poll triggered
on dashboard load. Bounded across users (well within Anthropic
tolerance), wrapped in asyncio.wait_for(2s) so a slow upstream never
blocks the request handler. Exceptions are swallowed and logged.

All synthesis decisions emit structured logs:
  event=subscription_window_synthesized window=... used=... limit=...
  event=subscription_render_synthesis_failed (warn fallback)
  event=subscription_on_demand_poll_triggered/skipped_floor/timeout/failed

Tests (12 new, all green):
  - render within window returns cached pct
  - render after reset synthesizes from local tokens
  - render after reset with zero local tokens => 0%
  - render capped at 100%
  - render handles missing resets_at gracefully
  - render preserves existing state keys (backward compat)
  - render with no snapshot returns base state
  - render synthesis fallback path logs and returns cached
  - synthesize helper handles None window
  - synthesize helper advances reset multiple windows when dashboard
    loaded long after reset (e.g. machine asleep)
  - maybe_poll_on_demand singleton 60s floor (mock-counted)
  - maybe_poll_on_demand swallows API failures

Closes #281
2026-05-04 08:17:25 -07:00