Files
steipete--codexbar/Sources/CodexBar/UsageStore+Refresh.swift
T
2026-07-13 12:22:33 +08:00

1138 lines
51 KiB
Swift

import CodexBarCore
import Foundation
extension UsageStore {
nonisolated static func codexSessionQuotaOwnerKey(
for refreshGuard: CodexAccountScopedRefreshGuard?) -> CodexSessionQuotaOwnerKey?
{
guard let refreshGuard else { return nil }
return CodexSessionQuotaOwnerKey(refreshGuard: refreshGuard)
}
nonisolated static func codexSessionQuotaOwnersMatch(
_ lhs: CodexAccountScopedRefreshGuard?,
_ rhs: CodexAccountScopedRefreshGuard?) -> Bool
{
guard let lhsKey = self.codexSessionQuotaOwnerKey(for: lhs),
let rhsKey = self.codexSessionQuotaOwnerKey(for: rhs)
else {
return false
}
return lhsKey == rhsKey
}
private struct ProviderRefreshOutcomeContext {
let generation: UInt64
let codexExpectedGuard: CodexAccountScopedRefreshGuard?
let tokenAccount: ProviderTokenAccount?
let codexLimitResetOwnerKey: CodexLimitResetOwnerKey?
let claudeOAuthHistoryPersistentRefHash: String?
let claudeOAuthActiveAccountObservation: ClaudeOAuthActiveAccountObservation
var codexSessionQuotaOwnerKey: CodexSessionQuotaOwnerKey? {
UsageStore.codexSessionQuotaOwnerKey(for: self.codexExpectedGuard)
}
}
private struct CodexRefreshPublicationPreparation {
let expectedGuard: CodexAccountScopedRefreshGuard
let limitResetOwnerKey: CodexLimitResetOwnerKey?
let previousSnapshot: UsageSnapshot?
let missingWindowBackfillSnapshot: UsageSnapshot?
}
static func commandCodeSnapshotResolvingDepletionOnEnrichmentFailure(
current: UsageSnapshot,
previous: UsageSnapshot?) -> UsageSnapshot
{
let previousProvesPaidDepletion = previous?.commandCodeHasSubscriptionPlan == true ||
(previous?.commandCodeSubscriptionEnrichmentUnavailable == true &&
previous?.commandCodeMonthlyGrantDepleted == true &&
previous?.primary?.usedPercent == 100)
guard current.commandCodeSubscriptionEnrichmentUnavailable,
current.commandCodeMonthlyGrantDepleted,
previousProvesPaidDepletion,
let previousPrimary = previous?.primary
else {
return current
}
let depleted = RateWindow(
usedPercent: 100,
windowMinutes: previousPrimary.windowMinutes,
resetsAt: previousPrimary.resetsAt,
resetDescription: previousPrimary.resetDescription)
return current.with(primary: depleted, secondary: current.secondary)
}
func refreshForSettingsChange() async {
await self.runRefresh(
startupConnectivityRetryAttempt: nil,
coalesceProviderRefreshesOverride: false,
waitForRefreshAvailability: true)
}
func prepareRefreshState(for provider: UsageProvider? = nil) {
guard provider == nil || provider == .codex else { return }
_ = self.settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()
}
/// Force refresh Augment session (called from UI button)
func forceRefreshAugmentSession() async {
await self.performRuntimeAction(.forceSessionRefresh, for: .augment)
}
private func providerRefreshSpec(_ provider: UsageProvider) async -> ProviderSpec? {
if let override = self._test_providerRefreshOverride {
await override(provider)
return nil
}
return self.providerSpecs[provider]
}
func refreshProvider(
_ provider: UsageProvider,
allowDisabled: Bool = false,
coalesceIfRefreshing: Bool = false) async
{
// Codex source reconciliation can persist a settings correction. Perform it before
// capturing the publication revision so the request cannot invalidate itself.
self.prepareRefreshState(for: provider)
while coalesceIfRefreshing,
let existingState = self.providerRefreshCoordinator.coalescingState(for: provider)
{
switch await self.providerRefreshCoordinator.wait(for: provider, state: existingState) {
case .cancelled:
return
case .retryRequired:
self.providerRefreshCoordinator.remove(existingState, for: provider)
continue
case .completed:
return
}
}
let request = self.providerRefreshCoordinator.beginReplacingRequest(for: provider)
self.providerRefreshPublicationContexts[provider] = (
generation: request.generation,
enablementRevision: self.settings.providerEnablementRevision(for: provider),
configRevision: self.settings.providerConfigRevision(for: provider),
allowDisabled: allowDisabled)
let task = Task { @MainActor [weak self] in
guard let self else { return }
var snapshotUpdatedAtBeforeRefresh: Date?
var didStartRefresh = false
for predecessorState in request.predecessorStates {
await predecessorState.waitForTaskCompletion()
}
if !Task.isCancelled,
self.providerRefreshCoordinator.isCurrent(request.generation, for: provider)
{
// A replacement can wait behind a predecessor while Settings changes. Capture
// the publication inputs at actual fetch start so that queued work uses the new
// configuration, while later changes still reject its suspended result.
self.providerRefreshPublicationContexts[provider] = (
generation: request.generation,
enablementRevision: self.settings.providerEnablementRevision(for: provider),
configRevision: self.settings.providerConfigRevision(for: provider),
allowDisabled: allowDisabled)
snapshotUpdatedAtBeforeRefresh = self.snapshot(for: provider)?.updatedAt
didStartRefresh = true
await self.refreshProviderTracked(
provider,
allowDisabled: allowDisabled,
generation: request.generation)
}
let publishedNewSnapshot = didStartRefresh &&
self.snapshot(for: provider)?.updatedAt != snapshotUpdatedAtBeforeRefresh
let retryRequired = !publishedNewSnapshot &&
(Task.isCancelled || !self.isCurrentProviderRefreshGeneration(
provider,
generation: request.generation))
self.providerRefreshCoordinator.complete(
request.state,
for: provider,
retryRequired: retryRequired)
}
request.state.install(task: task)
_ = await self.providerRefreshCoordinator.wait(for: provider, state: request.state)
}
func isCurrentProviderRefreshGeneration(_ provider: UsageProvider, generation: UInt64?) -> Bool {
guard let generation else { return true }
guard self.providerRefreshCoordinator.isCurrent(generation, for: provider),
let context = self.providerRefreshPublicationContexts[provider],
context.generation == generation
else {
return false
}
return context.enablementRevision == self.settings.providerEnablementRevision(for: provider) &&
context.configRevision == self.settings.providerConfigRevision(for: provider)
}
func currentProviderRefreshAllowsDisabledPublication(_ provider: UsageProvider) -> Bool {
guard let context = self.providerRefreshPublicationContexts[provider],
context.allowDisabled,
let state = self.providerRefreshCoordinator.coalescingState(for: provider),
state.generation == context.generation
else {
return false
}
return true
}
private func refreshProviderTracked(
_ provider: UsageProvider,
allowDisabled: Bool,
generation: UInt64) async
{
if self.providerRefreshCoordinator.beginActivity(for: provider) {
self.refreshingProviders.insert(provider)
}
defer {
if self.providerRefreshCoordinator.endActivity(for: provider) {
self.refreshingProviders.remove(provider)
}
}
await self.refreshProviderNow(
provider,
allowDisabled: allowDisabled,
generation: generation)
}
private func prepareCodexRefreshPublication() -> CodexRefreshPublicationPreparation {
let previousGuard = self.lastCodexUsagePublicationGuard
let expectedGuard = self.freshCodexAccountScopedRefreshGuard()
let hydrationCandidates = self.codexAccountSnapshots
let projection = self.settings.codexVisibleAccountProjection
let visibleAccounts = projection.visibleAccounts
let ownerKey = self.codexLimitResetOwnerKey(
expectedGuard: expectedGuard,
visibleAccounts: visibleAccounts)
let previousOwnerKey = previousGuard.flatMap {
CodexLimitResetOwnerKey(identity: $0.identity, accountEmail: $0.accountKey)
}
let ownerMatchesPrevious = ownerKey != nil && ownerKey == previousOwnerKey
self.reconcileCodexAccountStateForUsageOwner(expectedGuard)
let hydratedPrior: CodexAccountUsageSnapshot? = {
guard let ownerKey, let activeVisibleAccountID = projection.activeVisibleAccountID else { return nil }
let matches = hydrationCandidates.filter { row in
row.snapshot != nil &&
row.id == activeVisibleAccountID &&
self.codexLimitResetOwnerKey(
forVisibleAccount: row.account,
visibleAccounts: visibleAccounts) == ownerKey
}
guard matches.count == 1 else { return nil }
return matches[0]
}()
if self.snapshots[.codex] == nil,
let hydratedPrior,
let hydratedSnapshot = hydratedPrior.snapshot
{
self.snapshots[.codex] = hydratedSnapshot
self.lastKnownResetSnapshots[.codex] = hydratedSnapshot
self.errors[.codex] = hydratedPrior.error
self.lastSourceLabels[.codex] = hydratedPrior.sourceLabel
self.lastCodexUsagePublicationGuard = expectedGuard
self.lastCodexAccountScopedRefreshGuard = expectedGuard
}
var trustedCandidates = ownerMatchesPrevious
? [self.snapshots[.codex], self.lastKnownResetSnapshots[.codex]].compactMap(\.self)
: []
if let hydratedSnapshot = hydratedPrior?.snapshot {
trustedCandidates.append(hydratedSnapshot)
}
let weeklyCandidates = trustedCandidates.filter {
CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: $0) != nil
}
let previousSnapshot = (weeklyCandidates.isEmpty ? trustedCandidates : weeklyCandidates)
.max { $0.updatedAt < $1.updatedAt }
let missingWindowBackfillSnapshot = Self.codexMergedResetBackfillSnapshot(trustedCandidates)
return CodexRefreshPublicationPreparation(
expectedGuard: expectedGuard,
limitResetOwnerKey: ownerKey,
previousSnapshot: previousSnapshot,
missingWindowBackfillSnapshot: missingWindowBackfillSnapshot)
}
private func refreshProviderNow(
_ provider: UsageProvider,
allowDisabled: Bool,
generation: UInt64) async
{
guard let spec = await self.providerRefreshSpec(provider) else { return }
guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return }
let codexPreparation = provider == .codex ? self.prepareCodexRefreshPublication() : nil
let codexExpectedGuard = codexPreparation?.expectedGuard
let codexLimitResetOwnerKey = codexPreparation?.limitResetOwnerKey
if !spec.isEnabled(), !allowDisabled {
await self.clearDisabledProviderRefreshState(provider)
return
}
if provider == .codex, self.shouldFetchAllCodexVisibleAccounts() {
await self.refreshCodexVisibleAccountsForMenu(generation: generation)
return
} else if provider == .codex {
self.codexAccountSnapshots = []
}
if provider == .kilo, self.shouldFanOutKiloScopes() {
await self.refreshKiloScopes(generation: generation)
guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return }
// Continue to also fetch the personal snapshot through the regular path
// so the existing single-card render keeps working when only personal is shown.
// The presence of multi-element kiloScopeSnapshots triggers stacked rendering.
} else if provider == .kilo {
await MainActor.run { self.kiloScopeSnapshots = [] }
}
if provider == .claude {
self.scheduleClaudeSwapAccountRefresh(generation: generation)
}
let tokenAccounts = self.tokenAccounts(for: provider)
if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) {
await self.refreshTokenAccounts(
provider: provider,
accounts: tokenAccounts,
generation: generation)
return
} else {
_ = await MainActor.run {
self.reconcileSelectedTokenAccountSnapshotBeforeRefresh(
provider: provider,
accounts: tokenAccounts)
}
}
let claudeAuthStateBeforeFetch = provider == .claude
? await Self.captureClaudeRefreshAuthState(invalidateCredentialsFile: true)
: nil
let tokenAccount = self.settings.selectedTokenAccount(for: provider)
let fetchContext = self.makeFetchContext(provider: provider, override: nil)
let descriptor = spec.descriptor
let codexResetCreditsFetcher = self.codexResetCreditsFetcher()
let previousCodexSnapshot = codexPreparation?.previousSnapshot
let codexMissingWindowBackfillSnapshot = codexPreparation?.missingWindowBackfillSnapshot
let fetchOutcome: @Sendable () async -> ProviderFetchOutcome = {
let outcome = await descriptor.fetchOutcome(context: fetchContext)
guard provider == .codex else { return outcome }
return await Self.attachingCodexResetCreditsIfNeeded(
to: outcome,
env: fetchContext.env,
fetcher: codexResetCreditsFetcher)
}
// Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness.
let initialOutcome: ProviderFetchOutcome = if let override = self._test_providerFetchOutcomeOverride {
await override(provider)
} else {
await withTaskGroup(
of: ProviderFetchOutcome.self,
returning: ProviderFetchOutcome.self)
{ group in
group.addTask(operation: fetchOutcome)
return await group.next()!
}
}
let outcome: ProviderFetchOutcome
if provider == .codex {
if case let .success(result) = initialOutcome.result,
let codexExpectedGuard,
!self.shouldApplyCodexUsageResult(
expectedGuard: codexExpectedGuard,
usage: result.usage.scoped(to: .codex))
{
self.retireCodexStateIfRefreshOwnerChanged(
expectedGuard: codexExpectedGuard,
generation: generation)
return
}
guard let admittedOutcome = await Self.codexOutcomeAdmittedForPublication(
initialOutcome: initialOutcome,
previousSnapshot: previousCodexSnapshot,
missingWindowBackfillSnapshot: codexMissingWindowBackfillSnapshot,
fetchConfirmation: fetchOutcome)
else {
if let codexExpectedGuard {
self.retireCodexStateIfRefreshOwnerChanged(
expectedGuard: codexExpectedGuard,
generation: generation)
}
return
}
if case let .success(result) = admittedOutcome.result,
let codexExpectedGuard,
!self.shouldApplyCodexUsageResult(
expectedGuard: codexExpectedGuard,
usage: result.usage.scoped(to: .codex))
{
self.retireCodexStateIfRefreshOwnerChanged(
expectedGuard: codexExpectedGuard,
generation: generation)
return
}
outcome = admittedOutcome
} else {
outcome = initialOutcome
}
let claudeHistoryAccountState = provider == .claude
? await Self.captureClaudeHistoryAccountState()
: nil
let claudeAuthFingerprintAfterFetch = claudeHistoryAccountState?.fingerprintToken
let claudeAuthChangedDuringFetch = Self.claudeAuthChangedDuringFetch(
provider: provider,
beforeFetch: claudeAuthStateBeforeFetch,
afterFetchFingerprintToken: claudeAuthFingerprintAfterFetch)
await Self.invalidateClaudeCredentialsFileCacheIfNeeded(changedDuringFetch: claudeAuthChangedDuringFetch)
let claudeCredentialsChanged = Self.claudeCredentialsChanged(
beforeFetch: claudeAuthStateBeforeFetch,
changedDuringFetch: claudeAuthChangedDuringFetch)
let shouldConsumeClaudeKeychainFingerprint = Self.shouldConsumeClaudeKeychainFingerprintChange(
beforeFetch: claudeAuthStateBeforeFetch,
changedDuringFetch: claudeAuthChangedDuringFetch)
let claudeOAuthHistoryPersistentRefHash = Self.stableClaudeKeychainPersistentRefHash(
beforeFetch: claudeAuthStateBeforeFetch,
afterFetchFingerprintToken: claudeAuthFingerprintAfterFetch,
afterFetchPersistentRefHash: claudeHistoryAccountState?.keychainPersistentRefHash,
accountStateWasStable: claudeHistoryAccountState?.wasStable == true)
let claudeOAuthActiveAccountObservation = Self.claudeOAuthActiveAccountObservation(
beforeFetch: claudeAuthStateBeforeFetch,
afterFetch: claudeHistoryAccountState)
// Credential detection consumes change markers. Clean up before rejecting a superseded generation;
// replacement refreshes wait for their predecessor, so they cannot race this state reset.
if claudeCredentialsChanged {
await self.clearClaudeCredentialDerivedStateForCredentialSwap()
}
if shouldConsumeClaudeKeychainFingerprint {
_ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt()
}
guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return }
await self.applyProviderRefreshOutcome(
provider: provider,
outcome: outcome,
context: ProviderRefreshOutcomeContext(
generation: generation,
codexExpectedGuard: codexExpectedGuard,
tokenAccount: tokenAccount,
codexLimitResetOwnerKey: codexLimitResetOwnerKey,
claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash,
claudeOAuthActiveAccountObservation: claudeOAuthActiveAccountObservation))
}
private func applyProviderRefreshOutcome(
provider: UsageProvider,
outcome: ProviderFetchOutcome,
context: ProviderRefreshOutcomeContext) async
{
switch outcome.result {
case let .success(result):
await self.applyProviderRefreshSuccess(
provider: provider,
result: result,
attempts: outcome.attempts,
context: context)
case let .failure(error):
await self.applyProviderRefreshFailure(
provider: provider,
error: error,
attempts: outcome.attempts,
context: context)
}
}
private func applyProviderRefreshSuccess(
provider: UsageProvider,
result: ProviderFetchResult,
attempts: [ProviderFetchAttempt],
context: ProviderRefreshOutcomeContext) async
{
let rawScoped = result.usage.scoped(to: provider)
if provider == .codex,
let codexExpectedGuard = context.codexExpectedGuard,
!self.shouldApplyCodexUsageResult(expectedGuard: codexExpectedGuard, usage: rawScoped)
{
self.retireCodexStateIfRefreshOwnerChanged(
expectedGuard: codexExpectedGuard,
generation: context.generation)
return
}
let scoped = Self.codexUsageWithExpectedEmailIfMissing(
provider: provider,
usage: rawScoped,
expectedGuard: context.codexExpectedGuard)
let currentTokenAccount = context.tokenAccount.flatMap { account in
self.uniqueTokenAccount(provider: provider, accountID: account.id)
}
if context.tokenAccount != nil, currentTokenAccount == nil {
return
}
let accountScoped = if let tokenAccount = currentTokenAccount {
self.applyAccountLabel(scoped, provider: provider, account: tokenAccount)
} else {
scoped
}
let backfilled = await MainActor.run { () -> UsageSnapshot? in
guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else {
return nil
}
if provider == .codex,
let codexExpectedGuard = context.codexExpectedGuard,
!self.shouldApplyCodexUsageResult(expectedGuard: codexExpectedGuard, usage: rawScoped)
{
self.retireCodexStateIfRefreshOwnerChanged(
expectedGuard: codexExpectedGuard,
generation: context.generation)
return nil
}
self.lastFetchAttempts[provider] = attempts
let resetBackfillSource = if provider == .codex {
context.codexLimitResetOwnerKey == nil
? nil
: self.codexLastKnownResetSnapshot(matching: context.codexExpectedGuard)
} else {
self.lastKnownResetSnapshots[provider]
}
let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure(
current: accountScoped,
previous: self.snapshots[provider])
let backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource)
let predictivePaceWarningAccountDiscriminatorOverride: String? = if provider == .claude {
Self.predictivePaceWarningClaudeAccountDiscriminator(
strategyKind: result.strategyKind,
observation: context.claudeOAuthActiveAccountObservation,
oauthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier)
} else {
nil
}
self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled)
self.handleSessionQuotaTransition(
provider: provider,
snapshot: backfilled,
codexOwnerKey: provider == .codex ? context.codexSessionQuotaOwnerKey : nil)
self.handlePredictivePaceWarningTransitions(
provider: provider,
snapshot: backfilled,
accountDiscriminatorOverride: predictivePaceWarningAccountDiscriminatorOverride)
if provider == .codex {
self.handleCodexResetCreditNotifications(snapshot: backfilled)
}
self.lastKnownResetSnapshots[provider] = backfilled
self.snapshots[provider] = backfilled
if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) {
self.tokenSnapshots[provider] = tokenSnapshot
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.recordSuccess()
} else if Self.tokenCostRequiresProviderSnapshot(provider) {
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
}
self.lastSourceLabels[provider] = result.sourceLabel
self.errors[provider] = nil
if let tokenAccount = currentTokenAccount {
self.cacheTokenAccountSnapshot(
provider: provider,
account: tokenAccount,
snapshot: backfilled,
sourceLabel: result.sourceLabel)
}
if provider == .gemini {
self.clearGeminiConsumerTierDeprecationObservation()
}
self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider)
self.failureGates[provider]?.recordSuccess()
if provider == .codex {
self.rememberLiveSystemCodexEmailIfNeeded(scoped.accountEmail(for: .codex))
self.seedCodexAccountScopedRefreshGuard(accountEmail: scoped.accountEmail(for: .codex))
self.lastCodexUsagePublicationGuard = self.lastCodexAccountScopedRefreshGuard
self.persistSingleCodexAccountSnapshot(
backfilled,
sourceLabel: result.sourceLabel,
expectedGuard: context.codexExpectedGuard,
expectedOwnerKey: context.codexLimitResetOwnerKey)
}
return backfilled
}
guard let backfilled else { return }
let isClaudeOAuthSample = provider == .claude
&& result.strategyKind == .oauth
let claudeOAuthPersistentRefHash: String? = if isClaudeOAuthSample,
result.claudeOAuthKeychainPersistentRefHash == context
.claudeOAuthHistoryPersistentRefHash
{
result.claudeOAuthKeychainPersistentRefHash
} else {
nil
}
await self.recordPlanUtilizationHistorySample(
provider: provider,
snapshot: backfilled,
claudeOAuthPersistentRefHash: claudeOAuthPersistentRefHash,
claudeOAuthHistoryOwnerIdentifier: isClaudeOAuthSample
? result.claudeOAuthHistoryOwnerIdentifier
: nil,
claudeOAuthKeychainCredentialMismatch: isClaudeOAuthSample
&& result.claudeOAuthKeychainCredentialMismatch,
claudeOAuthKeychainCredentialAbsent: isClaudeOAuthSample
&& result.claudeOAuthKeychainCredentialAbsent,
claudeOAuthKeychainCredentialUnavailable: isClaudeOAuthSample
&& (result.claudeOAuthKeychainCredentialUnavailable
|| (result.claudeOAuthKeychainPersistentRefHash != nil
&& claudeOAuthPersistentRefHash == nil)),
claudeOAuthActiveAccountObservation: context.claudeOAuthActiveAccountObservation,
isClaudeOAuthSample: isClaudeOAuthSample,
codexLimitResetOwnerKey: context.codexLimitResetOwnerKey)
guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return }
if let runtime = self.providerRuntimes[provider] {
let runtimeContext = ProviderRuntimeContext(
provider: provider, settings: self.settings, store: self)
runtime.providerDidRefresh(context: runtimeContext, provider: provider)
}
if provider == .codex {
self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled)
}
}
private func applyProviderRefreshFailure(
provider: UsageProvider,
error: Error,
attempts: [ProviderFetchAttempt],
context: ProviderRefreshOutcomeContext) async
{
if provider == .codex,
let codexExpectedGuard = context.codexExpectedGuard,
!self.shouldApplyCodexScopedFailure(expectedGuard: codexExpectedGuard)
{
self.retireCodexStateIfRefreshOwnerChanged(
expectedGuard: codexExpectedGuard,
generation: context.generation)
return
}
// Credential-change cleanup already ran above; cancellation is now safe to suppress.
guard !Self.errorIsCancellation(error) else { return }
guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return }
self.bindCodexFailurePublicationOwner(
provider: provider,
expectedGuard: context.codexExpectedGuard)
self.lastFetchAttempts[provider] = attempts
self.recordStartupConnectivityRetryableFailure(error)
await self.handleProviderFetchFailure(
provider: provider,
error: error,
generation: context.generation)
}
private func bindCodexFailurePublicationOwner(
provider: UsageProvider,
expectedGuard: CodexAccountScopedRefreshGuard?)
{
guard provider == .codex, let expectedGuard else { return }
self.lastCodexUsagePublicationGuard = expectedGuard
}
private func retireCodexStateIfRefreshOwnerChanged(
expectedGuard: CodexAccountScopedRefreshGuard,
generation: UInt64)
{
guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return }
let currentGuard = self.freshCodexAccountScopedRefreshGuard()
guard !Self.codexScopedRefreshGuardsMatchAccount(expectedGuard, currentGuard) else { return }
self.reconcileCodexAccountStateForUsageOwner(currentGuard)
}
private nonisolated static func codexUsageWithExpectedEmailIfMissing(
provider: UsageProvider,
usage: UsageSnapshot,
expectedGuard: CodexAccountScopedRefreshGuard?) -> UsageSnapshot
{
guard provider == .codex,
CodexIdentityResolver.normalizeEmail(usage.accountEmail(for: .codex)) == nil,
let accountEmail = CodexIdentityResolver.normalizeEmail(expectedGuard?.accountKey)
else {
return usage
}
let identity = usage.identity(for: .codex)
return usage.withIdentity(ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: accountEmail,
accountOrganization: identity?.accountOrganization,
loginMethod: identity?.loginMethod))
}
private func persistSingleCodexAccountSnapshot(
_ snapshot: UsageSnapshot,
sourceLabel: String,
expectedGuard: CodexAccountScopedRefreshGuard?,
expectedOwnerKey: CodexLimitResetOwnerKey?)
{
guard let expectedGuard,
let expectedOwnerKey
else { return }
let currentGuard = self.freshCodexAccountScopedRefreshGuard()
guard Self.codexScopedRefreshGuardsMatchAccount(expectedGuard, currentGuard),
let currentOwnerKey = CodexLimitResetOwnerKey(
identity: currentGuard.identity,
accountEmail: currentGuard.accountKey),
currentOwnerKey == expectedOwnerKey
else { return }
let visibleAccounts = self.freshCodexVisibleAccountsForSnapshotHydration()
let activeMatches = visibleAccounts.filter {
$0.isActive &&
$0.selectionSource == currentGuard.source &&
CodexIdentityResolver.normalizeEmail($0.email) == currentGuard.accountKey
}
guard activeMatches.count == 1,
let account = activeMatches.first,
let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)),
snapshotEmail == CodexIdentityResolver.normalizeEmail(currentGuard.accountKey),
snapshotEmail == CodexIdentityResolver.normalizeEmail(account.email),
self.codexLimitResetOwnerKey(
forVisibleAccount: account,
visibleAccounts: visibleAccounts) == currentOwnerKey
else { return }
let identity = snapshot.identity(for: .codex)
let relabeled = snapshot.withIdentity(ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: account.email,
accountOrganization: identity?.accountOrganization,
loginMethod: identity?.loginMethod ?? account.workspaceLabel))
let currentSnapshots = [CodexAccountUsageSnapshot(
account: account,
snapshot: relabeled,
error: nil,
sourceLabel: sourceLabel)]
self.codexAccountSnapshots = currentSnapshots
self.codexAccountUsageSnapshotStore?.store(currentSnapshots)
}
private func clearDisabledProviderRefreshState(_ provider: UsageProvider) async {
self.clearProviderRuntimeState(provider)
}
private struct ClaudeRefreshAuthState {
let fingerprintToken: String
let credentialsFileChanged: Bool
let keychainFingerprintChanged: Bool
let keychainPersistentRefHash: String?
let activeAccountIdentity: String?
let accountStateWasStable: Bool
}
private struct ClaudeHistoryAccountState {
let fingerprintToken: String
let keychainPersistentRefHash: String?
let activeAccountIdentity: String?
let wasStable: Bool
}
private nonisolated static func claudeCredentialsChanged(
beforeFetch: ClaudeRefreshAuthState?,
changedDuringFetch: Bool) -> Bool
{
beforeFetch?.credentialsFileChanged == true ||
beforeFetch?.keychainFingerprintChanged == true ||
changedDuringFetch
}
private nonisolated static func shouldConsumeClaudeKeychainFingerprintChange(
beforeFetch: ClaudeRefreshAuthState?,
changedDuringFetch: Bool) -> Bool
{
beforeFetch?.keychainFingerprintChanged == true || changedDuringFetch
}
private nonisolated static func claudeAuthChangedDuringFetch(
provider: UsageProvider,
beforeFetch: ClaudeRefreshAuthState?,
afterFetchFingerprintToken: String?) -> Bool
{
provider == .claude && afterFetchFingerprintToken != beforeFetch?.fingerprintToken
}
private nonisolated static func captureClaudeRefreshAuthState(
invalidateCredentialsFile: Bool) async -> ClaudeRefreshAuthState
{
await withTaskGroup(of: ClaudeRefreshAuthState.self, returning: ClaudeRefreshAuthState.self) { group in
group.addTask {
let credentialsFileChanged = invalidateCredentialsFile
? ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
: false
let keychainFingerprintChanged = ClaudeOAuthCredentialsStore
.claudeKeychainFingerprintChangedWithoutConsuming()
let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken()
let persistentRefBefore = ClaudeOAuthCredentialsStore
.claudeKeychainPersistentRefHashWithoutPrompt()
let activeAccountIdentity = Self.activeClaudeAccountIdentity()
let persistentRefAfter = ClaudeOAuthCredentialsStore
.claudeKeychainPersistentRefHashWithoutPrompt()
let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken()
let accountStateWasStable = fingerprintBefore == fingerprintAfter
&& persistentRefBefore == persistentRefAfter
return ClaudeRefreshAuthState(
fingerprintToken: fingerprintAfter,
credentialsFileChanged: credentialsFileChanged,
keychainFingerprintChanged: keychainFingerprintChanged,
keychainPersistentRefHash: persistentRefAfter,
activeAccountIdentity: activeAccountIdentity,
accountStateWasStable: accountStateWasStable)
}
return await group.next()!
}
}
private nonisolated static func captureClaudeHistoryAccountState() async -> ClaudeHistoryAccountState {
await withTaskGroup(of: ClaudeHistoryAccountState.self, returning: ClaudeHistoryAccountState.self) { group in
group.addTask {
let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken()
let persistentRefBefore = ClaudeOAuthCredentialsStore
.claudeKeychainPersistentRefHashWithoutPrompt()
let activeAccountIdentity = Self.activeClaudeAccountIdentity()
let persistentRefAfter = ClaudeOAuthCredentialsStore
.claudeKeychainPersistentRefHashWithoutPrompt()
let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken()
let wasStable = fingerprintBefore == fingerprintAfter && persistentRefBefore == persistentRefAfter
return ClaudeHistoryAccountState(
fingerprintToken: fingerprintAfter,
keychainPersistentRefHash: persistentRefAfter,
activeAccountIdentity: activeAccountIdentity,
wasStable: wasStable)
}
return await group.next()!
}
}
private nonisolated static func claudeOAuthActiveAccountObservation(
beforeFetch: ClaudeRefreshAuthState?,
afterFetch: ClaudeHistoryAccountState?) -> ClaudeOAuthActiveAccountObservation
{
guard let beforeFetch,
beforeFetch.accountStateWasStable,
let afterFetch,
afterFetch.wasStable,
beforeFetch.activeAccountIdentity == afterFetch.activeAccountIdentity
else {
return .changed
}
return .stable(identity: afterFetch.activeAccountIdentity)
}
private nonisolated static func stableClaudeKeychainPersistentRefHash(
beforeFetch: ClaudeRefreshAuthState?,
afterFetchFingerprintToken: String?,
afterFetchPersistentRefHash: String?,
accountStateWasStable: Bool) -> String?
{
guard accountStateWasStable,
let beforeFetch,
beforeFetch.accountStateWasStable,
beforeFetch.fingerprintToken == afterFetchFingerprintToken,
let beforeFetchPersistentRefHash = beforeFetch.keychainPersistentRefHash,
beforeFetchPersistentRefHash == afterFetchPersistentRefHash
else {
return nil
}
return beforeFetchPersistentRefHash
}
#if DEBUG
nonisolated static func _stableClaudeKeychainPersistentRefHashForTesting(
beforeFetchFingerprintToken: String,
afterFetchFingerprintToken: String,
beforeFetchPersistentRefHash: String?,
afterFetchPersistentRefHash: String?) -> String?
{
self.stableClaudeKeychainPersistentRefHash(
beforeFetch: ClaudeRefreshAuthState(
fingerprintToken: beforeFetchFingerprintToken,
credentialsFileChanged: false,
keychainFingerprintChanged: false,
keychainPersistentRefHash: beforeFetchPersistentRefHash,
activeAccountIdentity: nil,
accountStateWasStable: true),
afterFetchFingerprintToken: afterFetchFingerprintToken,
afterFetchPersistentRefHash: afterFetchPersistentRefHash,
accountStateWasStable: true)
}
nonisolated static func _claudeOAuthActiveAccountObservationForTesting(
identityBeforeFetch: String?,
identityAfterFetch: String?,
beforeFetchWasStable: Bool = true,
afterFetchWasStable: Bool = true) -> ClaudeOAuthActiveAccountObservation
{
self.claudeOAuthActiveAccountObservation(
beforeFetch: ClaudeRefreshAuthState(
fingerprintToken: "before",
credentialsFileChanged: false,
keychainFingerprintChanged: false,
keychainPersistentRefHash: "before-ref",
activeAccountIdentity: identityBeforeFetch,
accountStateWasStable: beforeFetchWasStable),
afterFetch: ClaudeHistoryAccountState(
fingerprintToken: "after",
keychainPersistentRefHash: "after-ref",
activeAccountIdentity: identityAfterFetch,
wasStable: afterFetchWasStable))
}
#endif
private nonisolated static func invalidateClaudeCredentialsFileCacheIfChanged() async -> Bool {
await withTaskGroup(of: Bool.self, returning: Bool.self) { group in
group.addTask {
ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
}
return await group.next()!
}
}
private nonisolated static func invalidateClaudeCredentialsFileCacheIfNeeded(changedDuringFetch: Bool) async {
guard changedDuringFetch else { return }
_ = await self.invalidateClaudeCredentialsFileCacheIfChanged()
}
private nonisolated static func consumeClaudeKeychainFingerprintChangeWithoutPrompt() async -> Bool {
await withTaskGroup(of: Bool.self, returning: Bool.self) { group in
group.addTask {
ClaudeOAuthCredentialsStore.consumeClaudeKeychainFingerprintChangeWithoutPrompt()
}
return await group.next()!
}
}
private func clearClaudeCredentialDerivedStateForCredentialSwap() async {
await MainActor.run {
self.clearClaudeCredentialDerivedStateForCredentialSwapNow()
}
}
private func clearClaudeCredentialDerivedStateForCredentialSwapNow() {
self.snapshots.removeValue(forKey: .claude)
self.lastKnownResetSnapshots.removeValue(forKey: .claude)
self.errors[.claude] = nil
self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude)
self.lastSourceLabels.removeValue(forKey: .claude)
self.accountSnapshots.removeValue(forKey: .claude)
self.tokenSnapshots.removeValue(forKey: .claude)
self.tokenErrors[.claude] = nil
self.failureGates[.claude]?.reset()
self.tokenFailureGates[.claude]?.reset()
self.clearSessionQuotaTransitionState(provider: .claude)
self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != .claude }
self.lastTokenFetchAt.removeValue(forKey: .claude)
}
private func handleProviderFetchFailure(
provider: UsageProvider,
error: Error,
generation: UInt64) async
{
let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error)
await MainActor.run {
guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return }
if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {
// This is a durable provider migration signal, not a transient fetch failure.
// Surface it immediately so a cached snapshot cannot hide the required handoff.
self.observeGeminiConsumerTierDeprecation(from: error)
self.errors[provider] = error.localizedDescription
self.snapshots.removeValue(forKey: provider)
self.lastKnownResetSnapshots.removeValue(forKey: provider)
self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider)
self.lastSourceLabels.removeValue(forKey: provider)
self.failureGates[provider]?.reset()
return
}
let hadKnownUnavailableLimits = self.knownLimitsAvailabilityByProvider[provider]?.isUnavailable == true
self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider)
if provider == .claude,
ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(error.localizedDescription)
{
// This is a successful answer about quota availability, not a transient probe failure.
// Drop prior limits immediately so an Education subscription notice cannot leave stale bars visible.
self.snapshots.removeValue(forKey: provider)
self.lastKnownResetSnapshots.removeValue(forKey: provider)
self.clearSessionQuotaTransitionState(provider: provider)
self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider }
self.lastSourceLabels.removeValue(forKey: provider)
self.errors[provider] = nil
self.knownLimitsAvailabilityByProvider[provider] = .unavailable
self.failureGates[provider]?.reset()
return
}
if provider == .claude,
hadKnownUnavailableLimits,
Self.shouldPreservePriorSnapshot(after: error, hadPriorData: true) ||
Self.isClaudeCLIRateLimitFailure(error)
{
self.errors[provider] = nil
self.knownLimitsAvailabilityByProvider[provider] = .unavailable
return
}
let hadPriorData = self.snapshots[provider] != nil
let preservesPriorData = Self.shouldPreservePriorSnapshot(
after: error,
hadPriorData: hadPriorData) ||
(provider == .claude &&
hadPriorData &&
Self.isClaudeCLIRateLimitFailure(error))
let shouldSurface =
self.failureGates[provider]?
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
let preservesClaudeWebSessionFailure =
provider == .claude &&
hadPriorData &&
Self.isClaudeWebSessionRefreshFailure(error)
if preservesClaudeWebSessionFailure,
!shouldSurface
{
self.errors[provider] = nil
return
}
if provider == .claude,
preservesPriorData,
Self.isClaudeUsageProbeTimeout(error) || Self.isClaudeCLIRateLimitFailure(error)
{
self.errors[provider] = nil
return
}
if preservesPriorData, !shouldSurface {
self.errors[provider] = nil
return
}
if shouldSurface {
self.errors[provider] = error.localizedDescription
if !preservesPriorData, !preservesClaudeWebSessionFailure {
self.snapshots.removeValue(forKey: provider)
}
} else {
self.errors[provider] = nil
}
if shouldNotifyPermissionPrompt {
self.postPermissionPromptNotificationIfNeeded(provider: provider, error: error)
}
}
guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return }
if let runtime = self.providerRuntimes[provider] {
let context = ProviderRuntimeContext(
provider: provider, settings: self.settings, store: self)
runtime.providerDidFail(context: context, provider: provider, error: error)
}
}
private static func shouldPreservePriorSnapshot(after error: Error, hadPriorData: Bool) -> Bool {
guard hadPriorData else { return false }
if error is CancellationError {
return true
}
if self.isPreservableNetworkTransportError(error) {
return true
}
let message = error.localizedDescription.lowercased()
return message.contains("timed out") ||
message.contains("timeout") ||
message.contains("cancelled") ||
message.contains("network connection was lost") ||
message.contains("not connected to the internet")
}
static func isPreservableNetworkTransportError(_ error: Error) -> Bool {
let nsError = error as NSError
guard nsError.domain == NSURLErrorDomain else { return false }
switch nsError.code {
case NSURLErrorTimedOut,
NSURLErrorCancelled,
NSURLErrorNetworkConnectionLost,
NSURLErrorNotConnectedToInternet,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorDNSLookupFailed:
return true
default:
return false
}
}
static func startupConnectivityRetryDelay(forAttempt attempt: Int) -> TimeInterval? {
let delays: [TimeInterval] = [15, 45, 120, 300]
guard attempt >= 1, attempt <= delays.count else { return nil }
return delays[attempt - 1]
}
static func isStartupConnectivityRetryableError(_ error: Error) -> Bool {
if error is CancellationError {
return false
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
switch nsError.code {
case NSURLErrorTimedOut,
NSURLErrorNetworkConnectionLost,
NSURLErrorNotConnectedToInternet,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorDNSLookupFailed:
return true
default:
return false
}
}
let message = error.localizedDescription.lowercased()
return message.contains("timed out") ||
message.contains("timeout") ||
message.contains("network connection was lost") ||
message.contains("not connected to the internet") ||
message.contains("cannot find host") ||
message.contains("cannot connect to host") ||
message.contains("dns lookup")
}
private static func isClaudeUsageProbeTimeout(_ error: Error) -> Bool {
if case ClaudeStatusProbeError.timedOut = error {
return true
}
return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription
}
private static func isClaudeCLIRateLimitFailure(_ error: Error) -> Bool {
ClaudeUsageFetcher.isCLIRateLimitError(error)
}
private static func isClaudeWebSessionRefreshFailure(_ error: Error) -> Bool {
if case ClaudeWebAPIFetcher.FetchError.unauthorized = error {
return true
}
return error.localizedDescription == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription
}
nonisolated static func isPermissionPromptWaiting(_ error: Error) -> Bool {
let message = error.localizedDescription.lowercased()
return (message.contains("prompt") && message.contains("waiting")) ||
message.contains("permission prompt") ||
message.contains("folder trust prompt")
}
private func postPermissionPromptNotificationIfNeeded(provider: UsageProvider, error: Error) {
let now = Date()
if let last = self.lastPermissionPromptNotificationAt[provider],
now.timeIntervalSince(last) < 10 * 60
{
return
}
self.lastPermissionPromptNotificationAt[provider] = now
let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
AppNotifications.shared.post(
idPrefix: "permission-prompt-\(provider.rawValue)",
title: L("%@ is waiting for permission", providerName),
body: error.localizedDescription,
soundEnabled: false)
}
}