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) } }