Files
workouts/WATCH-SYNC-SCENARIOS.md
T
rzen 9ea5e54b15 Add phone/watch scenario diagrams companion doc
Sequence and state diagrams for every phone↔watch interaction (splits,
exercise drive, run end, edit locks, cold launch, session lifecycle),
companion to WATCH-SYNC.md's channel reference.

Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
2026-07-09 06:01:28 -04:00

19 KiB
Raw Blame History

Phone ↔ Watch — Scenario Diagrams

Companion to WATCH-SYNC.md (the channel reference). This file maps every phone↔watch interaction as a transition/sequence diagram, so the current protocol is fully legible before we redesign it.

The governing rule is unchanged: the iPhone is the sole writer of iCloud Drive. The watch keeps a local SwiftData cache fed only by phone pushes, edits optimistically, and round-trips every durable change through the phone.

Legend

Participants (consistent across every diagram):

Alias Type Source
Phone UI SwiftUI views WorkoutLogsView, WorkoutLogListView, ExerciseProgressView, ExerciseView, SplitDetailView
SyncEngine phone persistence/orchestrator Workouts/Sync/SyncEngine.swift
iCloud JSON documents (source of truth) iCloud.dev.rzen.indie.Workouts
PhoneBridge WatchConnectivity (phone) PhoneConnectivityBridge.swift
WatchBridge WatchConnectivity (watch) WatchConnectivityBridge.swift
Watch cache watch SwiftData (read-through) fed only by WatchCacheApplier
Watch UI SwiftUI views ActiveWorkoutGateView, WorkoutLogListView, ExerciseProgressView, LiveRunCoverView
Watch session HKWorkoutSession holder WorkoutSessionManager.swift
watchOS OS / HealthKit app launch + session runtime

Transports (named in every message so the reliability is explicit):

Transport API Semantics
appContext updateApplicationContext Phone → Watch only. Latest-state-wins single slot; delivered even when the watch is asleep. Every push is the whole authoritative state.
sendMessage sendMessage Bidirectional, reachable-only, immediate, not queued.
transferUserInfo transferUserInfo Watch → Phone fallback. Queued, guaranteed, survives app death — but unordered relative to sendMessage.
startWatchApp HealthKit startWatchApp(toHandle:) Phone → watchOS, not WatchConnectivity. The only way an iPhone app can foreground its watch app.

State machines

Workout status (shared, derived from logs)

WorkoutDocument.recomputeStatusFromLogs() is the single source of the rule; every screen that mutates a log calls it. A log is resolved when completed or skipped.

stateDiagram-v2
    [*] --> notStarted: phone mints run on split start
    notStarted --> inProgress: any log started
    inProgress --> completed: all logs resolved — end stamped
    inProgress --> notStarted: all logs reset to notStarted
    completed --> inProgress: a completed log reopened — end cleared
    inProgress --> [*]: discarded (tombstone)
    notStarted --> [*]: discarded (tombstone)
    completed --> [*]: pruned from watch after ~24h

Only inProgress / notStarted runs are "active"; a completed run still rides in the push (recent set, ≤25, ~24h) but drops off the watch's active list.

Watch HKWorkoutSession lifecycle

The session is what grants the watch app foreground runtime. This is where the current design is brittle — see the note below the diagram.

stateDiagram-v2
    [*] --> idle
    idle --> running: watchOS launch → WatchAppDelegate.handle(config) → start()
    running --> running: second start() ignored (idempotent)
    running --> ended: finishAndSave() — active list emptied, a completed run found
    running --> ended: discard() — active list emptied, no completed run
    running --> ended: system/error (didChangeTo .ended/.stopped, or didFailWithError)
    ended --> idle: clear()

The only app-driven exit from running is ActiveWorkoutGateView's onChange(of: activeWorkouts) firing on a non-empty → empty transition. That transition must be witnessed by a mounted, foregrounded gate view. When a run is ended from the phone while the watch app is backgrounded (kept alive only by the session) or was torn down and rebuilt, the onChange baseline is already empty, so it never fires — the session stays running and watchOS keeps re-foregrounding the app. See scenarios S4/S6 and the closing section.


Scenarios

S1 — Phone starts a split

Two independent effects fire: a durable state push (WatchConnectivity) and an out-of-band HealthKit launch. They race; neither waits on the other.

