Skip to content

Commit bb3689f

Browse files
authored
Discard stale refresh completions after account changes (#146)
* Discard stale refresh completions * Address automated review feedback * Make refresh synchronization rollback safe * Invalidate credentials before refresh tasks * Invalidate account changes before refresh tasks * Refresh account scheduling after OpenCode edits
1 parent 0d7f787 commit bb3689f

8 files changed

Lines changed: 691 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4343

4444
### Fixed
4545

46+
- Refreshes that finish after an account is removed, disabled, or given new
47+
credentials or routing settings no longer restore stale usage, errors,
48+
history, or alerts.
4649
- Browser sign-in now ignores malformed, stale, and unrelated local callback
4750
requests until the expected ChatGPT, Claude, or GitHub Copilot redirect arrives.
4851
- ChatGPT, Claude, GitHub Copilot, and Cursor browser sign-in now stops safely
@@ -113,8 +116,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
113116

114117
### Developer Experience
115118

119+
- Gated provider test doubles now suspend in-flight refreshes deterministically,
120+
covering batch and single-account completions after account changes.
116121
- The Mac XCTest suite is split into domain-focused test classes with narrowly
117-
scoped shared support, preserving the existing 344-test regression inventory.
122+
scoped shared support, preserving the existing 347-test regression inventory.
118123
- Deterministic loopback OAuth tests now execute ChatGPT, Claude, and GitHub
119124
Copilot token exchanges through success, malformed, missing-token, and
120125
sanitized provider-error responses without opening a browser or using live credentials.

CodexBarMac/AppModel.swift

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ final class AppModel: ObservableObject {
3131
self.isAwaitingConfigurationRecoveryCompletion =
3232
configurationStore.isConfigurationRecoveryCompletionPending
3333
configurationStore.seedDefaultConfigurationsIfNeeded()
34+
if !configurationStore.isConfigurationRecoveryRequired {
35+
refreshService.updateCurrentConfigurations(configurationStore.configurations)
36+
}
3437

3538
refreshService.objectWillChange
3639
.sink { [weak self] _ in
@@ -175,15 +178,26 @@ final class AppModel: ObservableObject {
175178
await refresh()
176179
}
177180

178-
func discoverLocalCredentials() async {
181+
@discardableResult
182+
func discoverLocalCredentials() async -> Bool {
183+
let previousConfigurations = configurationStore.enabledConfigurations
179184
let discovery = await Task.detached(priority: .utility) {
180185
LocalCredentialDiscovery.discover()
181186
}.value
182187

183188
configurationStore.applyLocalCredentialDiscoveries(discovery)
189+
let didChangeRefreshInputs = refreshInputsChanged(
190+
from: previousConfigurations,
191+
to: configurationStore.enabledConfigurations
192+
)
193+
if didChangeRefreshInputs {
194+
invalidateAccounts()
195+
}
196+
return didChangeRefreshInputs
184197
}
185198

186199
func refresh() async {
200+
refreshService.updateCurrentConfigurations(configurationStore.configurations)
187201
if refreshService.isRefreshing {
188202
pendingRefresh = true
189203
return
@@ -238,11 +252,24 @@ final class AppModel: ObservableObject {
238252
)
239253
}
240254

241-
func handleAccountsChanged() async {
255+
func invalidateAccounts() {
256+
refreshService.updateCurrentConfigurations(configurationStore.configurations)
257+
}
258+
259+
func refreshAfterAccountChange() async {
242260
updateAutoRefresh()
243261
await refresh()
244262
}
245263

264+
func refreshAfterCredentialChange() async {
265+
await refresh()
266+
}
267+
268+
func invalidateCredential(forAccountID accountID: String) {
269+
refreshService.updateCurrentConfigurations(configurationStore.configurations)
270+
refreshService.invalidateCredential(forAccountID: accountID)
271+
}
272+
246273
func completeConfigurationRecoveryIfPossible() {
247274
guard isAwaitingConfigurationRecoveryCompletion,
248275
configurationStore.completeConfigurationRecovery()
@@ -251,13 +278,32 @@ final class AppModel: ObservableObject {
251278
}
252279

253280
isAwaitingConfigurationRecoveryCompletion = false
281+
refreshService.updateCurrentConfigurations(configurationStore.configurations)
254282
historyStore.removeSnapshotsForMissingAccounts(
255283
validAccountIDs: Set(configurationStore.configurations.map(\.id))
256284
)
257285
}
258286

259287
func refreshAccount(_ configuration: ProviderAccountConfiguration) async -> ProviderUsageResult? {
260-
await refreshService.refresh(configuration: configuration)
288+
refreshService.updateCurrentConfigurations(configurationStore.configurations)
289+
guard
290+
let currentConfiguration = configurationStore.configuration(accountID: configuration.id),
291+
currentConfiguration.isEnabled,
292+
refreshService.hasSameRefreshInputs(configuration, currentConfiguration)
293+
else {
294+
return nil
295+
}
296+
guard let result = await refreshService.refresh(configuration: currentConfiguration) else {
297+
return nil
298+
}
299+
300+
lastRefreshedAt = Date()
301+
recordUsageHistory()
302+
await processUsageAlerts(
303+
results: alertEligibleResults(),
304+
preserving: refreshService.incompleteRefreshAccountIDs
305+
)
306+
return result
261307
}
262308

263309
func quit() {
@@ -277,6 +323,34 @@ final class AppModel: ObservableObject {
277323
historyStore.record(results: alertEligibleResults())
278324
}
279325

326+
private func refreshInputsChanged(
327+
from previousConfigurations: [ProviderAccountConfiguration],
328+
to currentConfigurations: [ProviderAccountConfiguration]
329+
) -> Bool {
330+
let previousByID = previousConfigurations.reduce(
331+
into: [String: ProviderAccountConfiguration]()
332+
) { configurationsByID, configuration in
333+
configurationsByID[configuration.id] = configuration
334+
}
335+
let currentByID = currentConfigurations.reduce(
336+
into: [String: ProviderAccountConfiguration]()
337+
) { configurationsByID, configuration in
338+
configurationsByID[configuration.id] = configuration
339+
}
340+
guard previousByID.keys == currentByID.keys else {
341+
return true
342+
}
343+
return previousByID.contains { accountID, previousConfiguration in
344+
guard let currentConfiguration = currentByID[accountID] else {
345+
return true
346+
}
347+
return !refreshService.hasSameRefreshInputs(
348+
previousConfiguration,
349+
currentConfiguration
350+
)
351+
}
352+
}
353+
280354
private func processUsageAlerts(
281355
results: [ProviderUsageResult],
282356
preserving preservedAccountIDs: Set<String>

0 commit comments

Comments
 (0)