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:
@@ -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 page→frame 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() {
|
||||
|
||||
Reference in New Issue
Block a user