Close the live-mirror flow hand-off gaps that skipped a timed phase

Field incident (2026-07-17): a Cardio flow run driven from a locked phone
durably completed the 30-min Main Circuit ~5 minutes in, then fought the
watch until manual swipes forced agreement. Second bulletproofing pass
(BULLETPROOFING.md, "2026-07-18 — Live-run mirror & flow hand-off"):

- H4: RunFlowView (both platforms) follows the driver's cross-log
  hand-off frame (forward-only by plan order, runnable target only), and
  a remote completion mid-flow hands off via onAdvance instead of
  dismissing the run (which tore down the Live Activity and stranded the
  flow in the mirror cover).
- H5: wall-clock plausibility gate — a duration exercise never
  auto-completes before startedAt + sets × duration; the flow-terminal
  rest self-repairs to computeResume() and the Finish auto-Done holds
  for a human tap. Human Done taps stay ungated.
- M6: both bridges persist liveVersion as a UserDefaults high-water mark
  so an app relaunch can't get every subsequent frame dropped as stale.
- M7: phone list + mirror cover absorbs gain the watch's strictly-newer
  guard, so a stale cache echo can't regress the working doc.
- Diagnostics: run-flow os_log breadcrumbs (page transitions, applied
  frames, repairs, hand-offs, gate trips) on both drivers.

