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
@@ -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"
@@ -5,6 +5,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import os
import SwiftUI
import UIKit
@@ -29,6 +30,10 @@ import UIKit
/// workout stopwatch in big glanceable digits. A row of phase dots tracks progress:
/// purple for work, teal for rest, with the current phase drawn as a wider dash.
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
@Environment(\.verticalSizeClass) private var verticalSizeClass
@Environment(\.modelContext) private var modelContext
@@ -486,6 +491,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 watch already recorded and owns this transition just follow it.
@@ -519,13 +525,24 @@ struct ExerciseProgressView: View {
}
.onChange(of: log?.status) { _, raw in
// The log resolved out from under us (the run was completed or ended early on
// another device). 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.
// another device). 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 (which would tear down
// the Live Activity and strand the flow in the mirror cover). 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 }
@@ -666,6 +683,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))
@@ -693,6 +711,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 }
@@ -704,6 +723,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))
@@ -786,6 +832,9 @@ struct ExerciseProgressView: View {
onActivate: announceComingUp,
onCountdownBeat: announceCountdownBeat
) { _ 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.
@@ -805,7 +854,8 @@ struct ExerciseProgressView: View {
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
anchorEnd: anchorEnd,
autoFireAllowed: { autoCompletionPlausible }
)
.overlay(alignment: .bottom) { adjustPill }
}
@@ -1777,6 +1827,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. `remaining` is what the Done button shows.
@@ -1824,7 +1879,7 @@ private struct FinishPhaseView: View {
if max(0, r) != remaining {
withAnimation(.snappy(duration: 0.25)) { remaining = max(0, r) }
}
if r <= 0 { fire() }
if r <= 0, autoFireAllowed() { fire() }
}
private func fire() {
@@ -89,10 +89,13 @@ struct LiveRunCoverView: View {
.onAppear {
if doc == nil, let workout { doc = WorkoutDocument(from: workout) }
}
.onChange(of: workout?.updatedAt) { _, _ in
.onChange(of: workout?.updatedAt) { _, incoming in
// Absorb the durable progress the watch drove (currentStateIndex / status). The
// live *page* is driven by frames, not this so re-seeding the doc is safe.
if let workout { doc = WorkoutDocument(from: workout) }
// Strictly newer only (matching the list absorbs): a stale cache echo must not
// regress the working copy, which the next persist would re-stamp as newest.
guard let incoming, incoming > (doc?.updatedAt ?? .distantPast), let workout else { return }
doc = WorkoutDocument(from: workout)
}
}
@@ -79,6 +79,26 @@ struct RunFlowView: View {
// than in the child's onDisappear no longer cuts off the next exercise's cue.
speechAnnouncer?.stop()
}
.onChange(of: liveRun.current) { _, 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/activity
@@ -171,8 +171,13 @@ struct WorkoutLogListView: View {
// cache reflects them, so the list shows live status on return. The observed
// expression itself must be liveness-guarded too: it's evaluated on every
// body pass, and reading a persisted property on a dead @Model traps.
.onChange(of: workout.isLive ? workout.updatedAt : nil) { _, _ in
if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
// Strictly-newer guard, matching the watch list: an equal stamp is our own
// write echoing back, and a stale cache echo (late-delivered older file
// version) must not regress the working copy mid-run the next local save
// would re-stamp that stale content as newest and clobber real progress.
.onChange(of: workout.isLive ? workout.updatedAt : nil) { _, incoming in
if let incoming, incoming > doc.updatedAt,
let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
}
.toolbar {
ToolbarItem(placement: .primaryAction) {