Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 29 additions & 22 deletions src/main/rate-limits/service/service-result-policy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
})
})
14 changes: 14 additions & 0 deletions src/renderer/src/components/status-bar/usage-error-copy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
switch (p.usageMetadata?.failureKind) {
case 'deferred-by-live-session':
return translate(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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) }
}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
97 changes: 97 additions & 0 deletions src/shared/claude-refreshing-sign-in.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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)
})
})
Loading