Open, documented: M5 (no reconciliation signal across a single-set
duration phase), M8 (watch context apply lacks per-log staleness guard),
L5 (spurious pager write-backs read as human swipes).
This commit is contained in:
2026-07-18 10:49:45 -04:00
parent d24d0c3317
commit c4fe412308
10 changed files with 257 additions and 19 deletions
+62 -1
View File
@@ -1,6 +1,6 @@
# Bulletproofing the Watch/Phone UX — Findings
An adversarial gap analysis of the iPhone ↔ Apple Watch communication layer, aimed at making the cross-device workout UX bulletproof. Architecture reference: `DEVICE-COMMUNICATION.md` (transports T1T4, exchanges P1P9 / W1W4). Analysis date: 2026-07-09.
An adversarial gap analysis of the iPhone ↔ Apple Watch communication layer, aimed at making the cross-device workout UX bulletproof. Architecture reference: `DEVICE-COMMUNICATION.md` (transports T1T4, exchanges P1P9 / W1W4). Analysis date: 2026-07-09. **Second pass 2026-07-18** (live-run mirror & flow hand-off — see the dated section at the end), prompted by a field incident.
Findings are ranked by user impact. Each carries code evidence, the concrete failure scenario, and a fix direction. Statuses: **open** / **fixed** (update in place as items land).
@@ -144,6 +144,67 @@ Observability: Console.app → iPhone → subsystem `dev.rzen.indie.Workouts` sh
---
# 2026-07-18 — Live-run mirror & flow hand-off
Second-pass analysis prompted by a field incident (2026-07-17, Cardio flow routine: Warm Up → Main Circuit → Cool Off, all single-set duration exercises): run started on the phone, phone locked in pocket, watch ran the flow correctly — but ~5 min into the 30-min Main Circuit the phone was already counting down Main Circuit's terminal "Coming up: Cool Off" rest, then durably completed the phase early; both devices fought (dismiss ↔ resurrect) until manual swipes forced agreement.
Design context: both devices run the *same* local phase engine; only **human** transitions are broadcast as `LiveProgress` frames — auto-advances are reached independently off shared wall-clock anchors. Flow routines cross exercises via `RunFlowView.advance`, and the incoming exercise broadcasts one hand-off frame.
### H4 — Remote completion dismisses the flow instead of handing off; cross-log frames are ignored · **fixed**
> **Fix (2026-07-18):** `RunFlowView` (both platforms) now follows a frame for a *different* log of the same flow workout by advancing to it — forward-only by plan order, and only into a still-runnable log, so a late-delivered frame for an earlier exercise can't yank the flow backwards. And `ExerciseProgressView`'s remote-resolve observer now hands off (`onAdvance(nextUnfinished)`) instead of dismissing when the current exercise completes remotely mid-flow; dismissal remains for skipped/last-exercise/non-flow cases.
Two halves of one gap. (a) `RunFlowView` filtered incoming frames to `currentLogID` only (phone `RunFlowView.swift:67`, watch `:54`) — the driver's hand-off frame for the *next* exercise was silently dropped, so a follower could only cross an exercise boundary via its own timers (which on the phone need background runtime). (b) When the peer's durable "exercise completed" write landed first, `.onChange(of: log?.status)` called `dismiss()` (phone `ExerciseProgressView.swift:520`, watch `:352`) — popping the whole run, tearing down the Live Activity, sending `liveEnded`, and re-entering later through the mirror cover (a host with different terminal behavior — M3's residual). Which path fired was a pure race between the local timer tick and the ingest of the peer's write inside the same background wake.
### H5 — The between-exercise rest completes a phase it never ran · **fixed**
> **Fix (2026-07-18):** wall-clock plausibility gate on *automatic* completion of duration exercises (both platforms): an auto-fire earlier than `startedAt + sets × duration slack` is provably wrong — the flow-terminal rest then self-repairs by jumping back to `computeResume()` instead of completing, and the Finish page's auto-Done holds at zero awaiting a human tap. Human Done taps are never gated.
The terminal "Coming up" rest fired `completeExercise()` purely because its countdown window elapsed — regardless of how the page was reached. For a **1-set** exercise any single forward page slip lands on the terminal page (work + 1), so one spurious transition (see L5) durably completed a 30-minute phase ~45 s later; the write then propagated to the watch, whose driver saw `completed` and popped, while its own re-records resurrected the log through the merge — the observed tug-of-war. Likely epicenter of the incident.
### M5 — Reconciliation dead zone across a single-set duration phase · open (mitigated by H5)
Between "started" and "completed" of a 1-set duration exercise there is **no durable write** (`recordProgress` caps at `setCount 1 = 0`) and **no frame** (all transitions are auto) — for a 30-min phase, a half-hour window in which nothing corrects a desynced peer. `repairFromDurable` can't help: it only jumps set boundaries, and a 1-set exercise has none. A lost hand-off frame (`sendMessage` is reachable-only; the backoff retry gives up after 5 attempts ≈ 15 s) leaves a late-waking follower anchored at local `now` — minutes of drift with no repair until the next human transition.
**Fix direction:** stage auto-advance frames too (the depth-1 latest-wins slot makes this one message per phase), and/or extend the durable repair to recompute the expected phase from `startedAt + duration` for duration exercises. H5's gate already bounds the worst outcome (early durable completion).
### M6 — `liveVersion` resets on app restart; the peer then drops every frame · **fixed**
> **Fix (2026-07-18):** both bridges persist the version as a high-water mark in `UserDefaults` (stamped on send, raised on receive), so a relaunched app keeps outranking what the peer has already seen.
The frame sequence lived in the bridge instance (`PhoneConnectivityBridge.swift:39`, `WatchConnectivityBridge.swift:44`). If the sender's app was killed mid-run (watchdog, memory), its next frames restarted at v1 while the receiver's `lastAppliedVersion` for the run was higher — `isNewer` rejected every one, and since a pocketed phone makes no human transitions to bump the sender's counter back up, the mirror stayed dead for the rest of the run, silently.
### M7 — Phone-side absorbs lack the watch's strictly-newer guard · **fixed**
> **Fix (2026-07-18):** the phone list absorb and the mirror cover absorb now require `incoming > doc.updatedAt`, matching the watch (`Workouts Watch App/Views/WorkoutLogListView.swift:115` and its rationale comment).
`WorkoutLogListView.swift:174` (phone) and `LiveRunCoverView.swift:92` re-seeded the working doc on *any* `updatedAt` change, including a regression (stale cache echo, late-delivered older file version). The next local save then re-stamped the stale content as newest — exactly the clobber the watch-side guard exists to prevent, but on the device that owns the durable write path.
### M8 — Context push can roll the watch cache back per-log · open
`WatchCacheApplier``CacheMapper.upsertWorkout` applies the phone's copy with **no per-log staleness check** — the watch-side counterpart of H1's per-log merge exists only on the phone ingest path (`SyncEngine.ingestFromWatch`). The open doc is shielded by the strictly-newer absorb, but only at *workout* granularity: a push newer overall yet stale for one log regresses that log in the cache mid-run. Mostly self-healing (the phone's ingest of the watch's next write re-merges and re-pushes), but it can flap the watch UI and feeds L4's rollback mechanics.
**Fix direction:** per-log `modTime` guard in the watch apply (or full merge symmetry with the phone).
### L5 — Spurious pager write-backs are indistinguishable from human swipes · open
Any `TabView` selection write-back not caused by a real swipe takes the `pageChangeCause == nil` branch in the page observer (both platforms) — recorded *and broadcast* as if the user swiped. One instance (the first-layout snap-to-0) is already guarded by `didRestorePage`, but a later relayout snap (foregrounding, page-count change) would broadcast a wrong page to the peer — and on a 1-set exercise any forward slip is the terminal page (see H5). H5's gate plus the new transition logging bound the damage; a structural fix (distinguishing UIKit write-backs from human interaction) is deferred.
### Diagnostics (added 2026-07-18)
Both platforms' `ExerciseProgressView` now log every page transition (old → new, cause, anchors), every applied remote frame (version/phase/set), durable repairs, remote-resolve hand-offs, and H5 gate trips under subsystem `dev.rzen.indie.Workouts`, category **`run-flow`** — the next incident should be attributable from Console.app instead of reconstructed.
### Recommended order (this pass)
1. **H4 + H5** — the incident killers (follow hand-offs; never auto-complete a phase that provably hasn't run). ✅
2. **M7, M6** — cheap correctness parity + restart survival. ✅
3. **Diagnostics**`run-flow` logging on both platforms. ✅
4. **M5** — auto-advance keep-alive frames + duration-aware repair (design change; do deliberately).
5. **M8** — per-log guard in the watch context apply.
6. **L5** — structural cause detection for pager write-backs.
---
## Recommended order
The common theme of every HIGH: **the watch doesn't own its session lifecycle — it's a passive recipient of a fire-and-forget launch.** Fix in this order: