Dismiss stale exercise flows on remote resolution; unfreeze the cover
Two stale-UI traps in the run flow (BULLETPROOFING.md M2, M3): A log resolved remotely (completed or skipped on the other device) left the open exercise screen live - its next recorded set wrote the log back to in-progress, resurrecting it through the per-log merge. Both platforms' ExerciseProgressView now observe the log's status and dismiss on a remote terminal flip (a locallyResolved flag exempts the screen's own Done / flow hand-off). The watch also gains the phone's startsSkipped terminal page, so opening a skipped exercise shows a static badge instead of a live flow. The live-mirror cover ran the flow engine with no onAdvance host, so an auto-advance split's terminal between-exercise rest completed the exercise then froze at 0:00. With no hand-off host it now dismisses instead; the driver's next-exercise frame re-presents the cover. Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
+10
-4
@@ -65,13 +65,17 @@ Timing variant: if B's session *does* start (old one already ended) but the tran
|
||||
|
||||
**Fix direction:** clear the locks on phone `scenePhase` → background/inactive; scope the split lock to the actual edit sheets rather than the detail screen.
|
||||
|
||||
### M2 — Remote completion doesn't pop the watch's open exercise screen · open
|
||||
### M2 — Remote completion doesn't pop the watch's open exercise screen · **fixed**
|
||||
|
||||
> **Fix (2026-07-09):** both platforms' `ExerciseProgressView` now observe `log?.status` and dismiss when the log resolves to `.completed`/`.skipped` remotely (a `locallyResolved` flag keeps their own Done / flow hand-off from re-dismissing) — a stale open flow can no longer write a resolved log back to in-progress. The watch also gained the phone's `startsSkipped` terminal page (`SkippedPhaseView`), so opening a skipped exercise shows a static badge instead of a live, resurrectable flow. Gate-level popping was deliberately not added: with the screen-level dismissal the resurrection path is closed, and reviewing a just-completed run's list is legitimate.
|
||||
|
||||
`popIfNavigatedRunUnavailable` pops on prune and on lock — but a run flipping to `.completed`/`.skipped` remotely **stays in the cache** (recently-completed runs are still pushed for ~24 h), so no pop fires. `repairFromDurable`'s safety net is gated on `status == .inProgress`, going silent exactly when needed. And the watch's `ExerciseProgressView` lacks the phone's `.skipped` handling, so a user sitting on a stale open exercise can keep ticking and write it back to a **non-terminal status** — resurrecting a skip through the per-log merge, since their edit is "newer".
|
||||
|
||||
**Fix direction:** pop (or overlay a terminal state) when the navigated run leaves the active set, not only when it leaves the cache; port the phone's `.skipped` terminal handling to the watch's progress view.
|
||||
|
||||
### M3 — Follower mirror cover freezes in auto-advance splits · open
|
||||
### M3 — Follower mirror cover freezes in auto-advance splits · **fixed** (freeze) / open (mid-set staleness)
|
||||
|
||||
> **Fix (2026-07-09):** the terminal between-exercise rest now falls back to `dismiss()` when there's no `onAdvance` host (the live-mirror cover on both platforms) — the exercise still completes durably, the cover closes instead of freezing at 0:00, and the driver's next-exercise frame re-presents it. Residual: a cover sitting on a *count-up work page* when the driver dies still has no staleness timeout — acceptable because that surface is a take-over driver by design and dismissible by hand.
|
||||
|
||||
The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive display — it runs the same local phase engine as a real driver, seeded from the last frame's anchors, and keeps advancing if the driver goes silent. Single-exercise runs self-heal (Finish auto-Done dismisses). But the cover is wired with `onAdvance: nil`, so in a flow-mode (auto-advance) split, when its local chain reaches the terminal between-exercise rest it completes the exercise (durable data stays correct) then **freezes at a 0:00 countdown** — the exercise hand-off exists only in `RunFlowView`, which the cover doesn't use. Related: if the driver dies without `sendLiveEnded`, the follower has **no staleness timeout** at all.
|
||||
|
||||
@@ -89,9 +93,11 @@ The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive disp
|
||||
|
||||
## LOW
|
||||
|
||||
### L1 — Nondeterministic metrics attribution with parallel runs · open
|
||||
### L1 — Nondeterministic metrics attribution with parallel runs · **fixed**
|
||||
|
||||
`SessionEndPlanner.decide` picks the completed survivor by iterating `previouslyActiveIDs` — a `Set`, so **iteration order is undefined** (`WorkoutSessionCoordinator.swift:49-51`). With two parallel runs finishing together, the `HKWorkout`'s metrics attach to an arbitrary one. Deterministic tie-break (e.g. most-recent `end`) would fix it.
|
||||
`SessionEndPlanner.decide` picked the completed survivor by iterating `previouslyActiveIDs` — a `Set`, so iteration order was undefined. With two parallel runs finishing together, the `HKWorkout`'s metrics attached to an arbitrary one.
|
||||
|
||||
> **Fix (2026-07-09):** `decide` now picks the most recently started completed survivor, ties broken by id (landed with the H-tier session-lifecycle commit; pinned by `parallelCompletions_pickMostRecentlyStarted`).
|
||||
|
||||
### L2 — `requestSync` failures vanish · open
|
||||
|
||||
|
||||
Reference in New Issue
Block a user