Skip to content

fix(usage): retry an expired Claude token while the Orca window stays focused - #33807

Open
frank-bee wants to merge 1 commit into
stablyai:mainfrom
frank-bee:fix/claude-stale-token-focused-retry
Open

frank-bee wants to merge 1 commit into
stablyai:mainfrom
frank-bee:fix/claude-stale-token-focused-retry

Conversation

@frank-bee

@frank-bee frank-bee commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #31061 (Claude Enterprise monthly spend cap, fixed by #33655): with that fix the cap renders, but after an Orca restart it can sit behind "Updates when Claude runs" for up to 15 minutes. Details in #33806.

ELI5

When Claude's short-lived login token has expired, Orca shows "Updates when Claude runs" and waits. Once Claude renews the token, Orca only noticed it when you clicked back into the Orca window or after 15 minutes. If you were already working in Orca, the message just stayed. Now, while the Orca window is in front, Orca checks again every so often (30s, then 60s, 2 min, … up to 15 min) and the usage shows up shortly after Claude renewed the token.

What Changed

Before: a Claude stale-token result (401/403 from /api/oauth/usage) was retried only on a window focus / show / restore event or on the regular 15-minute poll. In a focused window there is no such event, so even after the Claude CLI renewed the token the chip kept saying "Updates when Claude runs" until the next poll or a manual Refresh.

After: while the window is focused and Claude's last result is stale-token, a light 30s tick retries Claude on the existing per-provider failure backoff. Once the CLI has renewed the token, the next due retry succeeds.

Mechanism (src/main/rate-limits/service/service-polling.ts):

  • The failure-retry gate in getActiveWindowRefreshPlan (Retry-After + ACTIVE_FAILURE_REFETCH_MS exponential backoff capped at MAX_ACTIVE_FAILURE_REFETCH_MS) moves into isActiveFailureRetryDue, unchanged, so activation and the new tick share it.
  • startTimer / stopTimer also manage staleClaudeTokenRetryTimer. Each tick calls retryStaleClaudeTokenIfWindowActive, which does nothing unless the window is focused and visible (shouldBackgroundPoll), Claude's failureKind is stale-token, and the backoff says a retry is due. It then runs the existing Claude-only cycle through runActiveWindowRefreshPlan, so the retry timestamp and failure streak are tracked exactly as for a focus-triggered retry.

Why

