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
@@ -41,7 +41,12 @@ final class WatchConnectivityBridge: 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 per-run sequence 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 phone (depth 1, latest-wins).
/// Staged regardless of reachability and re-sent by `flushLive()` when reachability/activation
@@ -451,3 +456,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"
@@ -5,6 +5,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import os
import SwiftUI
import WatchKit
@@ -23,6 +24,10 @@ import WatchKit
/// A dot row tracks progress with one marker per work set the active set drawn as a
/// wider dash, with the gap widening at the rest you're currently in.
struct ExerciseProgressView: View {
/// Run-flow breadcrumbs (page transitions, applied frames, repairs) the mirror's
/// failure modes are cross-device timing races, unattributable without a trail.
private static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "run-flow")
@Environment(\.dismiss) private var dismiss
/// Live HR straight off the session's builder drives the target-HR readout when
@@ -321,6 +326,7 @@ struct ExerciseProgressView: View {
guard didRestorePage else { return }
let cause = pageChangeCause
pageChangeCause = nil
Self.log.info("page \(oldPage)\(newPage) cause=\(cause.map { "\($0)" } ?? "human", privacy: .public) log=\(logID, privacy: .public)")
switch cause {
case .remote:
// The phone already recorded and owns this transition just follow it.
@@ -351,13 +357,23 @@ struct ExerciseProgressView: View {
}
.onChange(of: log?.status) { _, raw in
// The log resolved out from under us (the run was completed or ended early on
// the phone). Leave the live flow staying would let the next recorded set
// write a resolved log back to in-progress, resurrecting it through the merge.
// `locallyResolved` keeps our own Done / flow hand-off from re-dismissing.
// the phone). Staying would let the next recorded set write a resolved log
// back to in-progress, resurrecting it through the merge. In flow mode a
// remote *completion* means the driver has already handed off follow it to
// the next unfinished exercise instead of falling out of the run. Everything
// else (skipped, last exercise, non-flow) leaves the flow as before.
// `locallyResolved` keeps our own Done / flow hand-off from re-triggering this.
guard didRestorePage, !locallyResolved,
let status = raw.flatMap(WorkoutStatus.init(rawValue:)),
status == .completed || status == .skipped else { return }
dismiss()
locallyResolved = true
if autoAdvance, status == .completed, let next = nextUnfinishedLogID, let onAdvance {
Self.log.info("remote completion of \(logID, privacy: .public) → flow hand-off to \(next, privacy: .public)")
onAdvance(next)
} else {
Self.log.info("remote resolve of \(logID, privacy: .public) (\(status.rawValue, privacy: .public)) → dismiss")
dismiss()
}
}
.onAppear {
guard !didRestorePage else { return }
@@ -444,6 +460,7 @@ struct ExerciseProgressView: View {
lastAppliedStart = frame.phaseStart
if frame.setCount != setCount { setCount = frame.setCount }
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
Self.log.info("apply frame v\(frame.version) \(frame.phase.rawValue, privacy: .public)/\(frame.setIndex) → page \(target) log=\(logID, privacy: .public)")
// A followed frame is progress this screen now *knows*, so a durable echo of the
// same transition can't re-trigger the repair jump (a remote reset re-baselines).
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
@@ -471,6 +488,7 @@ struct ExerciseProgressView: View {
if log.sets > setCount { setCount = log.sets }
let target = base + min(max(0, completed), setCount - 1) * 2
guard target > currentPage else { return }
Self.log.info("durable repair: completed=\(completed) → page \(target) log=\(logID, privacy: .public)")
clearAnchor()
pageChangeCause = .remote
withAnimation { currentPage = target }
@@ -482,6 +500,33 @@ struct ExerciseProgressView: View {
pageAnchor = nil
}
/// Wall-clock plausibility gate for *automatic* completion of a duration exercise: its
/// work windows alone take `sets × duration`, so an auto-fire earlier than that (minus
/// slack) proves the terminal page was reached erroneously a spurious pager
/// write-back or a stale anchor, not a run that actually happened. Human Done taps are
/// never gated; rep-based exercises have no wall-clock expectation and always pass, as
/// does a log missing its `startedAt` stamp (nothing to judge against).
private var autoCompletionPlausible: Bool {
guard isDuration, let log, let started = log.startedAt else { return true }
let minimum = Double(workDurationSeconds * setCount) - 2
return Date().timeIntervalSince(started) >= minimum
}
/// The flow-terminal rest fired before the exercise could have run (see
/// `autoCompletionPlausible`) jump back to where the durable timestamps say the run
/// actually is instead of durably completing a phase that never happened.
private func repairEarlyTerminal() {
let resume = computeResume()
Self.log.warning("early terminal auto-fire blocked for \(logID, privacy: .public); repairing to page \(resume.page)")
guard currentPage != resume.page else {
pageAnchor = resume.anchor
return
}
pageAnchor = resume.anchor
pageChangeCause = .remote
withAnimation { currentPage = resume.page }
}
/// Inverse of `liveSnapshot`'s pageframe mapping: a frame's phase/set page index.
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
let set = min(max(0, setIndex), max(0, setCount - 1))
@@ -611,6 +656,9 @@ struct ExerciseProgressView: View {
anchorStart: anchorStart,
anchorEnd: anchorEnd
) { _ in
// An auto-fire before the exercise could possibly have run proves
// this page was reached erroneously repair, don't complete.
guard autoCompletionPlausible else { repairEarlyTerminal(); return }
completeExercise()
// Re-resolve at fire time the next exercise may have been
// finished elsewhere while we rested. None left end the run.
@@ -629,7 +677,8 @@ struct ExerciseProgressView: View {
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
anchorEnd: anchorEnd,
autoFireAllowed: { autoCompletionPlausible }
)
}
} else if cycleIndex.isMultiple(of: 2) {
@@ -1191,6 +1240,11 @@ private struct FinishPhaseView: View {
/// sender's wall clock instead of local `now + countdown`, so both devices fire together.
var anchorEnd: Date? = nil
/// Gates the *automatic* Done only (the tap always works): when false, the countdown
/// holds at zero awaiting a human the host's wall-clock plausibility check says the
/// exercise can't genuinely be finished yet (see `autoCompletionPlausible`).
var autoFireAllowed: () -> Bool = { true }
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
/// Wall-clock deadline for the auto-Done (same Always-On rationale as the rest timer).
@@ -1236,7 +1290,7 @@ private struct FinishPhaseView: View {
guard isActive, !didFire else { return }
let r = Int(ceil(endDate.timeIntervalSinceNow))
remaining = max(0, r)
if r <= 0 { fire() }
if r <= 0, autoFireAllowed() { fire() }
}
private func fire() {
@@ -59,6 +59,26 @@ struct RunFlowView: View {
}
.onAppear { bridge.navigatedRunID = currentLogID }
.onDisappear { if bridge.navigatedRunID == currentLogID { bridge.navigatedRunID = nil } }
.onChange(of: bridge.liveIncoming) { _, frame in
followRemoteHandoff(frame)
}
}
/// Follow the driver across an exercise boundary. Auto-advances are never broadcast, so
/// a frame for a *different* log of this flow workout is always the peer's discrete
/// hand-off into the next exercise which the per-exercise child can't follow (its
/// incoming-frame filter is scoped to `currentLogID`). Forward-only by plan order and
/// only into a still-runnable log, so a late-delivered frame for an earlier exercise
/// can never yank the flow backwards.
private func followRemoteHandoff(_ frame: LiveProgress?) {
guard doc.autoAdvance == true,
let frame, frame.workoutID == doc.id, frame.logID != currentLogID else { return }
let sorted = doc.logs.sorted { $0.order != $1.order ? $0.order < $1.order : $0.id < $1.id }
guard let from = sorted.firstIndex(where: { $0.id == currentLogID }),
let to = sorted.firstIndex(where: { $0.id == frame.logID }), to > from else { return }
let status = WorkoutStatus(rawValue: sorted[to].status) ?? .notStarted
guard status == .notStarted || status == .inProgress else { return }
advance(to: frame.logID)
}
/// Hand off to the next exercise. Suppresses the leaving exercise's live-ended teardown