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
+9 -3
View File
@@ -21,7 +21,9 @@ Re-verified against code, not just prior docs — these need no work and should
## HIGH — the session lifecycle has a single point of failure
### H1 — The `HKWorkoutSession` can only ever be born via the T1 one-shot · open
### H1 — The `HKWorkoutSession` can only ever be born via the T1 one-shot · **fixed**
> **Fix (2026-07-09):** the coordinator now self-starts a session on any reconcile that finds an active run with none running (`SessionEndPlanner.shouldStart` / `runToStart`; activity type resolved from the run's split), and `WorkoutSessionManager.recover()` re-adopts a crash/reboot-orphaned session at launch (`recoverActiveWorkoutSession`). T1 is now a fast-launch optimization, not the sole session source.
`WorkoutSessionManager.start(with:)` has exactly one caller: `WatchAppDelegate.handle(_:)` (`WatchAppDelegate.swift:31`), i.e. the phone's `startWatchApp(toHandle:)` handoff. That handoff is best-effort `try?` with **no retry** (`WorkoutLauncher.swift:37-40`). There is no `HKHealthStore.recoverActiveWorkoutSession` anywhere, no `WKExtendedRuntimeSession`, and nothing gates run-flow entry on `sessionManager.isRunning`.
@@ -33,7 +35,9 @@ Failure scenarios (all real):
**Fix direction:** make the watch own its session lifecycle. Self-start a session when entering (or reconciling into) an active run with `isRunning == false`; implement session recovery at launch. T1 then becomes a fast-launch optimization rather than the sole source of sessions. This also absorbs most of H2 and much of the M-tier degradation.
### H2 — "End Current & Start New" swallows the new run's session · open
### H2 — "End Current & Start New" swallows the new run's session · **fixed**
> **Fix (2026-07-09):** absorbed by H1's self-start (a swallowed T1 heals at the next reconcile), plus a session-age rule in the planner: a `.finish` decided for a session younger than `minimumFinishAge` (30 s) demotes to `.discard`, so the transient "old run completed, new run not yet pushed" context can no longer save a junk seconds-long HKWorkout attributed to the old run — the session is discarded and self-start brings it back when the new run's push lands.
`endActiveThenStart` (`WorkoutLogsView.swift:271-281`) runs the end-saves in a `Task` but calls `start(with:)` synchronously — so **T1 goes out while the old run's session is still running on the watch**. `handle(_:)``start(with:)` hits the `session == nil` idempotency guard (`WorkoutSessionManager.swift:61`) and is **silently dropped**. Moments later the "A completed" push arrives and the coordinator finishes A's session — leaving run B sessionless (all of H1's fallout). The one-shot was consumed while blocked; nothing replays it.
@@ -41,7 +45,9 @@ Timing variant: if B's session *does* start (old one already ended) but the tran
**Fix direction:** on the watch, when `handle(_:)` arrives while a session is running, stash the configuration and start it immediately after the current session finishes; and/or a short grace window after `session.startActivity(...)` during which `decide` refuses `.finish`/`.discard`. H1's self-start largely absorbs this too.
### H3 — A system-ended session silently drops the Health save · open
### H3 — A system-ended session silently drops the Health save · **fixed**
> **Fix (2026-07-09):** `salvageSystemEndedSession` — a system-driven `.ended`/`.stopped` now ends collection and finishes the builder's workout (saving the `HKWorkout` to Health) instead of clearing everything. Residual: the salvaged workout isn't linked to a run document (`healthKitWorkoutUUID` stays nil) and no metrics are forwarded — the runs are still active, so there's no completed run to attach them to.
`workoutSession(_:didChangeTo:)` for `.ended`/`.stopped` only calls `clear()` (`WorkoutSessionManager.swift:199-202`) — no `endCollection`/`finishWorkout`, no metrics capture, no forward to the phone. `PLAN-watch-session-end.md` lists this as a known residual. Rare (OS reclaim under resource pressure), but it is a "my workout never made it to Health" support report waiting to happen.
+8
View File
@@ -1,5 +1,13 @@
**July 2026**
The watch now starts recording on its own whenever a workout is active, so a missed hand-off from the iPhone no longer leaves a session without heart rate or Health credit.
A workout recording now survives the watch app crashing or the watch restarting, reconnecting to the in-progress session instead of losing it.
A recording cut short by watchOS itself is now still saved to Apple Health instead of being silently dropped.
Ending a workout and immediately starting a new one no longer risks a stray seconds-long workout appearing in Apple Health.
A new Cardio exercise and matching starter split let you log aerobic sessions, tracked on your Apple Watch as a cardio workout.
A new Auto-Advance option lets a split flow hands-free from one exercise to the next, resting automatically between them.
@@ -25,6 +25,9 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate {
if ScreenshotSeed.isActive { return }
#endif
sessionManager.requestAuthorization()
// Re-adopt a session that outlived a crash or reboot watchOS relaunches us
// expecting this call; without it the session is orphaned and its workout lost.
sessionManager.recover()
}
func handle(_ workoutConfiguration: HKWorkoutConfiguration) {
@@ -6,6 +6,7 @@
//
import Foundation
import HealthKit
import SwiftData
/// What the watch should do with its `HKWorkoutSession` given the latest authoritative
@@ -16,17 +17,33 @@ enum SessionEndDecision: Equatable {
case finish(WorkoutDocument)
}
/// Pure decision seam for ending the watch's workout session the session-free core the
/// Pure decision seam for the watch's workout-session lifecycle the session-free core the
/// `WorkoutSessionCoordinator` delegates to, mirroring the `WatchCacheApplier` pattern so the
/// end-of-run rule is unit-testable without HealthKit or a live cache.
/// start/end rules are unit-testable without HealthKit or a live cache.
///
/// The rule reconciles against the *authoritative* workout set (the same set the phone pushes
/// and the watch prunes to), not against any view's lifetime a running session ends on a
/// genuine non-empty empty transition of the active set. Comparing against the active set
/// captured at the *previous* reconcile is what makes the launch race safe: at startup the
/// The end rule reconciles against the *authoritative* workout set (the same set the phone
/// pushes and the watch prunes to), not against any view's lifetime a running session ends
/// on a genuine non-empty empty transition of the active set. Comparing against the active
/// set captured at the *previous* reconcile is what makes the launch race safe: at startup the
/// session can be `running` before the run document has synced, but the previous active set is
/// still empty, so we never discard a run we simply haven't heard about yet.
///
/// The start rule makes the watch own its session: whenever the authoritative set holds an
/// active run and no session is running, one should be started. The phone's
/// `startWatchApp(toHandle:)` handoff then becomes a fast-launch optimization rather than the
/// only way a session is ever born a dropped handoff (watch unreachable at start, a handoff
/// swallowed by `start`'s idempotency guard while the old run's session was still running),
/// a crash/reboot, or a run engaged manually on the wrist all heal at the next reconcile.
enum SessionEndPlanner {
/// A `.finish` for a session younger than this is demoted to `.discard`: a seconds-old
/// session has no meaningful samples, and the demotion is what keeps the two stale-data
/// races from polluting Apple Health with junk workouts attributed to the wrong run
/// (a) a transient "old run completed, new run not yet pushed" context arriving right
/// after the new run's session started, and (b) a self-start off stale cache at launch,
/// corrected seconds later by the fresh context. In both cases the active set refills
/// and `shouldStart` brings the session right back.
static let minimumFinishAge: TimeInterval = 30
/// Ids of the runs that keep the session alive not-yet-finished (`notStarted` /
/// `inProgress`). A `completed` / `skipped` run has dropped off the active set.
static func activeIDs(in workouts: [WorkoutDocument]) -> Set<String> {
@@ -38,7 +55,25 @@ enum SessionEndPlanner {
.map(\.id))
}
/// True when a session should be running but isn't the watch self-starts one rather
/// than depending on the phone's one-shot launch handoff having arrived.
static func shouldStart(isRunning: Bool, workouts: [WorkoutDocument]) -> Bool {
!isRunning && !activeIDs(in: workouts).isEmpty
}
/// The run whose split configures a self-started session: the most recently started
/// active one (ties broken by id for determinism).
static func runToStart(in workouts: [WorkoutDocument]) -> WorkoutDocument? {
let ids = activeIDs(in: workouts)
return workouts
.filter { ids.contains($0.id) }
.max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start }
}
/// `sessionAge` is seconds since the running session started (nil when unknown e.g. a
/// recovered session, which is by definition old enough to finish normally).
static func decide(isRunning: Bool,
sessionAge: TimeInterval?,
previouslyActiveIDs: Set<String>,
workouts: [WorkoutDocument]) -> SessionEndDecision {
guard isRunning,
@@ -46,19 +81,29 @@ enum SessionEndPlanner {
!previouslyActiveIDs.isEmpty else { return .none }
// A previously-active run that survived as `.completed` is a finish (save + forward
// metrics); if none survived, every active run vanished (discarded/deleted) discard.
// Among several completed survivors (parallel runs), take the most recently started
// deterministic, and the one whose lifetime best matches the session's.
let finished = previouslyActiveIDs
.compactMap { id in workouts.first { $0.id == id } }
.first { WorkoutStatus(rawValue: $0.status) == .completed }
return finished.map(SessionEndDecision.finish) ?? .discard
.filter { WorkoutStatus(rawValue: $0.status) == .completed }
.max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start }
guard let finished else { return .discard }
// Too young to be a real finish see `minimumFinishAge`.
if let sessionAge, sessionAge < minimumFinishAge { return .discard }
return .finish(finished)
}
}
/// The single owner of watch workout-session termination. Long-lived (held by
/// `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate callback
/// with or without a mounted view, including in the session's own background runtime. This is
/// the fix for the leak where ending a run *from the phone* while the watch app was
/// backgrounded left the `HKWorkoutSession` running (watchOS then re-foregrounded the app on
/// every wrist raise) and skipped the metrics capture entirely.
/// The single owner of the watch's workout-session lifecycle both ends of it. Long-lived
/// (held by `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate
/// callback with or without a mounted view, including in the session's own background
/// runtime. Termination here is the fix for the leak where ending a run *from the phone*
/// while the watch app was backgrounded left the `HKWorkoutSession` running (watchOS then
/// re-foregrounded the app on every wrist raise) and skipped the metrics capture entirely.
/// Self-starting here is the counterpart fix: a session is started whenever the authoritative
/// set holds an active run and none is running, so a dropped phone launch handoff (or a
/// crash, or a run engaged manually on the wrist) can't leave a workout sessionless no HR,
/// no Health save, app suspending wrist-down.
///
/// Same move as the live-mirror's `repairFromDurable`, one layer down: drive the terminal
/// action from the authoritative cache apply rather than a transient UI observer.
@@ -91,16 +136,33 @@ final class WorkoutSessionCoordinator {
.map(WorkoutDocument.init(from:))
let decision = SessionEndPlanner.decide(
isRunning: sessionManager.isRunning,
sessionAge: sessionManager.sessionStartDate.map { -$0.timeIntervalSinceNow },
previouslyActiveIDs: previouslyActiveIDs,
workouts: workouts)
previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts)
switch decision {
case .none: return
case .none: startIfNeeded(workouts: workouts)
case .discard: sessionManager.discard()
case .finish(let doc): endAndForward(doc)
}
}
/// Self-start a session when an active run has none (see `SessionEndPlanner.shouldStart`).
/// The activity type comes from the run's split so the saved Health workout is categorized
/// the same way a phone-launched one would be; a run whose split is gone (deleted, or not
/// yet synced) falls back to traditional strength training.
private func startIfNeeded(workouts: [WorkoutDocument]) {
guard SessionEndPlanner.shouldStart(isRunning: sessionManager.isRunning, workouts: workouts),
let run = SessionEndPlanner.runToStart(in: workouts) else { return }
let split = run.splitID.flatMap { id in
(try? context.fetch(FetchDescriptor<Split>())).flatMap { $0.first { $0.id == id } }
}
let configuration = HKWorkoutConfiguration()
configuration.activityType = split?.activityTypeEnum.hkActivityType ?? .traditionalStrengthTraining
configuration.locationType = .indoor
sessionManager.start(with: configuration)
}
/// Finish the session (saving the `HKWorkout` to Health), attach the captured metrics, and
/// forward the run back to the phone. Lifted from the old `ActiveWorkoutGateView.endSession`
/// so the behavior is unchanged only its trigger moved off the view.
+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) {
@@ -2,20 +2,25 @@ import Foundation
import Testing
@testable import Workouts_Watch_App
/// Pins the pure session-end decision (`SessionEndPlanner.decide`) the rule that ends the
/// watch's `HKWorkoutSession` from the authoritative workout set instead of a view observer.
/// The decisive properties: a running session ends only on a genuine non-empty empty
/// transition of the active set; a survivor that's `.completed` is a finish, otherwise a
/// discard; and the launch race (session running before the run doc has synced) resolves to
/// `.none` so a real run is never discarded before we've heard about it.
/// Pins the pure session-lifecycle decisions (`SessionEndPlanner`) the rules that start and
/// end the watch's `HKWorkoutSession` from the authoritative workout set instead of a view
/// observer or the phone's one-shot launch handoff. The decisive properties: a running session
/// ends only on a genuine non-empty empty transition of the active set; a survivor that's
/// `.completed` is a finish, otherwise a discard; the launch race (session running before the
/// run doc has synced) resolves to `.none` so a real run is never discarded before we've heard
/// about it; a too-young `.finish` demotes to `.discard` so a stale-data race can't save a junk
/// workout to Health; and a session should start whenever an active run has none.
struct SessionEndPlannerTests {
private static let ts = Date(timeIntervalSince1970: 1_700_000_000)
private func workout(_ id: String, _ status: WorkoutStatus) -> WorkoutDocument {
/// Comfortably past `minimumFinishAge` the "normal running session" age.
private static let matureAge: TimeInterval = 3600
private func workout(_ id: String, _ status: WorkoutStatus, start: Date = ts) -> WorkoutDocument {
WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: id, splitID: nil, splitName: nil, start: Self.ts, end: nil,
status: status.rawValue, createdAt: Self.ts, updatedAt: Self.ts,
id: id, splitID: nil, splitName: nil, start: start, end: nil,
status: status.rawValue, createdAt: start, updatedAt: start,
logs: [], metrics: nil)
}
@@ -24,21 +29,24 @@ struct SessionEndPlannerTests {
@Test func launchRace_runningButNothingHeardYet_isNone() {
// Session running before any run document has synced: previous active set empty
// never discard a run we simply don't know about yet.
let d = SessionEndPlanner.decide(isRunning: true, previouslyActiveIDs: [], workouts: [])
let d = SessionEndPlanner.decide(
isRunning: true, sessionAge: 1, previouslyActiveIDs: [], workouts: [])
#expect(d == .none)
}
@Test func completedSurvivor_isFinish() {
let done = workout("A", .completed)
let d = SessionEndPlanner.decide(
isRunning: true, previouslyActiveIDs: ["A"], workouts: [done])
isRunning: true, sessionAge: Self.matureAge,
previouslyActiveIDs: ["A"], workouts: [done])
#expect(d == .finish(done))
}
@Test func vanishedRun_isDiscard() {
// "A" was active, now absent entirely (discarded/deleted on the phone) discard.
let d = SessionEndPlanner.decide(
isRunning: true, previouslyActiveIDs: ["A"], workouts: [])
isRunning: true, sessionAge: Self.matureAge,
previouslyActiveIDs: ["A"], workouts: [])
#expect(d == .discard)
}
@@ -46,7 +54,7 @@ struct SessionEndPlannerTests {
// One of two previously-active runs finished, the other is still in progress the
// active set isn't empty, so the session keeps running.
let d = SessionEndPlanner.decide(
isRunning: true,
isRunning: true, sessionAge: Self.matureAge,
previouslyActiveIDs: ["A", "B"],
workouts: [workout("A", .completed), workout("B", .inProgress)])
#expect(d == .none)
@@ -55,7 +63,8 @@ struct SessionEndPlannerTests {
@Test func notRunning_isNone() {
// A cold-relaunched watch with no session must never try to end one.
let d = SessionEndPlanner.decide(
isRunning: false, previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)])
isRunning: false, sessionAge: nil,
previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)])
#expect(d == .none)
}
@@ -63,7 +72,8 @@ struct SessionEndPlannerTests {
// Baseline already empty (we ended on a prior reconcile) a redelivered completed
// run must not re-trigger the end.
let d = SessionEndPlanner.decide(
isRunning: true, previouslyActiveIDs: [], workouts: [workout("A", .completed)])
isRunning: true, sessionAge: Self.matureAge,
previouslyActiveIDs: [], workouts: [workout("A", .completed)])
#expect(d == .none)
}
@@ -71,10 +81,82 @@ struct SessionEndPlannerTests {
// A run that resolved entirely to `.skipped` (ended early, nothing completed) is not a
// finish there's no completed run to save, so the session data is discarded.
let d = SessionEndPlanner.decide(
isRunning: true, previouslyActiveIDs: ["A"], workouts: [workout("A", .skipped)])
isRunning: true, sessionAge: Self.matureAge,
previouslyActiveIDs: ["A"], workouts: [workout("A", .skipped)])
#expect(d == .discard)
}
// MARK: - decide, session-age demotion
@Test func youngFinish_demotesToDiscard() {
// "End Current & Start New": the new run's session just started when the transient
// "old run completed, new run not yet pushed" context applies. A finish here would
// save a seconds-long junk workout to Health attributed to the OLD run demote to
// discard; `shouldStart` restarts the session when the new run's push lands.
let d = SessionEndPlanner.decide(
isRunning: true, sessionAge: 2,
previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)])
#expect(d == .discard)
}
@Test func matureFinish_staysFinish() {
let done = workout("A", .completed)
let d = SessionEndPlanner.decide(
isRunning: true, sessionAge: SessionEndPlanner.minimumFinishAge,
previouslyActiveIDs: ["A"], workouts: [done])
#expect(d == .finish(done))
}
@Test func unknownAge_staysFinish() {
// A recovered session has no in-process start date it predates this process, so
// it's never "young" and finishes normally.
let done = workout("A", .completed)
let d = SessionEndPlanner.decide(
isRunning: true, sessionAge: nil,
previouslyActiveIDs: ["A"], workouts: [done])
#expect(d == .finish(done))
}
@Test func parallelCompletions_pickMostRecentlyStarted() {
// Two parallel runs completing together: the finish deterministically attaches the
// session's metrics to the most recently started one, not Set-iteration luck.
let older = workout("A", .completed, start: Self.ts)
let newer = workout("B", .completed, start: Self.ts.addingTimeInterval(600))
let d = SessionEndPlanner.decide(
isRunning: true, sessionAge: Self.matureAge,
previouslyActiveIDs: ["A", "B"], workouts: [older, newer])
#expect(d == .finish(newer))
}
// MARK: - shouldStart / runToStart
@Test func activeRunWithoutSession_shouldStart() {
// The self-start rule: an active run with no session (dropped launch handoff, manual
// engagement, post-crash) gets one at the next reconcile.
#expect(SessionEndPlanner.shouldStart(
isRunning: false, workouts: [workout("A", .inProgress)]))
}
@Test func sessionAlreadyRunning_shouldNotStart() {
#expect(!SessionEndPlanner.shouldStart(
isRunning: true, workouts: [workout("A", .inProgress)]))
}
@Test func nothingActive_shouldNotStart() {
// Completed/skipped runs (or an empty cache) must never spin up a session.
#expect(!SessionEndPlanner.shouldStart(
isRunning: false,
workouts: [workout("A", .completed), workout("B", .skipped)]))
#expect(!SessionEndPlanner.shouldStart(isRunning: false, workouts: []))
}
@Test func runToStart_isMostRecentlyStartedActive() {
let older = workout("A", .inProgress, start: Self.ts)
let newer = workout("B", .notStarted, start: Self.ts.addingTimeInterval(600))
let done = workout("C", .completed, start: Self.ts.addingTimeInterval(1200))
#expect(SessionEndPlanner.runToStart(in: [older, newer, done]) == newer)
}
// MARK: - activeIDs
@Test func activeIDs_countsOnlyUnfinished() {