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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user