"Updates when Claude runs" (#33542) relies on Claude pushing usage through the managed statusline. That only covers plans with 5h/7d windows: the statusline script exits without rate_limits, and parseClaudeStatusLineBody drops a post with neither window. A Claude Enterprise seat (monthly spend cap only, #31061) never sends one, so for those accounts nothing but focus, the poll or a manual Refresh cleared the label.

Alternatives considered:

  • Trigger a fetch from the Claude statusline post: not possible for Enterprise, since no post reaches Orca (see above).
  • Trigger from Claude agent hooks (turn finished): would also cover Enterprise, but the hook events live in the agent-hook server, which can run in orcad separately from the rate-limit service and has no profile/config-dir mapping for a turn. Too much plumbing for this.
  • Retry every failed provider on a timer: first version of this change. It also altered post-429 Retry-After timing and the fetch cadence of other failure kinds (two existing tests caught that), so it is scoped to the case from the issue: Claude stale-token.

Orca still never refreshes the Claude login itself (#26801 and its credential-persistence ratchet are untouched). It only reads the Keychain/credentials again on each retry, as every fetch already does.

Linked Issue

Fixes #33806

Follow-up to #31061 / #33655. Related: #33542.

Visual Proof

N/A: no UI change. The chip moves from the existing "Updates when Claude runs" state to the existing usage display sooner. Timeline from the real occurrence is in #33806.

Testing

  • New in service-window-activation.test.ts:

    • retries an expired Claude token on its backoff while the window stays focused: a startup stale-token result recovers on the 30s tick with no window event, and Codex is not refetched. Fails on main (Claude called once), passes here.
    • leaves an expired Claude token to activation while the window is unfocused: no retries over 5 minutes while unfocused, so background budget behavior is unchanged.
  • Existing activation/backoff/Retry-After tests pass unchanged.

  • pnpm test src/main/rate-limits src/renderer/src/components/status-bar src/main/claude-accounts: 171 files, 1876 tests passed.

  • pnpm check:changed --maxWorkers=2: 20 checks passed (format, lint, typecheck, affected tests, ratchets).

  • End-to-end simulation with real timers (not committed). It reuses the service-inactive-claude-cooldown.test.ts setup: a real RateLimitService with the real Claude fetch, classify and credential-read path and the real ClaudeProfileRouter, running against a local HTTP server. The server returns 401 for the expired token and a Claude Enterprise payload (five_hour: null, seven_day: null, spend 83 %) for the renewed one. A focused fake window gets no window events. The token in the profile's .credentials.json is rewritten 5s after startup, which is what the Claude CLI does on renewal.

    • This branch: startup gets 401, the chip shows stale-token / "Updates when Claude runs", the 30s retry gets 200, and the chip recovers 25s after the renewal with monthly 83 % / 43200 min.
      [sim +0.1s] after startup: status=error failureKind=stale-token label="Updates when Claude runs"
      [sim +5.1s] Claude CLI renewed the token (credentials rewritten); window stays focused, no focus event
      [sim +30.2s] request at +0.0s token=synthetic-expired -> 401
      [sim +30.2s] request at +30.0s token=synthetic-renewed -> 200
      [sim +30.2s] RECOVERED 25.1s after renewal: monthly=83% window=43200min
      
    • main (service-polling.ts / service-state.ts from origin/main): no request after the first 401, still "Updates when Claude runs" 75s after the renewal.
      [sim +80.1s] request at +0.0s token=synthetic-expired -> 401
      [sim +80.1s] NOT RECOVERED within 75s of renewal: status=error label="Updates when Claude runs"
      
  • Not covered: a check in a dev build on macOS with a real lapsed Claude token. The simulation covers the service logic but not the real Keychain read or a real Electron focus state.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Implemented with Claude (Opus 5.5) in Claude Code. I reviewed the change.

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: no new credentials, requests to new endpoints, or logging. Same request Orca already makes, at most a few extra times per lapsed-token episode.
  • Performance/budget: the tick is a no-op unless Claude is stale-token and a backoff step is due. Retries follow 30s → 60s → … → 15 min, so a token that never renews ends up at the regular poll cadence. Retry-After from a 429 is still respected. Nothing runs while the window is unfocused, hidden or minimized.
  • Cross-platform: no platform-specific code. Remote/SSH: runs wherever the rate-limit service runs, same as the focus-triggered retry. Mixed versions: no new fields or IPC.
  • Mobile: no change. It receives the recovered state as before.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

… focused

Follow-up to stablyai#31061 / stablyai#33655. After a failed fetch Orca only retried on a
window focus/show/restore event or the 15-minute poll, so a Claude token
that the CLI had already renewed kept showing "Updates when Claude runs"
while the user worked in the focused window. Claude Enterprise seats never
send statusline rate limits, so nothing else cleared it.

Fixes stablyai#33806
@frank-bee
frank-bee marked this pull request as ready for review October 26, 2026 07:50
@coderabbitai

coderabbitai Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 364ab42e-cf38-489f-b438-d04b4e456eb9
Commits

Reviewing files that changed from the base of the PR and between 35af10d and 4edec9c.

Files selected for processing: 3
  • src/main/rate-limits/service-window-activation.test.ts
  • src/main/rate-limits/service/service-polling.ts
  • src/main/rate-limits/service/service-state.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Walkthrough

The polling service adds a timer that checks for due Claude stale-token retries while background polling is allowed. Retry eligibility accounts for active Retry-After periods and provider failure backoff. When a Claude retry is due, the service runs a Claude-only refresh plan. Tests cover recovery after a retry while the window is focused and the absence of automatic retries while it is unfocused.

Priority: Normal

Severity of issue fixed: Medium

Merge Risk: Minimal · up to 4edec

The focused retry is ready to merge after normal checks; no actionable risk remains from the reviewed change.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: retrying an expired Claude token while the Orca window remains focused.
Description check Passed The description is comprehensive. It includes the user impact, implementation details, rationale, linked issue reference, testing evidence, limitations, and checklist coverage. The linked issue uses `…
Linked Issues check Passed Issue #33806 requires that, after Claude renews an expired token, the Claude usage chip updates without a manual Refresh while the Orca window stays focused. The issue's suggested scope is to retry Cl…
Out of Scope Changes check Passed The whole-PR summary lists three changed files: service-polling.ts, service-state.ts, and service-window-activation.test.ts.

- service-polling.ts: adds the stale-Claude retry timer and retryStaleClau…
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 3…
Full details: Description check

Explanation

The description is comprehensive. It includes the user impact, implementation details, rationale, linked issue reference, testing evidence, limitations, and checklist coverage. The linked issue uses Fixes #33806 rather than a full URL, and the full build checklist item remains unchecked, but these are non-critical because the issue reference is identifiable and other validation is documented.

Full details: Linked Issues check

Explanation

Issue #33806 requires that, after Claude renews an expired token, the Claude usage chip updates without a manual Refresh while the Orca window stays focused. The issue's suggested scope is to retry Claude stale-token failures on the existing per-provider backoff only while focused. It also states that Orca must not renew the Claude login.

Implementation at the reviewed head (src/main/rate-limits/service/service-polling.ts):

  • startTimer creates staleClaudeTokenRetryTimer at ACTIVE_FAILURE_REFETCH_MS. stopTimer clears it.
  • retryStaleClaudeTokenIfWindowActive returns early unless all three hold: shouldBackgroundPoll() is true, state.claude.usageMetadata.failureKind === 'stale-token', and isActiveFailureRetryDue('claude', ...) is true.
  • When all three hold, it calls runActiveWindowRefreshPlan with a Claude-only providers plan. That plan calls fetchClaudeOnly.
  • isActiveFailureRetryDue keeps the Retry-After check and the exponential backoff capped at MAX_ACTIVE_FAILURE_REFETCH_MS. This is the 30s to 15 min schedule in the issue.
  • No changed code renews or rewrites the Claude login.

The Enterprise monthly-only case is covered because the trigger depends on failureKind, not on statusline payloads.

Tests: the test file could not be read at the reviewed head, and the diff tool could not read changed blobs. The named tests and the real-timer simulation are supported only by the PR description and the summary. The issue sets a behavior requirement, not a test requirement, so this does not change the verdict.

Note: the issue says recovery is expected within about a minute. Its own suggested fix uses backoff up to 15 min after repeated failures. The implementation follows that fix.

Full details: Out of Scope Changes check

Explanation

The whole-PR summary lists three changed files: service-polling.ts, service-state.ts, and service-window-activation.test.ts.

  • service-polling.ts: adds the stale-Claude retry timer and retryStaleClaudeTokenIfWindowActive. It also moves the existing failure-retry gate into isActiveFailureRetryDue, which the summary describes as unchanged logic. Both directly implement issue #33806.
  • service-state.ts: adds the staleClaudeTokenRetryTimer field needed by the timer. This is supporting code.
  • service-window-activation.test.ts: adds tests for the focused retry and the unfocused no-retry behavior. These are supporting tests.

The retry path applies only to Claude stale-token failures. Other failure kinds and providers keep their existing behavior. No unrelated change appears in the summary.

Limit: the diff tool could not read the changed blobs, so the file list rests on the summary.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 3 files.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
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.

[Bug]: Claude usage stays on "Updates when Claude runs" after Claude renewed its token while the window stays focused

1 participant