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
@@ -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() {