Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dc440cf
fix(claude): migrate email-keyed iCloud snapshots to slot keys
sf-jin-ku Aug 20, 2026
7080d1b
fix(sync): confirm CloudKit snapshot saves before deleting predecessors
sf-jin-ku Aug 20, 2026
5dc264d
fix(sync): persist leftover snapshot deletes across delayed retries
sf-jin-ku Aug 20, 2026
ec188c2
fix(sync): stop retrying terminal CloudKit replacement saves
sf-jin-ku Aug 20, 2026
eac53a8
fix(sync): bind delayed CloudKit deletes to the originating engine
sf-jin-ku Aug 20, 2026
7f93015
fix(sync): drop stale CloudKit predecessors that are live again
sf-jin-ku Aug 20, 2026
290cfee
fix(sync): cancel leftover snapshot deletes when they become live
sf-jin-ku Aug 20, 2026
7e663ae
fix(sync): do not retry CloudKit deletes for live snapshots
sf-jin-ku Aug 20, 2026
d400c40
fix(sync): isolate CloudKit migration state from the next iCloud account
sf-jin-ku Aug 20, 2026
7d75b12
fix(sync): persist predecessor deletes and requeue empty publications
sf-jin-ku Aug 20, 2026
a4b052d
fix(sync): wait for every shared-mailbox replacement before deleting
sf-jin-ku Aug 20, 2026
535a747
fix(sync): retry lost CloudKit responses and keep shared leftovers
sf-jin-ku Aug 20, 2026
f491337
fix(sync): queue predecessor deletes for unchanged slot payloads
sf-jin-ku Aug 20, 2026
dbc3445
fix(sync): confirm slot saves and ignore remote cache as live
sf-jin-ku Aug 21, 2026
aff7c19
fix(sync): do not treat terminal save failures as confirmed
sf-jin-ku Aug 21, 2026
9d6567f
fix(sync): record confirmed save hashes and skip all terminal snapsho…
sf-jin-ku Aug 21, 2026
4ee82a8
fix(sync): requeue in-flight snapshot updates and clear save markers …
sf-jin-ku Aug 21, 2026
cd69625
fix(sync): retry unavailable iCloud accounts and drop in-flight hashe…
sf-jin-ku Aug 21, 2026
78d553b
fix(sync): do not requeue fetched snapshots over in-flight local saves
sf-jin-ku Aug 21, 2026
ecb286e
fix(sync): drop retained snapshots when iCloud sync stops
sf-jin-ku Aug 21, 2026
0e29fc9
fix(sync): limit email-keyed snapshot cleanup to Claude Swap
sf-jin-ku Aug 21, 2026
3d0ffb1
fix(sync): defer restored predecessor deletes until live snapshots re…
sf-jin-ku Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
319 changes: 290 additions & 29 deletions Sources/CodexBar/Sync/CloudSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,132 @@ enum CloudSyncDirtyState {
}
}

enum CloudSyncSnapshotMigration {
static func obsoleteRecordNames(
liveSnapshots: [AccountSnapshotSyncPayload],
hashes: [String: String],
envelope: CloudSyncPersistence.Envelope) -> Set<String>
{
AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames(
liveSnapshots: liveSnapshots,
knownRecordNames: Set(hashes.keys).union(envelope.fleetSnapshots.keys))
}

static func drop(
_ names: Set<String>,
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 predecessorNames(
for snapshot: AccountSnapshotSyncPayload,
obsoleteNames: Set<String>) -> Set<String>
{
guard let predecessor = snapshot.emailKeyedPredecessorRecordName(),
obsoleteNames.contains(predecessor)
else {
return []
}
return [predecessor]
}

static func takeDeletes(
forSavedRecordNames savedNames: [String],
pending: inout [String: Set<String>]) -> Set<String>
{
var toDrop: Set<String> = []
for name in savedNames {
if let obsolete = pending.removeValue(forKey: name) {
toDrop.formUnion(obsolete)
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
}
}
return toDrop
}

static func retainingObsoletePredecessors(
in pending: inout [String: Set<String>],
obsoleteNames: Set<String>)
{
pending = pending.compactMapValues { predecessors in
let live = predecessors.intersection(obsoleteNames)
return live.isEmpty ? nil : live
}
}

static func assigningPredecessors(
_ predecessors: Set<String>,
to replacement: String,
pending: inout [String: Set<String>])
{
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
}
}

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 }
Comment on lines +286 to +287

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry records rejected with their CloudKit batch

