Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
jeongjinku Aug 20, 2026
7080d1b
fix(sync): confirm CloudKit snapshot saves before deleting predecessors
jeongjinku Aug 20, 2026
5dc264d
fix(sync): persist leftover snapshot deletes across delayed retries
jeongjinku Aug 20, 2026
ec188c2
fix(sync): stop retrying terminal CloudKit replacement saves
jeongjinku Aug 20, 2026
eac53a8
fix(sync): bind delayed CloudKit deletes to the originating engine
jeongjinku Aug 20, 2026
7f93015
fix(sync): drop stale CloudKit predecessors that are live again
jeongjinku Aug 20, 2026
290cfee
fix(sync): cancel leftover snapshot deletes when they become live
jeongjinku Aug 20, 2026
7e663ae
fix(sync): do not retry CloudKit deletes for live snapshots
jeongjinku Aug 20, 2026
d400c40
fix(sync): isolate CloudKit migration state from the next iCloud account
jeongjinku Aug 20, 2026
7d75b12
fix(sync): persist predecessor deletes and requeue empty publications
jeongjinku Aug 20, 2026
a4b052d
fix(sync): wait for every shared-mailbox replacement before deleting
jeongjinku Aug 20, 2026
535a747
fix(sync): retry lost CloudKit responses and keep shared leftovers
jeongjinku Aug 20, 2026
f491337
fix(sync): queue predecessor deletes for unchanged slot payloads
jeongjinku Aug 20, 2026
dbc3445
fix(sync): confirm slot saves and ignore remote cache as live
jeongjinku Aug 21, 2026
aff7c19
fix(sync): do not treat terminal save failures as confirmed
jeongjinku Aug 21, 2026
9d6567f
fix(sync): record confirmed save hashes and skip all terminal snapsho…
jeongjinku Aug 21, 2026
4ee82a8
fix(sync): requeue in-flight snapshot updates and clear save markers …
jeongjinku Aug 21, 2026
cd69625
fix(sync): retry unavailable iCloud accounts and drop in-flight hashe…
jeongjinku Aug 21, 2026
78d553b
fix(sync): do not requeue fetched snapshots over in-flight local saves
jeongjinku Aug 21, 2026
ecb286e
fix(sync): drop retained snapshots when iCloud sync stops
jeongjinku Aug 21, 2026
0e29fc9
fix(sync): limit email-keyed snapshot cleanup to Claude Swap
jeongjinku Aug 21, 2026
3d0ffb1
fix(sync): defer restored predecessor deletes until live snapshots re…
jeongjinku 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
214 changes: 185 additions & 29 deletions Sources/CodexBar/Sync/CloudSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,87 @@ 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 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
}
}
}

enum CloudSyncEntitlementGate {
static let entitlement = "com.apple.developer.icloud-services"

Expand Down Expand Up @@ -184,6 +265,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 @@ -524,35 +606,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 +659,10 @@ actor CloudSyncEngine: CKSyncEngineDelegate {
for failure in changes.failedRecordSaves {
await self.handleSaveFailure(failure, syncEngine: syncEngine)
}
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() }
Expand Down Expand Up @@ -1024,3 +1081,102 @@ 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)])
}
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)])
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
try await engine.sendChanges(.init(scope: .recordIDs([recordID])))
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
} 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)
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
} 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)
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
}
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
self.pendingSnapshots = []
self.lastSnapshotPushAt = Date()
self.persistEnvelope()
} catch {
await self.record(error: error)
}
}
}
32 changes: 32 additions & 0 deletions Sources/CodexBarCore/Sync/SyncModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Set<String>
{
let liveNames = Set(liveSnapshots.map(\.recordName))
var obsolete: Set<String> = []
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 {
Expand Down
Loading