sequenceDiagram
    participant PUI as Phone UI
    participant SE as SyncEngine
    participant ICD as iCloud
    participant PB as PhoneBridge
    participant OS as watchOS
    participant WS as Watch session
    participant WB as WatchBridge
    participant WUI as Watch UI

    PUI->>SE: save(workout — notStarted run)
    SE->>SE: upsert cache + context.save
    SE-->>ICD: enqueue file write (async)
    SE->>PB: onCacheChanged
    PB->>WB: appContext (splits + workouts + settings + locks)
    PUI->>OS: launchWatchWorkout → startWatchApp(config)
    OS->>WS: handle(config) → start()
    WS->>WS: HKWorkoutSession.startActivity — foreground runtime granted
    WB->>WB: upsert + prune cache
    WB->>WUI: @Query → run appears in ActiveWorkoutGateView
    opt list still empty at launch
        WUI->>WB: requestSync
        WB->>PB: sendMessage(requestSync)
        PB->>WB: appContext (fresh pushAll)
    end

If other runs are already active, the phone first prompts and may endKeepingProgress them (see S6) before this start.

S2 — Phone drives / starts an exercise

The live phase (Ready→Work→Rest→Finish) and the durable set data travel on different channels: ephemeral frames over sendMessage, persisted state over appContext.

sequenceDiagram
    participant PUI as Phone UI
    participant SE as SyncEngine
    participant PB as PhoneBridge
    participant WB as WatchBridge
    participant WUI as Watch UI

    Note over PUI: user advances a phase
    PUI->>PB: onLive(frame) → sendLiveProgress
    PB->>WB: sendMessage(liveProgress, version bumped)
    WB->>WUI: applyIncomingLive → follower cover (unless open/muted)
    Note over PUI: user completes a set
    PUI->>SE: onChange → save() (recompute status, persist)
    SE->>PB: onCacheChanged → pushAll
    PB->>WB: appContext (authoritative workout state)
    WB->>WB: upsert cache — durable set data lands
    Note over PUI: user leaves the flow
    PUI->>PB: onLiveEnded → sendLiveEnded
    PB->>WB: sendMessage(liveEnded)
    WB->>WUI: endIncomingLive → drop follower

If the watch is unreachable, the liveProgress/liveEnded frame is staged (depth 1, latest-wins) and re-sent on reconnect; a send that fails while reachable is retried with a short backoff. If the frame is lost anyway, the durable set data still lands via appContext, and the watch's open run screen jumps forward to match it (durable repair) — so a lost frame degrades to a briefly-stale page, never a stuck one.

S3 — Watch drives / starts an exercise

Symmetric to S2, but the durable write must round-trip through the phone (sole writer), so it uses the workoutUpdate channel with a transferUserInfo fallback.

sequenceDiagram
    participant WUI as Watch UI
    participant WB as WatchBridge
    participant PB as PhoneBridge
    participant SE as SyncEngine
    participant ICD as iCloud
    participant PUI as Phone UI

    Note over WUI: user advances a phase
    WUI->>WB: onLive(frame) → sendLiveProgress
    WB->>PB: sendMessage(liveProgress, version bumped)
    PB->>PUI: LiveRunState.apply → follower cover
    Note over WUI: user completes a set
    WUI->>WB: onChange → update(workout doc)
    WB->>WB: optimistic upsert into watch cache
    alt phone reachable
        WB->>PB: sendMessage(workoutUpdate)
    else unreachable or send failed
        WB->>PB: transferUserInfo(workoutUpdate) — queued
    end
    PB->>SE: ingestFromWatch(doc)
    SE->>ICD: write file (if updatedAt strictly newer)
    SE->>PB: onCacheChanged → pushAll
    PB->>WB: appContext (authoritative echo)
    Note over WUI: user leaves the flow
    WUI->>WB: onLiveEnded → sendLiveEnded
    WB->>PB: sendMessage(liveEnded)
    PB->>PUI: LiveRunState.end → drop follower

Recovery is symmetric to S2: a failed liveProgress/liveEnded send retries with backoff, and the phone's follower cover runs the same durable repair — when the WorkoutDocument echoed back over appContext shows completion beyond what the cover has followed, it jumps forward — so a lost watch→phone frame is briefly-stale, not stuck.

Live-mirror delivery invariants (apply to S2 ‖ S3)

The ephemeral channel is sendMessage-only (reachable-only), and phone↔watch reachability drops exactly when the driving device's wrist is down. Commit 8cbe078 hardened it with four invariants — full detail in WATCH-SYNC.md §3:

  • Retry with backoff — a send that fails while reachable retries (exponential, ≤5×) rather than waiting for a reachability edge that may never come mid-workout. (scheduleLiveRetry, both bridges.)
  • Anti-rollback arbitration — receiving a frame that outranks the locally staged outbound one drops the staged frame, so a reconnect re-send can't yank the run backward; a delivery the staged frame outranks is ignored. (applyIncomingLive.)
  • Version + anchor ordering — the shared per-run version isn't collision-free (after a lost frame both sides can mint the same number), so every staleness check tie-breaks on the wall-clock phaseStartLiveProgress.isNewer, pinned by LiveProgressOrderingTests.
  • Durable repair — the safety net: if a frame is lost outright, the transition's durable write still lands (S2 via appContext, S3 via the workoutUpdate round-trip), and the open run screen jumps forward to the first unfinished set. (repairFromDurable, both run screens.)

The last invariant is the template for the session-end fix below: reconcile against authoritative data; don't depend on a transient signal or a mounted view. The mirror now has that safety net — the session lifecycle (S4/S6/S7) still does not.

S4 — Phone ends an exercise (and it completes the run)

A set/exercise completion is the S2 persist path. The interesting case is the last exercise: the run flips to completed, drops off the watch's active list, and should end the watch session — but that end is the fragile part.

sequenceDiagram
    participant PUI as Phone UI
    participant SE as SyncEngine
    participant PB as PhoneBridge
    participant WB as WatchBridge
    participant WGate as Watch gate view
    participant WS as Watch session

    PUI->>SE: complete last exercise → save()
    SE->>SE: recomputeStatusFromLogs → completed (end stamped)
    SE->>PB: onCacheChanged → pushAll
    PB->>WB: appContext (run now completed — leaves active set)
    WB->>WB: upsert + prune → activeWorkouts empties
    WB->>WGate: @Query update
    alt gate view mounted AND foregrounded to witness the transition
        WGate->>WS: onChange (non-empty → empty) → finishAndSave()
        WS->>WS: session.end() → save HKWorkout, capture metrics
        WGate->>WB: update(workout + metrics) → back to phone (S3 tail)
    else watch backgrounded / view rebuilt with already-empty list
        Note over WGate,WS: onChange never fires → session stays running → app keeps re-foregrounding (BUG)
    end

S5 — Watch ends an exercise (and it completes the run)

The same completion, but originated on the watch. This path reliably ends the session, because the gate view is mounted and foregrounded on the watch exactly while the user finishes there — the contrast that pins down the S4/S6 bug.

sequenceDiagram
    participant WUI as Watch UI
    participant WGate as Watch gate view
    participant WS as Watch session
    participant WB as WatchBridge
    participant PB as PhoneBridge
    participant SE as SyncEngine

    WUI->>WB: complete last exercise → update(workout completed)
    WB->>WB: optimistic upsert → local run flips to completed
    WB->>WGate: @Query → activeWorkouts empties
    WGate->>WS: onChange (non-empty → empty) → finishAndSave()
    WS->>WS: session.end() + save HKWorkout, capture metrics
    WGate->>WB: update(workout + metrics)
    WB->>PB: sendMessage / transferUserInfo (workoutUpdate)
    PB->>SE: ingestFromWatch → persist → pushAll echo

S6 — Phone ends the whole workout early ("Save Workout" / start-new-split)

endKeepingProgress() marks every unfinished log skipped, so the recompute resolves the run to completed. Downstream, the watch-session outcome is the same fragile path as S4 — this is the exact flow the user hit ("when the split ends, the watch app keeps coming back up").

sequenceDiagram
    participant PUI as Phone UI
    participant SE as SyncEngine
    participant PB as PhoneBridge
    participant WB as WatchBridge
    participant WGate as Watch gate view
    participant WS as Watch session

    Note over PUI: Save Workout button, or picking a new split with runs active
    PUI->>SE: endKeepingProgress() → save()
    SE->>SE: unfinished logs → skipped; recompute → completed
    SE->>PB: onCacheChanged → pushAll
    PB->>WB: appContext (run completed — leaves active set)
    WB->>WB: upsert + prune → activeWorkouts empties
    alt gate view alive to witness transition
        WGate->>WS: onChange → finishAndSave() → session.end()
    else not witnessed (the common case here)
        Note over WGate,WS: session never ends → watch app resurfaces on every wrist raise (BUG)
    end

S7 — Phone discards a workout

A soft delete: tombstone written, live file removed. The watch prunes the run and, on witnessing the emptied list, discard()s the session (no completed run exists, so nothing is saved to Health).

