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
+8 -4
View File
@@ -99,13 +99,17 @@ The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive disp
> **Fix (2026-07-09):** `decide` now picks the most recently started completed survivor, ties broken by id (landed with the H-tier session-lifecycle commit; pinned by `parallelCompletions_pickMostRecentlyStarted`).
### L2 — `requestSync` failures vanish · open
### L2 — `requestSync` failures vanish · **fixed** (logged)
`sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)` (`WatchConnectivityBridge.swift`) — a failed pull is dropped with no retry. Low because P8 (activation/reachability edges) and the durable T2 slot cover the common cases.
`sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)` — a failed pull was dropped invisibly.
### L3 — Decode-failure freeze is silent on the watch · open
> **Fix (2026-07-09):** the send now logs its error. Deliberately no retry — the activation/reachability edges re-pull and the durable T2 slot delivers regardless.
The schema-mismatch trap (see `DEVICE-COMMUNICATION.md` §6) is dev-only today, but the watch could surface "can't read phone data — update both apps" instead of freezing silently; it already tracks `lastSyncDate` and logs the decode failure.
### L3 — Decode-failure freeze is silent on the watch · **fixed**
The schema-mismatch trap (see `DEVICE-COMMUNICATION.md` §6) is production-reachable after all: a user's phone app can update days before the watch app auto-updates, and every push in between fails to decode — silently.
> **Fix (2026-07-09):** `WatchConnectivityBridge.schemaMismatch` is set when a push fails to decode (cleared on the next good apply), and `ActiveWorkoutGateView` shows an "Update both apps to resume sync" banner while it's set — the freeze is now labeled instead of silent.
### L4 — Possible log-progress rollback from a late follower write · open · needs device testing
+2
View File
@@ -18,6 +18,8 @@ Opening a skipped exercise on the watch now shows a gray Skipped badge instead o
The mirrored workout view no longer freezes on a spent rest countdown when the other device drives an auto-advancing split.
The watch now shows an update notice when the two apps are on different versions and can't sync, instead of silently displaying stale workouts.
A new Cardio exercise and matching starter split let you log aerobic sessions, tracked on your Apple Watch as a cardio workout.
A new Auto-Advance option lets a split flow hands-free from one exercise to the next, resting automatically between them.
+4 -4
View File
@@ -77,7 +77,7 @@ Wire format for T2T4 lives in `Shared/Connectivity/WCPayload.swift`. Document
| Event / trigger | Datapoints on the wire | Transport & path | Internal structures updated | Notes |
|---|---|---|---|---|
| **P1 — Start a workout** (the split picker is the sole start path — `SplitPickerSheet.start(with:)`, `WorkoutLogsView.swift:283-312`) | `HKWorkoutConfiguration { activityType (from the split), locationType = .indoor }` | **T1** (HealthKit, direct — *not* WatchConnectivity) | **Watch:** `WatchAppDelegate.handle(_:)``WorkoutSessionManager.start(with:)` creates `HKWorkoutSession` + `HKLiveWorkoutBuilder`, `session.startActivity`, `builder.beginCollection`, `isRunning = true`. **Phone:** requests workout-share auth inline (`WorkoutLauncher.swift:38`). | Best-effort; no-ops without HealthKit or a reachable watch. The running `HKWorkoutSession` is what makes watchOS re-foreground the app on every wrist raise. Starting also mints a `WorkoutDocument` → triggers **P3** as a separate exchange. |
| **P1 — Start a workout** (the split picker is the sole start path — `SplitPickerSheet.start(with:)`, `WorkoutLogsView.swift:283-312`) | `HKWorkoutConfiguration { activityType (from the split), locationType = .indoor }` | **T1** (HealthKit, direct — *not* WatchConnectivity) | **Watch:** `WatchAppDelegate.handle(_:)``WorkoutSessionManager.start(with:)` creates `HKWorkoutSession` + `HKLiveWorkoutBuilder`, `session.startActivity`, `builder.beginCollection`, `isRunning = true`. **Phone:** requests workout-share auth inline (`WorkoutLauncher.swift:38`). | Best-effort; no-ops without HealthKit or a reachable watch. The running `HKWorkoutSession` is what makes watchOS re-foreground the app on every wrist raise. Starting also mints a `WorkoutDocument` → triggers **P3** as a separate exchange. T1 is a *fast-launch optimization*, not the only session source: the watch self-starts a session from any reconcile that finds an active run with none running, and recovers a crash-orphaned one at launch (BULLETPROOFING.md H1). |
| **P3 — Any phone cache mutation → full-state push** | `splits[]` (all `SplitDocument`, by `order`; each embeds its `ExerciseDocument`s) · `workouts[]` (every active [`notStarted`/`inProgress`] run **in full**, + ≤25 `completed` within 24 h; each embeds its `WorkoutLogDocument`s) · `restSeconds` · `doneCountdownSeconds` · `weightUnit` · `editingWorkoutID?` · `editingSplitID?` | **T2** (`pushAll``updateApplicationContext`), phone → watch | **Watch:** `WatchCacheApplier.apply` upserts every split/workout via `CacheMapper` **and prunes anything absent** → SwiftData cache; sets `lastSyncDate`; writes settings to `UserDefaults`; sets edit-lock state; fires `onWorkoutsChanged``WorkoutSessionCoordinator.reconcile()` (may end the `HKWorkoutSession`). `@Query` views refresh. | The workhorse. `onCacheChanged` fires on **every** cache change — local saves/deletes *and* remote iCloud deltas from `MetadataObserver` and `ingestFromWatch` (`SyncEngine.swift:290,301,307,323,378,465,725`). Active runs sent uncapped, so **"absent ⇒ no longer active"** — this is what prunes a discarded/deleted run and lets the coordinator end the session. Decode is **all-or-nothing**: one undecodable element → `nil``applyState` skips upsert/prune/reconcile entirely (schema-mismatch stale-cache trap, §5). Guarded on `activated && isPaired && isWatchAppInstalled`. |
| **P4 — Phone opens/closes an exercise editor** (`ExerciseView.swift:75,78`) | Same context as P3; salient field is `editingWorkoutID` (the workout's id, or absent = cleared) | **T2** (`setEditingWorkout` → immediate `pushAll`) | **Watch:** `editingWorkoutID` set; `ActiveWorkoutGateView` dims that run's row, blocks entry, and pops out if the user is inside it (`isLockedForEditing`). | Exclusive-edit lock: only one device drives a given run at a time, so the watch can't forward a stale optimistic write over a phone edit. Pushed immediately (doesn't wait for a cache change). |
| **P5 — Phone opens/closes a split detail/editor** (`SplitDetailView.swift:54,55`) | Same context; salient field is `editingSplitID` | **T2** (`setEditingSplit` → immediate `pushAll`) | **Watch:** `editingSplitID` set; any run whose `splitID` matches is parked (same UI treatment as P4). | Matches runs by source split rather than by run id. |
@@ -104,7 +104,7 @@ Wire format for T2T4 lives in `Shared/Connectivity/WCPayload.swift`. Document
| Structure | Type | Fed by | Notes |
|---|---|---|---|
| SwiftData cache (`Split`, `Workout`, `WorkoutLog`) | Rebuildable local cache | P3 (authoritative upsert **+ prune**) and W1 (optimistic self-edit) | The watch never originates a split/workout; pruning can't lose local-only data. This is the set `ActiveWorkoutGateView` renders. |
| `HKWorkoutSession` + `HKLiveWorkoutBuilder` | HealthKit runtime | Started by P1/T1; ended by W2 | `isRunning == true` is what makes watchOS re-foreground the app on wrist raise. Collects HR + active energy, accumulates `hrZoneSeconds`. |
| `HKWorkoutSession` + `HKLiveWorkoutBuilder` | HealthKit runtime | Started by P1/T1 **or self-started by the coordinator** (active run with no session); recovered at launch after a crash/reboot; ended by W2 (or salvaged into a Health save on a system-driven end) | `isRunning == true` is what makes watchOS re-foreground the app on wrist raise. Collects HR + active energy, accumulates `hrZoneSeconds`. |
| `WorkoutSessionCoordinator.previouslyActiveIDs` | Baseline set | Recomputed every `reconcile()` (each P3/W1 mutation + bootstrap) | The non-empty→empty transition of the active set is what ends the session. Empty baseline at launch is what makes the launch race safe. |
| `UserDefaults` (`restSeconds`, `doneCountdownSeconds`, `weightUnit`) | Settings | P3/P6 | Drives the watch's rest timer, done countdown, weight units. |
| Edit locks (`editingWorkoutID`, `editingSplitID`) | Published lock | P4/P5 | Park a run and block re-entry so the phone owns the edit. |
@@ -150,8 +150,8 @@ Wire format for T2T4 lives in `Shared/Connectivity/WCPayload.swift`. Document
## 6. Failure modes & gotchas
- **Schema-mismatch stale cache (the big one).** If the installed watch binary is older than the phone's, it can't decode the phone's `WorkoutDocument`/`SplitDocument` payloads. `WCPayload.decodeWorkouts` is all-or-nothing → `nil``applyState` **skips upsert, prune, and reconcile** (`WatchConnectivityBridge.swift`). The watch's cache freezes at the last successful decode; a running `HKWorkoutSession` can then never be ended (its terminating signal — active set → empty — can't arrive), so the app re-surfaces indefinitely. **Recovery:** rebuild/reinstall the watch app so both are on the same document schema (currently `WorkoutDocument` v5, `SplitDocument` v3). Cannot happen to end users — both apps ship at one version, and the `VersionedDocument` forward-gate covers files.
- **`updateApplicationContext` is single-slot.** Only the *latest* state is delivered; intermediate pushes coalesce. Fine because the context is authoritative full state, not a delta.
- **Schema-mismatch stale cache.** If the installed watch binary is older than the phone's, it can't decode the phone's `WorkoutDocument`/`SplitDocument` payloads. `WCPayload.decodeWorkouts` is all-or-nothing → `nil``applyState` **skips upsert, prune, and reconcile** (`WatchConnectivityBridge.swift`) — the watch's cache freezes at the last successful decode. This *is* production-reachable (the phone app can update before the watch app auto-updates), so the state is surfaced: `schemaMismatch` drives an "Update both apps to resume sync" banner in `ActiveWorkoutGateView`. **Recovery:** update/rebuild the watch app so both are on the same document schema (currently `WorkoutDocument` v5, `SplitDocument` v3); the `VersionedDocument` forward-gate covers files.
- **`updateApplicationContext` is single-slot.** Only the *latest* state is delivered; intermediate pushes coalesce. Fine because the context is authoritative full state, not a delta. A push that fails outright (realistically payload-too-large) retries with the recently-completed tail dropped, so an oversized context degrades instead of freezing the watch.
- **T3 is reachable-only.** Live frames (P7/W3) and `requestSync` (W4) silently no-op when unreachable; durable state (T2) and forwarded edits (W1's T4 fallback) still get through.
- **Unordered edit delivery.** T3 vs T4 can arrive out of order, and both devices may edit at once — handled by the per-log merge (W1), not by whole-document `updatedAt`.
- **The launch race is deliberately safe.** A phone-launched session can be `isRunning` before its run document has synced; `SessionEndPlanner.decide` guards with `!previouslyActiveIDs.isEmpty` so a not-yet-synced run is never mistaken for a finished one.
@@ -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.