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:
@@ -36,7 +36,12 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of
|
||||
/// any frame we *receive*, so the two devices share one increasing sequence per run and
|
||||
/// either side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
|
||||
private var liveVersion = 0
|
||||
/// Persisted as a high-water mark: an app relaunch resetting it to 0 would stamp frames
|
||||
/// the peer has already outranked — and with no human transition on the peer to catch
|
||||
/// the counter back up, every subsequent frame would be dropped as stale.
|
||||
private var liveVersion = UserDefaults.standard.integer(forKey: liveVersionDefaultsKey) {
|
||||
didSet { UserDefaults.standard.set(liveVersion, forKey: liveVersionDefaultsKey) }
|
||||
}
|
||||
|
||||
/// The latest live-run message we haven't confirmed reached the watch (depth 1, latest-wins).
|
||||
/// Staged regardless of reachability and re-sent by `flushLive()` when reachability/activation
|
||||
@@ -332,3 +337,6 @@ private enum PendingLive {
|
||||
case progress(LiveProgress)
|
||||
case ended(workoutID: String, logID: String)
|
||||
}
|
||||
|
||||
/// Defaults key for the persisted live-frame version high-water mark (see `liveVersion`).
|
||||
private let liveVersionDefaultsKey = "liveMirrorVersionHighWater"
|
||||
|
||||
Reference in New Issue
Block a user