From dc440cfdeffd2ecbbd0cf78375b7dfdb7875185a Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 10:28:01 -0700 Subject: [PATCH 01/22] fix(claude): migrate email-keyed iCloud snapshots to slot keys --- CHANGELOG.md | 1 + Sources/CodexBar/Sync/CloudSyncEngine.swift | 115 +++++++++++++++----- Sources/CodexBarCore/Sync/SyncModels.swift | 32 ++++++ Tests/CodexBarTests/SyncModelTests.swift | 68 ++++++++++++ 4 files changed, 187 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ad4774a0..7b4d44c3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Kiro: show overage credits spent against their cap, plus accrued charges against the overage budget. The `kiro-cli` report states credits against the plan alone and omits the overage section entirely for organization accounts, so CodexBar now reads the plan and overage ceilings from the same `GetUsageLimits` service the CLI itself calls, falling back to the CLI report when it is unavailable. (#3083). Thanks @sf-jin-ku! - Kiro: show overage credits spent against their cap, plus accrued charges against the overage budget. The `kiro-cli` report states credits against the plan alone and omits the overage section entirely for organization accounts, so CodexBar now reads the plan and overage ceilings from the same `GetUsageLimits` service the CLI itself calls, falling back to the CLI report when it is unavailable. - Claude: distinguish claude-swap accounts that share an email with the workspace name or slot, and honor a user-chosen display alias (#3082). Thanks @sf-jin-ku! +- Claude: rewrite email-keyed claude-swap iCloud snapshots onto slot keys and delete the leftover records so another Mac does not keep duplicate fleet cards. - z.ai: show the BigModel CN pay-as-you-go account balance inside Quota details, best-effort and CN-only (#3109). Thanks @RunhuaHuang! - Fixed the mainland Alibaba Personal/Solo Token Plan always reporting "login required": the console shell now serves its `sec_token` to CodexBar's fetch and the upper-case `SEC_TOKEN` shape is parsed (#2891, #3098). Thanks @LeoLin990405! - Fixed long agent session names stretching the menu: session rows now truncate inside the menu's width with the full label in a tooltip (#3096). Thanks @KaranocaVe! diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 48d6ddf845..c140129898 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -150,6 +150,42 @@ enum CloudSyncDirtyState { } } +enum CloudSyncSnapshotMigration { + static func obsoleteRecordNames( + liveSnapshots: [AccountSnapshotSyncPayload], + hashes: [String: String], + envelope: CloudSyncPersistence.Envelope) -> Set + { + AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames( + liveSnapshots: liveSnapshots, + knownRecordNames: Set(hashes.keys).union(envelope.fleetSnapshots.keys)) + } + + static func drop( + _ names: Set, + hashes: inout [String: String], + envelope: inout CloudSyncPersistence.Envelope, + desiredRecords: inout [CKRecord.ID: CKRecord], + zoneID: CKRecordZone.ID) -> [CKRecord.ID] + { + names.map { name in + let recordID = CKRecord.ID(recordName: name, zoneID: zoneID) + desiredRecords.removeValue(forKey: recordID) + hashes.removeValue(forKey: name) + envelope.fleetSnapshots.removeValue(forKey: name) + envelope.encodedSystemFields.removeValue(forKey: name) + envelope.recordMetadata.removeValue(forKey: name) + return recordID + } + } + + static func retryableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKRecord.ID] { + failures.compactMap { recordID, error in + error.code == .unknownItem ? nil : recordID + } + } +} + enum CloudSyncEntitlementGate { static let entitlement = "com.apple.developer.icloud-services" @@ -524,35 +560,6 @@ actor CloudSyncEngine: CKSyncEngineDelegate { self.persistEnvelope() } - private func pushPendingSnapshots() async { - guard let engine = self.engine, !self.pendingSnapshots.isEmpty else { return } - guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } - do { - for payload in self.pendingSnapshots { - let hash = try CanonicalSyncJSON.hash(payload) - guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } - let recordID = self.recordID(named: payload.recordName) - let record = self.record(type: .accountSnapshot, id: recordID) - record["schemaVersion"] = payload.schemaVersion as CKRecordValue - record["provider"] = payload.provider.rawValue as CKRecordValue - record["deviceID"] = payload.deviceID as CKRecordValue - record["accountKey"] = payload.accountKey as CKRecordValue - record["fetchedAt"] = payload.fetchedAt as CKRecordValue - record.encryptedValues["displayLabel"] = payload.displayLabel as CKRecordValue - record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue - self.desiredRecords[recordID] = record - self.lastSnapshotHashes[payload.recordName] = hash - self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload - engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) - } - self.pendingSnapshots = [] - self.lastSnapshotPushAt = Date() - self.persistEnvelope() - } catch { - await self.record(error: error) - } - } - // MARK: CKSyncEngineDelegate nonisolated func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { @@ -606,6 +613,9 @@ actor CloudSyncEngine: CKSyncEngineDelegate { for failure in changes.failedRecordSaves { await self.handleSaveFailure(failure, syncEngine: syncEngine) } + for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(changes.failedRecordDeletes) { + syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) + } self.persistEnvelope() if !changes.savedRecords.isEmpty { await MainActor.run { self.state.status.lastSuccessfulPushAt = Date() } @@ -1024,3 +1034,50 @@ actor CloudSyncEngine: CKSyncEngineDelegate { return String(bytes: bytes, encoding: .utf8) ?? "unknown" } } + +extension CloudSyncEngine { + private func pushPendingSnapshots() async { + guard let engine = self.engine, !self.pendingSnapshots.isEmpty else { return } + guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } + do { + let obsoleteNames = CloudSyncSnapshotMigration.obsoleteRecordNames( + liveSnapshots: self.pendingSnapshots, + hashes: self.lastSnapshotHashes, + envelope: self.persistenceEnvelope) + for payload in self.pendingSnapshots { + let hash = try CanonicalSyncJSON.hash(payload) + guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } + let recordID = self.recordID(named: payload.recordName) + let record = self.record(type: .accountSnapshot, id: recordID) + record["schemaVersion"] = payload.schemaVersion as CKRecordValue + record["provider"] = payload.provider.rawValue as CKRecordValue + record["deviceID"] = payload.deviceID as CKRecordValue + record["accountKey"] = payload.accountKey as CKRecordValue + record["fetchedAt"] = payload.fetchedAt as CKRecordValue + record.encryptedValues["displayLabel"] = payload.displayLabel as CKRecordValue + record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue + self.desiredRecords[recordID] = record + self.lastSnapshotHashes[payload.recordName] = hash + self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload + engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + } + for recordID in CloudSyncSnapshotMigration.drop( + obsoleteNames, + hashes: &self.lastSnapshotHashes, + envelope: &self.persistenceEnvelope, + desiredRecords: &self.desiredRecords, + zoneID: Self.zoneID) + { + engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) + } + if !obsoleteNames.isEmpty { + await MainActor.run { obsoleteNames.forEach { self.state.fleetSnapshots.removeValue(forKey: $0) } } + } + self.pendingSnapshots = [] + self.lastSnapshotPushAt = Date() + self.persistEnvelope() + } catch { + await self.record(error: error) + } + } +} diff --git a/Sources/CodexBarCore/Sync/SyncModels.swift b/Sources/CodexBarCore/Sync/SyncModels.swift index b814d435cb..7b41d9991d 100644 --- a/Sources/CodexBarCore/Sync/SyncModels.swift +++ b/Sources/CodexBarCore/Sync/SyncModels.swift @@ -225,6 +225,38 @@ public struct AccountSnapshotSyncPayload: Codable, Sendable { } return CanonicalSyncJSON.hash(data: Data(identity.lowercased().utf8)) } + + /// CloudKit record IDs cannot be renamed. When this snapshot is keyed by a distinct + /// account ID that replaced a mailbox identity, the previous email-keyed record on the + /// same device is the predecessor that must be deleted after the replacement is saved. + public func emailKeyedPredecessorRecordName() -> String? { + let accountID = self.usage.identity?.accountID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !accountID.isEmpty else { return nil } + let emailKey = Self.accountKey(for: self.usage.identity?.accountEmail) + guard emailKey != "default", + Self.accountKey(for: accountID) == self.accountKey, + emailKey != self.accountKey + else { + return nil + } + return "snap-\(self.provider.rawValue)-\(emailKey)-\(self.deviceID)" + } + + public static func obsoleteEmailKeyedRecordNames( + liveSnapshots: [AccountSnapshotSyncPayload], + knownRecordNames: Set) -> Set + { + let liveNames = Set(liveSnapshots.map(\.recordName)) + var obsolete: Set = [] + for snapshot in liveSnapshots { + guard let predecessor = snapshot.emailKeyedPredecessorRecordName(), + !liveNames.contains(predecessor), + knownRecordNames.contains(predecessor) + else { continue } + obsolete.insert(predecessor) + } + return obsolete + } } public struct SyncedPreferences: Codable, Sendable { diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 1ceaeb96e9..c7522fd1a2 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -176,6 +176,55 @@ struct SyncModelTests { #expect(decoded.fetchedAt == usage.updatedAt) } + @Test + func `slot keyed snapshot names the leftover email keyed CloudKit record`() { + let payload = Self.claudeSnapshot(accountID: "claude-swap:2", email: "Owner@Example.com") + let emailKey = AccountSnapshotSyncPayload.accountKey(for: "owner@example.com") + + #expect(payload.emailKeyedPredecessorRecordName() == "snap-claude-\(emailKey)-device-id") + #expect(payload.recordName != payload.emailKeyedPredecessorRecordName()) + } + + @Test + func `email keyed snapshot has no CloudKit predecessor`() { + let payload = Self.claudeSnapshot(accountID: "owner@example.com", email: "owner@example.com") + + #expect(payload.emailKeyedPredecessorRecordName() == nil) + } + + @Test + func `obsolete email keyed names skip live records and unknown CloudKit keys`() throws { + let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") + let oauth = Self.claudeSnapshot(accountID: "owner@example.com", email: "owner@example.com") + let predecessor = try #require(slot.emailKeyedPredecessorRecordName()) + + #expect( + AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames( + liveSnapshots: [slot], + knownRecordNames: [predecessor]) == [predecessor]) + #expect( + AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames( + liveSnapshots: [slot, oauth], + knownRecordNames: [predecessor]).isEmpty) + #expect( + AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames( + liveSnapshots: [slot], + knownRecordNames: []).isEmpty) + } + + @Test + func `duplicate swap slots sharing a mailbox retire one email keyed record`() throws { + let first = Self.claudeSnapshot(accountID: "claude-swap:1", email: "shared@example.com") + let second = Self.claudeSnapshot(accountID: "claude-swap:2", email: "shared@example.com") + let predecessor = try #require(first.emailKeyedPredecessorRecordName()) + + #expect(second.emailKeyedPredecessorRecordName() == predecessor) + #expect( + AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames( + liveSnapshots: [first, second], + knownRecordNames: [predecessor]) == [predecessor]) + } + @Test func `account snapshot ignores retired provider payload keys`() throws { let legacy = #""" @@ -219,6 +268,25 @@ struct SyncModelTests { #expect(decoded.usage.details.isEmpty) #expect(decoded.usage.updatedAt == decoded.fetchedAt) } + + private static func claudeSnapshot(accountID: String, email: String) -> AccountSnapshotSyncPayload { + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: email, + accountOrganization: nil, + loginMethod: "claude-swap", + accountID: accountID)) + return AccountSnapshotSyncPayload( + provider: .claude, + deviceID: "device-id", + accountIdentity: accountID, + displayLabel: email, + usage: usage) + } } import CloudKit From 7080d1bdb87c338dd851d08bac1cab84bdcf1a32 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 11:25:22 -0700 Subject: [PATCH 02/22] fix(sync): confirm CloudKit snapshot saves before deleting predecessors Terminal delete failures are reported once with delayed retries only for recoverable errors, and email-keyed leftovers wait until the replacement record is saved. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 133 +++++++++++++++++--- Tests/CodexBarTests/SyncModelTests.swift | 78 ++++++++++++ 2 files changed, 194 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index c140129898..998f1aae50 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -179,9 +179,54 @@ enum CloudSyncSnapshotMigration { } } + static func predecessorNames( + for snapshot: AccountSnapshotSyncPayload, + obsoleteNames: Set) -> Set + { + guard let predecessor = snapshot.emailKeyedPredecessorRecordName(), + obsoleteNames.contains(predecessor) + else { + return [] + } + return [predecessor] + } + + static func takeDeletes( + forSavedRecordNames savedNames: [String], + pending: inout [String: Set]) -> Set + { + var toDrop: Set = [] + for name in savedNames { + if let obsolete = pending.removeValue(forKey: name) { + toDrop.formUnion(obsolete) + } + } + return toDrop + } + static func retryableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKRecord.ID] { failures.compactMap { recordID, error in - error.code == .unknownItem ? nil : recordID + self.retryDelay(for: error) == nil ? nil : recordID + } + } + + static func reportableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKError] { + failures.values.filter { error in + error.code != .unknownItem && self.retryDelay(for: error) == nil + } + } + + /// Delayed retries only for recoverable CloudKit failures. Terminal per-record errors such as + /// `permissionFailure`, `notAuthenticated`, and `invalidArguments` are reported once. + static func retryDelay(for error: CKError) -> TimeInterval? { + switch error.code { + case .unknownItem, .permissionFailure, .notAuthenticated, .invalidArguments: + return nil + case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, .zoneBusy, .quotaExceeded: + return max(error.retryAfterSeconds ?? 1, 1) + default: + guard let retryAfter = error.retryAfterSeconds else { return nil } + return max(retryAfter, 1) } } } @@ -220,6 +265,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { private var lastSnapshotPushAt: Date? private var pendingSnapshots: [AccountSnapshotSyncPayload] = [] private var lastSnapshotHashes: [String: String] = [:] + private var pendingPredecessorDeletes: [String: Set] = [:] private var lastKnownProviderConfigs: [ProviderInstanceID: ProviderConfig] = [:] private var lastKnownPreferences: SyncedPreferences? private var lastKnownIncludeSecrets: Bool? @@ -613,9 +659,10 @@ actor CloudSyncEngine: CKSyncEngineDelegate { for failure in changes.failedRecordSaves { await self.handleSaveFailure(failure, syncEngine: syncEngine) } - for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(changes.failedRecordDeletes) { - syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) - } + await self.handleFailedDeletes(changes.failedRecordDeletes) + await self.finishConfirmedSnapshotMigrations( + savedRecordNames: changes.savedRecords.map(\.recordID.recordName), + syncEngine: syncEngine) self.persistEnvelope() if !changes.savedRecords.isEmpty { await MainActor.run { self.state.status.lastSuccessfulPushAt = Date() } @@ -1036,6 +1083,61 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } extension CloudSyncEngine { + private func finishConfirmedSnapshotMigrations( + savedRecordNames: [String], + syncEngine: CKSyncEngine) async + { + let toDrop = CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: savedRecordNames, + pending: &self.pendingPredecessorDeletes) + for name in savedRecordNames { + guard self.lastSnapshotHashes[name] == nil, + let payload = self.persistenceEnvelope.fleetSnapshots[name], + let hash = try? CanonicalSyncJSON.hash(payload) + else { continue } + self.lastSnapshotHashes[name] = hash + } + guard !toDrop.isEmpty else { return } + let recordIDs = CloudSyncSnapshotMigration.drop( + toDrop, + hashes: &self.lastSnapshotHashes, + envelope: &self.persistenceEnvelope, + desiredRecords: &self.desiredRecords, + zoneID: Self.zoneID) + for recordID in recordIDs { + syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) + } + await MainActor.run { + toDrop.forEach { self.state.fleetSnapshots.removeValue(forKey: $0) } + } + } + + private func handleFailedDeletes(_ failures: [CKRecord.ID: CKError]) async { + for error in CloudSyncSnapshotMigration.reportableFailedDeletes(failures) { + await self.record(error: error) + } + for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures) { + let delay = failures[recordID].flatMap(CloudSyncSnapshotMigration.retryDelay(for:)) ?? 1 + self.scheduleDeleteRetry(recordID: recordID, after: delay) + } + } + + private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { + Task { [weak self] in + do { + if delay > 0 { + try await Task.sleep(for: .seconds(delay)) + } + await Task.yield() + guard let self, let engine = await self.engine, await self.enabled else { return } + engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) + try await engine.sendChanges(.init(scope: .recordIDs([recordID]))) + } catch { + await self?.record(error: error) + } + } + } + private func pushPendingSnapshots() async { guard let engine = self.engine, !self.pendingSnapshots.isEmpty else { return } guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } @@ -1057,22 +1159,19 @@ extension CloudSyncEngine { record.encryptedValues["displayLabel"] = payload.displayLabel as CKRecordValue record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue self.desiredRecords[recordID] = record - self.lastSnapshotHashes[payload.recordName] = hash self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload + let predecessors = CloudSyncSnapshotMigration.predecessorNames( + for: payload, + obsoleteNames: obsoleteNames) + if predecessors.isEmpty { + self.lastSnapshotHashes[payload.recordName] = hash + } else { + // Keep the hash unset until CloudKit confirms the replacement save so a + // failed send can retry, and so the predecessor is not deleted first. + self.pendingPredecessorDeletes[payload.recordName, default: []].formUnion(predecessors) + } engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } - for recordID in CloudSyncSnapshotMigration.drop( - obsoleteNames, - hashes: &self.lastSnapshotHashes, - envelope: &self.persistenceEnvelope, - desiredRecords: &self.desiredRecords, - zoneID: Self.zoneID) - { - engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) - } - if !obsoleteNames.isEmpty { - await MainActor.run { obsoleteNames.forEach { self.state.fleetSnapshots.removeValue(forKey: $0) } } - } self.pendingSnapshots = [] self.lastSnapshotPushAt = Date() self.persistEnvelope() diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index c7522fd1a2..26bad4afd1 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -317,3 +317,81 @@ struct CloudSyncRecordRebaseTests { #expect(rebased.encryptedValues["cookieHeader"] as? String == "cookie=1") } } + +struct CloudSyncSnapshotMigrationDeleteRetryTests { + @Test + func `terminal CloudKit delete errors are reported once and not retried`() { + let zoneID = CloudSyncEngine.zoneID + func recordID(_ name: String) -> CKRecord.ID { + CKRecord.ID(recordName: name, zoneID: zoneID) + } + + let failures: [CKRecord.ID: CKError] = [ + recordID("unknown"): Self.cloudKitError(.unknownItem), + recordID("denied"): Self.cloudKitError(.permissionFailure), + recordID("unauth"): Self.cloudKitError(.notAuthenticated), + recordID("invalid"): Self.cloudKitError(.invalidArguments), + recordID("network"): Self.cloudKitError(.networkFailure), + recordID("quota"): Self.cloudKitError(.quotaExceeded, retryAfter: 30), + ] + + let retryable = Set(CloudSyncSnapshotMigration.retryableFailedDeletes(failures).map(\.recordName)) + let reported = Set(CloudSyncSnapshotMigration.reportableFailedDeletes(failures).map(\.code)) + + #expect(retryable == ["network", "quota"]) + #expect(reported == [.permissionFailure, .notAuthenticated, .invalidArguments]) + #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.unknownItem)) == nil) + #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.networkFailure)) == 1) + #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.quotaExceeded, retryAfter: 30)) == 30) + #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.permissionFailure)) == nil) + } + + private static func cloudKitError(_ code: CKError.Code, retryAfter: TimeInterval? = nil) -> CKError { + var userInfo: [String: Any] = [:] + if let retryAfter { + userInfo[CKErrorRetryAfterKey] = NSNumber(value: retryAfter) + } + let nsError = NSError(domain: CKErrorDomain, code: code.rawValue, userInfo: userInfo) + return CKError(_nsError: nsError) + } +} + +struct CloudSyncSnapshotMigrationSaveThenDeleteTests { + @Test + func `predecessor deletes wait until the replacement record is saved`() throws { + let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") + let predecessor = try #require(slot.emailKeyedPredecessorRecordName()) + let obsolete: Set = [predecessor] + + #expect(CloudSyncSnapshotMigration.predecessorNames(for: slot, obsoleteNames: obsolete) == [predecessor]) + #expect(CloudSyncSnapshotMigration.predecessorNames(for: slot, obsoleteNames: []).isEmpty) + + var pending = [slot.recordName: Set([predecessor])] + #expect(CloudSyncSnapshotMigration.takeDeletes(forSavedRecordNames: [], pending: &pending).isEmpty) + #expect(pending[slot.recordName] == [predecessor]) + #expect( + CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: [slot.recordName], + pending: &pending) == [predecessor]) + #expect(pending.isEmpty) + } + + private static func claudeSnapshot(accountID: String, email: String) -> AccountSnapshotSyncPayload { + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: email, + accountOrganization: nil, + loginMethod: "claude-swap", + accountID: accountID)) + return AccountSnapshotSyncPayload( + provider: .claude, + deviceID: "device-id", + accountIdentity: accountID, + displayLabel: email, + usage: usage) + } +} From 5dc264d114b6e4f0ac7294fb2547b7cb77bd539b Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 12:09:52 -0700 Subject: [PATCH 03/22] fix(sync): persist leftover snapshot deletes across delayed retries Keep pending predecessor deletes in the persistence envelope before sleeping so a relaunch can finish the CloudKit migration if the retry task never ran. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 38 ++++++++++++++++++- .../CodexBar/Sync/CloudSyncPersistence.swift | 9 ++++- .../CloudSyncSettingsTests.swift | 15 ++++++++ Tests/CodexBarTests/SyncModelTests.swift | 4 ++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 998f1aae50..a07b8e7acf 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -229,6 +229,12 @@ enum CloudSyncSnapshotMigration { return max(retryAfter, 1) } } + + static func finishedFailedDeleteNames(_ failures: [CKRecord.ID: CKError]) -> Set { + Set(failures.compactMap { recordID, error in + self.retryDelay(for: error) == nil ? recordID.recordName : nil + }) + } } enum CloudSyncEntitlementGate { @@ -330,6 +336,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { try await self.queueCurrentConfigurationAndPreferences() guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } try await self.queueDeviceRecord() + self.requeuePendingSnapshotDeletes() self.startPeriodicFetchTimer() self.scheduleFetchChanges(scopedToSyncZone: !initialized) } catch { @@ -659,7 +666,9 @@ actor CloudSyncEngine: CKSyncEngineDelegate { for failure in changes.failedRecordSaves { await self.handleSaveFailure(failure, syncEngine: syncEngine) } - await self.handleFailedDeletes(changes.failedRecordDeletes) + await self.handleSentRecordDeletes( + deletedIDs: changes.deletedRecordIDs, + failures: changes.failedRecordDeletes) await self.finishConfirmedSnapshotMigrations( savedRecordNames: changes.savedRecords.map(\.recordID.recordName), syncEngine: syncEngine) @@ -1107,21 +1116,46 @@ extension CloudSyncEngine { for recordID in recordIDs { syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) } + self.rememberPendingSnapshotDeletes(toDrop) await MainActor.run { toDrop.forEach { self.state.fleetSnapshots.removeValue(forKey: $0) } } } - private func handleFailedDeletes(_ failures: [CKRecord.ID: CKError]) async { + private func handleSentRecordDeletes(deletedIDs: [CKRecord.ID], failures: [CKRecord.ID: CKError]) async { + var finished = Set(deletedIDs.map(\.recordName)) + finished.formUnion(CloudSyncSnapshotMigration.finishedFailedDeleteNames(failures)) + self.forgetPendingSnapshotDeletes(finished) for error in CloudSyncSnapshotMigration.reportableFailedDeletes(failures) { await self.record(error: error) } for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures) { + self.rememberPendingSnapshotDeletes([recordID.recordName]) let delay = failures[recordID].flatMap(CloudSyncSnapshotMigration.retryDelay(for:)) ?? 1 self.scheduleDeleteRetry(recordID: recordID, after: delay) } } + private func rememberPendingSnapshotDeletes(_ names: Set) { + guard !names.isEmpty else { return } + self.persistenceEnvelope.pendingSnapshotDeletes.formUnion(names) + self.persistEnvelope() + } + + private func forgetPendingSnapshotDeletes(_ names: Set) { + let remaining = self.persistenceEnvelope.pendingSnapshotDeletes.subtracting(names) + guard remaining != self.persistenceEnvelope.pendingSnapshotDeletes else { return } + self.persistenceEnvelope.pendingSnapshotDeletes = remaining + self.persistEnvelope() + } + + private func requeuePendingSnapshotDeletes() { + guard let engine = self.engine else { return } + for name in self.persistenceEnvelope.pendingSnapshotDeletes { + engine.state.add(pendingRecordZoneChanges: [.deleteRecord(self.recordID(named: name))]) + } + } + private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { Task { [weak self] in do { diff --git a/Sources/CodexBar/Sync/CloudSyncPersistence.swift b/Sources/CodexBar/Sync/CloudSyncPersistence.swift index 4338429b71..ad24e2d498 100644 --- a/Sources/CodexBar/Sync/CloudSyncPersistence.swift +++ b/Sources/CodexBar/Sync/CloudSyncPersistence.swift @@ -19,6 +19,7 @@ struct CloudSyncPersistence: Sendable { var preferencesDirty: Bool var fleetDevices: [String: DeviceSyncPayload] var fleetSnapshots: [String: AccountSnapshotSyncPayload] + var pendingSnapshotDeletes: Set init( stateSerialization: CKSyncEngine.State.Serialization?, @@ -28,7 +29,8 @@ struct CloudSyncPersistence: Sendable { dirtyProviders: Set = [], preferencesDirty: Bool = false, fleetDevices: [String: DeviceSyncPayload] = [:], - fleetSnapshots: [String: AccountSnapshotSyncPayload] = [:]) + fleetSnapshots: [String: AccountSnapshotSyncPayload] = [:], + pendingSnapshotDeletes: Set = []) { self.stateSerialization = stateSerialization self.encodedSystemFields = encodedSystemFields @@ -38,6 +40,7 @@ struct CloudSyncPersistence: Sendable { self.preferencesDirty = preferencesDirty self.fleetDevices = fleetDevices self.fleetSnapshots = fleetSnapshots + self.pendingSnapshotDeletes = pendingSnapshotDeletes } private enum CodingKeys: String, CodingKey { @@ -49,6 +52,7 @@ struct CloudSyncPersistence: Sendable { case preferencesDirty case fleetDevices case fleetSnapshots + case pendingSnapshotDeletes } init(from decoder: any Decoder) throws { @@ -77,6 +81,9 @@ struct CloudSyncPersistence: Sendable { self.fleetSnapshots = try container.decodeIfPresent( [String: AccountSnapshotSyncPayload].self, forKey: .fleetSnapshots) ?? [:] + self.pendingSnapshotDeletes = try container.decodeIfPresent( + Set.self, + forKey: .pendingSnapshotDeletes) ?? [] } } diff --git a/Tests/CodexBarTests/CloudSyncSettingsTests.swift b/Tests/CodexBarTests/CloudSyncSettingsTests.swift index 6b090461fa..ad201a5431 100644 --- a/Tests/CodexBarTests/CloudSyncSettingsTests.swift +++ b/Tests/CodexBarTests/CloudSyncSettingsTests.swift @@ -162,6 +162,21 @@ struct CloudSyncSettingsTests { #expect(envelope.dirtyProviders.isEmpty) #expect(!envelope.preferencesDirty) + #expect(envelope.pendingSnapshotDeletes.isEmpty) + } + + @Test + func `pending snapshot deletes survive persistence round trip`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CloudSyncPendingDeletesTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("engine-state.json") + let persistence = CloudSyncPersistence(fileURL: fileURL) + var envelope = CloudSyncPersistence.Envelope(stateSerialization: nil, encodedSystemFields: [:]) + envelope.pendingSnapshotDeletes = ["snap-claude-old-device-id"] + try persistence.save(envelope) + + #expect(persistence.load().pendingSnapshotDeletes == ["snap-claude-old-device-id"]) } @Test diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 26bad4afd1..cb42bd6aa1 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -344,6 +344,10 @@ struct CloudSyncSnapshotMigrationDeleteRetryTests { #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.networkFailure)) == 1) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.quotaExceeded, retryAfter: 30)) == 30) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.permissionFailure)) == nil) + #expect( + CloudSyncSnapshotMigration.finishedFailedDeleteNames(failures) == [ + "unknown", "denied", "unauth", "invalid", + ]) } private static func cloudKitError(_ code: CKError.Code, retryAfter: TimeInterval? = nil) -> CKError { From ec188c201b95d33e3018d57550e1f0bcac858177 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 12:42:01 -0700 Subject: [PATCH 04/22] fix(sync): stop retrying terminal CloudKit replacement saves Mark slot-keyed migration snapshots complete after permission, auth, or invalid-argument save failures so the 120s snapshot push does not keep requeueing the same record. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 33 +++++++++++++++++++-- Tests/CodexBarTests/SyncModelTests.swift | 28 +++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index a07b8e7acf..224cc89647 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -235,6 +235,15 @@ enum CloudSyncSnapshotMigration { self.retryDelay(for: error) == nil ? recordID.recordName : nil }) } + + static func abandonedReplacementNames( + failures: [String: CKError], + pendingReplacements: Set) -> Set + { + Set(failures.compactMap { name, error in + pendingReplacements.contains(name) && self.retryDelay(for: error) == nil ? name : nil + }) + } } enum CloudSyncEntitlementGate { @@ -871,6 +880,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { self.recreateZoneAndRequeue(failure.record, syncEngine: syncEngine) } else { await self.record(error: failure.error) + self.abandonTerminalReplacementSave(failure) } } } @@ -1156,6 +1166,24 @@ extension CloudSyncEngine { } } + private func abandonTerminalReplacementSave( + _ failure: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave) + { + let name = failure.record.recordID.recordName + let abandoned = CloudSyncSnapshotMigration.abandonedReplacementNames( + failures: [name: failure.error], + pendingReplacements: Set(self.pendingPredecessorDeletes.keys)) + guard abandoned.contains(name), + self.pendingPredecessorDeletes.removeValue(forKey: name) != nil + else { return } + if self.lastSnapshotHashes[name] == nil, + let payload = self.persistenceEnvelope.fleetSnapshots[name], + let hash = try? CanonicalSyncJSON.hash(payload) + { + self.lastSnapshotHashes[name] = hash + } + } + private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { Task { [weak self] in do { @@ -1200,8 +1228,9 @@ extension CloudSyncEngine { if predecessors.isEmpty { self.lastSnapshotHashes[payload.recordName] = hash } else { - // Keep the hash unset until CloudKit confirms the replacement save so a - // failed send can retry, and so the predecessor is not deleted first. + // Keep the hash unset until CloudKit confirms the replacement save, or a + // terminal save failure abandons this payload, so the predecessor is not + // deleted first and recoverable failures can still retry. self.pendingPredecessorDeletes[payload.recordName, default: []].formUnion(predecessors) } engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index cb42bd6aa1..9ad6e4daa3 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -380,6 +380,34 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending.isEmpty) } + @Test + func `terminal replacement save failures stop retrying the same payload`() { + let slot = "snap-claude-slot-device-id" + let failures = [ + slot: Self.cloudKitError(.permissionFailure), + "snap-other": Self.cloudKitError(.networkFailure), + "snap-quota": Self.cloudKitError(.quotaExceeded, retryAfter: 12), + ] + + #expect( + CloudSyncSnapshotMigration.abandonedReplacementNames( + failures: failures, + pendingReplacements: [slot, "snap-quota"]) == [slot]) + #expect( + CloudSyncSnapshotMigration.abandonedReplacementNames( + failures: [slot: Self.cloudKitError(.networkFailure)], + pendingReplacements: [slot]).isEmpty) + } + + private static func cloudKitError(_ code: CKError.Code, retryAfter: TimeInterval? = nil) -> CKError { + var userInfo: [String: Any] = [:] + if let retryAfter { + userInfo[CKErrorRetryAfterKey] = NSNumber(value: retryAfter) + } + let nsError = NSError(domain: CKErrorDomain, code: code.rawValue, userInfo: userInfo) + return CKError(_nsError: nsError) + } + private static func claudeSnapshot(accountID: String, email: String) -> AccountSnapshotSyncPayload { let usage = UsageSnapshot( primary: nil, From eac53a8b5e1938971deb8ec0f97d28da9911729e Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 13:44:55 -0700 Subject: [PATCH 05/22] fix(sync): bind delayed CloudKit deletes to the originating engine Skip leftover-record retries after an account switch so a sleeping task cannot delete a same-named snapshot in a newly signed-in iCloud account. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 18 +++++++++++++++++- Tests/CodexBarTests/SyncModelTests.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 224cc89647..e1a30364ef 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -244,6 +244,14 @@ enum CloudSyncSnapshotMigration { pendingReplacements.contains(name) && self.retryDelay(for: error) == nil ? name : nil }) } + + static func shouldResumeDelayedRetry( + originatingEngine: ObjectIdentifier?, + currentEngine: ObjectIdentifier?) -> Bool + { + guard let originatingEngine, let currentEngine else { return false } + return originatingEngine == currentEngine + } } enum CloudSyncEntitlementGate { @@ -1186,14 +1194,22 @@ extension CloudSyncEngine { private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { Task { [weak self] in + let originatingEngine = await self?.engine.map { ObjectIdentifier($0) } do { if delay > 0 { try await Task.sleep(for: .seconds(delay)) } await Task.yield() - guard let self, let engine = await self.engine, await self.enabled else { return } + guard let self, await self.enabled else { return } + guard let engine = await self.engine, + CloudSyncSnapshotMigration.shouldResumeDelayedRetry( + originatingEngine: originatingEngine, + currentEngine: ObjectIdentifier(engine)) + else { return } engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) try await engine.sendChanges(.init(scope: .recordIDs([recordID]))) + } catch is CancellationError { + return } catch { await self?.record(error: error) } diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 9ad6e4daa3..517f4f6f92 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -399,6 +399,23 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { pendingReplacements: [slot]).isEmpty) } + @Test + func `delayed delete retries do not resume on a replacement sync engine`() { + let original = NSObject() + #expect( + CloudSyncSnapshotMigration.shouldResumeDelayedRetry( + originatingEngine: ObjectIdentifier(original), + currentEngine: ObjectIdentifier(original))) + #expect( + !CloudSyncSnapshotMigration.shouldResumeDelayedRetry( + originatingEngine: ObjectIdentifier(original), + currentEngine: ObjectIdentifier(NSObject()))) + #expect( + !CloudSyncSnapshotMigration.shouldResumeDelayedRetry( + originatingEngine: ObjectIdentifier(original), + currentEngine: nil)) + } + private static func cloudKitError(_ code: CKError.Code, retryAfter: TimeInterval? = nil) -> CKError { var userInfo: [String: Any] = [:] if let retryAfter { From 7f93015749de5c484488dd56a73402c02f82eeea Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 14:44:25 -0700 Subject: [PATCH 06/22] fix(sync): drop stale CloudKit predecessors that are live again A later live email-keyed snapshot must not stay queued for delete just because an earlier slot-keyed save still has a pending predecessor set. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 36 ++++++++++++++++++--- Tests/CodexBarTests/SyncModelTests.swift | 22 +++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index e1a30364ef..96dafeea80 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -204,6 +204,28 @@ enum CloudSyncSnapshotMigration { return toDrop } + static func retainingObsoletePredecessors( + in pending: inout [String: Set], + obsoleteNames: Set) + { + pending = pending.compactMapValues { predecessors in + let live = predecessors.intersection(obsoleteNames) + return live.isEmpty ? nil : live + } + } + + static func assigningPredecessors( + _ predecessors: Set, + to replacement: String, + pending: inout [String: Set]) + { + if predecessors.isEmpty { + pending.removeValue(forKey: replacement) + } else { + pending[replacement] = predecessors + } + } + static func retryableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKRecord.ID] { failures.compactMap { recordID, error in self.retryDelay(for: error) == nil ? nil : recordID @@ -1224,6 +1246,9 @@ extension CloudSyncEngine { liveSnapshots: self.pendingSnapshots, hashes: self.lastSnapshotHashes, envelope: self.persistenceEnvelope) + CloudSyncSnapshotMigration.retainingObsoletePredecessors( + in: &self.pendingPredecessorDeletes, + obsoleteNames: obsoleteNames) for payload in self.pendingSnapshots { let hash = try CanonicalSyncJSON.hash(payload) guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } @@ -1243,12 +1268,13 @@ extension CloudSyncEngine { obsoleteNames: obsoleteNames) if predecessors.isEmpty { self.lastSnapshotHashes[payload.recordName] = hash - } else { - // Keep the hash unset until CloudKit confirms the replacement save, or a - // terminal save failure abandons this payload, so the predecessor is not - // deleted first and recoverable failures can still retry. - self.pendingPredecessorDeletes[payload.recordName, default: []].formUnion(predecessors) } + // Replace, don't union: a later live email-keyed snapshot must not stay queued + // for delete after the slot-keyed save is confirmed. + CloudSyncSnapshotMigration.assigningPredecessors( + predecessors, + to: payload.recordName, + pending: &self.pendingPredecessorDeletes) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } self.pendingSnapshots = [] diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 517f4f6f92..0e2966f19b 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -380,6 +380,28 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending.isEmpty) } + @Test + func `pending predecessors drop names that are live again`() throws { + let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") + let predecessor = try #require(slot.emailKeyedPredecessorRecordName()) + var pending = [slot.recordName: Set([predecessor])] + + CloudSyncSnapshotMigration.retainingObsoletePredecessors( + in: &pending, + obsoleteNames: []) + #expect(pending.isEmpty) + + pending = [slot.recordName: [predecessor, "snap-stale"]] + CloudSyncSnapshotMigration.assigningPredecessors( + [predecessor], + to: slot.recordName, + pending: &pending) + #expect(pending[slot.recordName] == [predecessor]) + + CloudSyncSnapshotMigration.assigningPredecessors([], to: slot.recordName, pending: &pending) + #expect(pending.isEmpty) + } + @Test func `terminal replacement save failures stop retrying the same payload`() { let slot = "snap-claude-slot-device-id" From 290cfee9a2e2beeb02703f8330a942163ee0bd65 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 15:11:45 -0700 Subject: [PATCH 07/22] fix(sync): cancel leftover snapshot deletes when they become live A delayed CloudKit retry must not delete an email-keyed snapshot that was published again after a transient predecessor delete. Drop that name from the persisted retry set and the engine queue. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 23 +++++++++++++++++++++ Tests/CodexBarTests/SyncModelTests.swift | 15 ++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 96dafeea80..2b852c73e5 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -226,6 +226,13 @@ enum CloudSyncSnapshotMigration { } } + static func cancelledPersistedDeletes( + pendingDeletes: Set, + liveNames: Set) -> Set + { + pendingDeletes.intersection(liveNames) + } + static func retryableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKRecord.ID] { failures.compactMap { recordID, error in self.retryDelay(for: error) == nil ? nil : recordID @@ -1189,6 +1196,15 @@ extension CloudSyncEngine { self.persistEnvelope() } + private func cancelPendingSnapshotDeletes(_ names: Set) { + guard !names.isEmpty else { return } + self.forgetPendingSnapshotDeletes(names) + guard let engine = self.engine else { return } + engine.state.remove(pendingRecordZoneChanges: names.map { name in + .deleteRecord(self.recordID(named: name)) + }) + } + private func requeuePendingSnapshotDeletes() { guard let engine = self.engine else { return } for name in self.persistenceEnvelope.pendingSnapshotDeletes { @@ -1223,6 +1239,9 @@ extension CloudSyncEngine { } await Task.yield() guard let self, await self.enabled else { return } + guard await self.persistenceEnvelope.pendingSnapshotDeletes.contains(recordID.recordName) else { + return + } guard let engine = await self.engine, CloudSyncSnapshotMigration.shouldResumeDelayedRetry( originatingEngine: originatingEngine, @@ -1249,6 +1268,10 @@ extension CloudSyncEngine { CloudSyncSnapshotMigration.retainingObsoletePredecessors( in: &self.pendingPredecessorDeletes, obsoleteNames: obsoleteNames) + self.cancelPendingSnapshotDeletes( + CloudSyncSnapshotMigration.cancelledPersistedDeletes( + pendingDeletes: self.persistenceEnvelope.pendingSnapshotDeletes, + liveNames: Set(self.pendingSnapshots.map(\.recordName)))) for payload in self.pendingSnapshots { let hash = try CanonicalSyncJSON.hash(payload) guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 0e2966f19b..d99dd0d9fc 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -402,6 +402,21 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending.isEmpty) } + @Test + func `persisted deletes are cancelled when the predecessor is live again`() throws { + let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") + let predecessor = try #require(slot.emailKeyedPredecessorRecordName()) + + #expect( + CloudSyncSnapshotMigration.cancelledPersistedDeletes( + pendingDeletes: [predecessor, "snap-claude-stale-device-id"], + liveNames: [predecessor]) == [predecessor]) + #expect( + CloudSyncSnapshotMigration.cancelledPersistedDeletes( + pendingDeletes: ["snap-claude-stale-device-id"], + liveNames: [predecessor]).isEmpty) + } + @Test func `terminal replacement save failures stop retrying the same payload`() { let slot = "snap-claude-slot-device-id" From 7e663aea7a9d41266b769ac60a5837721e9ed1ea Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 15:38:30 -0700 Subject: [PATCH 08/22] fix(sync): do not retry CloudKit deletes for live snapshots A transient in-flight delete can land after the predecessor is live again. Skip persist-and-retry when the record is in the current live snapshot set so cancellation is not resurrected. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 21 ++++++++++++++--- Tests/CodexBarTests/SyncModelTests.swift | 25 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 2b852c73e5..f61c523846 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -233,9 +233,21 @@ enum CloudSyncSnapshotMigration { pendingDeletes.intersection(liveNames) } - static func retryableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKRecord.ID] { + static func liveSnapshotRecordNames( + pendingRecordNames: some Sequence, + storedRecordNames: some Sequence) -> Set + { + Set(pendingRecordNames).union(storedRecordNames) + } + + static func retryableFailedDeletes( + _ failures: [CKRecord.ID: CKError], + liveNames: Set = []) -> [CKRecord.ID] + { failures.compactMap { recordID, error in - self.retryDelay(for: error) == nil ? nil : recordID + guard self.retryDelay(for: error) != nil else { return nil } + guard !liveNames.contains(recordID.recordName) else { return nil } + return recordID } } @@ -1176,7 +1188,10 @@ extension CloudSyncEngine { for error in CloudSyncSnapshotMigration.reportableFailedDeletes(failures) { await self.record(error: error) } - for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures) { + let liveNames = CloudSyncSnapshotMigration.liveSnapshotRecordNames( + pendingRecordNames: self.pendingSnapshots.map(\.recordName), + storedRecordNames: self.persistenceEnvelope.fleetSnapshots.keys) + for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures, liveNames: liveNames) { self.rememberPendingSnapshotDeletes([recordID.recordName]) let delay = failures[recordID].flatMap(CloudSyncSnapshotMigration.retryDelay(for:)) ?? 1 self.scheduleDeleteRetry(recordID: recordID, after: delay) diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index d99dd0d9fc..74dd0e5d77 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -350,6 +350,31 @@ struct CloudSyncSnapshotMigrationDeleteRetryTests { ]) } + @Test + func `transient delete failures do not retry live snapshots`() { + let zoneID = CloudSyncEngine.zoneID + func recordID(_ name: String) -> CKRecord.ID { + CKRecord.ID(recordName: name, zoneID: zoneID) + } + + let failures: [CKRecord.ID: CKError] = [ + recordID("snap-live"): Self.cloudKitError(.networkFailure), + recordID("snap-obsolete"): Self.cloudKitError(.networkFailure), + recordID("snap-denied"): Self.cloudKitError(.permissionFailure), + ] + + let liveNames = CloudSyncSnapshotMigration.liveSnapshotRecordNames( + pendingRecordNames: ["snap-pending"], + storedRecordNames: ["snap-live", "snap-slot"]) + #expect(liveNames == ["snap-pending", "snap-live", "snap-slot"]) + + let retryable = Set( + CloudSyncSnapshotMigration.retryableFailedDeletes( + failures, + liveNames: ["snap-live"]).map(\.recordName)) + #expect(retryable == ["snap-obsolete"]) + } + private static func cloudKitError(_ code: CKError.Code, retryAfter: TimeInterval? = nil) -> CKError { var userInfo: [String: Any] = [:] if let retryAfter { From d400c407f08a86e8580bdc58049a9c4cd32140c7 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 15:58:05 -0700 Subject: [PATCH 09/22] fix(sync): isolate CloudKit migration state from the next iCloud account Clear predecessor maps and snapshot hashes when persistence is wiped, and requeue leftover deletes only after the current live snapshot set has been reconciled. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 19 +++++++++++++++++-- Tests/CodexBarTests/SyncModelTests.swift | 4 ++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index f61c523846..f15dcb4463 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -233,6 +233,13 @@ enum CloudSyncSnapshotMigration { pendingDeletes.intersection(liveNames) } + static func pendingDeletesToRequeue( + pendingDeletes: Set, + liveNames: Set) -> Set + { + pendingDeletes.subtracting(liveNames) + } + static func liveSnapshotRecordNames( pendingRecordNames: some Sequence, storedRecordNames: some Sequence) -> Set @@ -394,7 +401,6 @@ actor CloudSyncEngine: CKSyncEngineDelegate { try await self.queueCurrentConfigurationAndPreferences() guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } try await self.queueDeviceRecord() - self.requeuePendingSnapshotDeletes() self.startPeriodicFetchTimer() self.scheduleFetchChanges(scopedToSyncZone: !initialized) } catch { @@ -1035,6 +1041,8 @@ actor CloudSyncEngine: CKSyncEngineDelegate { self.quotaRetryState.reset() if clearPersistence { self.persistenceEnvelope = .init(stateSerialization: nil, encodedSystemFields: [:]) + self.lastSnapshotHashes = [:] + self.pendingPredecessorDeletes = [:] self.didRehydrateFleetState = false try? self.persistence.delete() await MainActor.run { @@ -1222,7 +1230,13 @@ extension CloudSyncEngine { private func requeuePendingSnapshotDeletes() { guard let engine = self.engine else { return } - for name in self.persistenceEnvelope.pendingSnapshotDeletes { + let liveNames = CloudSyncSnapshotMigration.liveSnapshotRecordNames( + pendingRecordNames: self.pendingSnapshots.map(\.recordName), + storedRecordNames: self.persistenceEnvelope.fleetSnapshots.keys) + let names = CloudSyncSnapshotMigration.pendingDeletesToRequeue( + pendingDeletes: self.persistenceEnvelope.pendingSnapshotDeletes, + liveNames: liveNames) + for name in names { engine.state.add(pendingRecordZoneChanges: [.deleteRecord(self.recordID(named: name))]) } } @@ -1287,6 +1301,7 @@ extension CloudSyncEngine { CloudSyncSnapshotMigration.cancelledPersistedDeletes( pendingDeletes: self.persistenceEnvelope.pendingSnapshotDeletes, liveNames: Set(self.pendingSnapshots.map(\.recordName)))) + self.requeuePendingSnapshotDeletes() for payload in self.pendingSnapshots { let hash = try CanonicalSyncJSON.hash(payload) guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 74dd0e5d77..d08f2fb76a 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -440,6 +440,10 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { CloudSyncSnapshotMigration.cancelledPersistedDeletes( pendingDeletes: ["snap-claude-stale-device-id"], liveNames: [predecessor]).isEmpty) + #expect( + CloudSyncSnapshotMigration.pendingDeletesToRequeue( + pendingDeletes: [predecessor, "snap-claude-stale-device-id"], + liveNames: [predecessor]) == ["snap-claude-stale-device-id"]) } @Test From 7d75b12c2a58efe17ff0d4e49fcf76709d40c5b4 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 16:11:19 -0700 Subject: [PATCH 10/22] fix(sync): persist predecessor deletes and requeue empty publications Keep the replacement-to-predecessor map in the persistence envelope across relaunch, and still requeue leftover snapshot deletes when the next publication is empty. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 31 ++++++++++--------- .../CodexBar/Sync/CloudSyncPersistence.swift | 9 +++++- .../CloudSyncSettingsTests.swift | 17 ++++++++++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index f15dcb4463..b9211bbd68 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -336,7 +336,6 @@ actor CloudSyncEngine: CKSyncEngineDelegate { private var lastSnapshotPushAt: Date? private var pendingSnapshots: [AccountSnapshotSyncPayload] = [] private var lastSnapshotHashes: [String: String] = [:] - private var pendingPredecessorDeletes: [String: Set] = [:] private var lastKnownProviderConfigs: [ProviderInstanceID: ProviderConfig] = [:] private var lastKnownPreferences: SyncedPreferences? private var lastKnownIncludeSecrets: Bool? @@ -1042,7 +1041,6 @@ actor CloudSyncEngine: CKSyncEngineDelegate { if clearPersistence { self.persistenceEnvelope = .init(stateSerialization: nil, encodedSystemFields: [:]) self.lastSnapshotHashes = [:] - self.pendingPredecessorDeletes = [:] self.didRehydrateFleetState = false try? self.persistence.delete() await MainActor.run { @@ -1165,7 +1163,7 @@ extension CloudSyncEngine { { let toDrop = CloudSyncSnapshotMigration.takeDeletes( forSavedRecordNames: savedRecordNames, - pending: &self.pendingPredecessorDeletes) + pending: &self.persistenceEnvelope.pendingPredecessorDeletes) for name in savedRecordNames { guard self.lastSnapshotHashes[name] == nil, let payload = self.persistenceEnvelope.fleetSnapshots[name], @@ -1247,9 +1245,9 @@ extension CloudSyncEngine { let name = failure.record.recordID.recordName let abandoned = CloudSyncSnapshotMigration.abandonedReplacementNames( failures: [name: failure.error], - pendingReplacements: Set(self.pendingPredecessorDeletes.keys)) + pendingReplacements: Set(self.persistenceEnvelope.pendingPredecessorDeletes.keys)) guard abandoned.contains(name), - self.pendingPredecessorDeletes.removeValue(forKey: name) != nil + self.persistenceEnvelope.pendingPredecessorDeletes.removeValue(forKey: name) != nil else { return } if self.lastSnapshotHashes[name] == nil, let payload = self.persistenceEnvelope.fleetSnapshots[name], @@ -1287,20 +1285,25 @@ extension CloudSyncEngine { } private func pushPendingSnapshots() async { - guard let engine = self.engine, !self.pendingSnapshots.isEmpty else { return } + guard let engine = self.engine else { return } + guard !self.pendingSnapshots.isEmpty || !self.persistenceEnvelope.pendingSnapshotDeletes.isEmpty else { + return + } guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } do { let obsoleteNames = CloudSyncSnapshotMigration.obsoleteRecordNames( liveSnapshots: self.pendingSnapshots, hashes: self.lastSnapshotHashes, envelope: self.persistenceEnvelope) - CloudSyncSnapshotMigration.retainingObsoletePredecessors( - in: &self.pendingPredecessorDeletes, - obsoleteNames: obsoleteNames) - self.cancelPendingSnapshotDeletes( - CloudSyncSnapshotMigration.cancelledPersistedDeletes( - pendingDeletes: self.persistenceEnvelope.pendingSnapshotDeletes, - liveNames: Set(self.pendingSnapshots.map(\.recordName)))) + if !self.pendingSnapshots.isEmpty { + CloudSyncSnapshotMigration.retainingObsoletePredecessors( + in: &self.persistenceEnvelope.pendingPredecessorDeletes, + obsoleteNames: obsoleteNames) + self.cancelPendingSnapshotDeletes( + CloudSyncSnapshotMigration.cancelledPersistedDeletes( + pendingDeletes: self.persistenceEnvelope.pendingSnapshotDeletes, + liveNames: Set(self.pendingSnapshots.map(\.recordName)))) + } self.requeuePendingSnapshotDeletes() for payload in self.pendingSnapshots { let hash = try CanonicalSyncJSON.hash(payload) @@ -1327,7 +1330,7 @@ extension CloudSyncEngine { CloudSyncSnapshotMigration.assigningPredecessors( predecessors, to: payload.recordName, - pending: &self.pendingPredecessorDeletes) + pending: &self.persistenceEnvelope.pendingPredecessorDeletes) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } self.pendingSnapshots = [] diff --git a/Sources/CodexBar/Sync/CloudSyncPersistence.swift b/Sources/CodexBar/Sync/CloudSyncPersistence.swift index ad24e2d498..bc0324bdaa 100644 --- a/Sources/CodexBar/Sync/CloudSyncPersistence.swift +++ b/Sources/CodexBar/Sync/CloudSyncPersistence.swift @@ -20,6 +20,7 @@ struct CloudSyncPersistence: Sendable { var fleetDevices: [String: DeviceSyncPayload] var fleetSnapshots: [String: AccountSnapshotSyncPayload] var pendingSnapshotDeletes: Set + var pendingPredecessorDeletes: [String: Set] init( stateSerialization: CKSyncEngine.State.Serialization?, @@ -30,7 +31,8 @@ struct CloudSyncPersistence: Sendable { preferencesDirty: Bool = false, fleetDevices: [String: DeviceSyncPayload] = [:], fleetSnapshots: [String: AccountSnapshotSyncPayload] = [:], - pendingSnapshotDeletes: Set = []) + pendingSnapshotDeletes: Set = [], + pendingPredecessorDeletes: [String: Set] = [:]) { self.stateSerialization = stateSerialization self.encodedSystemFields = encodedSystemFields @@ -41,6 +43,7 @@ struct CloudSyncPersistence: Sendable { self.fleetDevices = fleetDevices self.fleetSnapshots = fleetSnapshots self.pendingSnapshotDeletes = pendingSnapshotDeletes + self.pendingPredecessorDeletes = pendingPredecessorDeletes } private enum CodingKeys: String, CodingKey { @@ -53,6 +56,7 @@ struct CloudSyncPersistence: Sendable { case fleetDevices case fleetSnapshots case pendingSnapshotDeletes + case pendingPredecessorDeletes } init(from decoder: any Decoder) throws { @@ -84,6 +88,9 @@ struct CloudSyncPersistence: Sendable { self.pendingSnapshotDeletes = try container.decodeIfPresent( Set.self, forKey: .pendingSnapshotDeletes) ?? [] + self.pendingPredecessorDeletes = try container.decodeIfPresent( + [String: Set].self, + forKey: .pendingPredecessorDeletes) ?? [:] } } diff --git a/Tests/CodexBarTests/CloudSyncSettingsTests.swift b/Tests/CodexBarTests/CloudSyncSettingsTests.swift index ad201a5431..9f76b604c3 100644 --- a/Tests/CodexBarTests/CloudSyncSettingsTests.swift +++ b/Tests/CodexBarTests/CloudSyncSettingsTests.swift @@ -179,6 +179,23 @@ struct CloudSyncSettingsTests { #expect(persistence.load().pendingSnapshotDeletes == ["snap-claude-old-device-id"]) } + @Test + func `pending predecessor deletes survive persistence round trip`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CloudSyncPendingPredecessorsTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("engine-state.json") + let persistence = CloudSyncPersistence(fileURL: fileURL) + var envelope = CloudSyncPersistence.Envelope(stateSerialization: nil, encodedSystemFields: [:]) + envelope.pendingPredecessorDeletes = ["snap-claude-slot-device-id": ["snap-claude-old-device-id"]] + try persistence.save(envelope) + + #expect( + persistence.load().pendingPredecessorDeletes["snap-claude-slot-device-id"] == [ + "snap-claude-old-device-id", + ]) + } + @Test func `relaunch with cached fleet records and clean dirty set queues no configuration records`() { let metadata = CloudSyncPersistence.RecordMetadata( From a4b052df29ee1a55584c0fa3eb29c8f18f9ee7b3 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 16:23:50 -0700 Subject: [PATCH 11/22] fix(sync): wait for every shared-mailbox replacement before deleting Two Claude Swap slots can share one email-keyed predecessor. Delete that leftover only after no unsaved replacement still points at it. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 3 ++- Tests/CodexBarTests/SyncModelTests.swift | 22 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index b9211bbd68..30c29de9e5 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -201,7 +201,8 @@ enum CloudSyncSnapshotMigration { toDrop.formUnion(obsolete) } } - return toDrop + let stillReferenced = Set(pending.values.joined()) + return toDrop.subtracting(stillReferenced) } static func retainingObsoletePredecessors( diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index d08f2fb76a..cb3a9ddf1a 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -405,6 +405,28 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending.isEmpty) } + @Test + func `shared predecessor waits for every slot replacement to save`() throws { + let first = Self.claudeSnapshot(accountID: "claude-swap:1", email: "shared@example.com") + let second = Self.claudeSnapshot(accountID: "claude-swap:2", email: "shared@example.com") + let predecessor = try #require(first.emailKeyedPredecessorRecordName()) + var pending = [ + first.recordName: Set([predecessor]), + second.recordName: Set([predecessor]), + ] + + #expect( + CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: [first.recordName], + pending: &pending).isEmpty) + #expect(pending[second.recordName] == [predecessor]) + #expect( + CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: [second.recordName], + pending: &pending) == [predecessor]) + #expect(pending.isEmpty) + } + @Test func `pending predecessors drop names that are live again`() throws { let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") From 535a74795d1b0cbddd216890337740ee1728ed94 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 16:43:00 -0700 Subject: [PATCH 12/22] fix(sync): retry lost CloudKit responses and keep shared leftovers Treat serverResponseLost as a recoverable retry, and confirm saved replacements before abandoning failed siblings so a shared email-keyed record is not deleted early. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 11 +++++--- Tests/CodexBarTests/SyncModelTests.swift | 30 ++++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 30c29de9e5..060dfdc764 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -271,7 +271,8 @@ enum CloudSyncSnapshotMigration { switch error.code { case .unknownItem, .permissionFailure, .notAuthenticated, .invalidArguments: return nil - case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, .zoneBusy, .quotaExceeded: + case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, .zoneBusy, .quotaExceeded, + .serverResponseLost: return max(error.retryAfterSeconds ?? 1, 1) default: guard let retryAfter = error.retryAfterSeconds else { return nil } @@ -727,15 +728,17 @@ actor CloudSyncEngine: CKSyncEngineDelegate { CloudSyncDirtyState.clearSavedRecords( changes.savedRecords.map(\.recordID.recordName), envelope: &self.persistenceEnvelope) + // Evaluate confirmed saves before abandoning terminal failures so a mixed batch + // still counts a failed sibling's shared predecessor as referenced. + await self.finishConfirmedSnapshotMigrations( + savedRecordNames: changes.savedRecords.map(\.recordID.recordName), + syncEngine: syncEngine) for failure in changes.failedRecordSaves { await self.handleSaveFailure(failure, syncEngine: syncEngine) } await self.handleSentRecordDeletes( deletedIDs: changes.deletedRecordIDs, failures: changes.failedRecordDeletes) - await self.finishConfirmedSnapshotMigrations( - savedRecordNames: changes.savedRecords.map(\.recordID.recordName), - syncEngine: syncEngine) self.persistEnvelope() if !changes.savedRecords.isEmpty { await MainActor.run { self.state.status.lastSuccessfulPushAt = Date() } diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index cb3a9ddf1a..5d3638487f 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -333,16 +333,18 @@ struct CloudSyncSnapshotMigrationDeleteRetryTests { recordID("invalid"): Self.cloudKitError(.invalidArguments), recordID("network"): Self.cloudKitError(.networkFailure), recordID("quota"): Self.cloudKitError(.quotaExceeded, retryAfter: 30), + recordID("lost"): Self.cloudKitError(.serverResponseLost), ] let retryable = Set(CloudSyncSnapshotMigration.retryableFailedDeletes(failures).map(\.recordName)) let reported = Set(CloudSyncSnapshotMigration.reportableFailedDeletes(failures).map(\.code)) - #expect(retryable == ["network", "quota"]) + #expect(retryable == ["network", "quota", "lost"]) #expect(reported == [.permissionFailure, .notAuthenticated, .invalidArguments]) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.unknownItem)) == nil) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.networkFailure)) == 1) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.quotaExceeded, retryAfter: 30)) == 30) + #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.serverResponseLost)) == 1) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.permissionFailure)) == nil) #expect( CloudSyncSnapshotMigration.finishedFailedDeleteNames(failures) == [ @@ -427,6 +429,32 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending.isEmpty) } + @Test + func `failed sibling replacement keeps a shared predecessor after a confirmed save`() throws { + let first = Self.claudeSnapshot(accountID: "claude-swap:1", email: "shared@example.com") + let second = Self.claudeSnapshot(accountID: "claude-swap:2", email: "shared@example.com") + let predecessor = try #require(first.emailKeyedPredecessorRecordName()) + var pending = [ + first.recordName: Set([predecessor]), + second.recordName: Set([predecessor]), + ] + + #expect( + CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: [first.recordName], + pending: &pending).isEmpty) + #expect(pending[second.recordName] == [predecessor]) + + let abandoned = CloudSyncSnapshotMigration.abandonedReplacementNames( + failures: [second.recordName: Self.cloudKitError(.permissionFailure)], + pendingReplacements: Set(pending.keys)) + #expect(abandoned == [second.recordName]) + for name in abandoned { + pending.removeValue(forKey: name) + } + #expect(pending.isEmpty) + } + @Test func `pending predecessors drop names that are live again`() throws { let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") From f49133799c96155f17417562f2923ab96efadaa2 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 16:58:16 -0700 Subject: [PATCH 13/22] fix(sync): queue predecessor deletes for unchanged slot payloads When a slot snapshot is already published, newly obsolete email-keyed leftovers still need to be recorded and deleted instead of being skipped by the payload-hash shortcut. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 28 +++++++++++++-------- Tests/CodexBarTests/SyncModelTests.swift | 17 +++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 060dfdc764..eb69e7f0b8 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -1311,7 +1311,24 @@ extension CloudSyncEngine { self.requeuePendingSnapshotDeletes() for payload in self.pendingSnapshots { let hash = try CanonicalSyncJSON.hash(payload) - guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } + let predecessors = CloudSyncSnapshotMigration.predecessorNames( + for: payload, + obsoleteNames: obsoleteNames) + // Replace, don't union: a later live email-keyed snapshot must not stay queued + // for delete after the slot-keyed save is confirmed. + CloudSyncSnapshotMigration.assigningPredecessors( + predecessors, + to: payload.recordName, + pending: &self.persistenceEnvelope.pendingPredecessorDeletes) + let alreadyPublished = self.lastSnapshotHashes[payload.recordName] == hash + if alreadyPublished { + if !predecessors.isEmpty { + await self.finishConfirmedSnapshotMigrations( + savedRecordNames: [payload.recordName], + syncEngine: engine) + } + continue + } let recordID = self.recordID(named: payload.recordName) let record = self.record(type: .accountSnapshot, id: recordID) record["schemaVersion"] = payload.schemaVersion as CKRecordValue @@ -1323,18 +1340,9 @@ extension CloudSyncEngine { record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue self.desiredRecords[recordID] = record self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload - let predecessors = CloudSyncSnapshotMigration.predecessorNames( - for: payload, - obsoleteNames: obsoleteNames) if predecessors.isEmpty { self.lastSnapshotHashes[payload.recordName] = hash } - // Replace, don't union: a later live email-keyed snapshot must not stay queued - // for delete after the slot-keyed save is confirmed. - CloudSyncSnapshotMigration.assigningPredecessors( - predecessors, - to: payload.recordName, - pending: &self.persistenceEnvelope.pendingPredecessorDeletes) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } self.pendingSnapshots = [] diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 5d3638487f..8bff843f61 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -455,6 +455,23 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending.isEmpty) } + @Test + func `already published replacements still delete newly obsolete predecessors`() throws { + let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") + let predecessor = try #require(slot.emailKeyedPredecessorRecordName()) + var pending: [String: Set] = [:] + + CloudSyncSnapshotMigration.assigningPredecessors( + [predecessor], + to: slot.recordName, + pending: &pending) + #expect( + CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: [slot.recordName], + pending: &pending) == [predecessor]) + #expect(pending.isEmpty) + } + @Test func `pending predecessors drop names that are live again`() throws { let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") From dbc3445215fe30675631e176ff7da1c4791d762e Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 17:16:05 -0700 Subject: [PATCH 14/22] fix(sync): confirm slot saves and ignore remote cache as live Predecessor deletes now wait for a confirmed replacement hash, and delete retries treat only local pending/confirmed snapshots as live so a fetched leftover cannot cancel its own removal. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 14 +++++++------- Tests/CodexBarTests/SyncModelTests.swift | 5 +++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index eb69e7f0b8..9c71092ee5 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -1199,8 +1199,9 @@ extension CloudSyncEngine { await self.record(error: error) } let liveNames = CloudSyncSnapshotMigration.liveSnapshotRecordNames( - pendingRecordNames: self.pendingSnapshots.map(\.recordName), - storedRecordNames: self.persistenceEnvelope.fleetSnapshots.keys) + pendingRecordNames: self.pendingSnapshots.map(\.recordName) + + self.desiredRecords.keys.map(\.recordName), + storedRecordNames: self.lastSnapshotHashes.keys) for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures, liveNames: liveNames) { self.rememberPendingSnapshotDeletes([recordID.recordName]) let delay = failures[recordID].flatMap(CloudSyncSnapshotMigration.retryDelay(for:)) ?? 1 @@ -1233,8 +1234,9 @@ extension CloudSyncEngine { private func requeuePendingSnapshotDeletes() { guard let engine = self.engine else { return } let liveNames = CloudSyncSnapshotMigration.liveSnapshotRecordNames( - pendingRecordNames: self.pendingSnapshots.map(\.recordName), - storedRecordNames: self.persistenceEnvelope.fleetSnapshots.keys) + pendingRecordNames: self.pendingSnapshots.map(\.recordName) + + self.desiredRecords.keys.map(\.recordName), + storedRecordNames: self.lastSnapshotHashes.keys) let names = CloudSyncSnapshotMigration.pendingDeletesToRequeue( pendingDeletes: self.persistenceEnvelope.pendingSnapshotDeletes, liveNames: liveNames) @@ -1320,6 +1322,7 @@ extension CloudSyncEngine { predecessors, to: payload.recordName, pending: &self.persistenceEnvelope.pendingPredecessorDeletes) + // Hashes are recorded only after CloudKit confirms a save. let alreadyPublished = self.lastSnapshotHashes[payload.recordName] == hash if alreadyPublished { if !predecessors.isEmpty { @@ -1340,9 +1343,6 @@ extension CloudSyncEngine { record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue self.desiredRecords[recordID] = record self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload - if predecessors.isEmpty { - self.lastSnapshotHashes[payload.recordName] = hash - } engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } self.pendingSnapshots = [] diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 8bff843f61..745c9b1343 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -367,8 +367,9 @@ struct CloudSyncSnapshotMigrationDeleteRetryTests { let liveNames = CloudSyncSnapshotMigration.liveSnapshotRecordNames( pendingRecordNames: ["snap-pending"], - storedRecordNames: ["snap-live", "snap-slot"]) - #expect(liveNames == ["snap-pending", "snap-live", "snap-slot"]) + storedRecordNames: ["snap-confirmed"]) + #expect(liveNames == ["snap-pending", "snap-confirmed"]) + #expect(!liveNames.contains("snap-remote-cache")) let retryable = Set( CloudSyncSnapshotMigration.retryableFailedDeletes( From aff7c1941e9ec9fa406a1308ab564aeb9e500a0d Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 17:30:36 -0700 Subject: [PATCH 15/22] fix(sync): do not treat terminal save failures as confirmed Skip retrying an unchanged terminal replacement without recording it in lastSnapshotHashes, so an unconfirmed slot cannot retire an email-keyed leftover. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 9c71092ee5..6ab82fe5f1 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -338,6 +338,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { private var lastSnapshotPushAt: Date? private var pendingSnapshots: [AccountSnapshotSyncPayload] = [] private var lastSnapshotHashes: [String: String] = [:] + private var skippedTerminalReplacementHashes: [String: String] = [:] private var lastKnownProviderConfigs: [ProviderInstanceID: ProviderConfig] = [:] private var lastKnownPreferences: SyncedPreferences? private var lastKnownIncludeSecrets: Bool? @@ -1045,6 +1046,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { if clearPersistence { self.persistenceEnvelope = .init(stateSerialization: nil, encodedSystemFields: [:]) self.lastSnapshotHashes = [:] + self.skippedTerminalReplacementHashes = [:] self.didRehydrateFleetState = false try? self.persistence.delete() await MainActor.run { @@ -1255,11 +1257,10 @@ extension CloudSyncEngine { guard abandoned.contains(name), self.persistenceEnvelope.pendingPredecessorDeletes.removeValue(forKey: name) != nil else { return } - if self.lastSnapshotHashes[name] == nil, - let payload = self.persistenceEnvelope.fleetSnapshots[name], + if let payload = self.persistenceEnvelope.fleetSnapshots[name], let hash = try? CanonicalSyncJSON.hash(payload) { - self.lastSnapshotHashes[name] = hash + self.skippedTerminalReplacementHashes[name] = hash } } @@ -1313,6 +1314,9 @@ extension CloudSyncEngine { self.requeuePendingSnapshotDeletes() for payload in self.pendingSnapshots { let hash = try CanonicalSyncJSON.hash(payload) + if self.skippedTerminalReplacementHashes[payload.recordName] == hash { + continue + } let predecessors = CloudSyncSnapshotMigration.predecessorNames( for: payload, obsoleteNames: obsoleteNames) @@ -1343,6 +1347,7 @@ extension CloudSyncEngine { record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue self.desiredRecords[recordID] = record self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload + self.skippedTerminalReplacementHashes.removeValue(forKey: payload.recordName) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } self.pendingSnapshots = [] From 9d6567f90272d3c19b2961797f26f6357a324b7d Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 17:49:45 -0700 Subject: [PATCH 16/22] fix(sync): record confirmed save hashes and skip all terminal snapshot saves Confirmed CloudKit saves now keep the in-flight payload hash, and terminal failures skip retrying that hash even when the snapshot has no predecessor. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 61 ++++++++++++++++----- Tests/CodexBarTests/SyncModelTests.swift | 39 +++++++++++++ 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 6ab82fe5f1..cd6ddf1aa3 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -295,6 +295,32 @@ enum CloudSyncSnapshotMigration { }) } + static func applyConfirmedSaveHashes( + savedRecordNames: [String], + pendingSaveHashes: inout [String: String], + lastSnapshotHashes: inout [String: String]) + { + for name in savedRecordNames { + guard let hash = pendingSaveHashes.removeValue(forKey: name) else { continue } + lastSnapshotHashes[name] = hash + } + } + + static func applyTerminalSaveSkip( + recordName: String, + error: CKError, + pendingSaveHashes: inout [String: String], + skippedTerminalReplacementHashes: inout [String: String]) + { + guard self.retryDelay(for: error) == nil else { return } + guard let hash = pendingSaveHashes.removeValue(forKey: recordName) else { return } + skippedTerminalReplacementHashes[recordName] = hash + } + + static func hasInFlightSave(recordName: String, pendingSaveHashes: [String: String]) -> Bool { + pendingSaveHashes[recordName] != nil + } + static func shouldResumeDelayedRetry( originatingEngine: ObjectIdentifier?, currentEngine: ObjectIdentifier?) -> Bool @@ -339,6 +365,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { private var pendingSnapshots: [AccountSnapshotSyncPayload] = [] private var lastSnapshotHashes: [String: String] = [:] private var skippedTerminalReplacementHashes: [String: String] = [:] + private var pendingSaveHashes: [String: String] = [:] private var lastKnownProviderConfigs: [ProviderInstanceID: ProviderConfig] = [:] private var lastKnownPreferences: SyncedPreferences? private var lastKnownIncludeSecrets: Bool? @@ -1047,6 +1074,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { self.persistenceEnvelope = .init(stateSerialization: nil, encodedSystemFields: [:]) self.lastSnapshotHashes = [:] self.skippedTerminalReplacementHashes = [:] + self.pendingSaveHashes = [:] self.didRehydrateFleetState = false try? self.persistence.delete() await MainActor.run { @@ -1170,13 +1198,10 @@ extension CloudSyncEngine { let toDrop = CloudSyncSnapshotMigration.takeDeletes( forSavedRecordNames: savedRecordNames, pending: &self.persistenceEnvelope.pendingPredecessorDeletes) - for name in savedRecordNames { - guard self.lastSnapshotHashes[name] == nil, - let payload = self.persistenceEnvelope.fleetSnapshots[name], - let hash = try? CanonicalSyncJSON.hash(payload) - else { continue } - self.lastSnapshotHashes[name] = hash - } + CloudSyncSnapshotMigration.applyConfirmedSaveHashes( + savedRecordNames: savedRecordNames, + pendingSaveHashes: &self.pendingSaveHashes, + lastSnapshotHashes: &self.lastSnapshotHashes) guard !toDrop.isEmpty else { return } let recordIDs = CloudSyncSnapshotMigration.drop( toDrop, @@ -1254,14 +1279,14 @@ extension CloudSyncEngine { let abandoned = CloudSyncSnapshotMigration.abandonedReplacementNames( failures: [name: failure.error], pendingReplacements: Set(self.persistenceEnvelope.pendingPredecessorDeletes.keys)) - guard abandoned.contains(name), - self.persistenceEnvelope.pendingPredecessorDeletes.removeValue(forKey: name) != nil - else { return } - if let payload = self.persistenceEnvelope.fleetSnapshots[name], - let hash = try? CanonicalSyncJSON.hash(payload) - { - self.skippedTerminalReplacementHashes[name] = hash + if abandoned.contains(name) { + self.persistenceEnvelope.pendingPredecessorDeletes.removeValue(forKey: name) } + CloudSyncSnapshotMigration.applyTerminalSaveSkip( + recordName: name, + error: failure.error, + pendingSaveHashes: &self.pendingSaveHashes, + skippedTerminalReplacementHashes: &self.skippedTerminalReplacementHashes) } private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { @@ -1336,6 +1361,13 @@ extension CloudSyncEngine { } continue } + if CloudSyncSnapshotMigration.hasInFlightSave( + recordName: payload.recordName, + pendingSaveHashes: self.pendingSaveHashes) + { + self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload + continue + } let recordID = self.recordID(named: payload.recordName) let record = self.record(type: .accountSnapshot, id: recordID) record["schemaVersion"] = payload.schemaVersion as CKRecordValue @@ -1348,6 +1380,7 @@ extension CloudSyncEngine { self.desiredRecords[recordID] = record self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload self.skippedTerminalReplacementHashes.removeValue(forKey: payload.recordName) + self.pendingSaveHashes[payload.recordName] = hash engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } self.pendingSnapshots = [] diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 745c9b1343..aff55a0a00 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -533,6 +533,45 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { pendingReplacements: [slot]).isEmpty) } + @Test + func `confirmed save hashes replace the previously stored version`() { + var pending = ["snap-a": "hash-new"] + var last = ["snap-a": "hash-old", "snap-b": "hash-other"] + + CloudSyncSnapshotMigration.applyConfirmedSaveHashes( + savedRecordNames: ["snap-a"], + pendingSaveHashes: &pending, + lastSnapshotHashes: &last) + + #expect(last["snap-a"] == "hash-new") + #expect(last["snap-b"] == "hash-other") + #expect(pending.isEmpty) + } + + @Test + func `terminal save skips apply without a predecessor mapping`() { + var pending = ["snap-new": "hash-sent"] + var skipped: [String: String] = [:] + + CloudSyncSnapshotMigration.applyTerminalSaveSkip( + recordName: "snap-new", + error: Self.cloudKitError(.permissionFailure), + pendingSaveHashes: &pending, + skippedTerminalReplacementHashes: &skipped) + #expect(skipped["snap-new"] == "hash-sent") + #expect(pending.isEmpty) + + pending = ["snap-new": "hash-sent"] + skipped = [:] + CloudSyncSnapshotMigration.applyTerminalSaveSkip( + recordName: "snap-new", + error: Self.cloudKitError(.networkFailure), + pendingSaveHashes: &pending, + skippedTerminalReplacementHashes: &skipped) + #expect(skipped.isEmpty) + #expect(pending["snap-new"] == "hash-sent") + } + @Test func `delayed delete retries do not resume on a replacement sync engine`() { let original = NSObject() From 4ee82a8f73f4551ec977c3070d563a210d6e737a Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 18:04:12 -0700 Subject: [PATCH 17/22] fix(sync): requeue in-flight snapshot updates and clear save markers on stop A newer payload that arrives during an unconfirmed save stays pending and is flushed after that save completes, and toggling iCloud off no longer leaves in-flight hashes that skip every later publication. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 54 ++++++++++++++++++++- Tests/CodexBarTests/SyncModelTests.swift | 37 ++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index cd6ddf1aa3..7ee3a70811 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -321,6 +321,40 @@ enum CloudSyncSnapshotMigration { pendingSaveHashes[recordName] != nil } + static func mergingPendingSnapshots( + _ pending: [AccountSnapshotSyncPayload], + with extras: [AccountSnapshotSyncPayload]) -> [AccountSnapshotSyncPayload] + { + var byName: [String: Int] = [:] + var result = pending + for (index, payload) in pending.enumerated() { + byName[payload.recordName] = index + } + for payload in extras { + if let index = byName[payload.recordName] { + result[index] = payload + } else { + byName[payload.recordName] = result.count + result.append(payload) + } + } + return result + } + + static func unpublishedFleetSnapshots( + savedRecordNames: [String], + fleetSnapshots: [String: AccountSnapshotSyncPayload], + lastSnapshotHashes: [String: String]) -> [AccountSnapshotSyncPayload] + { + savedRecordNames.compactMap { name in + guard let payload = fleetSnapshots[name], + let hash = try? CanonicalSyncJSON.hash(payload), + lastSnapshotHashes[name] != hash + else { return nil } + return payload + } + } + static func shouldResumeDelayedRetry( originatingEngine: ObjectIdentifier?, currentEngine: ObjectIdentifier?) -> Bool @@ -432,6 +466,9 @@ actor CloudSyncEngine: CKSyncEngineDelegate { try await self.queueDeviceRecord() self.startPeriodicFetchTimer() self.scheduleFetchChanges(scopedToSyncZone: !initialized) + if !self.pendingSnapshots.isEmpty { + await self.pushPendingSnapshots() + } } catch { await self.record(error: error) } @@ -771,6 +808,11 @@ actor CloudSyncEngine: CKSyncEngineDelegate { if !changes.savedRecords.isEmpty { await MainActor.run { self.state.status.lastSuccessfulPushAt = Date() } } + if !self.pendingSnapshots.isEmpty { + Task { [weak self] in + await self?.pushPendingSnapshots() + } + } case let .fetchedDatabaseChanges(changes): if changes.deletions.contains(where: { $0.zoneID == Self.zoneID }) { syncEngine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: Self.zoneID))]) @@ -1070,11 +1112,11 @@ actor CloudSyncEngine: CKSyncEngineDelegate { self.engine = nil self.desiredRecords = [:] self.quotaRetryState.reset() + self.pendingSaveHashes = [:] if clearPersistence { self.persistenceEnvelope = .init(stateSerialization: nil, encodedSystemFields: [:]) self.lastSnapshotHashes = [:] self.skippedTerminalReplacementHashes = [:] - self.pendingSaveHashes = [:] self.didRehydrateFleetState = false try? self.persistence.delete() await MainActor.run { @@ -1202,6 +1244,12 @@ extension CloudSyncEngine { savedRecordNames: savedRecordNames, pendingSaveHashes: &self.pendingSaveHashes, lastSnapshotHashes: &self.lastSnapshotHashes) + self.pendingSnapshots = CloudSyncSnapshotMigration.mergingPendingSnapshots( + self.pendingSnapshots, + with: CloudSyncSnapshotMigration.unpublishedFleetSnapshots( + savedRecordNames: savedRecordNames, + fleetSnapshots: self.persistenceEnvelope.fleetSnapshots, + lastSnapshotHashes: self.lastSnapshotHashes)) guard !toDrop.isEmpty else { return } let recordIDs = CloudSyncSnapshotMigration.drop( toDrop, @@ -1337,6 +1385,7 @@ extension CloudSyncEngine { liveNames: Set(self.pendingSnapshots.map(\.recordName)))) } self.requeuePendingSnapshotDeletes() + var stillPending: [AccountSnapshotSyncPayload] = [] for payload in self.pendingSnapshots { let hash = try CanonicalSyncJSON.hash(payload) if self.skippedTerminalReplacementHashes[payload.recordName] == hash { @@ -1366,6 +1415,7 @@ extension CloudSyncEngine { pendingSaveHashes: self.pendingSaveHashes) { self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload + stillPending.append(payload) continue } let recordID = self.recordID(named: payload.recordName) @@ -1383,7 +1433,7 @@ extension CloudSyncEngine { self.pendingSaveHashes[payload.recordName] = hash engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } - self.pendingSnapshots = [] + self.pendingSnapshots = stillPending self.lastSnapshotPushAt = Date() self.persistEnvelope() } catch { diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index aff55a0a00..99caab71da 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -572,6 +572,43 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending["snap-new"] == "hash-sent") } + @Test + func `newer in-flight snapshot payloads stay pending until confirmed`() throws { + let older = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") + let newer = AccountSnapshotSyncPayload( + provider: older.provider, + deviceID: older.deviceID, + accountKey: older.accountKey, + fetchedAt: Date(timeIntervalSince1970: 200), + displayLabel: older.displayLabel, + usage: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 200), + identity: older.usage.identity), + schemaVersion: older.schemaVersion) + let olderHash = try CanonicalSyncJSON.hash(older) + let newerHash = try CanonicalSyncJSON.hash(newer) + #expect(olderHash != newerHash) + + let merged = CloudSyncSnapshotMigration.mergingPendingSnapshots([], with: [newer]) + #expect(merged.map(\.recordName) == [newer.recordName]) + + var last = [newer.recordName: olderHash] + let unpublished = CloudSyncSnapshotMigration.unpublishedFleetSnapshots( + savedRecordNames: [newer.recordName], + fleetSnapshots: [newer.recordName: newer], + lastSnapshotHashes: last) + #expect(unpublished.map(\.recordName) == [newer.recordName]) + + last[newer.recordName] = newerHash + #expect( + CloudSyncSnapshotMigration.unpublishedFleetSnapshots( + savedRecordNames: [newer.recordName], + fleetSnapshots: [newer.recordName: newer], + lastSnapshotHashes: last).isEmpty) + } + @Test func `delayed delete retries do not resume on a replacement sync engine`() { let original = NSObject() From cd69625f2bd454374b6b9556dfc9bc579557531d Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 18:17:53 -0700 Subject: [PATCH 18/22] fix(sync): retry unavailable iCloud accounts and drop in-flight hashes on conflict accountTemporarilyUnavailable is treated as a transient CloudKit error, and a server-winning conflict no longer leaves pendingSaveHashes blocking later snapshot publications. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 8 +++++++- Tests/CodexBarTests/SyncModelTests.swift | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 7ee3a70811..09b7f20a10 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -272,7 +272,7 @@ enum CloudSyncSnapshotMigration { case .unknownItem, .permissionFailure, .notAuthenticated, .invalidArguments: return nil case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, .zoneBusy, .quotaExceeded, - .serverResponseLost: + .serverResponseLost, .accountTemporarilyUnavailable: return max(error.retryAfterSeconds ?? 1, 1) default: guard let retryAfter = error.retryAfterSeconds else { return nil } @@ -993,9 +993,13 @@ actor CloudSyncEngine: CKSyncEngineDelegate { case .quotaExceeded: let retry = self.quotaRetryState.nextDelay(serverRetryAfter: failure.error.retryAfterSeconds) self.scheduleRetry(recordID: failure.record.recordID, after: retry) + case .accountTemporarilyUnavailable: + let retry = CloudSyncSnapshotMigration.retryDelay(for: failure.error) ?? 1 + self.scheduleRetry(recordID: failure.record.recordID, after: retry) case .serverRecordChanged: guard let server = failure.error.serverRecord else { await self.record(error: failure.error) + self.pendingSaveHashes.removeValue(forKey: failure.record.recordID.recordName) return } await self.resolveConflict(with: server, syncEngine: syncEngine) @@ -1041,6 +1045,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { guard self.schemaVersion(server) <= CodexBarSyncSchema.currentVersion else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(server.recordID)]) self.desiredRecords.removeValue(forKey: server.recordID) + self.pendingSaveHashes.removeValue(forKey: server.recordID.recordName) self.cacheSystemFields(server) self.persistEnvelope() await MainActor.run { self.state.status.needsAppUpdate = true } @@ -1062,6 +1067,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(server.recordID)]) self.desiredRecords.removeValue(forKey: server.recordID) + self.pendingSaveHashes.removeValue(forKey: server.recordID.recordName) await self.applyFetchedRecords([server]) } } diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 99caab71da..4a99d23708 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -334,17 +334,19 @@ struct CloudSyncSnapshotMigrationDeleteRetryTests { recordID("network"): Self.cloudKitError(.networkFailure), recordID("quota"): Self.cloudKitError(.quotaExceeded, retryAfter: 30), recordID("lost"): Self.cloudKitError(.serverResponseLost), + recordID("temp"): Self.cloudKitError(.accountTemporarilyUnavailable), ] let retryable = Set(CloudSyncSnapshotMigration.retryableFailedDeletes(failures).map(\.recordName)) let reported = Set(CloudSyncSnapshotMigration.reportableFailedDeletes(failures).map(\.code)) - #expect(retryable == ["network", "quota", "lost"]) + #expect(retryable == ["network", "quota", "lost", "temp"]) #expect(reported == [.permissionFailure, .notAuthenticated, .invalidArguments]) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.unknownItem)) == nil) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.networkFailure)) == 1) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.quotaExceeded, retryAfter: 30)) == 30) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.serverResponseLost)) == 1) + #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.accountTemporarilyUnavailable)) == 1) #expect(CloudSyncSnapshotMigration.retryDelay(for: Self.cloudKitError(.permissionFailure)) == nil) #expect( CloudSyncSnapshotMigration.finishedFailedDeleteNames(failures) == [ From 78d553bd11ef6a18ff3b037ccaa65d490e0af809 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 18:29:55 -0700 Subject: [PATCH 19/22] fix(sync): do not requeue fetched snapshots over in-flight local saves Fetched CloudKit snapshots no longer overwrite an in-flight local payload, and pending local updates win when merging unpublished fleet cache entries. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 14 +++++++------- Tests/CodexBarTests/SyncModelTests.swift | 3 +++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 09b7f20a10..8d277a67e4 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -330,13 +330,9 @@ enum CloudSyncSnapshotMigration { for (index, payload) in pending.enumerated() { byName[payload.recordName] = index } - for payload in extras { - if let index = byName[payload.recordName] { - result[index] = payload - } else { - byName[payload.recordName] = result.count - result.append(payload) - } + for payload in extras where byName[payload.recordName] == nil { + byName[payload.recordName] = result.count + result.append(payload) } return result } @@ -964,6 +960,10 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } private func applyAccountSnapshot(_ record: CKRecord) async throws { + guard !CloudSyncSnapshotMigration.hasInFlightSave( + recordName: record.recordID.recordName, + pendingSaveHashes: self.pendingSaveHashes) + else { return } guard let providerRaw = record["provider"] as? String, let provider = ProviderInstanceID(rawValue: providerRaw), let deviceID = record["deviceID"] as? String, diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 4a99d23708..1efcbe964a 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -595,6 +595,9 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { let merged = CloudSyncSnapshotMigration.mergingPendingSnapshots([], with: [newer]) #expect(merged.map(\.recordName) == [newer.recordName]) + #expect( + CloudSyncSnapshotMigration.mergingPendingSnapshots([newer], with: [older]).first?.fetchedAt + == newer.fetchedAt) var last = [newer.recordName: olderHash] let unpublished = CloudSyncSnapshotMigration.unpublishedFleetSnapshots( From ecb286e95d92e095638952be9a0397593d7d45b5 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Thu, 20 Aug 2026 18:45:07 -0700 Subject: [PATCH 20/22] fix(sync): drop retained snapshots when iCloud sync stops Pending snapshot payloads from before disable are discarded so re-enabling sync cannot upload or delete against a stale account set. --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 8d277a67e4..7bab160187 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -462,9 +462,6 @@ actor CloudSyncEngine: CKSyncEngineDelegate { try await self.queueDeviceRecord() self.startPeriodicFetchTimer() self.scheduleFetchChanges(scopedToSyncZone: !initialized) - if !self.pendingSnapshots.isEmpty { - await self.pushPendingSnapshots() - } } catch { await self.record(error: error) } @@ -1119,6 +1116,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { self.desiredRecords = [:] self.quotaRetryState.reset() self.pendingSaveHashes = [:] + self.pendingSnapshots = [] if clearPersistence { self.persistenceEnvelope = .init(stateSerialization: nil, encodedSystemFields: [:]) self.lastSnapshotHashes = [:] From 0e29fc9b5726004d58c8a1abfde3bf0a086080eb Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Fri, 21 Aug 2026 12:37:15 -0700 Subject: [PATCH 21/22] fix(sync): limit email-keyed snapshot cleanup to Claude Swap Predecessor deletes must not run for other providers that move from email to a durable account ID. Drop the unreleased changelog line. Co-authored-by: Cursor --- CHANGELOG.md | 1 - Sources/CodexBarCore/Sync/SyncModels.swift | 12 ++++-- Tests/CodexBarTests/SyncModelTests.swift | 47 ++++++++++++++++++++-- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b4d44c3d7..a0ad4774a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ - Kiro: show overage credits spent against their cap, plus accrued charges against the overage budget. The `kiro-cli` report states credits against the plan alone and omits the overage section entirely for organization accounts, so CodexBar now reads the plan and overage ceilings from the same `GetUsageLimits` service the CLI itself calls, falling back to the CLI report when it is unavailable. (#3083). Thanks @sf-jin-ku! - Kiro: show overage credits spent against their cap, plus accrued charges against the overage budget. The `kiro-cli` report states credits against the plan alone and omits the overage section entirely for organization accounts, so CodexBar now reads the plan and overage ceilings from the same `GetUsageLimits` service the CLI itself calls, falling back to the CLI report when it is unavailable. - Claude: distinguish claude-swap accounts that share an email with the workspace name or slot, and honor a user-chosen display alias (#3082). Thanks @sf-jin-ku! -- Claude: rewrite email-keyed claude-swap iCloud snapshots onto slot keys and delete the leftover records so another Mac does not keep duplicate fleet cards. - z.ai: show the BigModel CN pay-as-you-go account balance inside Quota details, best-effort and CN-only (#3109). Thanks @RunhuaHuang! - Fixed the mainland Alibaba Personal/Solo Token Plan always reporting "login required": the console shell now serves its `sec_token` to CodexBar's fetch and the upper-case `SEC_TOKEN` shape is parsed (#2891, #3098). Thanks @LeoLin990405! - Fixed long agent session names stretching the menu: session rows now truncate inside the menu's width with the full label in a tooltip (#3096). Thanks @KaranocaVe! diff --git a/Sources/CodexBarCore/Sync/SyncModels.swift b/Sources/CodexBarCore/Sync/SyncModels.swift index 7b41d9991d..2f6e9d495e 100644 --- a/Sources/CodexBarCore/Sync/SyncModels.swift +++ b/Sources/CodexBarCore/Sync/SyncModels.swift @@ -226,12 +226,16 @@ public struct AccountSnapshotSyncPayload: Codable, Sendable { return CanonicalSyncJSON.hash(data: Data(identity.lowercased().utf8)) } - /// CloudKit record IDs cannot be renamed. When this snapshot is keyed by a distinct - /// account ID that replaced a mailbox identity, the previous email-keyed record on the - /// same device is the predecessor that must be deleted after the replacement is saved. + /// CloudKit record IDs cannot be renamed. Claude Swap snapshots keyed by + /// `claude-swap:` replace a same-device email-keyed record; that + /// predecessor is deleted only after the slot-keyed replacement is saved. + /// Other providers must not classify an email-to-durable-ID change as obsolete. public func emailKeyedPredecessorRecordName() -> String? { + guard self.provider == .claude, + self.usage.identity?.loginMethod == ClaudeSwapAccountProjection.sourceLabel + else { return nil } let accountID = self.usage.identity?.accountID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !accountID.isEmpty else { return nil } + guard accountID.hasPrefix("\(ClaudeSwapAccountProjection.sourceName):") else { return nil } let emailKey = Self.accountKey(for: self.usage.identity?.accountEmail) guard emailKey != "default", Self.accountKey(for: accountID) == self.accountKey, diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index 1efcbe964a..a2b2618520 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -225,6 +225,34 @@ struct SyncModelTests { knownRecordNames: [predecessor]) == [predecessor]) } + @Test + func `non Claude snapshot does not name an email keyed CloudKit predecessor`() { + let payload = Self.snapshot( + provider: .codex, + loginMethod: "pro", + accountID: "user-workspace-1", + email: "owner@example.com") + let emailKey = AccountSnapshotSyncPayload.accountKey(for: "owner@example.com") + let leftover = "snap-codex-\(emailKey)-device-id" + + #expect(payload.emailKeyedPredecessorRecordName() == nil) + #expect( + AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames( + liveSnapshots: [payload], + knownRecordNames: [leftover]).isEmpty) + } + + @Test + func `claude subscription snapshot does not name an email keyed CloudKit predecessor`() { + let payload = Self.snapshot( + provider: .claude, + loginMethod: "Claude.ai", + accountID: "user_abc", + email: "owner@example.com") + + #expect(payload.emailKeyedPredecessorRecordName() == nil) + } + @Test func `account snapshot ignores retired provider payload keys`() throws { let legacy = #""" @@ -270,18 +298,31 @@ struct SyncModelTests { } private static func claudeSnapshot(accountID: String, email: String) -> AccountSnapshotSyncPayload { + self.snapshot( + provider: .claude, + loginMethod: ClaudeSwapAccountProjection.sourceLabel, + accountID: accountID, + email: email) + } + + private static func snapshot( + provider: ProviderInstanceID, + loginMethod: String, + accountID: String, + email: String) -> AccountSnapshotSyncPayload + { let usage = UsageSnapshot( primary: nil, secondary: nil, updatedAt: Date(timeIntervalSince1970: 100), identity: ProviderIdentitySnapshot( - providerID: .claude, + providerID: provider, accountEmail: email, accountOrganization: nil, - loginMethod: "claude-swap", + loginMethod: loginMethod, accountID: accountID)) return AccountSnapshotSyncPayload( - provider: .claude, + provider: provider, deviceID: "device-id", accountIdentity: accountID, displayLabel: email, From 3d0ffb1003f053a72e1f481aafcdbd2778d06459 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Fri, 21 Aug 2026 13:38:12 -0700 Subject: [PATCH 22/22] fix(sync): defer restored predecessor deletes until live snapshots reconcile CKSyncEngine can confirm an in-flight slot save on relaunch before local snapshots publish. Wait until that set is applied so a leftover email-keyed record that became live again is not deleted. Co-authored-by: Cursor --- Sources/CodexBar/Sync/CloudSyncEngine.swift | 16 +++++++++++++++- Sources/CodexBarCore/Sync/SyncModels.swift | 1 + Tests/CodexBarTests/SyncModelTests.swift | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 7bab160187..d9861e1d25 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -191,6 +191,15 @@ enum CloudSyncSnapshotMigration { return [predecessor] } + static func takeDeletes( + forSavedRecordNames savedNames: [String], + pending: inout [String: Set], + afterLiveSnapshotReconciliation hasReconciledLiveSnapshots: Bool) -> Set + { + guard hasReconciledLiveSnapshots else { return [] } + return self.takeDeletes(forSavedRecordNames: savedNames, pending: &pending) + } + static func takeDeletes( forSavedRecordNames savedNames: [String], pending: inout [String: Set]) -> Set @@ -401,6 +410,8 @@ actor CloudSyncEngine: CKSyncEngineDelegate { private var lastKnownIncludeSecrets: Bool? private var quotaRetryState = CloudSyncQuotaRetryState() private var didRehydrateFleetState = false + /// Restored predecessor mappings must not delete until local live snapshots have been applied. + private var hasReconciledLiveSnapshots = false private let logger = CodexBarLog.logger(LogCategories.settings) init( @@ -1117,6 +1128,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { self.quotaRetryState.reset() self.pendingSaveHashes = [:] self.pendingSnapshots = [] + self.hasReconciledLiveSnapshots = false if clearPersistence { self.persistenceEnvelope = .init(stateSerialization: nil, encodedSystemFields: [:]) self.lastSnapshotHashes = [:] @@ -1243,7 +1255,8 @@ extension CloudSyncEngine { { let toDrop = CloudSyncSnapshotMigration.takeDeletes( forSavedRecordNames: savedRecordNames, - pending: &self.persistenceEnvelope.pendingPredecessorDeletes) + pending: &self.persistenceEnvelope.pendingPredecessorDeletes, + afterLiveSnapshotReconciliation: self.hasReconciledLiveSnapshots) CloudSyncSnapshotMigration.applyConfirmedSaveHashes( savedRecordNames: savedRecordNames, pendingSaveHashes: &self.pendingSaveHashes, @@ -1387,6 +1400,7 @@ extension CloudSyncEngine { CloudSyncSnapshotMigration.cancelledPersistedDeletes( pendingDeletes: self.persistenceEnvelope.pendingSnapshotDeletes, liveNames: Set(self.pendingSnapshots.map(\.recordName)))) + self.hasReconciledLiveSnapshots = true } self.requeuePendingSnapshotDeletes() var stillPending: [AccountSnapshotSyncPayload] = [] diff --git a/Sources/CodexBarCore/Sync/SyncModels.swift b/Sources/CodexBarCore/Sync/SyncModels.swift index 2f6e9d495e..5025950f27 100644 --- a/Sources/CodexBarCore/Sync/SyncModels.swift +++ b/Sources/CodexBarCore/Sync/SyncModels.swift @@ -231,6 +231,7 @@ public struct AccountSnapshotSyncPayload: Codable, Sendable { /// predecessor is deleted only after the slot-keyed replacement is saved. /// Other providers must not classify an email-to-durable-ID change as obsolete. public func emailKeyedPredecessorRecordName() -> String? { + // Provider-specific by design: only Claude Swap slot keys retire leftover email-keyed CloudKit snapshots. guard self.provider == .claude, self.usage.identity?.loginMethod == ClaudeSwapAccountProjection.sourceLabel else { return nil } diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index a2b2618520..83a6a17422 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -538,6 +538,26 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { #expect(pending.isEmpty) } + @Test + func `restored predecessor deletes wait until live snapshots reconcile`() throws { + let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com") + let predecessor = try #require(slot.emailKeyedPredecessorRecordName()) + var pending = [slot.recordName: Set([predecessor])] + + #expect( + CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: [slot.recordName], + pending: &pending, + afterLiveSnapshotReconciliation: false).isEmpty) + #expect(pending[slot.recordName] == [predecessor]) + #expect( + CloudSyncSnapshotMigration.takeDeletes( + forSavedRecordNames: [slot.recordName], + pending: &pending, + afterLiveSnapshotReconciliation: true) == [predecessor]) + #expect(pending.isEmpty) + } + @Test func `persisted deletes are cancelled when the predecessor is live again`() throws { let slot = Self.claudeSnapshot(accountID: "claude-swap:2", email: "owner@example.com")