sequenceDiagram
    participant PUI as Phone UI
    participant SE as SyncEngine
    participant ICD as iCloud
    participant PB as PhoneBridge
    participant WB as WatchBridge
    participant WGate as Watch gate view
    participant WS as Watch session

    PUI->>SE: delete(workout)
    SE->>ICD: write Stubs/id.json (tombstone) + remove live file
    SE->>PB: onCacheChanged → pushAll
    PB->>WB: appContext (workout absent from sets)
    WB->>WB: prune → activeWorkouts empties
    WB->>WGate: @Query update
    WGate->>WGate: endSession — no completed run found
    WGate->>WS: discard() → session.end(), data thrown away
    Note over WGate,WS: same "must witness the transition" fragility as S4/S6

S8 — Edit lock (phone opens an editor)

An exclusive-edit lock, pushed immediately so it doesn't wait on a cache change. The watch parks the matching run and blocks re-entry so the two devices never drive the same run at once.

sequenceDiagram
    participant PUI as Phone editor
    participant PB as PhoneBridge
    participant WB as WatchBridge
    participant WGate as Watch gate view

    PUI->>PB: onAppear → setEditingWorkout(id) / setEditingSplit(id)
    PB->>WB: appContext (editing id set) — pushed immediately
    WB->>WGate: park run (isLockedForEditing) + pop out if inside it
    Note over WGate: row dimmed "Editing on iPhone…", re-entry blocked
    PUI->>PB: onDisappear → setEditing…(nil)
    PB->>WB: appContext (lock cleared — absent key means not editing)
    WB->>WGate: run re-enabled

ExerciseView drives setEditingWorkout; SplitDetailView drives setEditingSplit (matched against a run's splitID).

S9 — Watch cold launch / requestSync

On real hardware WCSession activation is async, so the eager read sees an empty dictionary; the delegate re-applies once activation completes.

sequenceDiagram
    participant WB as WatchBridge
    participant PB as PhoneBridge
    participant WUI as Watch UI

    Note over WB: activate() at launch
    WB->>WB: applyReceivedContext (last context — may be empty on cold launch)
    WB->>PB: sendMessage(requestSync)
    Note over WB: activationDidComplete (async on device)
    WB->>WB: applyReceivedContext again (now valid)
    WB->>PB: requestSync again
    PB->>WB: appContext (fresh pushAll)
    WB->>WUI: @Query populates

S10 — Stale watch push vetoed

The phone gates every inbound workoutUpdate so a stale or out-of-order watch copy can never resurrect a deleted run or roll back a newer edit.

sequenceDiagram
    participant WB as WatchBridge
    participant PB as PhoneBridge
    participant SE as SyncEngine

    WB->>PB: workoutUpdate (stale copy)
    PB->>SE: ingestFromWatch(doc)
    alt tombstone or pending-delete exists
        SE->>PB: veto (no write) → onCacheChanged
        PB->>WB: appContext (authoritative — run absent)
        WB->>WB: prune stale run
    else updatedAt older than cache
        SE->>PB: re-push (no write) → onCacheChanged
        PB->>WB: appContext (corrects the watch)
    else updatedAt equal (echo)
        Note over SE: ignored — duplicate
    end

What actually ends the watch session (and why it leaks)

Ending the run is the one operation whose reliability differs sharply by where it originates:

Scenario Originates Ends watch session? Why
S5 — watch completes last exercise Watch Reliable Gate view is mounted + foregrounded on the watch, so it witnesses activeWorkouts empty and calls finishAndSave().
S4 — phone completes last exercise Phone Unreliable Depends on the watch's gate view being alive to witness the emptied list.
S6 — phone "Save Workout" / new split Phone Unreliable Same as S4 — the reported bug.
S7 — phone discards Phone Unreliable Same fragility; discard() instead of finishAndSave().

Root cause. Ending the HKWorkoutSession is a lifecycle/data concern, but it is implemented as a view-level side effectActiveWorkoutGateView.onChange(of: activeWorkouts). When a run ends from the phone, the watch app is typically backgrounded (kept alive only by the still-running session) or gets rebuilt by watchOS; in either case the non-empty → empty transition is never observed by a live view, finishAndSave() / discard() never run, and the session stays running. A running session is precisely what makes watchOS keep re-foregrounding the app — so it "keeps coming back up even if you close it."

There is no protocol-level "the run is over" signal today: the watch infers the end from the authoritative set shrinking, and the inference is bound to a UI observer.

Commit 8cbe078 fixed the analogous fragility in the ephemeral mirror by adding a durable safety net (repairFromDurable reconciles the open run screen against the authoritative document instead of trusting the transient frame). The session lifecycle needs the same move one layer down: drive session.end() from the authoritative data apply, in a long-lived coordinator rather than a mounted view. The concrete plan is in PLAN-watch-session-end.md.