From 2670fba53e354d6ee6f589e82ef5879f3da7aecf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 01:39:20 +0000 Subject: [PATCH 1/5] docs: e2e audit of stall detection / auto-stop pipeline Follow-up to #652 / PR #653 and the 2026-07-14 Echo session report (sets force-ended at 6 and ~3 reps on Harder/Hardest with VBT auto-end disabled). Nine findings, ranked. Root cause of the report: DELOAD_OCCURRED is a routine firmware event in Echo mode (Echo levels are defined by deload windows) but is treated as cable release and arms the 5s auto-stop stall timer (F1). Compounding: VBT velocity-loss baseline contaminated by firmware warm-up reps (F2), Phase 35C warm-up-set early return skips biomech/VBT resets (F3), global stall-detection toggle does not govern routine sets (F6). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ee4mJkzUkJTbwEUvMCSSaq --- .../2026-07-14-stall-detection-e2e-audit.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/audits/2026-07-14-stall-detection-e2e-audit.md diff --git a/docs/audits/2026-07-14-stall-detection-e2e-audit.md b/docs/audits/2026-07-14-stall-detection-e2e-audit.md new file mode 100644 index 00000000..e261c290 --- /dev/null +++ b/docs/audits/2026-07-14-stall-detection-e2e-audit.md @@ -0,0 +1,134 @@ +# E2E Audit: Stall Detection & Auto-Stop Pipeline + +**Date:** 2026-07-14 +**Baseline:** `main@0f00f4b` (PR #653 base) +**Trigger:** User report (follow-up to #652 / PR #653): Echo session, set 1 on **Hard** completed all 10 reps; set 2 on **Harder** was force-ended at 6 reps; set 3 on **Hardest** ended after ~3 reps — with *Auto-End on Velocity Loss* **disabled**. + +--- + +## 1. Scope & signal flow + +Audited the full path from firmware packet to `handleSetCompletion()`: + +``` +Firmware monitor packet (velocity 0.1mm/s, status flags) + → MonitorDataProcessor.process() (parse, validate, EMA-smooth velocity → mm/s; + DELOAD_OCCURRED debounced 2s → callback) + → KableBleRepository (metricsFlow / deloadOccurredEvents) + → ActiveSessionEngine + collector #5: deloadOccurredEvents → arms 5s stall timer + collector #7: metricsFlow → handleMonitorMetric → checkAutoStop() + collector #6: repEvents → handleRepNotification → processBiomechanicsForRep + → checkVelocityThreshold (VBT alert / auto-end) + → requestAutoStop() → triggerAutoStop() → handleSetCompletion() +``` + +Three independent mechanisms can end a set: + +| Path | Timer | Gate | +|---|---|---| +| Velocity stall (`checkAutoStop` §1) | 5.0s (`STALL_DURATION_SECONDS`) | `params.stallDetectionEnabled` | +| Firmware deload (collector #5) | arms the same 5.0s stall timer | `params.stallDetectionEnabled` | +| Position auto-stop (`checkAutoStop` §2) | 2.5s | Just Lift / AMRAP / timed cable only | + +VBT auto-end (`checkVelocityThreshold`) is a fourth, gated by `autoEndOnVelocityLoss` (off for this user). + +Verified-sound items: velocity units are consistent (firmware int16 in 0.1 mm/s ÷ 10 → mm/s, `MonitorDataProcessor.kt:189-197`) against thresholds `STALL_VELOCITY_LOW=2.5` / `HIGH=10.0` mm/s; warmup gating of the stall path (`isWarmupGateOpenForAutoStop`); the standard-set deferral until the first working/pending rep (`shouldDeferStandardSetStall`, Issue #256 semantics); per-set `resetAutoStopState()` on every set-start path; `stallDetectionEnabled=false` correctly neutralizes a deload-armed timer (the `else` branch of `checkAutoStop` §1 resets it). + +--- + +## 2. Findings (ranked) + +### F1 — CRITICAL: `DELOAD_OCCURRED` treated as "cable release", but in Echo mode it is a routine firmware event +**Where:** `ActiveSessionEngine.kt:400-437` (collector #5); context: `Models.kt:298-303`, `WorkoutParameters` comment at `Models.kt:358-362`. + +Echo difficulty levels are *defined by the firmware's deload window* — Issue #553's own comment documents HARD = deload at 1.0s @ 50 mm/s, HARDER = 1.25s @ 40 mm/s. On higher levels the machine deloads earlier in the set as the athlete fatigues and bar speed drops. That deload raises the `DELOAD_OCCURRED` status flag (0x8000), which collector #5 interprets as *"Machine detected cable release"* and uses to arm the 5-second auto-stop stall timer — with no Echo-mode discrimination and no velocity corroboration at arm time. + +Once armed, the only things that cancel the countdown on `main` are: +- a metric sample > 10 mm/s (`checkAutoStop:1485`), or +- a set/workout reset. + +A fatigued user mid-grind, or paused while Echo rebuilds weight after its deload, can easily stay ≤ 10 mm/s for 5s → `requestAutoStop()` → set force-completed. **This reproduces the report exactly:** Hard set (no firmware deload) survived 10 reps; Harder deloaded around rep 6; Hardest deloaded around rep 3; each set ended ~5s later. The user attributed it to VBT, but the VBT auto-end gate (`autoEndOnVelocityLoss`) was correctly off — the killer is the deload→stall path. + +**Recommendation:** +- In Echo mode, do not arm the stall timer from `DELOAD_OCCURRED` (or require corroborating stalled velocity at arm time, and cancel on any subsequent rep event, not only on >10 mm/s samples). +- PR #653 (reset on completed rep) is necessary but **not sufficient**: a deload arming early in a slow rep can expire before the next rep boundary is reported. + +### F2 — HIGH: VBT velocity-loss baseline is contaminated by firmware warm-up/calibration reps +**Where:** `ActiveSessionEngine.kt:1097-1113` (`handleRepNotification`), `1273-1322` (`processBiomechanicsForRep`), `BiomechanicsEngine.kt:238-260`; `RepCounterFromMachine.kt:321-322, 689`. + +Every cable set runs 3 firmware calibration warm-up reps (`Constants.DEFAULT_WARMUP_REPS=3`, kept deliberately per Issue #411). `handleRepNotification` calls `processBiomechanicsForRep(repCountAfter=totalReps)` for **every** rep increment — warm-up reps included ("GATE-04: unconditional capture"). `BiomechanicsEngine` latches `firstRepMcv` on the first processed rep, i.e. **warm-up rep 1** (light/ramping → fast). All later working reps compute `velocityLossPercent` against that inflated baseline, so `shouldStopSet` trips far too early. + +Consequences: +- Premature `VELOCITY_THRESHOLD_REACHED` alerts — the "VBT kicking in" the user hears. +- With `autoEndOnVelocityLoss` **on**, sets can auto-end ~2 working reps after the warmup gate opens. +- Secondary: the `isWarmupComplete` gate at `ActiveSessionEngine.kt:1319` opens on the *final warm-up rep itself* (`_repCount` is updated before the check), so the first threshold evaluation can run on warm-up rep 3's result. + +**Recommendation:** exclude warm-up reps from `processRep` (or re-baseline `firstRepMcv` at warm-up completion), and pass the *working* rep index rather than `totalReps` so `repNumber==1` semantics hold. + +### F3 — HIGH: Variable warm-up set fast path leaks biomechanics/VBT state into the working set +**Where:** `ActiveSessionEngine.kt:3935-3981` (Phase 35C early returns at 3958 and 3980) vs. the reset block at `4024-4035`. + +`handleSetCompletion()` resets the biomechanics engine, `velocityThresholdAlertEmitted`, `consecutiveThresholdReps`, `deferAutoStopDeadlineMs`, and `repBoundaryTimestamps` at lines 4024-4035 — **after** the Phase 35C warm-up-set branch returns early. So when an exercise uses variable warm-up sets (e.g. 50% × 8): + +- `firstRepMcv` from warm-up set 1 (at a fraction of working weight) survives into every later warm-up set **and the first working set** → velocity-loss threshold effectively guaranteed to trip (compounds F2). +- `repResults` accumulate across sets → the first working set's persisted per-rep biomechanics and set summary include warm-up-set reps. +- The one-shot `velocityThresholdAlertEmitted` may already be consumed, and stale `consecutiveThresholdReps` ≥ 1 means with auto-end **on** a single qualifying working rep can end the set. +- `repBoundaryTimestamps` retain boundaries from the previous set (benign for segmentation since `collectedMetrics` *is* cleared per set at 2896, but the `boundaries.size >= 2` fallback logic then misclassifies the first rep's window). + +**Recommendation:** hoist the biomech/VBT reset block above the Phase 35C early returns, or move it into the per-set path of `startWorkout()` next to `repQualityScorer.reset()` (line 2517), which currently resets the quality scorer but *not* the biomechanics engine — an asymmetry that invites exactly this class of bug. + +### F4 — MEDIUM: `isActivelyUsing` latches on `hasMeaningfulRange`, so a racked pause still runs the stall countdown +**Where:** `ActiveSessionEngine.kt:1478-1487`. + +`isActivelyUsing = maxPosition > 10mm || hasMeaningfulRange`. Once the set has established ≥50mm ROM, `hasMeaningfulRange` stays true, so resting the handles fully at the bottom (velocity ≈ 0) arms the stall timer in a **standard** set. Any ≥5s mid-set breather/regrip ends the set. Whether "5s of no movement ends the set" is desired for standard sets is a product decision, but the arm condition is also never re-evaluated during the countdown, and combined with #652 (timer surviving completed reps on `main`) it manifests as "sets randomly ending". The `AutoStopUiState` countdown is only surfaced after 1s elapsed (line 1498) and is easy to miss mid-rep. + +### F5 — MEDIUM: hysteresis dead band (2.5–10 mm/s) cannot cancel a running countdown +**Where:** `ActiveSessionEngine.kt:1474-1487`; constants `WorkoutCoordinator.kt:61-72`. + +A single sample < 2.5 mm/s (e.g. a turnaround or brief pause) arms the timer; only a sample > 10 mm/s cancels. Sustained slow grinding at 3–9 mm/s — plausible at a sticking point under Echo HARDEST, TUT, or heavy eccentric loading — neither arms nor cancels, so a countdown armed at a turnaround runs to expiry *through a moving rep*. PR #653's rep-boundary reset mitigates this for completed reps, but only at the boundary — a rep slower than ~5s from arm to boundary still loses the race. + +### F6 — MEDIUM: the global "Stall Detection" setting does not govern routine sets +**Where:** `UserPreferences.kt:16` (default `true`), `SettingsManager.kt:87-89`, vs. routine paths `RoutineFlowManager.kt:1107/1182/1427`, `ActiveSessionEngine.kt:4622/4786`, `DefaultWorkoutSessionManager.kt:931`. + +Routine set-starts always take `RoutineExercise.stallDetectionEnabled` (default `true`, snapshotted into the routine at exercise-config time — `Routine.kt:103`, `ExerciseConfigViewModel.kt:823`). The global Settings toggle feeds only Just Lift / single-exercise defaults. A user who turns stall detection off globally still gets stall auto-stop in every routine exercise unless they edit each exercise individually. This is a UX trap that directly feeds the "I don't have it configured to stop my sets" confusion — the user believes they disabled auto-stop when only the VBT auto-end (a separate toggle) and/or the global default is off. + +### F7 — LOW: divergent duplicate of `resetAutoStopState()` in RoutineFlowManager +**Where:** `RoutineFlowManager.kt:1775-1782` vs. `ActiveSessionEngine.kt:964-974`. + +The RFM copy does not (and cannot) zero `deferAutoStopDeadlineMs` (private to ASE) and does not reset the biomech/VBT one-shots. Today ASE's `startWorkout()` re-clears the deadline on every set start, so no live bug was found, but two hand-maintained reset lists for the same state family is how #649-class leaks recur. Consolidate to one reset entry point. + +### F8 — LOW: 30s verbal-cue defer window suspends *all* auto-stop paths +**Where:** `ActiveSessionEngine.kt:1367-1373, 1451-1466`, `VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS = 30_000` (line 5075). + +With verbal encouragement on and VBT auto-end off, one VBT cue disables both the stall and position auto-stop for up to 30s (cleared earlier only by a completed rep). In Just Lift/AMRAP, genuinely finishing the set right after the cue means auto-stop takes up to ~32.5s. Intentional per #649, but 30s is generous; consider ending the defer on `HandleState.Released` as well. + +### F9 — LOW: cross-thread mutation of stall state without synchronization +**Where:** `WorkoutCoordinator.kt:413-418`, `ActiveSessionEngine.kt:1371-1372` (resets from `Dispatchers.Default` inside `checkVelocityThreshold`). + +`stallStartTime`/`isCurrentlyStalled`/`autoStopStartTime` are plain vars written from the metrics collector, the deload collector, and `Dispatchers.Default` (VBT path). `deferAutoStopDeadlineMs` was made `@Volatile` for exactly this reason (#649 Codex P2), but the stall fields were not. Mostly same-dispatcher today; fragile against future dispatcher changes. Also minor: `MonitorDataProcessor.lastDeloadEventTime` is deliberately not reset between sessions (2s debounce, negligible). + +--- + +## 3. Incident reconstruction (user session, 2026-07-14) + +| Set | Echo level | Firmware deload window | Observed | Explanation | +|---|---|---|---|---| +| 1 | Hard | 1.0s @ 50 mm/s | 10/10 reps, no VBT | Bar speed stayed above the deload window; no `DELOAD_OCCURRED`; velocity stall never accrued 5s below 2.5 mm/s. | +| 2 | Harder | 1.25s @ 40 mm/s | ended at 6 reps | Fatigue → firmware deload ≈ rep 6 → F1 arms 5s stall timer → post-deload movement never exceeded 10 mm/s → auto-stop. On `main`, completing another rep would not have cancelled it (#652). | +| 3 | Hardest | (wider window, heavier Echo matching) | "VBT failed" ≈ rep 3 | Same F1 chain, earlier; premature VBT alert (F2 baseline contamination) fired around the same reps, making it *look* like VBT ended the set. | + +The user's "I don't have it configured to stop sets on VBT thresholds" is accurate — `autoEndOnVelocityLoss` played no role. The set-ender is the deload→stall path (enabled by default, F6), and the VBT audio alert (F2) is a coincident symptom, not the cause. + +## 4. Assessment of PR #653 + +The one-line fix (reset the shared stall timer on a completed working rep) is **correct and should merge** — it closes the #652 class where a timer armed at a turnaround survives valid reps. It is **not sufficient** for this incident: it does not prevent arming from routine Echo deloads (F1), cannot cancel a countdown that expires mid-rep before the boundary event (F5), and does not touch the VBT baseline issues (F2/F3). + +## 5. Recommended fix order + +1. **F1** — suppress/qualify deload-armed stall in Echo mode (direct cause of the reported set kills). +2. **F3** — hoist biomech/VBT resets above the Phase 35C early returns (one-line-class fix, closes a compounding leak). +3. **F2** — baseline `firstRepMcv` on the first *working* rep. +4. **F6** — make the global toggle a master gate (`params.stallDetectionEnabled && prefs.stallDetectionEnabled`) or surface per-exercise state in the routine UI. +5. F4/F5 — revisit arm/cancel semantics for standard sets (product decision; at minimum re-check `isActivelyUsing` during countdown and let rep *events* — not only >10 mm/s samples — cancel). +6. F7–F9 — hygiene. From 0a9da9775a038c450399b923234797cbdfc2b43f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:30:54 +0000 Subject: [PATCH 2/5] fix: stall-detection audit fixes F1/F3/F4/F7/F8 + absorb #653 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: ignore DELOAD_OCCURRED in Echo mode — Echo levels are defined by the firmware's deload window, so the event fires routinely mid-set and must not arm the 5s auto-stop stall timer (root cause of sets force-ending at 6/3 reps on Harder/Hardest). - #652/#653 absorbed: a completed working rep resets the shared stall timer in handleRepNotification (supersedes PR #653). - F3: startWorkout() resets biomechanics engine, VBT one-shot flags, and rep boundary timestamps at every set start — the Phase 35C variable warm-up fast path bypassed handleSetCompletion's reset block, leaking the warm-up set's velocity baseline into the working set. - F4: velocity-armed stall countdowns now require handles in use (maxPosition > STALL_MIN_POSITION) to arm and to continue, re-checked per sample — a racked pause between reps no longer ends a standard set. New stallArmedByDeload flag exempts deload-armed countdowns (genuine cable release retracts to ~0mm and must still auto-stop); a deload upgrades an existing velocity-armed countdown. - F7: deferAutoStopDeadlineMs moved to WorkoutCoordinator (@Volatile); WorkoutCoordinator.resetAutoStopState() is the single reset for all auto-stop/stall/defer fields; ASE and RFM delegate to it. - F8: releasing the handles during the verbal-cue defer window clears the deadline so auto-stop resumes promptly instead of waiting out 30s. Regression tests: Echo deload ignored / Echo velocity stall kept, Issue 652 rep-cancels-stall, F3 set-start reset, 4x F4 arm-source scenarios, F8 clear-on-release, coordinator reset unit test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ee4mJkzUkJTbwEUvMCSSaq --- .../manager/ActiveSessionEngine.kt | 100 +++-- .../manager/RoutineFlowManager.kt | 14 +- .../manager/WorkoutCoordinator.kt | 38 ++ .../manager/DWSMWorkoutLifecycleTest.kt | 417 ++++++++++++++++++ .../WorkoutCoordinatorAutoStopResetTest.kt | 45 ++ 5 files changed, 573 insertions(+), 41 deletions(-) create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 643fb095..73c0c6f0 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -243,18 +243,8 @@ class ActiveSessionEngine( private var velocityThresholdAlertEmitted = false private var consecutiveThresholdReps = 0 - // Issue #649: defer position/stall auto-stop until the cue + short - // transition window elapses, or a completed working rep clears it. The - // deadline (@Volatile Long) is the single source of truth — 0L means no - // defer. checkVelocityThreshold() arms it on Dispatchers.Default; - // checkAutoStop() reads it on the metrics thread. @Volatile is required so - // the metrics thread never sees a stale old deadline across a thread switch. - // Resets: resetAutoStopState() (skip/restart/stop), per-set reset sites, - // and the next completed working rep all zero it. - // ponytail: one Long + one const ceiling; a tunable window is the only thing - // we'd add if users reported legitimate pauses being ended by this. - @kotlin.concurrent.Volatile - private var deferAutoStopDeadlineMs = 0L + // Issue #649 defer deadline: moved to WorkoutCoordinator (F7, stall-detection + // audit) so RoutineFlowManager reset paths can clear it too. // ===== Init Block: Workout-Related Collectors (moved from DWSM) ===== @@ -373,6 +363,19 @@ class ActiveSessionEngine( } } + // F8 (Issue #649 follow-up, stall-detection audit): releasing the + // handles during the verbal-cue defer window proves the set is over — + // let the auto-stop paths resume immediately instead of waiting out + // the 30s window. Transient blips can't instantly end a set: the + // stall/position countdowns still need their own 2.5-5s to fire. + if (currentState is WorkoutState.Active && + activityState == HandleState.Released && + coordinator.deferAutoStopDeadlineMs != 0L + ) { + Logger.d("Handles released during verbal-cue defer window - clearing defer deadline") + coordinator.deferAutoStopDeadlineMs = 0L + } + // Handle auto-STOP when Active in Just Lift mode and handles released. // Warmup/ROM gate: auto-stop must remain disabled until warmup is complete. if (params.isJustLift && currentState is WorkoutState.Active) { @@ -406,6 +409,14 @@ class ActiveSessionEngine( val currentState = coordinator._workoutState.value if (params.stallDetectionEnabled && currentState is WorkoutState.Active) { + // Echo levels are defined by the firmware's deload window (e.g. HARDER = + // deload after 1.25s below 40 mm/s — Issue #553), so DELOAD_OCCURRED fires + // routinely mid-set as the athlete fatigues. It is NOT a cable-release + // signal in Echo mode and must never arm the auto-stop stall timer. + if (params.isEchoMode) { + Logger.d("DELOAD_OCCURRED ignored - Echo mode (deload windows define Echo levels)") + return@collect + } if (!isWarmupGateOpenForAutoStop()) { Logger.d("DELOAD_OCCURRED ignored - warmup/ROM not established yet") return@collect @@ -428,7 +439,13 @@ class ActiveSessionEngine( if (coordinator.stallStartTime == null && !inGrace) { coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true + coordinator.stallArmedByDeload = true Logger.d("Auto-stop stall timer STARTED via DELOAD_OCCURRED flag") + } else if (coordinator.stallStartTime != null && !inGrace) { + // F4: a real deload is the stronger signal — upgrade a + // velocity-armed countdown so the retracting cables + // (position -> 0) don't cancel it via the racked-handles check. + coordinator.stallArmedByDeload = true } else if (inGrace) { Logger.d("DELOAD_OCCURRED ignored - in AMRAP startup grace period") } @@ -953,6 +970,7 @@ class ActiveSessionEngine( private fun resetStallTimer() { coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false + coordinator.stallArmedByDeload = false if (coordinator.autoStopStartTime == null && !coordinator.autoStopTriggered) { coordinator._autoStopState.value = AutoStopUiState() } @@ -961,17 +979,7 @@ class ActiveSessionEngine( /** * Fully reset auto-stop state for a new workout/set. */ - internal fun resetAutoStopState() { - coordinator.autoStopStartTime = null - coordinator.autoStopTriggered = false - coordinator.autoStopStopRequested = false - coordinator.stallStartTime = null - coordinator.isCurrentlyStalled = false - coordinator._autoStopState.value = AutoStopUiState() - // Issue #649: zero the deadline so a new set never inherits a stale - // verbal-cue defer (skip / restart / startWorkout paths included). - deferAutoStopDeadlineMs = 0L - } + internal fun resetAutoStopState() = coordinator.resetAutoStopState() /** * Issue #204: Returns true if we're in the startup grace period for auto-stop modes. @@ -1099,7 +1107,13 @@ class ActiveSessionEngine( // Issue #649: a completed working rep proves the user is back in // motion; let normal AMRAP / stall auto-stop resume by zeroing the // deadline (the source-of-truth field). - deferAutoStopDeadlineMs = 0L + coordinator.deferAutoStopDeadlineMs = 0L + // Issue #652: the same completed-rep boundary is authoritative for + // the shared stall countdown (both DELOAD_OCCURRED and low-velocity + // arms write to coordinator.stallStartTime). Without this, a stale + // countdown started at a turnaround, brief pause, or firmware de-load + // can survive a subsequent valid rep and later auto-complete the set. + resetStallTimer() // Capture rep boundary timestamp BEFORE scoring so scoreCurrentRep() // and processBiomechanicsForRep() both see the correct metric window. @@ -1367,7 +1381,7 @@ class ActiveSessionEngine( if (!coordinator.autoEndOnVelocityLoss && coordinator._workoutState.value is WorkoutState.Active ) { - deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS + coordinator.deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS resetStallTimer() resetAutoStopTimer() } @@ -1454,10 +1468,10 @@ class ActiveSessionEngine( // Any reset path (next completed working rep, resetAutoStopState, per-set // boundary, deadline expiry) zeros it; this predicate then falls through. // Reset the live countdowns defensively each metric. - val deferDeadline = deferAutoStopDeadlineMs + val deferDeadline = coordinator.deferAutoStopDeadlineMs if (deferDeadline != 0L) { if (currentTimeMillis() >= deferDeadline) { - deferAutoStopDeadlineMs = 0L + coordinator.deferAutoStopDeadlineMs = 0L } else { resetStallTimer() resetAutoStopTimer() @@ -1476,18 +1490,32 @@ class ActiveSessionEngine( val isDefinitelyMoving = maxVelocity > WorkoutCoordinator.STALL_VELOCITY_HIGH val maxPosition = maxOf(metric.positionA, metric.positionB) - val isActivelyUsing = maxPosition > WorkoutCoordinator.STALL_MIN_POSITION || hasMeaningfulRange + // F4 (stall-detection audit): the handles must be in use RIGHT NOW for a + // velocity stall — the old `|| hasMeaningfulRange` latch stayed true for + // the rest of the set, so a racked pause between reps armed the countdown + // and force-ended standard sets. Racked handles are the position path's + // job (AMRAP/Just Lift); a genuine mid-set release is the deload path's job. + val isActivelyUsing = maxPosition > WorkoutCoordinator.STALL_MIN_POSITION val inGrace = isInAmrapStartupGrace(hasMeaningfulRange) if (isDefinitelyStalled && isActivelyUsing && coordinator.stallStartTime == null && !inGrace) { coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true + coordinator.stallArmedByDeload = false } else if (isDefinitelyMoving && coordinator.stallStartTime != null) { resetStallTimer() } val startTime = coordinator.stallStartTime if (startTime != null) { + // F4: re-check per sample — a velocity-armed countdown must not keep + // running once the handles return to rest (racked pause). A deload-armed + // countdown must survive this (real cable release retracts to ~0mm). + if (!coordinator.stallArmedByDeload && maxPosition <= WorkoutCoordinator.STALL_MIN_POSITION) { + resetStallTimer() + return + } + val stallElapsed = (currentTimeMillis() - startTime) / 1000f if (stallElapsed >= WorkoutCoordinator.STALL_DURATION_SECONDS && !coordinator.autoStopTriggered) { @@ -2068,7 +2096,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 - deferAutoStopDeadlineMs = 0L + coordinator.deferAutoStopDeadlineMs = 0L coordinator.repBoundaryTimestamps.value = emptyList() coordinator.warmupCompleteTimeMs = 0 // Reset variable warm-up state @@ -2491,7 +2519,6 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 - deferAutoStopDeadlineMs = 0L coordinator.repQualityScorer.reset() coordinator._latestRepQuality.value = null coordinator._loadBaselineA.value = 0f @@ -2516,6 +2543,17 @@ class ActiveSessionEngine( // Reset rep quality scorer for fresh set coordinator.repQualityScorer.reset() coordinator._latestRepQuality.value = null + // F3 (stall-detection audit): a set start must never inherit the previous + // set's biomech/VBT state. The Phase 35C variable warm-up fast path in + // handleSetCompletion() returns early — before its biomech/VBT reset block — + // so a warm-up set's firstRepMcv (at a fraction of working weight) and the + // one-shot VBT alert flags leaked into the first working set. Resetting here + // covers every set-start path; the handleSetCompletion resets remain (they + // must run before the summary is displayed) and are idempotent with these. + coordinator.biomechanicsEngine.reset() + velocityThresholdAlertEmitted = false + consecutiveThresholdReps = 0 + coordinator.repBoundaryTimestamps.value = emptyList() // Reset quality streak only at actual workout start, not between sets. // skipCountdown=true indicates a set-to-set transition within the same workout. if (!skipCountdown) { @@ -4031,7 +4069,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 - deferAutoStopDeadlineMs = 0L + coordinator.deferAutoStopDeadlineMs = 0L coordinator.repBoundaryTimestamps.value = emptyList() val completedReps = coordinator._repCount.value.workingReps diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt index 8c52dac5..ccf74b3f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt @@ -1,7 +1,6 @@ package com.devil.phoenixproject.presentation.manager import co.touchlab.kermit.Logger -import com.devil.phoenixproject.data.repository.AutoStopUiState import com.devil.phoenixproject.data.repository.CompletedSetRepository import com.devil.phoenixproject.data.repository.EquipmentRackRepository import com.devil.phoenixproject.data.repository.ExerciseRepository @@ -1770,16 +1769,11 @@ class RoutineFlowManager( /** * Fully reset auto-stop state for a new workout/set. - * Operates directly on coordinator fields. + * F7 (stall-detection audit): delegates to the coordinator's single reset — + * the previous hand-copied list here could not clear the (then engine-private) + * verbal-cue defer deadline. */ - private fun resetAutoStopState() { - coordinator.autoStopStartTime = null - coordinator.autoStopTriggered = false - coordinator.autoStopStopRequested = false - coordinator.stallStartTime = null - coordinator.isCurrentlyStalled = false - coordinator._autoStopState.value = AutoStopUiState() - } + private fun resetAutoStopState() = coordinator.resetAutoStopState() /** * Clear the active cycle context (e.g., when starting a non-cycle workout). diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt index 91305d57..2d980bc4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt @@ -417,6 +417,44 @@ class WorkoutCoordinator( @Volatile internal var isCurrentlyStalled = false + // F4 (stall-detection audit): true when the stall countdown was armed by a + // firmware DELOAD_OCCURRED (genuine cable release — must survive handles + // returning to rest); false when armed by the velocity hysteresis (racked + // handles must cancel it). + @Volatile + internal var stallArmedByDeload = false + + // Issue #649: defer position/stall auto-stop until the verbal-cue + short + // transition window elapses, or a completed working rep clears it. The + // deadline (@Volatile Long) is the single source of truth — 0L means no + // defer. checkVelocityThreshold() arms it on Dispatchers.Default; + // checkAutoStop() reads it on the metrics thread. @Volatile is required so + // the metrics thread never sees a stale old deadline across a thread switch. + // F7 (stall-detection audit): lives on the coordinator so BOTH managers' + // reset paths (ActiveSessionEngine and RoutineFlowManager) clear it via + // resetAutoStopState() below. + @Volatile + internal var deferAutoStopDeadlineMs = 0L + + /** + * Fully reset auto-stop / stall / defer state for a new workout or set. + * + * F7 (stall-detection audit): single source of truth — previously + * ActiveSessionEngine and RoutineFlowManager each maintained a hand-copied + * reset list, and the RFM copy could not clear the (then engine-private) + * defer deadline. + */ + internal fun resetAutoStopState() { + autoStopStartTime = null + autoStopTriggered = false + autoStopStopRequested = false + stallStartTime = null + isCurrentlyStalled = false + stallArmedByDeload = false + deferAutoStopDeadlineMs = 0L + _autoStopState.value = AutoStopUiState() + } + // ===== Exercise Timer Control State (Issue #190: Pause/Resume/Reset for timed exercises) ===== internal val _isExerciseTimerPaused = MutableStateFlow(false) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt index 77ce980a..bb4c3379 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.presentation.manager import app.cash.turbine.test +import com.devil.phoenixproject.data.repository.HandleState import com.devil.phoenixproject.data.repository.RepNotification import com.devil.phoenixproject.domain.model.Badge import com.devil.phoenixproject.domain.model.BadgeCategory @@ -1204,6 +1205,422 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } + @Test + fun `F1 - Echo mode deload event does not arm stall timer`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.Echo, + echoLevel = EchoLevel.HARDER, + reps = 10, + warmupReps = 0, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + assertIs(harness.dwsm.coordinator.workoutState.value) + + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 10) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 10) + advanceUntilIdle() + assertTrue(harness.dwsm.coordinator.repCount.value.isWarmupComplete) + + // In Echo mode DELOAD_OCCURRED fires routinely as the athlete fatigues + // (Echo levels are defined by the firmware's deload window) — it must not + // arm the auto-stop stall countdown. + harness.fakeBleRepo.emitDeloadOccurred() + advanceUntilIdle() + + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "Echo-mode DELOAD_OCCURRED is routine firmware behavior and must not arm the stall timer", + ) + assertFalse(harness.dwsm.coordinator.isCurrentlyStalled) + assertIs(harness.dwsm.coordinator.workoutState.value) + harness.cleanup() + } + + @Test + fun `F1 - Echo mode velocity stall still arms mid-rep`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.Echo, + echoLevel = EchoLevel.HARDER, + reps = 10, + warmupReps = 0, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 10) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 10) + advanceUntilIdle() + + // Genuinely stalled mid-rep: handles extended, no movement. + harness.fakeBleRepo.emitMetric( + WorkoutMetric( + positionA = 120f, + positionB = 120f, + velocityA = 0.0, + velocityB = 0.0, + loadA = 10f, + loadB = 10f, + ), + ) + advanceUntilIdle() + + assertNotNull( + harness.dwsm.coordinator.stallStartTime, + "Echo mode must keep the velocity-based stall protection (only the deload arm is suppressed)", + ) + harness.cleanup() + } + + @Test + fun `Issue 652 - completed Echo rep cancels an armed stall timer`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 10, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 10) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 10) + advanceUntilIdle() + + harness.fakeBleRepo.emitDeloadOccurred() + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + + harness.fakeBleRepo.emitRepNotification( + RepNotification( + topCounter = 5, + completeCounter = 5, + repsRomCount = 3, + repsRomTotal = 3, + repsSetCount = 2, + repsSetTotal = 10, + rangeTop = 800f, + rangeBottom = 0f, + rawData = ByteArray(24), + timestamp = 5L, + ), + ) + advanceUntilIdle() + + assertEquals(2, harness.dwsm.coordinator.repCount.value.workingReps) + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "A completed working rep proves motion resumed and must cancel the stale stall countdown", + ) + assertFalse(harness.dwsm.coordinator.isCurrentlyStalled) + assertIs(harness.dwsm.coordinator.workoutState.value) + harness.cleanup() + } + + @Test + fun `F3 - startWorkout for next set clears rep boundary timestamps and biomech state`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + assertIs(harness.dwsm.coordinator.workoutState.value) + + // Seed biomech/VBT state deterministically (processRep is synchronous), + // simulating a completed warm-up set on the Phase 35C fast path. + val repMetric = WorkoutMetric( + positionA = 100f, + positionB = 100f, + velocityA = 500.0, + velocityB = 500.0, + loadA = 10f, + loadB = 10f, + ) + harness.dwsm.coordinator.repBoundaryTimestamps.value = listOf(currentTimeMillis()) + harness.dwsm.coordinator.biomechanicsEngine.processRep( + repNumber = 1, + concentricMetrics = listOf(repMetric), + allRepMetrics = listOf(repMetric), + timestamp = currentTimeMillis(), + ) + assertNotNull(harness.dwsm.coordinator.biomechanicsEngine.latestRepResult.value) + + // The Phase 35C variable warm-up fast path advances via startWorkout(skipCountdown = true) + // without running handleSetCompletion's biomech/VBT reset block. + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + + assertEquals( + null, + harness.dwsm.coordinator.biomechanicsEngine.latestRepResult.value, + "A new set must not inherit the previous set's biomechanics state (VBT baseline leak)", + ) + assertTrue( + harness.dwsm.coordinator.repBoundaryTimestamps.value.isEmpty(), + "Rep boundary timestamps must reset at set start", + ) + harness.cleanup() + } + + @Test + fun `F4 - racked handles reset a velocity-armed stall countdown in standard set`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + advanceUntilIdle() + + // Velocity-arm the stall countdown mid-rep (handles extended, no movement) + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 120f, positionB = 120f, velocityA = 0.0, velocityB = 0.0, loadA = 10f, loadB = 10f), + ) + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + assertFalse(harness.dwsm.coordinator.stallArmedByDeload) + + // Rack the handles and let the (backdated) countdown "expire" — a racked + // pause between reps must cancel the velocity-armed countdown, not end the set. + harness.dwsm.coordinator.stallStartTime = currentTimeMillis() - 6_000L + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 0f, positionB = 0f, velocityA = 0.0, velocityB = 0.0, loadA = 0f, loadB = 0f), + ) + advanceUntilIdle() + + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "Racked handles must cancel a velocity-armed stall countdown (resting between reps is not a stall)", + ) + assertFalse(harness.dwsm.coordinator.autoStopTriggered) + assertIs(harness.dwsm.coordinator.workoutState.value) + harness.cleanup() + } + + @Test + fun `F4 - deload-armed stall survives racked handles and auto-stops`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + advanceUntilIdle() + + // Genuine cable release: firmware deload arms the countdown; the cables + // retract to ~0mm afterwards, which must NOT cancel it. + harness.fakeBleRepo.emitDeloadOccurred() + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + assertTrue(harness.dwsm.coordinator.stallArmedByDeload) + + harness.dwsm.coordinator.stallStartTime = currentTimeMillis() - 6_000L + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 0f, positionB = 0f, velocityA = 0.0, velocityB = 0.0, loadA = 0f, loadB = 0f), + ) + advanceUntilIdle() + + assertIs( + harness.dwsm.coordinator.workoutState.value, + "A deload-armed stall countdown must fire even with the cables retracted (genuine release)", + ) + harness.cleanup() + } + + @Test + fun `F4 - velocity stall does not arm at rest despite established range`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + advanceUntilIdle() + + // Handles fully at rest between reps. Before the F4 fix, the + // hasMeaningfulRange latch made this arm the 5s countdown. + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 0f, positionB = 0f, velocityA = 0.0, velocityB = 0.0, loadA = 0f, loadB = 0f), + ) + advanceUntilIdle() + + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "Handles at rest must not arm the velocity stall countdown in a standard set", + ) + harness.cleanup() + } + + @Test + fun `F4 - deload event upgrades a velocity-armed stall so racking does not cancel it`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + advanceUntilIdle() + + // Velocity-arm first (stalled mid-rep) ... + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 120f, positionB = 120f, velocityA = 0.0, velocityB = 0.0, loadA = 10f, loadB = 10f), + ) + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + assertFalse(harness.dwsm.coordinator.stallArmedByDeload) + + // ... then the machine detects the release: the countdown must be upgraded + // so the retracting cables can't cancel it. + harness.fakeBleRepo.emitDeloadOccurred() + advanceUntilIdle() + assertTrue(harness.dwsm.coordinator.stallArmedByDeload) + + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 0f, positionB = 0f, velocityA = 0.0, velocityB = 0.0, loadA = 0f, loadB = 0f), + ) + advanceUntilIdle() + + assertNotNull( + harness.dwsm.coordinator.stallStartTime, + "A deload-upgraded countdown must survive the cables retracting to rest", + ) + harness.cleanup() + } + + @Test + fun `F8 - handle release clears verbal cue defer deadline`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + advanceUntilIdle() + + // Verbal-cue defer active: the stall path must not arm. + harness.dwsm.coordinator.deferAutoStopDeadlineMs = currentTimeMillis() + 30_000L + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 120f, positionB = 120f, velocityA = 0.0, velocityB = 0.0, loadA = 10f, loadB = 10f), + ) + advanceUntilIdle() + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "Stall must stay deferred while the verbal-cue window is active", + ) + + // Releasing the handles proves the set is over — the defer must clear ... + harness.fakeBleRepo.setHandleState(HandleState.Released) + advanceUntilIdle() + assertEquals( + 0L, + harness.dwsm.coordinator.deferAutoStopDeadlineMs, + "Handle release must clear the verbal-cue defer deadline", + ) + + // ... and auto-stop paths resume immediately. + harness.fakeBleRepo.emitMetric( + WorkoutMetric(positionA = 120f, positionB = 120f, velocityA = 0.0, velocityB = 0.0, loadA = 10f, loadB = 10f), + ) + advanceUntilIdle() + assertNotNull( + harness.dwsm.coordinator.stallStartTime, + "Auto-stop must resume once the defer is cleared by handle release", + ) + harness.cleanup() + } + @Test fun `Issue 267 Just Lift warmup to working rep transitions without failed stall state`() = runTest { val harness = DWSMTestHarness(this) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt new file mode 100644 index 00000000..4d583b9c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt @@ -0,0 +1,45 @@ +package com.devil.phoenixproject.presentation.manager + +import com.devil.phoenixproject.data.repository.AutoStopUiState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * F7 (stall-detection audit): WorkoutCoordinator.resetAutoStopState() is the single + * source of truth for auto-stop/stall/defer resets. Both ActiveSessionEngine and + * RoutineFlowManager delegate to it — previously each maintained a hand-copied list + * and the RoutineFlowManager copy could not clear the (then engine-private) + * verbal-cue defer deadline. + */ +class WorkoutCoordinatorAutoStopResetTest { + + @Test + fun `resetAutoStopState clears every auto-stop, stall, and defer field`() { + val coordinator = WorkoutCoordinator() + + coordinator.autoStopStartTime = 12_345L + coordinator.autoStopTriggered = true + coordinator.autoStopStopRequested = true + coordinator.stallStartTime = 67_890L + coordinator.isCurrentlyStalled = true + coordinator.stallArmedByDeload = true + coordinator.deferAutoStopDeadlineMs = 99_999L + coordinator._autoStopState.value = AutoStopUiState( + isActive = true, + progress = 0.5f, + secondsRemaining = 3, + ) + + coordinator.resetAutoStopState() + + assertEquals(null, coordinator.autoStopStartTime) + assertFalse(coordinator.autoStopTriggered) + assertFalse(coordinator.autoStopStopRequested) + assertEquals(null, coordinator.stallStartTime) + assertFalse(coordinator.isCurrentlyStalled) + assertFalse(coordinator.stallArmedByDeload) + assertEquals(0L, coordinator.deferAutoStopDeadlineMs) + assertEquals(AutoStopUiState(), coordinator._autoStopState.value) + } +} From b520b0040bd74acaf8aa65a78cea129a02f4f17c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:30:54 +0000 Subject: [PATCH 3/5] refactor(F6): remove dead global stallDetectionEnabled preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global UserPreferences.stallDetectionEnabled was consumed by no workout path and had no wired UI toggle — every workout honors the per-exercise RoutineExercise.stallDetectionEnabled or the Just Lift per-session toggle. Its only effect was incorrectly gating the 'Auto-End on Velocity Loss' row in Settings, implying a global control that did not exist. Removed: the preference field, persistence key/load/setter (PreferencesManager), SettingsManager/MainViewModel passthroughs, the SettingsTab parameter and gating (auto-end row is now always enabled), and the fake's override. JustLiftDefaults.stallDetectionEnabled and the per-exercise flag are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ee4mJkzUkJTbwEUvMCSSaq --- .../data/preferences/PreferencesManager.kt | 8 -------- .../phoenixproject/domain/model/UserPreferences.kt | 1 - .../presentation/manager/SettingsManager.kt | 4 ---- .../presentation/navigation/NavGraph.kt | 1 - .../presentation/screen/SettingsTab.kt | 14 ++------------ .../presentation/viewmodel/MainViewModel.kt | 1 - .../testutil/FakePreferencesManager.kt | 4 ---- 7 files changed, 2 insertions(+), 31 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt index b1491f0a..304a6b7b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt @@ -113,7 +113,6 @@ interface PreferencesManager { suspend fun setEnableVideoPlayback(enabled: Boolean) suspend fun setBeepsEnabled(enabled: Boolean) suspend fun setColorScheme(scheme: Int) - suspend fun setStallDetectionEnabled(enabled: Boolean) suspend fun setDiscoModeUnlocked(unlocked: Boolean) suspend fun setAudioRepCountEnabled(enabled: Boolean) suspend fun setRepCountTiming(timing: RepCountTiming) @@ -218,7 +217,6 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa private const val KEY_VIDEO_PLAYBACK = "video_playback" private const val KEY_BEEPS_ENABLED = "beeps_enabled" private const val KEY_COLOR_SCHEME = "color_scheme" - private const val KEY_STALL_DETECTION = "stall_detection_enabled" private const val KEY_DISCO_MODE_UNLOCKED = "disco_mode_unlocked" private const val KEY_AUDIO_REP_COUNT = "audio_rep_count_enabled" private const val LEGACY_KEY_HUD_PRESET = "hud_preset" @@ -283,7 +281,6 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa enableVideoPlayback = settings.getBoolean(KEY_VIDEO_PLAYBACK, true), beepsEnabled = settings.getBoolean(KEY_BEEPS_ENABLED, true), colorScheme = settings.getInt(KEY_COLOR_SCHEME, 0), - stallDetectionEnabled = settings.getBoolean(KEY_STALL_DETECTION, true), discoModeUnlocked = settings.getBoolean(KEY_DISCO_MODE_UNLOCKED, false), audioRepCountEnabled = settings.getBoolean(KEY_AUDIO_REP_COUNT, false), repCountTiming = settings.getStringOrNull(KEY_REP_COUNT_TIMING)?.let { @@ -370,11 +367,6 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa settings.putInt(KEY_COLOR_SCHEME, scheme) updateAndEmit { copy(colorScheme = scheme) } } - override suspend fun setStallDetectionEnabled(enabled: Boolean) { - settings.putBoolean(KEY_STALL_DETECTION, enabled) - updateAndEmit { copy(stallDetectionEnabled = enabled) } - } - override suspend fun setDiscoModeUnlocked(unlocked: Boolean) { settings.putBoolean(KEY_DISCO_MODE_UNLOCKED, unlocked) updateAndEmit { copy(discoModeUnlocked = unlocked) } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt index ef812319..e0b4f5d2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt @@ -13,7 +13,6 @@ data class UserPreferences( val enableVideoPlayback: Boolean = true, // true = show videos, false = hide videos to avoid slow loading val beepsEnabled: Boolean = true, // true = play audio cues during workouts, false = haptic only val colorScheme: Int = 0, - val stallDetectionEnabled: Boolean = true, // Stall detection auto-stop toggle val discoModeUnlocked: Boolean = false, // Easter egg - unlocked by tapping LED header 7 times val audioRepCountEnabled: Boolean = false, // Audio rep count announcements during workout val repCountTiming: RepCountTiming = RepCountTiming.TOP, // When to count working reps (TOP=concentric, BOTTOM=eccentric) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt index 78abe5e5..10c02de9 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt @@ -84,10 +84,6 @@ class SettingsManager( scope.launch { preferencesManager.setEnableVideoPlayback(enabled) } } - fun setStallDetectionEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setStallDetectionEnabled(enabled) } - } - fun setAudioRepCountEnabled(enabled: Boolean) { scope.launch { preferencesManager.setAudioRepCountEnabled(enabled) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt index 90e38483..eb60e4d7 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt @@ -415,7 +415,6 @@ fun NavGraph( onVelocityLossThresholdChange = { viewModel.setVelocityLossThreshold(it) }, autoEndOnVelocityLoss = userPreferences.autoEndOnVelocityLoss, onAutoEndOnVelocityLossChange = { viewModel.setAutoEndOnVelocityLoss(it) }, - stallDetectionEnabled = userPreferences.stallDetectionEnabled, // Issue #517: system-wide default scaling basis defaultScalingBasis = userPreferences.defaultScalingBasis, onDefaultScalingBasisChange = { viewModel.setDefaultScalingBasis(it) }, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt index f4e3f05a..76ea38bf 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt @@ -387,7 +387,6 @@ fun SettingsTab( onVelocityLossThresholdChange: (Int) -> Unit = {}, autoEndOnVelocityLoss: Boolean = false, onAutoEndOnVelocityLossChange: (Boolean) -> Unit = {}, - stallDetectionEnabled: Boolean = true, // Issue #517: System-wide default scaling basis for % of 1RM defaultScalingBasis: ScalingBasis = ScalingBasis.MAX_WEIGHT_PR, onDefaultScalingBasisChange: (ScalingBasis) -> Unit = {}, @@ -2186,19 +2185,11 @@ fun SettingsTab( "Auto-End on Velocity Loss", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, - color = if (stallDetectionEnabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(4.dp)) Text( - if (stallDetectionEnabled) { - "Automatically end set when threshold is reached" - } else { - "Enable Stall Detection in Workout Settings to use auto-end" - }, + "Automatically end set when threshold is reached", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -2206,7 +2197,6 @@ fun SettingsTab( Switch( checked = autoEndOnVelocityLoss, onCheckedChange = onAutoEndOnVelocityLossChange, - enabled = stallDetectionEnabled, ) } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt index 82fa7862..642d79dc 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt @@ -318,7 +318,6 @@ class MainViewModel constructor( fun setWeightUnit(unit: WeightUnit) = settingsManager.setWeightUnit(unit) fun setStopAtTop(enabled: Boolean) = settingsManager.setStopAtTop(enabled) fun setEnableVideoPlayback(enabled: Boolean) = settingsManager.setEnableVideoPlayback(enabled) - fun setStallDetectionEnabled(enabled: Boolean) = settingsManager.setStallDetectionEnabled(enabled) fun setAudioRepCountEnabled(enabled: Boolean) = settingsManager.setAudioRepCountEnabled(enabled) fun setRepCountTiming(timing: RepCountTiming) = settingsManager.setRepCountTiming(timing) fun setSummaryCountdownSeconds(seconds: Int) = settingsManager.setSummaryCountdownSeconds(seconds) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt index 2e5fcb85..a8591961 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt @@ -57,10 +57,6 @@ class FakePreferencesManager : PreferencesManager { _preferencesFlow.value = _preferencesFlow.value.copy(colorScheme = scheme) } - override suspend fun setStallDetectionEnabled(enabled: Boolean) { - _preferencesFlow.value = _preferencesFlow.value.copy(stallDetectionEnabled = enabled) - } - override suspend fun setDiscoModeUnlocked(unlocked: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(discoModeUnlocked = unlocked) } From 161a016dae9cf66a07b9f47bb48f384b66ab53e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:30:54 +0000 Subject: [PATCH 4/5] docs: audit corrections & resolutions addendum F2 and F9 withdrawn (totalReps excludes warm-up reps; stall fields already @Volatile), F5 resolved via #653 absorb + F4 continuation check, and per-finding resolution status for the fixes on this branch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ee4mJkzUkJTbwEUvMCSSaq --- .../2026-07-14-stall-detection-e2e-audit.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/audits/2026-07-14-stall-detection-e2e-audit.md b/docs/audits/2026-07-14-stall-detection-e2e-audit.md index e261c290..01820e47 100644 --- a/docs/audits/2026-07-14-stall-detection-e2e-audit.md +++ b/docs/audits/2026-07-14-stall-detection-e2e-audit.md @@ -132,3 +132,22 @@ The one-line fix (reset the shared stall timer on a completed working rep) is ** 4. **F6** — make the global toggle a master gate (`params.stallDetectionEnabled && prefs.stallDetectionEnabled`) or surface per-exercise state in the routine UI. 5. F4/F5 — revisit arm/cancel semantics for standard sets (product decision; at minimum re-check `isActivelyUsing` during countdown and let rep *events* — not only >10 mm/s samples — cancel). 6. F7–F9 — hygiene. + +--- + +## 6. Corrections & Resolutions (implementation pass, same branch) + +Deeper verification during the implementation phase corrected two findings, and all actionable findings were fixed on this branch. Per-finding status: + +| Finding | Status | Resolution | +|---|---|---| +| F1 | **FIXED** | `ActiveSessionEngine` deload collector now ignores `DELOAD_OCCURRED` entirely in Echo mode (`params.isEchoMode` gate). Echo sets keep velocity-stall and (Just Lift) release/position protection. | +| F2 | **WITHDRAWN — incorrect** | `RepCounterFromMachine.getRepCount()` sets `totalReps = workingReps` (line 684, "Exclude warm-up reps from total count"), so `handleRepNotification` never fires the rep-scoring/biomechanics path for firmware warm-up reps. There is **no intra-set warm-up contamination** of `firstRepMcv`; the audit's §2 F2 claim (and the derivative claim that the last warm-up rep can drive the first threshold check) is wrong. The only real contamination vector was the *cross-set* leak described in F3. | +| F3 | **FIXED** | `startWorkout()` now resets `biomechanicsEngine`, `velocityThresholdAlertEmitted`, `consecutiveThresholdReps`, and `repBoundaryTimestamps` at every set start, covering both Phase 35C early-return branches (which advance via `startWorkout(skipCountdown = true)`). | +| F4 | **FIXED** | Velocity-armed stall countdowns now require the handles to be in use (`maxPosition > STALL_MIN_POSITION`) both to arm (the `hasMeaningfulRange` latch is removed from `isActivelyUsing`) and to keep counting (re-checked per sample) — a racked pause between reps no longer ends a standard set. A new `WorkoutCoordinator.stallArmedByDeload` flag exempts deload-armed countdowns (genuine cable release retracts to ~0 mm and must still auto-stop); a deload event also *upgrades* an existing velocity-armed countdown. | +| F5 | **RESOLVED (no dedicated change)** | Covered by the absorbed PR #653 fix (completed working rep cancels the countdown at the rep boundary) plus F4's per-sample position re-check. | +| F6 | **FIXED (pref removed)** | The global `UserPreferences.stallDetectionEnabled` was consumed by **no** workout path and had no wired UI toggle — it only (incorrectly) gated the "Auto-End on Velocity Loss" row in Settings. Removed entirely (field, persistence key, setters in PreferencesManager/SettingsManager/MainViewModel, SettingsTab param); the auto-end row is now always enabled. Per-exercise (`RoutineExercise.stallDetectionEnabled`) and Just Lift toggles are unchanged and remain the only controls. | +| F7 | **FIXED** | `deferAutoStopDeadlineMs` moved to `WorkoutCoordinator` (`@Volatile`), and `WorkoutCoordinator.resetAutoStopState()` is the single reset for all auto-stop/stall/defer fields; `ActiveSessionEngine` and `RoutineFlowManager` both delegate to it (the RFM copy previously could not clear the defer deadline). | +| F8 | **FIXED** | Releasing the handles while a verbal-cue defer window is active now clears the deadline immediately (collector #4), so a genuine set-end auto-stops promptly instead of waiting out the 30s window. | +| F9 | **WITHDRAWN — incorrect** | The stall/auto-stop fields were already `@Volatile` (`WorkoutCoordinator.kt:392-418`), and `deferAutoStopDeadlineMs` was already `@Volatile` before its move. No change needed. | +| — | **PR #653 absorbed** | The one-line Issue #652 fix (`resetStallTimer()` on a completed working rep in `handleRepNotification`) and its regression test are included on this branch; PR #653 is superseded. | From 1a6b5b27152925df339d1c724163010704a4556f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:49:21 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20remove=20comma=20from=20test=20name?= =?UTF-8?q?=20=E2=80=94=20Kotlin/Native=20forbids=20','=20in=20backticked?= =?UTF-8?q?=20identifiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS Test Target Compile CI job failed on WorkoutCoordinatorAutoStopResetTest with 'Name contains illegal characters: ","'. JVM targets accept commas in backticked test names; Kotlin/Native does not. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ee4mJkzUkJTbwEUvMCSSaq --- .../presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt index 4d583b9c..1a53c431 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt @@ -15,7 +15,7 @@ import kotlin.test.assertFalse class WorkoutCoordinatorAutoStopResetTest { @Test - fun `resetAutoStopState clears every auto-stop, stall, and defer field`() { + fun `resetAutoStopState clears every auto-stop stall and defer field`() { val coordinator = WorkoutCoordinator() coordinator.autoStopStartTime = 12_345L