diff --git a/BULLETPROOFING.md b/BULLETPROOFING.md index e64868a..edaedd9 100644 --- a/BULLETPROOFING.md +++ b/BULLETPROOFING.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a97f6fa..ef7d30f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/DEVICE-COMMUNICATION.md b/DEVICE-COMMUNICATION.md index c108c7b..c397eda 100644 --- a/DEVICE-COMMUNICATION.md +++ b/DEVICE-COMMUNICATION.md @@ -77,7 +77,7 @@ Wire format for T2–T4 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 T2–T4 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 T2–T4 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. diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index b4dab19..b8b5b4e 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -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 diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift index 650a2fd..7da6768 100644 --- a/Workouts Watch App/Views/ActiveWorkoutGateView.swift +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -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.