When CloudKit reports .batchRequestFailed for a record because another record in the same batch failed, that error normally has no retryAfterSeconds, so this fallback classifies it as terminal. Save handling then suppresses the snapshot payload for the rest of the process, while delete handling forgets the persisted delete intent; after the peer record's conflict or transient failure is resolved, the batch-rejected replacement or predecessor deletion is never retried. Treat batchRequestFailed as recoverable and requeue it after the causal batch failure is handled.

Useful? React with 👍 / 👎.

return max(retryAfter, 1)
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
}
}

static func finishedFailedDeleteNames(_ failures: [CKRecord.ID: CKError]) -> Set<String> {
Set(failures.compactMap { recordID, error in
self.retryDelay(for: error) == nil ? recordID.recordName : nil
})
}

static func abandonedReplacementNames(
failures: [String: CKError],
pendingReplacements: Set<String>) -> Set<String>
{
Set(failures.compactMap { name, error in
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 {
static let entitlement = "com.apple.developer.icloud-services"

Expand Down Expand Up @@ -184,6 +310,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate {
private var lastSnapshotPushAt: Date?
private var pendingSnapshots: [AccountSnapshotSyncPayload] = []
private var lastSnapshotHashes: [String: String] = [:]
private var pendingPredecessorDeletes: [String: Set<String>] = [:]
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
private var lastKnownProviderConfigs: [ProviderInstanceID: ProviderConfig] = [:]
private var lastKnownPreferences: SyncedPreferences?
private var lastKnownIncludeSecrets: Bool?
Expand Down Expand Up @@ -248,6 +375,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()
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
self.startPeriodicFetchTimer()
self.scheduleFetchChanges(scopedToSyncZone: !initialized)
} catch {
Expand Down Expand Up @@ -524,35 +652,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 {
Expand Down Expand Up @@ -606,6 +705,12 @@ actor CloudSyncEngine: CKSyncEngineDelegate {
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() }
Expand Down Expand Up @@ -805,6 +910,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate {
self.recreateZoneAndRequeue(failure.record, syncEngine: syncEngine)
} else {
await self.record(error: failure.error)
self.abandonTerminalReplacementSave(failure)
}
}
}
Expand Down Expand Up @@ -1024,3 +1130,158 @@ actor CloudSyncEngine: CKSyncEngineDelegate {
return String(bytes: bytes, encoding: .utf8) ?? "unknown"
}
}

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
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
}
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)])
}
self.rememberPendingSnapshotDeletes(toDrop)
await MainActor.run {
toDrop.forEach { self.state.fleetSnapshots.removeValue(forKey: $0) }
}
}

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)
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
}
}

private func rememberPendingSnapshotDeletes(_ names: Set<String>) {
guard !names.isEmpty else { return }
self.persistenceEnvelope.pendingSnapshotDeletes.formUnion(names)
self.persistEnvelope()
}

private func forgetPendingSnapshotDeletes(_ names: Set<String>) {
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 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
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
}
}

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, 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)])
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
try await engine.sendChanges(.init(scope: .recordIDs([recordID])))
} catch is CancellationError {
return
} catch {
await self?.record(error: error)
}
}
}

private func pushPendingSnapshots() async {
guard let engine = self.engine, !self.pendingSnapshots.isEmpty else { return }
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
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)
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
for payload in self.pendingSnapshots {
let hash = try CanonicalSyncJSON.hash(payload)
guard self.lastSnapshotHashes[payload.recordName] != hash else { continue }
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
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.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.pendingPredecessorDeletes)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
self.pendingSnapshots = []
self.lastSnapshotPushAt = Date()
self.persistEnvelope()
} catch {
await self.record(error: error)
}
}
}
Loading