From 72b895235339f05147591a1637b50e20ccef2fc2 Mon Sep 17 00:00:00 2001 From: hermes-bot Date: Fri, 10 Jul 2026 17:48:20 -0400 Subject: [PATCH 1/5] fix: defer AMRAP/stall auto-stop after verbal VBT cue when autoEndOnVelocityLoss=false (#649) When the user has VBT Auto-End on Velocity Loss disabled but verbal encouragement enabled, AMRAP position-based (2.5s) and velocity-stall (5s) auto-stop timers remained independent of the verbal cue and could call handleSetCompletion() mid-playback, cutting off the cue and ending the AMRAP set prematurely. Add a single-set guard: when VERBAL_ENCOURAGEMENT is emitted with autoEndOnVelocityLoss=false, defer both AMRAP position and velocity-stall auto-stop timers until the next completed working rep. Cleared by the next completed working rep or at the per-set reset boundary. The VBT two-consecutive-rep auto-end branch (gated on autoEndOnVelocityLoss) is untouched. RCA: https://github.com/9thLevelSoftware/Project-Phoenix-MP/issues/649#issuecomment-4939731593 Fixes 9thLevelSoftware/Project-Phoenix-MP#649 --- .../manager/ActiveSessionEngine.kt | 33 +++++++ .../VerbalEncouragementAutoStopDeferTest.kt | 91 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.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 041e18515..1943d5a0d 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,6 +243,11 @@ class ActiveSessionEngine( private var velocityThresholdAlertEmitted = false private var consecutiveThresholdReps = 0 + // Issue #649: defer position/stall auto-stop until the next completed working + // rep after a verbal VBT cue was emitted with autoEndOnVelocityLoss=false. + // ponytail: single boolean; cleared on next completed working rep + per-set reset. + private var deferAutoStopUntilNextWorkingRep = false + // ===== Init Block: Workout-Related Collectors (moved from DWSM) ===== init { @@ -1080,6 +1085,10 @@ class ActiveSessionEngine( // Score the rep if rep count actually incremented val repCountAfter = repCounter.getRepCount().totalReps if (repCountAfter > repCountBefore) { + // Issue #649: a completed working rep proves the user is back in motion; + // let normal AMRAP / stall auto-stop resume. + deferAutoStopUntilNextWorkingRep = false + // Capture rep boundary timestamp BEFORE scoring so scoreCurrentRep() // and processBiomechanicsForRep() both see the correct metric window. val now = KmpUtils.currentTimeMillis() @@ -1327,6 +1336,17 @@ class ActiveSessionEngine( ), ) Logger.i { "VBT: VERBAL_ENCOURAGEMENT emitted (tier=${prefs.vulgarTier}, dominatrix=${prefs.dominatrixModeActive}, vulgar=${prefs.vulgarModeEnabled})" } + + // Issue #649: when the user has VBT auto-end OFF, the verbal cue is + // the only velocity signal — don't let AMRAP position / velocity-stall + // timers end the set while the cue is still audible. Defer both timers + // until the user completes another working rep; any later auto-end + // branch (VBT or stall) proceeds normally. + if (!coordinator.autoEndOnVelocityLoss) { + deferAutoStopUntilNextWorkingRep = true + resetStallTimer() + resetAutoStopTimer() + } } } @@ -1404,6 +1424,16 @@ class ActiveSessionEngine( return } + // Issue #649: while a verbal VBT cue is still in flight and the user has VBT + // auto-end OFF, neither AMRAP position nor velocity-stall may end the set. + // Reset the live countdowns defensively each metric; cleared on the next + // completed working rep or at the per-set reset boundary. + if (deferAutoStopUntilNextWorkingRep) { + resetStallTimer() + resetAutoStopTimer() + return + } + val hasMeaningfulRange = repCounter.hasMeaningfulRange(WorkoutCoordinator.MIN_RANGE_THRESHOLD) val params = coordinator._workoutParameters.value val repCount = coordinator._repCount.value @@ -2007,6 +2037,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 + deferAutoStopUntilNextWorkingRep = false coordinator.repBoundaryTimestamps.value = emptyList() coordinator.warmupCompleteTimeMs = 0 // Reset variable warm-up state @@ -2429,6 +2460,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 + deferAutoStopUntilNextWorkingRep = false coordinator.repQualityScorer.reset() coordinator._latestRepQuality.value = null coordinator._loadBaselineA.value = 0f @@ -3968,6 +4000,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 + deferAutoStopUntilNextWorkingRep = false coordinator.repBoundaryTimestamps.value = emptyList() val completedReps = coordinator._repCount.value.workingReps diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt new file mode 100644 index 000000000..111a29344 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt @@ -0,0 +1,91 @@ +package com.devil.phoenixproject.presentation.manager + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #649: while a verbal VBT cue is playing and the user has VBT auto-end OFF, + * neither the AMRAP position-based nor the velocity-stall auto-stop may end the set. + * + * Mirrors the velocity-threshold state machine from VbtAutoEndTest / VerbalEncouragementGateTest. + * Keeps the logic testable without spinning up the 17+ dependency ActiveSessionEngine. + */ +private class VerbalEncouragementAutoStopDeferTracker( + private val autoEndOnVelocityLoss: Boolean, +) { + var alertEmitted = false + private set + var deferAutoStop = false + private set + + fun onRepCompleted(shouldStopSet: Boolean) { + if (shouldStopSet) { + if (!alertEmitted) { + alertEmitted = true + // Verbal cue fired -> defer position/stall auto-stop until the next + // completed working rep, but only if VBT auto-end is OFF. + if (!autoEndOnVelocityLoss) { + deferAutoStop = true + } + } + } + } + + fun onCompletedWorkingRep() { + // A completed working rep proves the user is back in motion; + // let normal AMRAP / stall auto-stop resume. + deferAutoStop = false + } +} + +class VerbalEncouragementAutoStopDeferTest { + + @Test + fun `autoEnd off - first velocity-threshold rep defers auto-stop`() { + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true) + + assertTrue(tracker.alertEmitted, "Velocity threshold alert is one-shot per set") + assertTrue(tracker.deferAutoStop, "Defer must be active while verbal cue is in flight") + } + + @Test + fun `autoEnd on - first velocity-threshold rep does NOT defer auto-stop`() { + // With VBT auto-end ON, the existing two-consecutive-rep behavior decides set end; + // the defer guard would only suppress that and must NOT be armed. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = true) + tracker.onRepCompleted(shouldStopSet = true) + + assertTrue(tracker.alertEmitted) + assertFalse( + tracker.deferAutoStop, + "Defer must NOT arm when VBT auto-end is on — that branch handles set end itself", + ) + } + + @Test + fun `autoEnd off - next completed working rep clears the defer`() { + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true) + assertTrue(tracker.deferAutoStop) + + tracker.onCompletedWorkingRep() + + assertFalse( + tracker.deferAutoStop, + "After a completed working rep the defer must clear so AMRAP/stall can fire again", + ) + } + + @Test + fun `autoEnd off - non-threshold reps do not arm the defer`() { + // ShouldStopSet=false never reaches the alert branch, so the defer never sets. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = false) + tracker.onRepCompleted(shouldStopSet = false) + + assertFalse(tracker.alertEmitted) + assertFalse(tracker.deferAutoStop) + } +} From 1e4c0c30dbe292ae1df88b90d9fd1559aca43173 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Fri, 10 Jul 2026 17:56:41 -0400 Subject: [PATCH 2/5] fix(#649): @Volatile for cross-thread defer + add deadline window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gemini review: deferAutoStopUntilNextWorkingRep is written on Dispatchers.Default (checkVelocityThreshold) and read on the metrics collection thread (checkAutoStop). Marked @Volatile so the metrics thread immediately sees the armed flag and avoids a premature auto-stop window starting before the cue's audio finishes. - Codex review (P2): if the athlete racks mid-set after the cue and never completes another working rep, the original fix would have suppressed AMRAP/stall auto-stop forever. Added a 30s deadline (VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS) — cue + short post-cue transition window — that auto-clears the defer so a legitimately racked mid-set handle still ends the set normally. Per-set reset sites and the next completed working rep continue to clear it. Tests: VerbalEncouragementAutoStopDeferTest now 6/6 (added within-window active + deadline expiry). ./gradlew :shared:testAndroidHostTest -Pskip.supabase.check=true -> 2,426 tests pass, 0 failures, 0 errors. --- .../manager/ActiveSessionEngine.kt | 37 +++++++++-- .../VerbalEncouragementAutoStopDeferTest.kt | 65 +++++++++++++++++-- 2 files changed, 88 insertions(+), 14 deletions(-) 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 1943d5a0d..9516d2c39 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 @@ -245,8 +245,20 @@ class ActiveSessionEngine( // Issue #649: defer position/stall auto-stop until the next completed working // rep after a verbal VBT cue was emitted with autoEndOnVelocityLoss=false. - // ponytail: single boolean; cleared on next completed working rep + per-set reset. + // Written on Dispatchers.Default in checkVelocityThreshold() and read on the + // metrics collection thread in checkAutoStop(); @Volatile keeps the cross- + // thread visibility without a wrapper holder. + // The deadline covers the verbal cue plus a short transition window so a user + // who rackets mid-set after the cue still gets normal AMRAP/stall auto-stop. + // ponytail: single boolean + deadline; cleared on next completed working rep, + // expiry, or per-set reset. Add a tunable when legitimate pauses get ended. + @kotlin.concurrent.Volatile private var deferAutoStopUntilNextWorkingRep = false + // @Volatile not strictly required for the deadline — both writes and reads + // happen on the metrics-collection thread — but reads after a metrics thread + // switch on arming can still race; mark volatile to match the flag's lifetime. + @kotlin.concurrent.Volatile + private var deferAutoStopDeadlineMs = 0L // ===== Init Block: Workout-Related Collectors (moved from DWSM) ===== @@ -1340,10 +1352,12 @@ class ActiveSessionEngine( // Issue #649: when the user has VBT auto-end OFF, the verbal cue is // the only velocity signal — don't let AMRAP position / velocity-stall // timers end the set while the cue is still audible. Defer both timers - // until the user completes another working rep; any later auto-end - // branch (VBT or stall) proceeds normally. + // until the user completes another working rep or the deadline + // (cue + short transition window) elapses; any later auto-end branch + // (VBT or stall) proceeds normally. if (!coordinator.autoEndOnVelocityLoss) { deferAutoStopUntilNextWorkingRep = true + deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS resetStallTimer() resetAutoStopTimer() } @@ -1427,11 +1441,16 @@ class ActiveSessionEngine( // Issue #649: while a verbal VBT cue is still in flight and the user has VBT // auto-end OFF, neither AMRAP position nor velocity-stall may end the set. // Reset the live countdowns defensively each metric; cleared on the next - // completed working rep or at the per-set reset boundary. + // completed working rep, the deadline (cue + transition window), or at + // the per-set reset boundary. if (deferAutoStopUntilNextWorkingRep) { - resetStallTimer() - resetAutoStopTimer() - return + if (currentTimeMillis() >= deferAutoStopDeadlineMs) { + deferAutoStopUntilNextWorkingRep = false + } else { + resetStallTimer() + resetAutoStopTimer() + return + } } val hasMeaningfulRange = repCounter.hasMeaningfulRange(WorkoutCoordinator.MIN_RANGE_THRESHOLD) @@ -5038,5 +5057,9 @@ class ActiveSessionEngine( private companion object { const val TEMPLATE_531_ID = "template_531" + // Issue #649: verbal cues are typically <30s; this ceiling covers the cue + // plus a short post-cue transition window. Exceeding it releases the defer + // so a racked mid-set handle can end the set normally. + const val VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS = 30_000L } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt index 111a29344..6b95adffb 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt @@ -10,7 +10,14 @@ import kotlin.test.assertTrue * * Mirrors the velocity-threshold state machine from VbtAutoEndTest / VerbalEncouragementGateTest. * Keeps the logic testable without spinning up the 17+ dependency ActiveSessionEngine. + * + * Cross-thread note: the production field is @Volatile. The tracker here doesn't + * model the threading — it only mirrors the predicate (defer armed? deadline + * passed?). The companion object constant is mirrored inline; production uses + * ActiveSessionEngine.VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS. */ +private const val VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS_TRACKER = 30_000L + private class VerbalEncouragementAutoStopDeferTracker( private val autoEndOnVelocityLoss: Boolean, ) { @@ -18,15 +25,18 @@ private class VerbalEncouragementAutoStopDeferTracker( private set var deferAutoStop = false private set + private var deferDeadlineMs: Long = 0L - fun onRepCompleted(shouldStopSet: Boolean) { + fun onRepCompleted(shouldStopSet: Boolean, nowMs: Long) { if (shouldStopSet) { if (!alertEmitted) { alertEmitted = true // Verbal cue fired -> defer position/stall auto-stop until the next - // completed working rep, but only if VBT auto-end is OFF. + // completed working rep or the cue-window deadline, but only if + // VBT auto-end is OFF. if (!autoEndOnVelocityLoss) { deferAutoStop = true + deferDeadlineMs = nowMs + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS_TRACKER } } } @@ -36,6 +46,18 @@ private class VerbalEncouragementAutoStopDeferTracker( // A completed working rep proves the user is back in motion; // let normal AMRAP / stall auto-stop resume. deferAutoStop = false + deferDeadlineMs = 0L + } + + /** Returns true if the defer is still active at `nowMs`. */ + fun isDeferActive(nowMs: Long): Boolean { + if (!deferAutoStop) return false + if (nowMs >= deferDeadlineMs) { + deferAutoStop = false + deferDeadlineMs = 0L + return false + } + return true } } @@ -44,7 +66,7 @@ class VerbalEncouragementAutoStopDeferTest { @Test fun `autoEnd off - first velocity-threshold rep defers auto-stop`() { val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) - tracker.onRepCompleted(shouldStopSet = true) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) assertTrue(tracker.alertEmitted, "Velocity threshold alert is one-shot per set") assertTrue(tracker.deferAutoStop, "Defer must be active while verbal cue is in flight") @@ -55,7 +77,7 @@ class VerbalEncouragementAutoStopDeferTest { // With VBT auto-end ON, the existing two-consecutive-rep behavior decides set end; // the defer guard would only suppress that and must NOT be armed. val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = true) - tracker.onRepCompleted(shouldStopSet = true) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) assertTrue(tracker.alertEmitted) assertFalse( @@ -67,7 +89,7 @@ class VerbalEncouragementAutoStopDeferTest { @Test fun `autoEnd off - next completed working rep clears the defer`() { val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) - tracker.onRepCompleted(shouldStopSet = true) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) assertTrue(tracker.deferAutoStop) tracker.onCompletedWorkingRep() @@ -82,10 +104,39 @@ class VerbalEncouragementAutoStopDeferTest { fun `autoEnd off - non-threshold reps do not arm the defer`() { // ShouldStopSet=false never reaches the alert branch, so the defer never sets. val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) - tracker.onRepCompleted(shouldStopSet = false) - tracker.onRepCompleted(shouldStopSet = false) + tracker.onRepCompleted(shouldStopSet = false, nowMs = 0L) + tracker.onRepCompleted(shouldStopSet = false, nowMs = 0L) assertFalse(tracker.alertEmitted) assertFalse(tracker.deferAutoStop) } + + @Test + fun `autoEnd off - defer stays active within the cue window`() { + // Cue fires, user keeps exercising without a completed working rep, but the + // window hasn't elapsed — defer must stay armed so position/stall can't end the set. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 10_000L) + + assertTrue(tracker.isDeferActive(nowMs = 20_000L)) + assertTrue( + tracker.deferAutoStop, + "Within the cue window defer must remain active even without a completed rep", + ) + } + + @Test + fun `autoEnd off - defer expires at the cue window deadline`() { + // User rackets handles mid-set after the cue and never completes a working rep. + // Once the deadline passes, the defer must release so AMRAP/stall auto-stop can fire. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) + + assertTrue(tracker.isDeferActive(nowMs = 29_999L), "Just before deadline still defers") + assertFalse( + tracker.isDeferActive(nowMs = 30_001L), + "Past the deadline the defer must clear so auto-stop can fire normally", + ) + assertFalse(tracker.deferAutoStop, "Expired defer must clear the flag") + } } From d59b44e0cdae165d29187cce20c76667d5d943a2 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Fri, 10 Jul 2026 18:09:27 -0400 Subject: [PATCH 3/5] fix(#649): single-source deadline + resetAutoStopState clears defer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex P2 review follow-ups: 1) Cross-thread publish ordering — two @Volatile fields (flag, deadline) don't chain happens-before across the JMM, so a metrics-thread reader could observe flag=true with a stale 0L deadline and clear the defer. Refactored to a single source of truth: deferAutoStopDeadlineMs == 0L means no defer. Reader derives the flag from the deadline and never sees 'flag=true && deadline=0L'. Writer still publishes deadline first when arming for clarity. 2) Reset path coverage — the defer flag was cleared only in per-set reset paths; resetAutoStopState() is the canonical reset hub and is called by stopAndReturnToSetReady() / startWorkout() / skip flows. If a user stops or skips shortly after the cue, the stale flag could carry into the next active set and suppress AMRAP/stall auto-stop there. Moved the defer-clear into resetAutoStopState() so every reset path covers it; existing per-set clears left as defensive belt-and-suspenders. Tests: VerbalEncouragementAutoStopDeferTest 8/8 (added reset-paths and arm-publishes-deadline). ./gradlew :shared:testAndroidHostTest -Pskip.supabase.check=true -> 2,428 tests pass, 0 failures, 0 errors. --- .../manager/ActiveSessionEngine.kt | 28 +++++++--- .../VerbalEncouragementAutoStopDeferTest.kt | 51 ++++++++++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) 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 9516d2c39..de913416b 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 @@ -972,6 +972,10 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator._autoStopState.value = AutoStopUiState() + // Issue #649: clear the verbal-cue defer so a new set never inherits a + // stale flag from the previous one (skip / restart paths included). + deferAutoStopUntilNextWorkingRep = false + deferAutoStopDeadlineMs = 0L } /** @@ -1354,10 +1358,12 @@ class ActiveSessionEngine( // timers end the set while the cue is still audible. Defer both timers // until the user completes another working rep or the deadline // (cue + short transition window) elapses; any later auto-end branch - // (VBT or stall) proceeds normally. + // (VBT or stall) proceeds normally. Publish deadline BEFORE flipping + // the flag so a metrics-thread reader never sees flag=true with the + // stale 0L deadline (cross-thread ordering with @Volatile fields). if (!coordinator.autoEndOnVelocityLoss) { - deferAutoStopUntilNextWorkingRep = true deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS + deferAutoStopUntilNextWorkingRep = true resetStallTimer() resetAutoStopTimer() } @@ -1440,13 +1446,21 @@ class ActiveSessionEngine( // Issue #649: while a verbal VBT cue is still in flight and the user has VBT // auto-end OFF, neither AMRAP position nor velocity-stall may end the set. - // Reset the live countdowns defensively each metric; cleared on the next - // completed working rep, the deadline (cue + transition window), or at - // the per-set reset boundary. - if (deferAutoStopUntilNextWorkingRep) { - if (currentTimeMillis() >= deferAutoStopDeadlineMs) { + // Source of truth is the deadline field — flag is derived. The deadline is + // 0L when no defer is active; a metrics-thread reader can never observe + // `flag=true && deadline=0L` because the writer publishes the future + // deadline before flipping the flag (cross-thread ordering of two @Volatile + // fields isn't guaranteed, but a single @Volatile source eliminates the + // race by construction). Reset the live countdowns defensively each metric; + // cleared on the next completed working rep, deadline expiry, or at the + // per-set reset boundary / resetAutoStopState(). + val deferDeadline = deferAutoStopDeadlineMs + if (deferDeadline != 0L) { + if (currentTimeMillis() >= deferDeadline) { deferAutoStopUntilNextWorkingRep = false + deferAutoStopDeadlineMs = 0L } else { + deferAutoStopUntilNextWorkingRep = true resetStallTimer() resetAutoStopTimer() return diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt index 6b95adffb..ca0f0071b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.presentation.manager import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -51,14 +52,25 @@ private class VerbalEncouragementAutoStopDeferTracker( /** Returns true if the defer is still active at `nowMs`. */ fun isDeferActive(nowMs: Long): Boolean { - if (!deferAutoStop) return false - if (nowMs >= deferDeadlineMs) { + val deadline = deferDeadlineMs + if (deadline == 0L) return false + if (nowMs >= deadline) { deferAutoStop = false deferDeadlineMs = 0L return false } + deferAutoStop = true return true } + + /** Mirrors resetAutoStopState(): every new-set reset clears the defer. */ + fun onResetAutoStopState() { + deferAutoStop = false + deferDeadlineMs = 0L + } + + /** Test seam: read the published deadline (mirrors the @Volatile field). */ + fun deferDeadlineSnapshot(): Long = deferDeadlineMs } class VerbalEncouragementAutoStopDeferTest { @@ -139,4 +151,39 @@ class VerbalEncouragementAutoStopDeferTest { ) assertFalse(tracker.deferAutoStop, "Expired defer must clear the flag") } + + @Test + fun `autoEnd off - resetAutoStopState clears the defer for the next set`() { + // Verbal cue armed the defer mid-set. Before the 30s deadline the user + // skips / restarts. The next active set must not inherit the defer — + // otherwise checkAutoStop() would suppress AMRAP/stall auto-stop there too. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 10_000L) + assertTrue(tracker.isDeferActive(nowMs = 20_000L)) + + tracker.onResetAutoStopState() + + assertFalse(tracker.deferAutoStop) + assertEquals( + 0L, tracker.deferDeadlineSnapshot(), + "resetAutoStopState must zero the deadline so the source-of-truth predicate clears", + ) + assertFalse( + tracker.isDeferActive(nowMs = 20_000L), + "After resetAutoStopState() the new set must allow normal auto-stop behavior", + ) + } + + @Test + fun `arm publishes deadline before flag is observable to readers`() { + // Mirrors the production publish order: deadline first, flag second. + // The tracker mirrors the production single-source-of-truth pattern + // (deadline != 0L is the gate); this test pins the deadline value at + // arming time so a reader that arrives the instant the cue fires sees + // a future deadline and never the stale 0L value. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 5_000L) + assertEquals(35_000L, tracker.deferDeadlineSnapshot()) + assertTrue(tracker.isDeferActive(nowMs = 5_000L)) + } } From 34897f5db06f819bf2939c55dd5277f77a3442fc Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Fri, 10 Jul 2026 18:24:26 -0400 Subject: [PATCH 4/5] fix(#649): kill derived flag, single-source deadline Codex P2 + Kilo review both flagged the same bug: the working-rep clear at line 1106 only zeroed the flag but not the deadline field. The metrics-thread read site then re-derived 'flag=true' from the still-nonzero deadline and silently re-armed the defer, so the 'cleared on next completed working rep' contract was a lie. Removed the derived flag entirely. The @Volatile Long deadline is the only source of truth; checkAutoStop() gates on 'deadline != 0L' so there is nothing to re-derive. Every reset path (next completed working rep, resetAutoStopState, per-set boundaries, deadline expiry) zeros the same field. Net effect in production: -29 lines, one @Volatile Long, one const ceiling, no flag/deadline synchronization to maintain. Tests: VerbalEncouragementAutoStopDeferTest 8/8 (rewritten to mirror the single-source-of-truth pattern; explicit tests pin arm-with-future-deadline, working-rep-clears-regardless-of-deadline, expiry, resetAutoStopState, non-arm cases). ./gradlew :shared:testAndroidHostTest -Pskip.supabase.check=true -> 2,428 tests pass, 0 failures, 0 errors. --- .../manager/ActiveSessionEngine.kt | 66 +++++------ .../VerbalEncouragementAutoStopDeferTest.kt | 103 ++++++++---------- 2 files changed, 70 insertions(+), 99 deletions(-) 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 de913416b..c4051e259 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,20 +243,16 @@ class ActiveSessionEngine( private var velocityThresholdAlertEmitted = false private var consecutiveThresholdReps = 0 - // Issue #649: defer position/stall auto-stop until the next completed working - // rep after a verbal VBT cue was emitted with autoEndOnVelocityLoss=false. - // Written on Dispatchers.Default in checkVelocityThreshold() and read on the - // metrics collection thread in checkAutoStop(); @Volatile keeps the cross- - // thread visibility without a wrapper holder. - // The deadline covers the verbal cue plus a short transition window so a user - // who rackets mid-set after the cue still gets normal AMRAP/stall auto-stop. - // ponytail: single boolean + deadline; cleared on next completed working rep, - // expiry, or per-set reset. Add a tunable when legitimate pauses get ended. - @kotlin.concurrent.Volatile - private var deferAutoStopUntilNextWorkingRep = false - // @Volatile not strictly required for the deadline — both writes and reads - // happen on the metrics-collection thread — but reads after a metrics thread - // switch on arming can still race; mark volatile to match the flag's lifetime. + // 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 @@ -972,9 +968,8 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator._autoStopState.value = AutoStopUiState() - // Issue #649: clear the verbal-cue defer so a new set never inherits a - // stale flag from the previous one (skip / restart paths included). - deferAutoStopUntilNextWorkingRep = false + // Issue #649: zero the deadline so a new set never inherits a stale + // verbal-cue defer (skip / restart / startWorkout paths included). deferAutoStopDeadlineMs = 0L } @@ -1101,9 +1096,10 @@ class ActiveSessionEngine( // Score the rep if rep count actually incremented val repCountAfter = repCounter.getRepCount().totalReps if (repCountAfter > repCountBefore) { - // Issue #649: a completed working rep proves the user is back in motion; - // let normal AMRAP / stall auto-stop resume. - deferAutoStopUntilNextWorkingRep = false + // 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 // Capture rep boundary timestamp BEFORE scoring so scoreCurrentRep() // and processBiomechanicsForRep() both see the correct metric window. @@ -1356,14 +1352,12 @@ class ActiveSessionEngine( // Issue #649: when the user has VBT auto-end OFF, the verbal cue is // the only velocity signal — don't let AMRAP position / velocity-stall // timers end the set while the cue is still audible. Defer both timers - // until the user completes another working rep or the deadline - // (cue + short transition window) elapses; any later auto-end branch - // (VBT or stall) proceeds normally. Publish deadline BEFORE flipping - // the flag so a metrics-thread reader never sees flag=true with the - // stale 0L deadline (cross-thread ordering with @Volatile fields). + // until the deadline (cue + short transition window) elapses + // or a completed working rep clears it. The deadline field + // alone is the source of truth — no derived flag to keep in + // sync. if (!coordinator.autoEndOnVelocityLoss) { deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS - deferAutoStopUntilNextWorkingRep = true resetStallTimer() resetAutoStopTimer() } @@ -1446,21 +1440,15 @@ class ActiveSessionEngine( // Issue #649: while a verbal VBT cue is still in flight and the user has VBT // auto-end OFF, neither AMRAP position nor velocity-stall may end the set. - // Source of truth is the deadline field — flag is derived. The deadline is - // 0L when no defer is active; a metrics-thread reader can never observe - // `flag=true && deadline=0L` because the writer publishes the future - // deadline before flipping the flag (cross-thread ordering of two @Volatile - // fields isn't guaranteed, but a single @Volatile source eliminates the - // race by construction). Reset the live countdowns defensively each metric; - // cleared on the next completed working rep, deadline expiry, or at the - // per-set reset boundary / resetAutoStopState(). + // The deadline field is the only source of truth — 0L means no defer. + // 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 if (deferDeadline != 0L) { if (currentTimeMillis() >= deferDeadline) { - deferAutoStopUntilNextWorkingRep = false deferAutoStopDeadlineMs = 0L } else { - deferAutoStopUntilNextWorkingRep = true resetStallTimer() resetAutoStopTimer() return @@ -2070,7 +2058,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 - deferAutoStopUntilNextWorkingRep = false + deferAutoStopDeadlineMs = 0L coordinator.repBoundaryTimestamps.value = emptyList() coordinator.warmupCompleteTimeMs = 0 // Reset variable warm-up state @@ -2493,7 +2481,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 - deferAutoStopUntilNextWorkingRep = false + deferAutoStopDeadlineMs = 0L coordinator.repQualityScorer.reset() coordinator._latestRepQuality.value = null coordinator._loadBaselineA.value = 0f @@ -4033,7 +4021,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 - deferAutoStopUntilNextWorkingRep = false + deferAutoStopDeadlineMs = 0L coordinator.repBoundaryTimestamps.value = emptyList() val completedReps = coordinator._repCount.value.workingReps diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt index ca0f0071b..0817dab2f 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt @@ -12,41 +12,29 @@ import kotlin.test.assertTrue * Mirrors the velocity-threshold state machine from VbtAutoEndTest / VerbalEncouragementGateTest. * Keeps the logic testable without spinning up the 17+ dependency ActiveSessionEngine. * - * Cross-thread note: the production field is @Volatile. The tracker here doesn't - * model the threading — it only mirrors the predicate (defer armed? deadline - * passed?). The companion object constant is mirrored inline; production uses - * ActiveSessionEngine.VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS. + * Single source of truth is a @Volatile Long deadline — 0L means no defer. The + * tracker mirrors that pattern: an `isDeferActive(nowMs)` predicate derived from + * the deadline only, with the flag computed lazily inside the predicate. */ private const val VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS_TRACKER = 30_000L private class VerbalEncouragementAutoStopDeferTracker( private val autoEndOnVelocityLoss: Boolean, ) { - var alertEmitted = false - private set - var deferAutoStop = false - private set private var deferDeadlineMs: Long = 0L fun onRepCompleted(shouldStopSet: Boolean, nowMs: Long) { if (shouldStopSet) { - if (!alertEmitted) { - alertEmitted = true - // Verbal cue fired -> defer position/stall auto-stop until the next - // completed working rep or the cue-window deadline, but only if - // VBT auto-end is OFF. - if (!autoEndOnVelocityLoss) { - deferAutoStop = true - deferDeadlineMs = nowMs + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS_TRACKER - } + if (deferDeadlineMs == 0L && !autoEndOnVelocityLoss) { + // Verbal cue fires once per set when VBT auto-end is OFF. + deferDeadlineMs = nowMs + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS_TRACKER } } } fun onCompletedWorkingRep() { - // A completed working rep proves the user is back in motion; - // let normal AMRAP / stall auto-stop resume. - deferAutoStop = false + // A completed working rep proves the user is back in motion; let normal + // AMRAP / stall auto-stop resume by zeroing the deadline. deferDeadlineMs = 0L } @@ -55,90 +43,90 @@ private class VerbalEncouragementAutoStopDeferTracker( val deadline = deferDeadlineMs if (deadline == 0L) return false if (nowMs >= deadline) { - deferAutoStop = false deferDeadlineMs = 0L return false } - deferAutoStop = true return true } /** Mirrors resetAutoStopState(): every new-set reset clears the defer. */ fun onResetAutoStopState() { - deferAutoStop = false deferDeadlineMs = 0L } /** Test seam: read the published deadline (mirrors the @Volatile field). */ fun deferDeadlineSnapshot(): Long = deferDeadlineMs + + /** Test seam: true when no defer is armed (one-shot is fired unless VBT auto-end). */ + fun armEligible(): Boolean = deferDeadlineMs == 0L && !autoEndOnVelocityLoss } class VerbalEncouragementAutoStopDeferTest { @Test - fun `autoEnd off - first velocity-threshold rep defers auto-stop`() { + fun `autoEnd off - first velocity-threshold rep arms the defer with a future deadline`() { val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) - assertTrue(tracker.alertEmitted, "Velocity threshold alert is one-shot per set") - assertTrue(tracker.deferAutoStop, "Defer must be active while verbal cue is in flight") + assertEquals(30_000L, tracker.deferDeadlineSnapshot()) + assertTrue(tracker.isDeferActive(nowMs = 0L)) + assertTrue(tracker.isDeferActive(nowMs = 29_999L)) } @Test - fun `autoEnd on - first velocity-threshold rep does NOT defer auto-stop`() { + fun `autoEnd on - first velocity-threshold rep does NOT arm the defer`() { // With VBT auto-end ON, the existing two-consecutive-rep behavior decides set end; // the defer guard would only suppress that and must NOT be armed. val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = true) tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) - assertTrue(tracker.alertEmitted) - assertFalse( - tracker.deferAutoStop, - "Defer must NOT arm when VBT auto-end is on — that branch handles set end itself", - ) + assertEquals(0L, tracker.deferDeadlineSnapshot()) + assertFalse(tracker.isDeferActive(nowMs = 0L)) } @Test - fun `autoEnd off - next completed working rep clears the defer`() { + fun `next completed working rep clears the defer regardless of deadline remaining`() { + // Verbal cue armed at t=5s, user completes a working rep at t=10s. + // The defer must clear immediately, even though the 30s window hasn't expired, + // so AMRAP / stall auto-stop can fire normally on subsequent metrics. val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) - tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) - assertTrue(tracker.deferAutoStop) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 5_000L) + assertTrue(tracker.isDeferActive(nowMs = 10_000L)) tracker.onCompletedWorkingRep() + assertEquals(0L, tracker.deferDeadlineSnapshot()) assertFalse( - tracker.deferAutoStop, + tracker.isDeferActive(nowMs = 10_000L), "After a completed working rep the defer must clear so AMRAP/stall can fire again", ) + assertFalse( + tracker.isDeferActive(nowMs = 29_999L), + "Re-arm must require another velocity-threshold-triggered cue, not automatic re-derivation", + ) } @Test fun `autoEnd off - non-threshold reps do not arm the defer`() { - // ShouldStopSet=false never reaches the alert branch, so the defer never sets. val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) tracker.onRepCompleted(shouldStopSet = false, nowMs = 0L) tracker.onRepCompleted(shouldStopSet = false, nowMs = 0L) - assertFalse(tracker.alertEmitted) - assertFalse(tracker.deferAutoStop) + assertEquals(0L, tracker.deferDeadlineSnapshot()) + assertFalse(tracker.isDeferActive(nowMs = 0L)) + assertTrue(tracker.armEligible(), "Cue hasn't fired yet, so arm remains eligible") } @Test - fun `autoEnd off - defer stays active within the cue window`() { - // Cue fires, user keeps exercising without a completed working rep, but the - // window hasn't elapsed — defer must stay armed so position/stall can't end the set. + fun `defer stays active within the cue window`() { val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) tracker.onRepCompleted(shouldStopSet = true, nowMs = 10_000L) assertTrue(tracker.isDeferActive(nowMs = 20_000L)) - assertTrue( - tracker.deferAutoStop, - "Within the cue window defer must remain active even without a completed rep", - ) } @Test - fun `autoEnd off - defer expires at the cue window deadline`() { + fun `defer expires at the cue window deadline`() { // User rackets handles mid-set after the cue and never completes a working rep. // Once the deadline passes, the defer must release so AMRAP/stall auto-stop can fire. val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) @@ -149,11 +137,11 @@ class VerbalEncouragementAutoStopDeferTest { tracker.isDeferActive(nowMs = 30_001L), "Past the deadline the defer must clear so auto-stop can fire normally", ) - assertFalse(tracker.deferAutoStop, "Expired defer must clear the flag") + assertEquals(0L, tracker.deferDeadlineSnapshot(), "Expiry must zero the deadline") } @Test - fun `autoEnd off - resetAutoStopState clears the defer for the next set`() { + fun `resetAutoStopState clears the defer for the next set`() { // Verbal cue armed the defer mid-set. Before the 30s deadline the user // skips / restarts. The next active set must not inherit the defer — // otherwise checkAutoStop() would suppress AMRAP/stall auto-stop there too. @@ -163,11 +151,8 @@ class VerbalEncouragementAutoStopDeferTest { tracker.onResetAutoStopState() - assertFalse(tracker.deferAutoStop) - assertEquals( - 0L, tracker.deferDeadlineSnapshot(), - "resetAutoStopState must zero the deadline so the source-of-truth predicate clears", - ) + assertEquals(0L, tracker.deferDeadlineSnapshot()) + assertTrue(tracker.armEligible(), "Next set must allow a fresh cue to arm again") assertFalse( tracker.isDeferActive(nowMs = 20_000L), "After resetAutoStopState() the new set must allow normal auto-stop behavior", @@ -175,12 +160,10 @@ class VerbalEncouragementAutoStopDeferTest { } @Test - fun `arm publishes deadline before flag is observable to readers`() { - // Mirrors the production publish order: deadline first, flag second. - // The tracker mirrors the production single-source-of-truth pattern - // (deadline != 0L is the gate); this test pins the deadline value at - // arming time so a reader that arrives the instant the cue fires sees - // a future deadline and never the stale 0L value. + fun `arm publishes future deadline that a metrics-thread reader sees immediately`() { + // Single-source-of-truth guarantee: the deadline field is the gate. + // Even a reader that observes the field the instant the cue fires sees + // a future deadline, never the stale 0L value. val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) tracker.onRepCompleted(shouldStopSet = true, nowMs = 5_000L) assertEquals(35_000L, tracker.deferDeadlineSnapshot()) From 0d0f4ad74f923ec743955129f4138d7fb4420659 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Fri, 10 Jul 2026 18:36:26 -0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(#649):=20narrow=20arm=20race=20window?= =?UTF-8?q?=20=E2=80=94=20drop=20stale-callback=20arms=20after=20set=20tra?= =?UTF-8?q?nsition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (22:28): a stale processBiomechanicsForRep() callback resumed from Dispatchers.Default after startWorkout() reset state for the next set could write a fresh 30s defer into the new active set's checkAutoStop predicate. Note the underlying race also affects the existing velocityThresholdAlertEmitted flag (same call site); a full fix needs processBiomechanicsForRep() to verify the analyzed rep belongs to the current set, which is out of scope for the #649 fix. Narrow the window for the defer specifically: re-read coordinator._workoutState.value inside the arm guard and skip the arm if the workout has already left Active (manual stop / set transition). If the arm happens to win the race, the next completed working rep will clear the defer within a rep or two; or it expires in 30s. Either way the worst case is a bounded grace window on the next set. Tests: ./gradlew :shared:testAndroidHostTest -Pskip.supabase.check=true -> 2,428 tests pass, 0 failures, 0 errors. --- .../presentation/manager/ActiveSessionEngine.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 c4051e259..643fb0952 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 @@ -1356,7 +1356,17 @@ class ActiveSessionEngine( // or a completed working rep clears it. The deadline field // alone is the source of truth — no derived flag to keep in // sync. - if (!coordinator.autoEndOnVelocityLoss) { + // + // Issue #649 (Codex P2, race-window): checkVelocityThreshold runs + // on Dispatchers.Default; a stale callback resumed after a set + // transition could publish a fresh deadline into the next set. + // Same race characteristic as velocityThresholdAlertEmitted above. + // Narrow the window for this fix by re-checking the active state + // before writing; if the workout has already left Active (manual + // stop / reset / next set started), drop the arm silently. + if (!coordinator.autoEndOnVelocityLoss && + coordinator._workoutState.value is WorkoutState.Active + ) { deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS resetStallTimer() resetAutoStopTimer()