End the watch workout session from durable state, not a view

The HKWorkoutSession that keeps the watch app foregrounded was ended only
by ActiveWorkoutGateView's onChange(of: activeWorkouts) — a view-level side
effect. When a run ended from the phone while the watch app was
backgrounded (kept alive only by the session) or torn down and rebuilt with
an already-empty list, that onChange never fired: the session leaked (the
app kept re-foregrounding on every wrist raise) and finishAndSave() never
ran, so the HR/energy summary was neither saved to Health nor forwarded.

Move session-end off the view into a long-lived WorkoutSessionCoordinator
owned by WatchAppServices, driven by a new bridge.onWorkoutsChanged
callback fired after every authoritative cache mutation (phone push or the
watch's own optimistic edit). The decision is a pure SessionEndPlanner
seam (mirrors WatchCacheApplier): a running session ends only on a genuine
non-empty -> empty transition of the active set, so the launch race (session
running before the run doc syncs) resolves to .none and never discards a
run we haven't heard about yet. Same move as the live-mirror's
repairFromDurable, one layer down.

Watch-only; no schema or wire change. SessionEndPlannerTests pins the
decision table; the OS-initiated-end path (system ends the session itself)
stays a documented residual in PLAN-watch-session-end.md.
This commit is contained in:
2026-07-09 08:12:06 -04:00
parent 9ea5e54b15
commit 04523c875a
8 changed files with 480 additions and 39 deletions
@@ -18,7 +18,6 @@ import SwiftData
/// exercise list.
struct ActiveWorkoutGateView: View {
@Environment(WatchConnectivityBridge.self) private var bridge
@Environment(WorkoutSessionManager.self) private var sessionManager
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
@Query private var splits: [Split]
@@ -72,13 +71,6 @@ struct ActiveWorkoutGateView: View {
// Nothing to run yet pull fresh state in case the phone just started one.
if activeWorkouts.isEmpty { bridge.requestSync() }
}
.onChange(of: activeWorkouts.map(\.id)) { previouslyActive, nowActive in
// The active list just emptied the run either completed or was discarded.
// Route the HealthKit session accordingly.
if nowActive.isEmpty, !previouslyActive.isEmpty {
endSession(previouslyActive: previouslyActive)
}
}
// The phone just entered (or left) an editor if we're inside the now-locked run,
// pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run
// we're inside was pruned (discarded/deleted on the phone, or aged out of the push).
@@ -98,35 +90,6 @@ struct ActiveWorkoutGateView: View {
}
}
/// The active list emptied: decide whether the run completed (save it to Health,
/// attach the captured metrics, and forward them to the phone) or was discarded
/// (throw the session data away). A completed run stays in the cache as `.completed`;
/// a discarded one is tombstoned on the phone and pruned from the cache, so its
/// absence from `workouts` is the discard signal.
private func endSession(previouslyActive ids: [String]) {
let finished = ids
.compactMap { id in workouts.first { $0.id == id } }
.first { $0.status == .completed }
guard let finished else {
// Nothing survived as completed the run was discarded. Don't save it.
sessionManager.discard()
return
}
let doc = WorkoutDocument(from: finished)
Task {
guard var metrics = await sessionManager.finishAndSave() else { return }
metrics.totalVolume = WorkoutVolume.total(doc.logs)
var updated = doc
updated.metrics = metrics
updated.updatedAt = Date()
// Carry the captured metrics back to the phone (and thus iCloud) over the
// existing workout-update path.
bridge.update(workout: updated)
}
}
/// If the run we're currently navigated into is no longer available pruned from the
/// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked
/// because the phone took over editing it pop back to the gate.