Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,13 @@ class ActiveSessionEngine(
val params = coordinator._workoutParameters.value
val currentState = coordinator._workoutState.value
val currentLease = executionGuard.currentLease
if (activityState == HandleState.Moving &&
// A confirmed Just Lift grab proves a fresh execution is in motion.
// HandleStateDetector can transition directly Released -> Grabbed when the
// first pull already exceeds the velocity threshold, so waiting only for
// Moving would leave the freshness gate unarmed without a zero packet.
val hasFreshJustLiftGrab = activityState == HandleState.Grabbed &&
currentLease?.isJustLift == true
if ((activityState == HandleState.Moving || hasFreshJustLiftGrab) &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: This only arms on a new Grabbed emission, but the normal Just Lift auto-start path can already be in Grabbed before the new lease is activated. The grab that starts the auto-start countdown is emitted before activation; startActiveWorkoutPolling() restarts polling with forAutoStart=false, and the polling engine does not reset the detector, so there is no second Grabbed event after repFreshnessGate.resetFor(). The first unlimited rep packet is then dropped with PROGRESS_BEFORE_EVIDENCE, and the rest of the set stays at zero reps — a very confident way to count absolutely nothing.

🩹 The Fix: After activating the Just Lift lease, inspect the current handle state and call repFreshnessGate.observeMovement(activeLease) when it is Grabbed (or carry the already-confirmed grab evidence across the auto-start boundary). Add a regression test where Grabbed is set before workout activation and no post-activation state transition occurs.

📏 Severity: warning


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 1fafc9d: immediately after activating a Just Lift lease, the engine now carries an already-confirmed Grabbed handle state into the freshness gate before polling restarts. Added a zero-less unlimited-warmup lifecycle regression where the confirmed grab predates lease activation.

currentLease?.activationCutoverTimestampMs != null &&
executionGuard.isCurrent(currentLease)
) {
Expand Down Expand Up @@ -3278,6 +3284,12 @@ class ActiveSessionEngine(
val activeLease = executionGuard.activate(lease, wallClockMillisProvider())
?: return@launch
repFreshnessGate.resetFor(activeLease)
// Auto-start can confirm Grabbed before this lease becomes active. The polling
// restart preserves that confirmed detector state, so carry it into the new
// Just Lift lease rather than requiring a second state emission or zero packet.
if (activeLease.isJustLift && bleRepository.handleState.value == HandleState.Grabbed) {
repFreshnessGate.observeMovement(activeLease)
}
bleRepository.startActiveWorkoutPolling()

if (!executionGuard.isCurrent(activeLease)) return@launch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal enum class RepDropReason {
LEASE_NOT_ACTIVE,
PRE_CUTOVER_TIMESTAMP,
TARGET_MISMATCH,
PROGRESS_BEFORE_EVIDENCE,
TERMINAL_BEFORE_EVIDENCE,
}

Expand Down Expand Up @@ -56,12 +57,25 @@ internal class RepNotificationFreshnessGate {
val identity = lease.identity()
if (notification.isLegacyFormat) return evaluateLegacy(identity, notification)

val targetMatches = notification.repsSetTotal == 0 ||
notification.repsSetTotal == lease.workingRepTarget
// Issue #698: Just Lift uses unlimited target semantics (0xFF/252),
// so the device-reported repsSetTotal will never match the finite UI
// lease target. Accept only the known unlimited representation or zero
// for Just Lift; reject stale packets from prior finite-target sets,
// including one whose target happens to equal the UI lease target.
val targetMatches = if (lease.isJustLift) {
notification.repsSetTotal == UNLIMITED_REPS_SET_TOTAL ||
notification.repsSetTotal == 0
Comment thread
9thLevelSoftware marked this conversation as resolved.
} else {
notification.repsSetTotal == 0 ||
notification.repsSetTotal == lease.workingRepTarget
}
if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH)
if (stateFor(lease) is RepFreshnessState.Armed) return RepFreshnessDecision.Process

val terminal = lease.workingRepTarget > 0 &&
// Issue #698: Just Lift has no finite rep target, so repsSetCount
// should never be treated as terminal. Exempt from terminal check.
val terminal = !lease.isJustLift &&
lease.workingRepTarget > 0 &&
notification.repsSetCount >= lease.workingRepTarget
val allZero = notification.topCounter == 0 &&
notification.completeCounter == 0 &&
Expand All @@ -78,6 +92,9 @@ internal class RepNotificationFreshnessGate {
states[identity] = RepFreshnessState.Armed
return RepFreshnessDecision.BaselineOnly
}
if (lease.isJustLift && hasNonTerminalProgress) {
return RepFreshnessDecision.Drop(RepDropReason.PROGRESS_BEFORE_EVIDENCE)
Comment on lines +95 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Arm the gate on confirmed handle movement

When a Just Lift set receives no all-zero rep packet and the user pulls continuously above the velocity threshold, HandleStateDetector transitions directly from Released to Grabbed, while ActiveSessionEngine calls observeMovement() only for HandleState.Moving (the low-velocity state). The lease therefore remains AwaitingEvidence, and this branch drops the first progress packet and every subsequent cumulative packet, leaving the entire set at zero reps. Treat the confirmed Grabbed transition as movement evidence, or otherwise ensure ordinary first-rep progress can arm the gate without requiring an optional zero packet.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 80b94a9: a confirmed Released → Grabbed transition now arms the Just Lift freshness gate, so a normal zero-less first-rep path is accepted. Added a lifecycle regression that starts from that direct transition and verifies unlimited (252) warmup progress reaches completion.

}
if (hasNonTerminalProgress) {
states[identity] = RepFreshnessState.Armed
return RepFreshnessDecision.Process
Expand Down Expand Up @@ -108,4 +125,9 @@ internal class RepNotificationFreshnessGate {
val executionId: Long,
val sessionId: String,
)

companion object {
/** repsSetTotal value the device sends for unlimited/Just Lift/AMRAP sets. */
const val UNLIMITED_REPS_SET_TOTAL = 252
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1696,6 +1696,84 @@ class DWSMWorkoutLifecycleTest {
harness.cleanup()
}

@Test
fun `Just Lift warmup accepts unlimited progress after a confirmed handle grab without zero baseline`() = runTest {
val harness = DWSMTestHarness(this)
harness.fakeBleRepo.simulateConnect("Vee_Test")
harness.dwsm.updateWorkoutParameters(
WorkoutParameters(
programMode = ProgramMode.OldSchool,
reps = 8,
warmupReps = 0,
weightPerCableKg = 20f,
progressionRegressionKg = 0f,
stallDetectionEnabled = true,
isAMRAP = false,
isJustLift = true,
),
)
harness.dwsm.startWorkout(skipCountdown = true)
advanceUntilIdle()
assertIs<WorkoutState.Active>(harness.dwsm.coordinator.workoutState.value)

// The detector may transition directly from Released to Grabbed when the
// user pulls above the velocity threshold, with no Moving state emitted.
harness.fakeBleRepo.setHandleState(HandleState.Released)
advanceUntilIdle()
harness.fakeBleRepo.setHandleState(HandleState.Grabbed)
advanceUntilIdle()

completeWarmupReps(
harness,
warmupTarget = 3,
workingTarget = 8,
repsSetTotal = 252,
)
advanceUntilIdle()

val afterWarmup = harness.dwsm.coordinator.repCount.value
assertTrue(afterWarmup.isWarmupComplete)
assertEquals(0, afterWarmup.workingReps)
harness.cleanup()
}

@Test
fun `Just Lift warmup accepts unlimited progress when the confirmed grab predates lease activation`() = runTest {
val harness = DWSMTestHarness(this)
harness.fakeBleRepo.simulateConnect("Vee_Test")
harness.fakeBleRepo.setHandleState(HandleState.Grabbed)
harness.dwsm.updateWorkoutParameters(
WorkoutParameters(
programMode = ProgramMode.OldSchool,
reps = 8,
warmupReps = 0,
weightPerCableKg = 20f,
progressionRegressionKg = 0f,
stallDetectionEnabled = true,
isAMRAP = false,
isJustLift = true,
),
)
harness.dwsm.startWorkout(skipCountdown = true)
advanceUntilIdle()
assertIs<WorkoutState.Active>(harness.dwsm.coordinator.workoutState.value)

// Auto-start can confirm Grabbed before the new execution lease is activated.
// startActiveWorkoutPolling preserves that state, so no second emission is required.
completeWarmupReps(
harness,
warmupTarget = 3,
workingTarget = 8,
repsSetTotal = 252,
)
advanceUntilIdle()

val afterWarmup = harness.dwsm.coordinator.repCount.value
assertTrue(afterWarmup.isWarmupComplete)
assertEquals(0, afterWarmup.workingReps)
harness.cleanup()
}

@Test
fun `Issue 267 Just Lift warmup to working rep transitions without failed stall state`() = runTest {
val harness = DWSMTestHarness(this)
Expand All @@ -1717,14 +1795,20 @@ class DWSMWorkoutLifecycleTest {
advanceUntilIdle()
assertIs<WorkoutState.Active>(harness.dwsm.coordinator.workoutState.value)

completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8)
completeWarmupReps(
harness,
warmupTarget = 3,
workingTarget = 8,
repsSetTotal = 252,
includeZeroBaseline = true,
)
advanceUntilIdle()

val afterWarmup = harness.dwsm.coordinator.repCount.value
assertTrue(afterWarmup.isWarmupComplete)
assertEquals(0, afterWarmup.workingReps)

completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8)
completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8, repsSetTotal = 252)
advanceUntilIdle()

val afterWorkingRep = harness.dwsm.coordinator.repCount.value
Expand Down Expand Up @@ -2236,7 +2320,13 @@ class DWSMWorkoutLifecycleTest {
harness.cleanup()
}

private suspend fun completeWarmupReps(harness: DWSMTestHarness, warmupTarget: Int = 3, workingTarget: Int = 8) {
private suspend fun completeWarmupReps(
harness: DWSMTestHarness,
warmupTarget: Int = 3,
workingTarget: Int = 8,
repsSetTotal: Int = workingTarget,
includeZeroBaseline: Boolean = false,
) {
val activeMetric = WorkoutMetric(
positionA = 120f,
positionB = 120f,
Expand All @@ -2246,6 +2336,23 @@ class DWSMWorkoutLifecycleTest {
loadB = 10f,
)

if (includeZeroBaseline) {
harness.fakeBleRepo.emitRepNotification(
RepNotification(
topCounter = 0,
completeCounter = 0,
repsRomCount = 0,
repsRomTotal = warmupTarget,
repsSetCount = 0,
repsSetTotal = repsSetTotal,
rangeTop = 800f,
rangeBottom = 0f,
rawData = ByteArray(24),
timestamp = harness.nowMs,
),
)
}

for (warmupRep in 1..warmupTarget) {
harness.fakeBleRepo.emitMetric(activeMetric)
harness.fakeBleRepo.emitRepNotification(
Expand All @@ -2255,7 +2362,7 @@ class DWSMWorkoutLifecycleTest {
repsRomCount = warmupRep,
repsRomTotal = warmupTarget,
repsSetCount = 0,
repsSetTotal = workingTarget,
repsSetTotal = repsSetTotal,
rangeTop = 800f,
rangeBottom = 0f,
rawData = ByteArray(24),
Expand Down Expand Up @@ -2746,7 +2853,12 @@ class DWSMWorkoutLifecycleTest {
harness.cleanup()
}

private suspend fun completeFirstWorkingRep(harness: DWSMTestHarness, warmupTarget: Int = 3, workingTarget: Int = 8) {
private suspend fun completeFirstWorkingRep(
harness: DWSMTestHarness,
warmupTarget: Int = 3,
workingTarget: Int = 8,
repsSetTotal: Int = workingTarget,
) {
val activeMetric = WorkoutMetric(
positionA = 120f,
positionB = 120f,
Expand All @@ -2764,7 +2876,7 @@ class DWSMWorkoutLifecycleTest {
repsRomCount = warmupTarget,
repsRomTotal = warmupTarget,
repsSetCount = 1,
repsSetTotal = workingTarget,
repsSetTotal = repsSetTotal,
rangeTop = 800f,
rangeBottom = 0f,
rawData = ByteArray(24),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,102 @@ class RepNotificationFreshnessGateTest {
)
}

// --- Issue #698: Just Lift target mismatch exemption ---

@Test
fun `just lift lease accepts repsSetTotal 252 despite finite UI target`() {
val gate = RepNotificationFreshnessGate()
val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true)

// First packet establishes baseline and arms
assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L)))
assertEquals(RepFreshnessState.Armed, gate.stateFor(lease))

// repsSetTotal=252 (unlimited) should NOT be dropped as TARGET_MISMATCH
assertEquals(
RepFreshnessDecision.Process,
gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_002L)),
)
}

@Test
fun `just lift unlimited progress waits for a zero baseline or observed movement`() {
val gate = RepNotificationFreshnessGate()
val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true)

// A delayed packet from a prior Just Lift execution has the same
// unlimited target, so it cannot establish freshness by itself.
assertEquals(
RepFreshnessDecision.Drop(RepDropReason.PROGRESS_BEFORE_EVIDENCE),
gate.evaluate(lease, modernPacket(repsSetCount = 5, repsSetTotal = 252, timestamp = 1_001L)),
)
assertEquals(RepFreshnessState.AwaitingEvidence, gate.stateFor(lease))

// An all-zero packet establishes the new-session baseline.
assertEquals(
RepFreshnessDecision.BaselineOnly,
gate.evaluate(lease, modernPacket(repsSetTotal = 252, timestamp = 1_002L)),
)
assertEquals(RepFreshnessState.Armed, gate.stateFor(lease))
assertEquals(
RepFreshnessDecision.Process,
gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_003L)),
)
}

@Test
fun `observed movement arms just lift unlimited progress`() {
val gate = RepNotificationFreshnessGate()
val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true)

assertTrue(gate.observeMovement(lease))
assertEquals(
RepFreshnessDecision.Process,
gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_001L)),
)
}

@Test
fun `just lift lease does not treat repsSetCount as terminal`() {
val gate = RepNotificationFreshnessGate()
val lease = activeLease(target = 3, cutover = 1_000L).copy(isJustLift = true)

// repsSetCount=3 >= workingRepTarget=3 would be terminal for finite,
// but Just Lift should process it normally after baseline
assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L)))
assertEquals(
RepFreshnessDecision.Process,
gate.evaluate(lease, modernPacket(repsSetCount = 3, repsSetTotal = 252, timestamp = 1_002L)),
)
}

@Test
fun `just lift rejects stale finite repsSetTotal matching the UI target`() {
val gate = RepNotificationFreshnessGate()
val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true)

// Baseline arms
assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L)))

// A stale packet from a prior finite-target set can happen to have the
// same target as the Just Lift UI lease. It must still be rejected.
assertEquals(
RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH),
gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 10, timestamp = 1_002L)),
)
}

@Test
fun `finite lease still rejects mismatched repsSetTotal after fix`() {
val gate = RepNotificationFreshnessGate()
val lease = activeLease(target = 3, cutover = 1_000L) // isJustLift = false

assertEquals(
RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH),
gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_001L)),
)
}

private fun activeLease(target: Int, cutover: Long) = ExecutionLease(
executionId = 1L,
sessionId = "session-a",
Expand Down
Loading