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 1bb1432c8..3d9911d1c 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 @@ -7752,6 +7752,11 @@ class ActiveSessionEngine( } else { variableWarmupTarget ?: seedParams.reps } + val usesUnlimitedRepTarget = requiresMachine && + !isBodyweightAtStart && + !isTimedCableAtStart && + variableWarmupTarget == null && + (isJustLiftMode || seedParams.isJustLift || seedParams.isAMRAP) val outgoingLease = executionGuard.currentLease val executionSeed = ExecutionSeed( sessionId = KmpUtils.randomUUID(), @@ -7762,6 +7767,7 @@ class ActiveSessionEngine( isJustLift = isJustLiftMode || seedParams.isJustLift, isAmrap = seedParams.isAMRAP, isTimedCable = isTimedCableAtStart, + usesUnlimitedRepTarget = usesUnlimitedRepTarget, ) beforeExecutionBeginForTest?.invoke() val leaseResult = when { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt index ef9e9dd3f..8809b3e42 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt @@ -2,6 +2,8 @@ package com.devil.phoenixproject.presentation.manager import com.devil.phoenixproject.data.repository.RepNotification +private const val UNLIMITED_REPS_SET_TOTAL = 252 + internal sealed interface RepFreshnessState { data object AwaitingEvidence : RepFreshnessState data class LegacyBaseline(val topCounter: Int, val completeCounter: Int) : RepFreshnessState @@ -56,13 +58,16 @@ internal class RepNotificationFreshnessGate { val identity = lease.identity() if (notification.isLegacyFormat) return evaluateLegacy(identity, notification) - // Issue #698/#700: Just Lift and AMRAP with target=0 use unlimited - // target semantics (0xFF/252), so the device-reported repsSetTotal - // will never match the finite UI lease target. Exempt both from - // target equality check. AMRAP with a finite target (>0) must still - // match — only unlimited AMRAP gets the exemption. + // Issue #698/#700/#712: Just Lift and AMRAP use unlimited target + // semantics on the wire (0xFF, reported back as 252). Routine AMRAP + // leases retain their configured finite UI fallback target (for + // example 10), so lease.workingRepTarget == 0 is not a reliable + // unlimited discriminator. The notification sentinel is. + val isUnlimitedAmrapPacket = lease.isAmrap && + lease.usesUnlimitedRepTarget && + notification.repsSetTotal == UNLIMITED_REPS_SET_TOTAL val targetMatches = lease.isJustLift || - (lease.isAmrap && lease.workingRepTarget == 0) || + isUnlimitedAmrapPacket || notification.repsSetTotal == 0 || notification.repsSetTotal == lease.workingRepTarget if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuard.kt index 8d397543c..e3ee358d7 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuard.kt @@ -55,6 +55,7 @@ internal data class ExecutionSeed( val isJustLift: Boolean = false, val isAmrap: Boolean = false, val isTimedCable: Boolean = false, + val usesUnlimitedRepTarget: Boolean = false, ) internal data class ExecutionLease( @@ -67,6 +68,7 @@ internal data class ExecutionLease( val isJustLift: Boolean, val isAmrap: Boolean, val isTimedCable: Boolean, + val usesUnlimitedRepTarget: Boolean = false, val activationCutoverTimestampMs: Long? = null, ) @@ -405,6 +407,7 @@ internal class WorkoutExecutionGuard( isJustLift = seed.isJustLift, isAmrap = seed.isAmrap, isTimedCable = seed.isTimedCable, + usesUnlimitedRepTarget = seed.usesUnlimitedRepTarget, ) currentLeaseRef.value?.let { outgoingLease -> if (sameIdentity(completionClaim?.lease, outgoingLease)) { diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolverTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolverTest.kt index be34d5d1d..f88f6b7ea 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolverTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolverTest.kt @@ -14,7 +14,7 @@ class RoutineSetWeightResolverTest { ) @Test - fun `resolves programmed, scaled, and manually adjusted routine set weights`() { + fun `resolves programmed scaled and manually adjusted routine set weights`() { data class Case( val name: String, val routineExercise: RoutineExercise, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt index 28f4910a9..7a0ab5e3b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt @@ -202,7 +202,12 @@ class RepNotificationFreshnessGateTest { @Test fun `amrap lease accepts repsSetTotal 252 despite finite UI target`() { val gate = RepNotificationFreshnessGate() - val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = true) + // Routine AMRAP retains its configured UI fallback target on the lease + // even though the machine command uses the unlimited 0xFF sentinel. + val lease = activeLease(target = 10, cutover = 1_000L).copy( + isAmrap = true, + usesUnlimitedRepTarget = true, + ) // First packet establishes baseline and arms assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) @@ -218,7 +223,10 @@ class RepNotificationFreshnessGateTest { @Test fun `amrap lease does not treat repsSetCount as terminal`() { val gate = RepNotificationFreshnessGate() - val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = true) + val lease = activeLease(target = 0, cutover = 1_000L).copy( + isAmrap = true, + usesUnlimitedRepTarget = true, + ) // AMRAP should never treat repsSetCount as terminal, same as Just Lift assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) @@ -228,6 +236,20 @@ class RepNotificationFreshnessGateTest { ) } + @Test + fun `amrap variable warmup execution rejects delayed unlimited packet`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 3, cutover = 1_000L).copy( + isAmrap = true, + usesUnlimitedRepTarget = false, + ) + + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_001L)), + ) + } + @Test fun `finite amrap lease still rejects mismatched repsSetTotal`() { val gate = RepNotificationFreshnessGate() diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RestoredRuntimeTimerRaceTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RestoredRuntimeTimerRaceTest.kt index c642f0ec8..12d59a857 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RestoredRuntimeTimerRaceTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RestoredRuntimeTimerRaceTest.kt @@ -101,7 +101,7 @@ class RestoredRuntimeTimerRaceTest { val staleReset = async(Dispatchers.Default) { harness.dwsm.resetForNewWorkout() } - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { withTimeout(2_000L) { staleDetachEntered.await() } } @@ -118,7 +118,7 @@ class RestoredRuntimeTimerRaceTest { assertTrue(newerJob.isActive) releaseStaleDetach.complete(Unit) - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { withTimeout(2_000L) { staleReset.await() } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuardRestoredRuntimeTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuardRestoredRuntimeTest.kt index d73b7820f..d6813e2a0 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuardRestoredRuntimeTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExecutionGuardRestoredRuntimeTest.kt @@ -256,7 +256,7 @@ class WorkoutExecutionGuardRestoredRuntimeTest { var ownerWasCurrentBeforeCallbackReturned = false var publications = 0 - val publication = async(Dispatchers.IO) { + val publication = async(Dispatchers.Default) { guard.commitRestoredTimerPublication( owner = owner, candidateStillCurrent = { true }, @@ -270,7 +270,7 @@ class WorkoutExecutionGuardRestoredRuntimeTest { } withTimeout(2_000) { callbackEntered.await() } - val supersession = async(Dispatchers.IO) { + val supersession = async(Dispatchers.Default) { supersessionStarted.complete(Unit) guard.supersedeRecoveryPublication() supersessionFinished.complete(Unit)