Surface phone/watch schema mismatch on the watch; log dropped pulls

The decode-failure freeze (BULLETPROOFING.md L3) is production-reachable
after all - a phone app can update days before the watch app
auto-updates, and every push in between fails to decode, silently
freezing the watch at its last good sync. The bridge now tracks
schemaMismatch (set on a failed decode, cleared on the next good apply)
and ActiveWorkoutGateView shows an "Update both apps to resume sync"
banner while it's set.

Also give requestSync an error handler that logs the dropped pull (L2;
deliberately no retry - the activation/reachability edges re-pull), and
sync DEVICE-COMMUNICATION.md to the post-bulletproofing reality (T1 as
optimization, session recovery, degraded pushes, surfaced mismatch).

Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
2026-07-09 23:12:39 -04:00
parent 62a2882f0e
commit ccd5194c6c
5 changed files with 41 additions and 9 deletions
@@ -19,6 +19,12 @@ final class WatchConnectivityBridge: NSObject {
/// Last time state was received from the phone (for a sync indicator).
private(set) var lastSyncDate: Date?
/// True while the phone's pushes fail to decode the two apps are on different
/// document schemas (the phone updated first; the watch app hasn't yet). The cache is
/// deliberately left untouched in that state, so surface it (`ActiveWorkoutGateView`)
/// instead of silently showing stale workouts until the watch app updates.
private(set) var schemaMismatch = false
/// Fired after every authoritative cache mutation (a phone push applied, or the watch's
/// own optimistic `update(workout:)`), once the write is committed. The
/// `WorkoutSessionCoordinator` hangs off this to end the `HKWorkoutSession` from the
@@ -115,8 +121,10 @@ final class WatchConnectivityBridge: NSObject {
private func applyState(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) {
guard WatchCacheApplier.apply(splits: splits, workouts: workouts, into: context) else {
Self.log.error("applyState: payload failed to decode (splits=\(splits == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
schemaMismatch = true
return
}
schemaMismatch = false
Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts")
lastSyncDate = Date()
onWorkoutsChanged?()
@@ -127,7 +135,11 @@ final class WatchConnectivityBridge: NSObject {
Self.log.info("requestSync skipped: activation=\(self.session?.activationState.rawValue ?? -1) reachable=\(self.session?.isReachable ?? false)")
return
}
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: { @Sendable error in
// No retry the activation/reachability edges re-pull, and the durable
// context slot delivers regardless but a dropped pull must be visible.
Self.log.warning("requestSync send failed: \(error, privacy: .public)")
})
}
/// Optimistically applies a workout edit to the local cache and forwards it to
@@ -66,6 +66,20 @@ struct ActiveWorkoutGateView: View {
WorkoutLogListView(workout: workout)
}
}
// The phone's pushes aren't decoding (its app updated first; this one hasn't
// yet) everything shown is frozen at the last good sync. Say so rather than
// silently presenting stale workouts until the watch app catches up.
.safeAreaInset(edge: .bottom) {
if bridge.schemaMismatch {
Label("Update both apps to resume sync", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.font(.caption2)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 2)
.background(.ultraThinMaterial)
}
}
}
.task {
// Nothing to run yet pull fresh state in case the phone just started one.