Make the watch own its workout-session lifecycle

The HKWorkoutSession could only ever be born via the phone's one-shot
startWatchApp handoff (BULLETPROOFING.md H1-H3): a dropped handoff, a
watch crash/reboot, or a run engaged manually on the wrist left the
whole workout sessionless - no heart rate, no Health save, app
suspending wrist-down - and "End Current & Start New" swallowed the
handoff against the old session's idempotency guard.

Now the coordinator self-starts a session on any reconcile that finds
an active run with none running (SessionEndPlanner.shouldStart /
runToStart, activity type from the run's split), recover() re-adopts a
crash-orphaned session at launch, and a system-ended session salvages
its Health save instead of dropping it. A .finish decided for a
session younger than 30s demotes to .discard so the stale-context
races can't save junk workouts attributed to the wrong run; parallel
completions now pick the survivor deterministically.

Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
2026-07-09 22:57:26 -04:00
parent e6cea299dc
commit abb223daca
6 changed files with 254 additions and 37 deletions
+59 -3
View File
@@ -28,6 +28,11 @@ final class WorkoutSessionManager: NSObject {
private(set) var isRunning = false
/// When the running session started, for the coordinator's session-age rule (a
/// too-young `.finish` demotes to `.discard`). `distantPast` for a recovered
/// session it predates this process, so it's never "young".
private(set) var sessionStartDate: Date?
/// Live values for the in-workout HUD. Nil until the first sample arrives (or if
/// the corresponding read authorization was denied).
private(set) var currentHeartRate: Double?
@@ -74,12 +79,45 @@ final class WorkoutSessionManager: NSObject {
let startDate = Date()
session.startActivity(with: startDate)
Task { try? await builder.beginCollection(at: startDate) }
sessionStartDate = startDate
isRunning = true
} catch {
clear()
}
}
/// Re-adopt a session that outlived a crash or reboot. watchOS keeps the
/// `HKWorkoutSession` alive across an app death and relaunches us expecting a
/// recovery call; without it the session is orphaned its `HKWorkout` never saved
/// and the live HUD dead. Called once at launch; a no-op when there's nothing to
/// recover or a fresh start already claimed the slot.
func recover() {
guard HKHealthStore.isHealthDataAvailable(), session == nil else { return }
Task {
guard let recovered = try? await healthStore.recoverActiveWorkoutSession(),
self.session == nil else { return }
adopt(recovered)
}
}
private func adopt(_ recovered: HKWorkoutSession) {
let builder = recovered.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore, workoutConfiguration: recovered.workoutConfiguration)
recovered.delegate = self
builder.delegate = self
self.session = recovered
self.builder = builder
// Zone seconds accumulated before the crash are gone (they lived in memory);
// resume accumulation from here rather than faking the missing stretch.
resetMetricAccumulators()
maxHeartRate = computeMaxHeartRate()
sessionStartDate = .distantPast
isRunning = true
}
/// Finish the session and save it to Health, returning the captured metrics so the
/// caller can attach them to the workout and forward to the phone. `totalVolume`
/// is left nil here the caller fills it from the workout's logs.
@@ -178,11 +216,26 @@ final class WorkoutSessionManager: NSObject {
maxHeartRate = nil
}
/// The system ended the session out from under us (resource reclaim, watchOS policy).
/// Salvage what the builder collected into a real Health save instead of dropping it
/// the run documents stay active, so no metrics are forwarded to the phone (there is no
/// completed run to attach them to); the `HKWorkout` itself is what must not be lost.
private func salvageSystemEndedSession(at endDate: Date) {
guard let builder, !isFinishing else { clear(); return }
isFinishing = true
Task {
defer { clear() }
try? await builder.endCollection(at: endDate)
_ = try? await builder.finishWorkout()
}
}
private func clear() {
session = nil
builder = nil
isRunning = false
isFinishing = false
sessionStartDate = nil
resetMetricAccumulators()
}
}
@@ -197,9 +250,12 @@ extension WorkoutSessionManager: HKWorkoutSessionDelegate {
date: Date
) {
guard toState == .ended || toState == .stopped else { return }
// Only reset if we didn't drive the end ourselves (finishAndSave/discard set
// `isFinishing` and clear on their own); this catches system-ended sessions.
Task { @MainActor in if !self.isFinishing { self.clear() } }
// Only act if we didn't drive the end ourselves (finishAndSave/discard set
// `isFinishing` and clear on their own). A system-ended session is salvaged
// into a Health save rather than silently dropped.
Task { @MainActor in
if !self.isFinishing { self.salvageSystemEndedSession(at: date) }
}
}
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {