diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts index db00053fdcf1..9111d9faf861 100644 --- a/src/main/rate-limits/service/service-result-policy.ts +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -1,4 +1,5 @@ import { RateLimitServiceFetchControl } from './service-fetch-control' +import { stampClaudeRefreshingSince } from '../../../shared/claude-refreshing-sign-in' import { MAX_ACTIVE_FAILURE_STREAK, RATE_LIMITED_STALE_THRESHOLD_MS, @@ -14,19 +15,22 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch ): ProviderRateLimits { // Fresh data is fine — use it if (fresh.status === 'ok') { - return { - ...fresh, - usageMetadata: { - ...fresh.usageMetadata, - lastSuccessfulSource: - fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource - } - } + return stampClaudeRefreshingSince( + { + ...fresh, + usageMetadata: { + ...fresh.usageMetadata, + lastSuccessfulSource: + fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource + } + }, + previous + ) } // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. if (fresh.status === 'unavailable') { - return fresh + return stampClaudeRefreshingSince(fresh, previous) } const previousHasData = Boolean( @@ -39,7 +43,7 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch // No previous data to fall back on if (!previous || !previousHasData) { - return fresh + return stampClaudeRefreshingSince(fresh, previous) } // Previous data is too old — don't show stale data @@ -48,21 +52,24 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch ? RATE_LIMITED_STALE_THRESHOLD_MS : STALE_THRESHOLD_MS if (Date.now() - previous.updatedAt > staleThresholdMs) { - return fresh + return stampClaudeRefreshingSince(fresh, previous) } // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. - return { - ...previous, - error: fresh.error, - status: 'error', - usageMetadata: { - ...previous.usageMetadata, - ...fresh.usageMetadata, - lastSuccessfulSource: - previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source - } - } + return stampClaudeRefreshingSince( + { + ...previous, + error: fresh.error, + status: 'error', + usageMetadata: { + ...previous.usageMetadata, + ...fresh.usageMetadata, + lastSuccessfulSource: + previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source + } + }, + previous + ) } protected trackActiveFailureStreak( diff --git a/src/renderer/src/components/status-bar/repro-8974-refreshing-sign-in-stuck.test.ts b/src/renderer/src/components/status-bar/repro-8974-refreshing-sign-in-stuck.test.ts new file mode 100644 index 000000000000..147463a25985 --- /dev/null +++ b/src/renderer/src/components/status-bar/repro-8974-refreshing-sign-in-stuck.test.ts @@ -0,0 +1,93 @@ +/** + * Issue #8974 — Claude usage stuck on "Refreshing sign-in". + * + * Transient failureKinds map to soft "Refreshing sign-in" copy while repair is + * in flight. Once `refreshingSinceMs` ages past the escalate window, the UI + * must leave the indefinite spinner and surface re-auth copy / CTA. + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/renderer/src/components/status-bar/repro-8974-refreshing-sign-in-stuck.test.ts + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { + CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS, + stampClaudeRefreshingSince +} from '../../../../shared/claude-refreshing-sign-in' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy' +import { getUsageRosterRowState } from './usage-roster-row-state' + +function claudeError(failureKind: string, refreshingSinceMs?: number): ProviderRateLimits { + return { + provider: 'claude', + status: 'error', + error: 'token expired', + updatedAt: Date.now(), + session: null, + weekly: null, + monthly: null, + usageMetadata: { + failureKind: failureKind as never, + ...(refreshingSinceMs != null ? { refreshingSinceMs } : {}) + } + } +} + +describe('issue #8974 Refreshing sign-in escalation', () => { + it('maps auth-refresh failureKinds to Refreshing sign-in while the window is open', () => { + const now = Date.now() + for (const kind of [ + 'stale-token', + 'refreshable-credentials-without-token', + 'delegated-refresh-required' + ]) { + const p = claudeError(kind, now) + expect(getProviderUsageStatusLabel(p)).toBe('Refreshing sign-in') + expect(getProviderUsageErrorMessage(p)).toMatch(/sign-in is being refreshed/i) + expect(getUsageRosterRowState(p, false)).toEqual({ + kind: 'error', + statusLabel: 'Refreshing sign-in' + }) + } + }) + + it('escalates copy and roster CTA once refreshingSinceMs ages out', () => { + const since = Date.now() - CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS + const p = claudeError('stale-token', since) + + expect(getProviderUsageStatusLabel(p)).toBe('Sign-in needs refresh') + expect(getProviderUsageErrorMessage(p)).toMatch(/could not be refreshed/i) + expect(getUsageRosterRowState(p, false)).toEqual({ + kind: 'sign-in', + statusLabel: 'Sign-in needs refresh' + }) + }) + + it('preserves the first refresh stamp so continuous polls can escalate', () => { + // Keep the synthetic clock near Date.now() so label helpers (which read wall clock) stay in-window. + const t0 = Date.now() - 90_000 + let state = stampClaudeRefreshingSince(claudeError('stale-token'), null, t0) + for (let i = 1; i <= 5; i++) { + state = stampClaudeRefreshingSince( + claudeError(i % 2 === 0 ? 'stale-token' : 'refreshable-credentials-without-token'), + state, + t0 + i * 5_000 + ) + } + expect(state.usageMetadata?.refreshingSinceMs).toBe(t0) + // ~90s + 25s still under the 2-minute escalate window. + expect(getProviderUsageStatusLabel(state)).toBe('Refreshing sign-in') + + const escalated = claudeError( + 'stale-token', + Date.now() - CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS + ) + expect(getProviderUsageStatusLabel(escalated)).toBe('Sign-in needs refresh') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index dff1d178034e..d383de42bb5e 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -1,4 +1,5 @@ import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { isClaudeRefreshingSignInEscalated } from '../../../../shared/claude-refreshing-sign-in' import { translate } from '@/i18n/i18n' export function getProviderDisplayName(provider: ProviderRateLimits['provider']): string { @@ -84,6 +85,13 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { return translate('auto.components.status.bar.tooltip.f90b3d7a16', 'Run Kimi to refresh') } if (p.provider === 'claude') { + // Why: #8974 — in-flight repair stays soft; once the stamp ages out, stop implying progress. + if (isClaudeRefreshingSignInEscalated(p)) { + return translate( + 'auto.components.status.bar.usage-error-copy.3f8f498baa', + 'Sign-in needs refresh' + ) + } switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': return translate( @@ -144,6 +152,12 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { ) } if (p.provider === 'claude') { + if (isClaudeRefreshingSignInEscalated(p)) { + return translate( + 'auto.components.status.bar.usage-error-copy.1c37de1668', + 'Claude usage sign-in could not be refreshed. Agent sessions may still be signed in — re-authenticate to restore usage tracking.' + ) + } switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': return translate( diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts index b63a9a3ad5b1..c2fddd62c3c5 100644 --- a/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts @@ -33,7 +33,7 @@ describe('getUsageRosterRowState', () => { provider({ status: 'error', error: 'OAuth token is stale', - usageMetadata: { failureKind: 'stale-token' } + usageMetadata: { failureKind: 'stale-token', refreshingSinceMs: Date.now() } }), false ) @@ -50,6 +50,23 @@ describe('getUsageRosterRowState', () => { ).toEqual({ kind: 'error', statusLabel: 'Network issue' }) }) + it('offers sign-in after Claude refreshing sign-in has been stuck past the escalate window', () => { + expect( + getUsageRosterRowState( + provider({ + status: 'error', + error: 'OAuth token is stale', + usageMetadata: { + failureKind: 'stale-token', + // Well past CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS (2 minutes). + refreshingSinceMs: Date.now() - 5 * 60 * 1000 + } + }), + false + ) + ).toEqual({ kind: 'sign-in', statusLabel: 'Sign-in needs refresh' }) + }) + it('offers sign-in only for confirmed signed-out failures', () => { expect( getUsageRosterRowState( diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.ts index 687623275994..bc5be4fb0065 100644 --- a/src/renderer/src/components/status-bar/usage-roster-row-state.ts +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.ts @@ -1,5 +1,6 @@ import { translate } from '@/i18n/i18n' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { isClaudeRefreshingSignInEscalated } from '../../../../shared/claude-refreshing-sign-in' import { getProviderUsageStatusLabel } from './usage-error-copy' export type UsageRosterRowState = { @@ -55,6 +56,13 @@ export function getUsageRosterRowState( ) } } + // Why: #8974 — after the refresh window ages out, offer re-auth instead of a permanent spinner. + if (isClaudeRefreshingSignInEscalated(provider)) { + return { + kind: 'sign-in', + statusLabel: getProviderUsageStatusLabel(provider) + } + } if (provider.status === 'error') { return { kind: 'error', statusLabel: getProviderUsageStatusLabel(provider) } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b32729987a1..947bf096f84b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4029,6 +4029,10 @@ "contact_note": "The host may still be running; only the Orca connection is unavailable.", "last_connected": "Last connected {{value0}}", "reconnect_attempt": "Attempt {{value0}}" + }, + "usage-error-copy": { + "3f8f498baa": "Sign-in needs refresh", + "1c37de1668": "Claude usage sign-in could not be refreshed. Agent sessions may still be signed in — re-authenticate to restore usage tracking." } } }, diff --git a/src/shared/claude-refreshing-sign-in.test.ts b/src/shared/claude-refreshing-sign-in.test.ts new file mode 100644 index 000000000000..d6890e851d3d --- /dev/null +++ b/src/shared/claude-refreshing-sign-in.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import type { ProviderRateLimits } from './rate-limit-types' +import { + CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS, + isClaudeRefreshingSignInEscalated, + stampClaudeRefreshingSince +} from './claude-refreshing-sign-in' + +function claudeError( + failureKind: string, + overrides: Partial = {} +): ProviderRateLimits { + return { + provider: 'claude', + status: 'error', + error: 'token expired', + updatedAt: Date.now(), + session: null, + weekly: null, + monthly: null, + usageMetadata: { failureKind: failureKind as never }, + ...overrides + } +} + +describe('stampClaudeRefreshingSince', () => { + it('stamps refreshingSinceMs on first Claude refreshing failure', () => { + const now = 1_700_000_000_000 + const stamped = stampClaudeRefreshingSince(claudeError('stale-token'), null, now) + expect(stamped.usageMetadata?.refreshingSinceMs).toBe(now) + }) + + it('preserves the original stamp across continuous refreshing failures', () => { + const first = 1_700_000_000_000 + const later = first + 30_000 + const previous = stampClaudeRefreshingSince(claudeError('stale-token'), null, first) + const next = stampClaudeRefreshingSince( + claudeError('refreshable-credentials-without-token'), + previous, + later + ) + expect(next.usageMetadata?.refreshingSinceMs).toBe(first) + }) + + it('clears the stamp when usage recovers', () => { + const previous = stampClaudeRefreshingSince( + claudeError('stale-token'), + null, + 1_700_000_000_000 + ) + const ok: ProviderRateLimits = { + provider: 'claude', + status: 'ok', + error: null, + updatedAt: Date.now(), + session: { usedPercent: 10, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + usageMetadata: { + source: 'oauth', + refreshingSinceMs: previous.usageMetadata?.refreshingSinceMs + } + } + const recovered = stampClaudeRefreshingSince(ok, previous) + expect(recovered.usageMetadata?.refreshingSinceMs).toBeUndefined() + }) + + it('does not stamp non-Claude providers', () => { + const p: ProviderRateLimits = { + provider: 'codex', + status: 'error', + error: 'token expired', + updatedAt: Date.now(), + session: null, + weekly: null, + usageMetadata: { failureKind: 'stale-token' } + } + expect(stampClaudeRefreshingSince(p, null).usageMetadata?.refreshingSinceMs).toBeUndefined() + }) +}) + +describe('isClaudeRefreshingSignInEscalated', () => { + it('is false while the refresh window is still open', () => { + const since = 1_700_000_000_000 + const p = stampClaudeRefreshingSince(claudeError('stale-token'), null, since) + expect( + isClaudeRefreshingSignInEscalated(p, since + CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS - 1) + ).toBe(false) + }) + + it('is true once the refresh window ages out (#8974)', () => { + const since = 1_700_000_000_000 + const p = stampClaudeRefreshingSince(claudeError('stale-token'), null, since) + expect( + isClaudeRefreshingSignInEscalated(p, since + CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS) + ).toBe(true) + }) +}) diff --git a/src/shared/claude-refreshing-sign-in.ts b/src/shared/claude-refreshing-sign-in.ts new file mode 100644 index 000000000000..95c091d0df69 --- /dev/null +++ b/src/shared/claude-refreshing-sign-in.ts @@ -0,0 +1,83 @@ +import type { ProviderRateLimits, UsageRateLimitFailureKind } from './rate-limit-types' + +/** Failure kinds that surface as "Refreshing sign-in" while Orca tries in-app repair. */ +export const CLAUDE_REFRESHING_FAILURE_KINDS = [ + 'stale-token', + 'refreshable-credentials-without-token', + 'delegated-refresh-required' +] as const satisfies readonly UsageRateLimitFailureKind[] + +export type ClaudeRefreshingFailureKind = (typeof CLAUDE_REFRESHING_FAILURE_KINDS)[number] + +// Why: short enough to stop the chip from looking stuck forever, long enough that +// a successful in-app OAuth/CLI repair can finish without flapping into re-auth. +export const CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS = 2 * 60 * 1000 + +export function isClaudeRefreshingFailureKind( + kind: UsageRateLimitFailureKind | undefined +): kind is ClaudeRefreshingFailureKind { + return ( + kind != null && CLAUDE_REFRESHING_FAILURE_KINDS.includes(kind as ClaudeRefreshingFailureKind) + ) +} + +/** + * Stamp / preserve when Claude usage first entered a transient "refreshing" failure. + * Continuous polls must keep the original timestamp so the UI can escalate. + */ +export function stampClaudeRefreshingSince( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null, + now = Date.now() +): ProviderRateLimits { + if (fresh.provider !== 'claude') { + return fresh + } + + const kind = fresh.usageMetadata?.failureKind + if (fresh.status !== 'error' || !isClaudeRefreshingFailureKind(kind)) { + if (fresh.usageMetadata?.refreshingSinceMs == null) { + return fresh + } + const { refreshingSinceMs: _drop, ...restMeta } = fresh.usageMetadata + return { + ...fresh, + usageMetadata: restMeta + } + } + + const previousContinuous = + previous?.provider === 'claude' && + previous.status === 'error' && + isClaudeRefreshingFailureKind(previous.usageMetadata?.failureKind) + + const refreshingSinceMs = + previousContinuous && previous.usageMetadata?.refreshingSinceMs != null + ? previous.usageMetadata.refreshingSinceMs + : (fresh.usageMetadata?.refreshingSinceMs ?? now) + + return { + ...fresh, + usageMetadata: { + ...fresh.usageMetadata, + refreshingSinceMs + } + } +} + +export function isClaudeRefreshingSignInEscalated( + provider: ProviderRateLimits, + now = Date.now() +): boolean { + if (provider.provider !== 'claude' || provider.status !== 'error') { + return false + } + if (!isClaudeRefreshingFailureKind(provider.usageMetadata?.failureKind)) { + return false + } + const since = provider.usageMetadata?.refreshingSinceMs + if (since == null) { + return false + } + return now - since >= CLAUDE_REFRESHING_SIGN_IN_ESCALATE_AFTER_MS +} diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5744e3e67497..068caf004740 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -43,6 +43,12 @@ export type UsageRateLimitMetadata = { lastSuccessfulSource?: UsageRateLimitSource /** Unix ms timestamp before which usage refetches should not be attempted (from HTTP Retry-After). */ retryAtMs?: number + /** + * Unix ms when Claude usage first entered a transient "refreshing sign-in" failure + * (`stale-token` / `refreshable-credentials-without-token` / `delegated-refresh-required`). + * Preserved across repeated failed polls so the UI can escalate past an indefinite spinner (#8974). + */ + refreshingSinceMs?: number } export type ProviderRateLimits = {