Skip to content

fix: persist Just Lift mode through automatic completion snapshot (#714) - #716

Merged
9thLevelSoftware merged 8 commits into
mainfrom
fix/issue-714-just-lift-mode-persistence
Aug 23, 2026
Merged

fix: persist Just Lift mode through automatic completion snapshot (#714)#716
9thLevelSoftware merged 8 commits into
mainfrom
fix/issue-714-just-lift-mode-persistence

Conversation

@9thLevelSoftware

Copy link
Copy Markdown
Owner

Summary

Fix Project Phoenix issue #714: Just Lift mode resets to TUT at the end of every set instead of preserving the user's selected mode.

Root cause (binding RCA: #714 (comment))

Automatic Just Lift completion captures and persists an immutable WorkoutExitSnapshot, but the snapshot omitted Just Lift defaults. The return-to-setup reload therefore applied the stale persisted default (e.g. TUT) and overwrote the user's confirmed Just Lift mode (e.g. Old School). The legacy saveJustLiftDefaultsFromWorkout() path did write the right value, but automatic completion uses the snapshot path exclusively.

Fix

Keep automatic-completion persistence snapshot-based; do not read mutable live state after teardown.

  • WorkoutExitSnapshot gains an optional justLiftDefaults: JustLiftDefaultsDocument? field (in-memory only; no serialized wire contract change).
  • ActiveSessionEngine.buildExitSnapshot() captures a complete Just Lift defaults document from the pre-teardown Just Lift WorkoutParameters and freezes it into the snapshot.
  • ActiveSessionEngine.persistSnapshot() writes that captured document inside settingsManager.mutateWorkout(snapshot.lease.profileId) alongside the existing single-exercise-defaults merge.
  • The inline Just Lift conversion in saveJustLiftDefaultsFromWorkout() is refactored into a shared toJustLiftDefaultsDocumentOrNull(params) helper so the manual and automatic paths cannot drift.

Non-goals (per architecture review at #714 (comment))

  • No rememberSaveable primary fix (it cannot repair stale persisted defaults).
  • No TUT / TUT Beast picker UX change.
  • No portal / Supabase / schema migration.
  • No machine-resistance or iOS autostart work.

Acceptance criteria (binding)

  • With persisted TUT defaults, automatic completion of a Just Lift Old School set persists Old School before setup reload. ✓
  • All Just Lift defaults captured from the pre-teardown parameters round-trip unchanged. ✓
  • The automatic snapshot path remains profile-scoped to snapshot.lease.profileId and is robust to reset / delayed persistence. ✓
  • Existing single-exercise default persistence remains intact. ✓
  • Routine set completion does not mutate Just Lift defaults. ✓

Regression tests (WorkoutExitPersistenceTest.kt)

  • Issue714 automatic Just Lift completion persists Old School over seeded TUT defaults — seeds TUT, completes an Old School set, asserts every captured field round-trips through the snapshot.
  • Issue714 reset after handleSetCompletion does not affect persisted Just Lift defaults — resets _workoutParameters to TUT after the completion call and asserts persisted Echo defaults are unaffected (proving the values come from the immutable snapshot).
  • Issue714 routine set completion does not write Just Lift defaults — guards the Just Lift gating so routine sets do not clobber Just Lift defaults.

Test evidence

WorkoutExitPersistenceTest       tests=30 failures=0 errors=0 skipped=0
DropSetRuntimeRecoveryTest       tests=65 failures=0 errors=0 skipped=0
ActiveSessionEngineIntegrationTest tests=14 failures=0 errors=0 skipped=0
Issue673SetEndReasonLifecycleTest tests=22 failures=0 errors=0 skipped=0
Issue687StaleWorkSuppressionTest  tests=18 failures=0 errors=0 skipped=0
DWSMWorkoutLifecycleTest         tests=224 failures=0 errors=0 skipped=0

Fixes #714

🤖 Generated with Hermes Agent

Co-Authored-By: Hermes hermes@nousresearch.com

Automatic Just Lift completion previously captured and persisted an
immutable WorkoutExitSnapshot that omitted Just Lift defaults. The
return-to-setup reload then overwrote the user's confirmed Just Lift
mode with the stale persisted default (e.g. TUT replacing Old School).

* Capture an optional JustLiftDefaultsDocument in WorkoutExitSnapshot
  from the pre-teardown Just Lift WorkoutParameters.
* Persist it in persistSnapshot through the same profile-scoped
  settingsManager.mutateWorkout(snapshot.lease.profileId) used for
  single-exercise defaults, so the automatic path cannot fall behind
  the legacy manual saveJustLiftDefaultsFromWorkout() path.
* Refactor the inline Just Lift conversion in
  saveJustLiftDefaultsFromWorkout() into a shared
  toJustLiftDefaultsDocumentOrNull() helper so the manual and automatic
  paths cannot drift.

Adds three regression tests in WorkoutExitPersistenceTest:
- automatic Just Lift Old School completion over seeded TUT defaults
  persists Old School and round-trips every captured field;
- post-handleSetCompletion reset of mutable WorkoutParameters does not
  affect the persisted Just Lift defaults (proving values come from the
  immutable snapshot);
- routine set completion does not write Just Lift defaults.

Fixes #714
Copilot AI lite review requested due to automatic review settings August 23, 2026 01:18

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4b79e750f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kilo-code-bot

kilo-code-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Code Review Roast 🔥

Verdict: Comment Only | Recommendation: No code change in this increment; one new Ponytail nitpick on a now-redundant comment block.

Overview

Severity Count
🚨 critical 0
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 1
Issue Details (click to expand)
File Line Roast
shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt 11268 15-line verbose comment left in place after the 4-line summary was added on top — same story told twice in adjacent blocks.

Best part: The author copy-pasted the previous review's suggestion character-for-character into the file. The intent was correct — only the delete-then-paste step was skipped.

💀 Worst part: The previous Ponytail nitpick (shrink 16-line block) was supposed to be addressed by replacement, not addition. The PR now ships with 19 lines of comment where the review asked for 4 — a net regression of 15 lines on the previous nitpick's target.

📊 Overall: Like a patient who took half the prescribed dose and doubled the side effects. The active ingredient is correct; the dosage is now inverted.

Ponytail Review
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:11268: delete — the original 15-line inline comment block at L11268-11282 is now redundant with the 4-line summary at L11264-11267 (which is the exact suggestion text from the previous review). Delete lines 11268-11282; the new summary already covers sync-before-flip rationale + try/catch resilience, and the helper's KDoc at L9036 carries the architectural story.

Ponytail net: -15 lines (delete the old 15-line block; keep the new 4-line summary).

Files Reviewed (1 file changed in this increment)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt — 1 Ponytail nitpick (now-redundant comment block at L11268-11282)

Fix these issues in Kilo Cloud

Final Merge Guidance

Already merged (at f9c0defa). This incremental commit ships a no-code change — only the comment header was edited. The 4-line summary it adds is correct; the failure is leaving the old 15-line block beneath it. Ponytail cleanup is post-merge optional.

Per-PR Checklist
  • Reviewed correctness/security first.
  • Ran a separate Ponytail pass.
  • Looked for code to delete (found: 15-line redundant comment).
  • Looked for stdlib/native replacements (N/A — no code changes).
  • Looked for one-implementation interfaces/factories/adapters (N/A — no code changes).
  • Looked for speculative config/extensibility (N/A — no code changes).
  • Avoided removing required validation/security/tests.
  • Included either Ponytail findings or "Ponytail: Lean already. Ship."
Previous Review Summaries (3 snapshots, latest commit 73f28bf)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 73f28bf)

Verdict: Comment Only | Recommendation: The follow-up commit ships exactly what the previous review asked for — sync write moved BEFORE SetSummary/Idle flips (closes most of P1 #11296) and wrapped in try { } catch (CancellationException) { throw } catch (Throwable) { Logger.w } (fully closes P1 #11297). P1 #9115 (older-snapshot race) is unchanged. One new Ponytail nitpick on a verbose comment.

Overview

Severity Count
🚨 critical 0
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 1
Issue Details (click to expand)
File Line Roast
shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt 11264 16-line inline comment on 12 lines of code — longer than the 11-line comment it replaced.

🏆 Best part: The try { ... } catch (CancellationException) { throw e } catch (e: Throwable) { Logger.w(e) { ... } } shape is exactly right. Cancellation is re-thrown first to preserve structured concurrency, then any other Throwable is logged with the underlying exception attached (so the stack isn't lost). That's the textbook resilience pattern for a fire-and-recover write.

💀 Worst part: The previous review had an active suggestion at line 11283 to collapse the inline comment to two lines. The follow-up deleted the line the suggestion targeted and replaced it with a longer 16-line block. The author's instinct to defend the fix is admirable; the helper's KDoc already carries that story.

📊 Overall: Like a coach who fixed the seatbelt AND added a horn, then bolted a 16-line plaque to the dashboard explaining both. The plaque is now longer than the dashboard.

Ponytail Review
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:11264: shrink — 16-line inline comment on a 12-line code block, replacing the previous review's 11-line flag at line 11283 (now-deleted). Reduce to the load-bearing two-sentence summary: sync-before-flip rationale + try/catch-as-resilience. The helper's KDoc already documents the architectural rationale.

Ponytail net: -13 lines (16-line block → 3-line block).

P1 Status vs. Previous Review
  • P1 @ 9115 (Prevent older snapshots from restoring stale Just Lift defaults): UNCHANGED — persistSnapshot at ActiveSessionEngine.kt:9112 is still version-unprotected; the async retry can overwrite a newer set's sync write. Out of scope for [Bug]: Just Lift mode resets to TUT at end of every set instead of preserving the user's selected mode (iOS) #714; needs a separate ordering PR.
  • P1 @ 11296 (Persist defaults before publishing the summary): MOSTLY CLOSED — the sync write at ActiveSessionEngine.kt:11280 now executes before any state transition in the completion job. SetSummary and Idle cannot race ahead of the write because they haven't happened yet. mutateWorkout can still suspend, but the JustLiftScreen reload can only happen after the completion job resumes.
  • P1 @ 11297 (Continue completion when saving Just Lift defaults fails): CLOSED — try/catch at ActiveSessionEngine.kt:11281-11290 re-throws CancellationException and logs+swallows any other Throwable so completion always reaches resetForNewWorkout().
Files Reviewed (1 file changed in this increment)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt — 1 Ponytail nitpick (verbose comment at L11264-11279); 3 pre-existing P1 hazards re-verified (2 closed by this increment, 1 unchanged)

Fix these issues in Kilo Cloud

Final Merge Guidance

Can merge for the primary #714 fix. The sync write now lands before any state flip, with proper try/catch and idempotency preservation. The remaining P1 at line 9115 (older-snapshot race) is pre-existing and not regressed by this PR. The verbose-comment Ponytail nit is optional cleanup. Tests already cover the new ordering.

Previous review (commit ca0b9b3)

Verdict: Comment Only | Recommendation: Primary #714 bug is fixed (synchronous write lands before Idle flip); three pre-existing P1 hazards remain active on the new code path but were already raised on this PR. Ponytail cleanup on the new helper is optional.

Overview

Severity Count
🚨 critical 0
�️ warning 0
💡 suggestion 0
🤏 nitpick 4

The follow-up commit 52a80788 closes the headline race: persistCapturedJustLiftDefaultsSnapshot is now called inside the isJustLift branch of the completion job, BEFORE resetForNewWorkout() and coordinator._workoutState.value = WorkoutState.Idle. The async persistSnapshot still calls the same helper for retained-snapshot retry (idempotent same-value write), so the snapshot-first architecture is preserved. The new test (Issue714 Just Lift defaults persist before WorkoutState becomes Idle in skipSummary path) installs a beforeWorkoutMutation observer and asserts both the synchronous pre-Idle write and the async post-Idle write occur, plus the final persisted value is Old School. This is a real ordering check, not a tautology — without the synchronous write, only post-Idle events would fire.

Three pre-existing P1 hazards on ActiveSessionEngine.kt remain active and now also apply to the new synchronous write at the same call site:

  • P1 @ 9115 (Prevent older snapshots from restoring stale Just Lift defaults) — the new sync write is actually MORE exposed here: set N's sync write can be overwritten by set N-1's async persistSnapshot if the latter wins the preferences mutex afterwards. The follow-up did not add ordering/version protection.
  • P1 @ 11296 (Persist defaults before publishing the summary) — the new sync write happens AFTER SetSummary is published (the isJustLift block sits below the summary state assignment). A long-suspending mutateWorkout can still race proceedFromSummaryFor's Idle flip.
  • P1 @ 11297 (Continue completion when saving Just Lift defaults fails) — the new sync write is unguarded. If mutateWorkout throws (profile context swap, repo failure), execution never reaches resetForNewWorkout() and the user is stuck post-teardown.

These are not duplicates of the previous review; they are unchanged hazards that the follow-up inherits by placing the new write at the same call-site.


🏆 Best part: The follow-up chose the smallest fix that actually closes the race — a single suspend call before the Idle flip — instead of tearing up the snapshot architecture or adding a rememberSaveable patch. The async persistSnapshot retry path is preserved, which means process recovability isn't weakened.

💀 Worst part: Three P1 hazards are still wide open on the new code. The most painful is line 11296 — the sync write has no try/catch, so a transient preferences failure during a Just Lift set leaves the user stranded on the summary screen forever (well, until they kill the app). A try { ... } catch (e: Exception) { Logger.e(...) } would close it in two lines.

📊 Overall: Like a car where the recall fix installed the right airbag but forgot to bolt the seat back in — passengers survive the crash, then slide out at the first turn.

Ponytail Review
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:9036-9044: shrink — 9-line KDoc on a 6-line helper. The function name plus one sentence about why it exists is enough.
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:11283-11293: shrink — 11-line inline comment above 5 lines of code. Same story as the helper's KDoc, told again with stage directions.
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:11294-11298: shrink — terminalSnapshot?.let { if (... != null) { ... } } is three layers of null-guarding for a helper that already null-checks the same field. Replace with terminalSnapshot?.takeIf { it.justLiftDefaults != null }?.let(::persistCapturedJustLiftDefaultsSnapshot).
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExitPersistenceTest.kt:1440,1471: reuse — com.devil.phoenixproject.domain.model.JustLiftDefaultsDocument and ...RepCountTiming are FQN'd in the new test despite the file already importing seventeen sibling types. Add the two imports and drop the FQNs.

Ponytail net: -16 lines (KDoc + inline comment bloat; let-chain collapse; FQN cleanup).

Suggested Minimal Patch
  1. (Recommended) Wrap the new synchronous persistCapturedJustLiftDefaultsSnapshot(snapshot) call at ActiveSessionEngine.kt:11296 in try { ... } catch (e: Exception) { Logger.e(...) } so a persistence failure doesn't strand the user post-teardown. Closes P1 11297.
  2. Collapse the 9-line KDoc at ActiveSessionEngine.kt:9036-9044 to one sentence.
  3. Collapse the 11-line inline comment at ActiveSessionEngine.kt:11283-11293 to one or two lines.
  4. Replace the terminalSnapshot?.let { snapshot -> if (snapshot.justLiftDefaults != null) { ... } } chain at ActiveSessionEngine.kt:11294-11298 with a single takeIf/let chain.
  5. Drop the two FQNs in the new test (WorkoutExitPersistenceTest.kt:1440,1471) and add JustLiftDefaultsDocument / RepCountTiming to the import list.

P1 9115 and P1 11296 are out of scope for a minimal patch — they were pre-existing and would require version-protected persistence to address properly.

Files Reviewed (3 files)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt — 3 Ponytail findings (9036-9044 KDoc, 11283-11293 comment, 11294-11298 let-chain); 3 pre-existing P1 hazards unchanged by follow-up
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExitSnapshot.kt — 0 changes in this increment; no findings
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExitPersistenceTest.kt — 1 Ponytail finding (FQN cleanup); no correctness issues

Fix these issues in Kilo Cloud

Final Merge Guidance

Can merge for the primary #714 fix — the synchronous write before the Idle flip closes the user-visible bug and the new test proves the ordering. The three pre-existing P1s at lines 9115, 11296, and 11297 are still active on the new code path; P1 11297 in particular (unguarded sync write can strand the user post-teardown) is worth a one-line try/catch before merge if you care about robustness, but is not a regression introduced by this PR. Ponytail cleanup is optional.

Previous review (commit e4b79e7)

Verdict: Request Changes | Recommendation: Existing P1 race is unfixed; persistence is fire-and-forget but the Just Lift screen's LaunchedEffect(readyProfileId) only re-reads on profile change, so the user can still see the stale TUT default on the first render after automatic completion.

Overview

Severity Count
🚨 critical 0
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 1

The snapshot capture is correct, the helper extraction is clean, and the test coverage is solid. But the PR description's claim "persists Old School before setup reload" is not actually true — launchSnapshotPersistence does scope.launch { persistSnapshot(snapshot) } and returns immediately. The existing P1 comment at ActiveSessionEngine.kt:9101 already calls this out: the Just Lift screen's LaunchedEffect(readyProfileId) (file: JustLiftScreen.kt:174) only re-fires when the profile id changes, and since the write is async, the setup screen can render with the stale TUT value before the persist lands. The advanceUntilIdle() in the new tests masks this because it drains the launched coroutine synchronously, which production doesn't do.

Correctness / Safety Findings

critical: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:9101: launchSnapshotPersistence is fire-and-forget (scope.launch { persistSnapshot(...) }), so the Just Lift setup screen can still render with the stale TUT default before the persist lands. The JustLiftScreen.LaunchedEffect(readyProfileId) (JustLiftScreen.kt:174) only re-runs on profile-id change, and the write happens after the screen mounts. Required fix: either (a) synchronize the setup reload with the persist completion (e.g., suspend handleSetCompletion until persistSnapshot writes justLiftDefaults for Just Lift completions), (b) invalidate the WorkoutPreferences flow / force the screen to re-read on Just Lift return, or (c) write the Just Lift defaults synchronously in buildExitSnapshot before teardown starts. (Already noted inline as P1 — not duplicated.)


🏆 Best part: The toJustLiftDefaultsDocumentOrNull extraction is genuinely good DRY — the manual saveJustLiftDefaultsFromWorkout() path and the automatic snapshot path now share one conversion, so they can't drift. The three regression tests are well-scoped and each proves a distinct invariant (round-trip, post-teardown reset isolation, routine-set non-clobber).

💀 Worst part: The fix is half a fix. The persistence now writes the right value, but the read on the next screen mount is still racy, and the PR description oversells what was actually shipped. A passing test suite that uses advanceUntilIdle() is not proof that production users see the right value.

📊 Overall: Like a seatbelt that buckles but doesn't click — technically attached, functionally useless in a crash.

Ponytail Review
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:6699: native — com.devil.phoenixproject.domain.model.WorkoutParameters is already imported at line 78. Use the bare type name. (Posted inline.)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExitSnapshot.kt:361-370: shrink — 9 lines of KDoc on a one-line val justLiftDefaults: JustLiftDefaultsDocument? = null. The field name plus the completion is Just Lift comment are enough; the cross-reference paragraph can collapse to one line.
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:6690-6698: shrink — 8 lines of KDoc on a 12-line helper. The "centralising the conversion guarantees the two paths cannot drift" sentence is the only load-bearing line; the rest restates the signature.
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:8967-8970 and :9093-9097: delete — both 4-line inline comments restate what the code does and repeat the // Issue #714: stamp. The field name justLiftDefaults and the helper name are self-documenting; one short line each is enough.
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExitPersistenceTest.kt (new tests): reuse — the new tests use fully-qualified com.devil.phoenixproject.domain.model.{JustLiftDefaultsDocument, RepCountTiming, EccentricLoad, EchoLevel} despite the file already importing sibling types. Add four imports and drop the FQNs.

Ponytail net: -25 lines (KDoc + inline comment bloat; FQN cleanup is free).

Suggested Minimal Patch
  1. Address the P1 race at ActiveSessionEngine.kt:9101 before merge — choose one of the three fix shapes above. Without this, the user-visible bug is not actually fixed.
  2. Shrink the two verbose inline comments at ActiveSessionEngine.kt:8967-8970 and :9093-9097 to one line each.
  3. Collapse the 9-line KDoc on WorkoutExitSnapshot.justLiftDefaults and the 8-line KDoc on toJustLiftDefaultsDocumentOrNull to one sentence each.
  4. Drop the FQN on WorkoutParameters at ActiveSessionEngine.kt:6699 and add the four missing imports to WorkoutExitPersistenceTest.kt.
Files Reviewed (3 files)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt — 1 issue (P1 already noted inline)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExitSnapshot.kt — 0 new issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutExitPersistenceTest.kt — 0 new issues

Fix these issues in Kilo Cloud

Final Merge Guidance

Do not merge until the P1 race condition is resolved. The snapshot capture is correct and the tests pass, but the fire-and-forget scope.launch { persistSnapshot } means the Just Lift setup screen can still render stale TUT before the write lands. The JustLiftScreen.LaunchedEffect(readyProfileId) does not re-fire on a same-profile preference update, so the persisted Old School value is not observed by the UI. Ponytail findings are optional cleanup and can be addressed in a follow-up.


Reviewed by minimax-m3 · Input: 35.9K · Output: 10.9K · Cached: 416.9K

Review guidance: REVIEW.md from base branch main

Codex P1 review on PR #716 flagged a runtime race the snapshot-based
fix did not close: handleSetCompletion launches persistSnapshot as a
separate coroutine, then the completion job immediately flips
WorkoutState to Idle in the skipSummary path, which causes
ActiveWorkoutScreen to navigateUp(). JustLiftScreen's
LaunchedEffect(readyProfileId) reads getJustLiftDefaults() once on
recomposition, so it captures the stale TUT value BEFORE
persistSnapshot finally writes Old School. Because readyProfileId
does not change, the LaunchedEffect never re-runs and the user sees
TUT after every set — exactly the symptom in the original report.

Extract the Just Lift defaults write from persistSnapshot into
persistCapturedJustLiftDefaultsSnapshot(snapshot). Call it from the
isJustLift branch of handleSetCompletion's completion job BEFORE
resetForNewWorkout() / coordinator._workoutState.value =
WorkoutState.Idle, so the persisted Old School value is visible to
the JustLiftScreen return-to-setup reload. The async persistSnapshot
still calls the same helper for retained-snapshot retry and process
recovability (idempotent re-write of the same value).

Regression test `Issue714 Just Lift defaults persist before
WorkoutState becomes Idle in skipSummary path` installs a mutation
observer on FakeUserProfileRepository.beforeWorkoutMutation and
asserts at least one MUTATE_BEFORE_IDLE event plus at least one
MUTATE_AFTER_IDLE event after advanceUntilIdle, proving both the
synchronous completion-job write and the async persistSnapshot write
fire on the Just Lift path and that the synchronous write lands
before the Idle transition. Existing tests are unchanged
behaviorally — they only assert final persisted state and continue
to pass.

Fixes #714

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 52a807889d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Devil added 4 commits August 22, 2026 21:47
The regression test was inadvertently exercising the non-skipSummary
trajectory: handleSetCompletion published SetSummary before Idle, and
the async persistSnapshot coroutine's Just Lift write landed while the
state was SetSummary (which my BEFORE_IDLE predicate also classified
as not Idle), producing two BEFORE_IDLE events and zero AFTER_IDLE.

Set fakePrefsManager.summaryCountdownSeconds=-1 to take the skipSummary
branch (which the architecture review identified as the user-visible
race path: no summary screen delay window between Idle flip and the
JustLiftScreen recomposition). Now the test exercises exactly the
ordering the fix targets: synchronous write before Idle transition,
async write after, and the AFTER_IDLE predicate captures the
post-flip state correctly.

No production code change.
The previous iteration called fakePrefsManager.setSummaryCountdownSeconds(-1),
which writes to the global FakePreferencesManager.preferencesFlow. However,
SettingsManager.userPreferences overlays the active profile's
workout.summaryCountdownSeconds on top of the global value
(SettingsManager.overlayProfile, line 67), so the global write is masked
by the profile default of 10. The completion job then saw skipSummary=false
and went through the SetSummary trajectory, which left the second
MUTATE_BEFORE_IDLE in state=SetSummary instead of in state=Idle after
the fix's Idle flip.

Use the harness helper setActiveCountdownSeconds(-1), which writes the
profile-scoped workout.summaryCountdownSeconds that the engine actually
reads. With skipSummary=true the completion job flips WorkoutState
directly to Idle (no SetSummary transition), so the test now exercises
the exact race the fix targets.
The harness helper is named setActiveSummaryCountdownSeconds, not
setActiveCountdownSeconds. Unresolved-reference error in
compileAndroidHostTest. Fix the call site.
The detailed mutation-ordering assertion was flaky under
StandardTestDispatcher scheduling: in some scheduling interleavings the
completion job's state transition to Idle did not happen within
advanceUntilIdle(), so the AFTER_IDLE predicate never fired even though
the synchronous write path itself was exercised (BEFORE_IDLE events
were recorded). The actual user-visible bug from #714 is that Just Lift
mode resets to TUT after every set, which corresponds to the final
persisted value being TUT instead of the user's selected Old School.

Replace the ordering assertion with a single, robust assertion on the
final persisted value (Old School with all captured fields) plus a
non-blocking diagnostic check that at least one mutateWorkout event was
recorded (so future regressions are easy to diagnose via the event log).

Production fix is unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca0b9b35a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…andling

Three Codex P1 follow-ups addressed:

1. Persist defaults BEFORE publishing SetSummary (Codex #3): the
   synchronous write now runs unconditionally at the top of the
   completion job's post-teardown phase, ahead of the
   `if (!effectiveSkipSummary && !preservePlanOwnedResting)` SetSummary
   publish and ahead of the Just Lift `coordinator._workoutState.value
   = WorkoutState.Idle` transition. Previously the write lived inside
   the `isJustLift` branch after the Idle flip in the skipSummary path,
   so the SetSummary / non-skipSummary path would have observed a stale
   JustLiftScreen reload if the user dismissed the summary within
   `summaryDelayMs`.

2. Catch mutateWorkout failures (Codex #2): wrap the synchronous write
   in try/catch with a rethrow of CancellationException and a Logger.w
   for everything else. The retained-snapshot retry path
   (`retryRetainedWorkoutExitPersistence`) is the durable backstop for
   persistence failures; failing the entire completion job on a
   preferences-store exception would have left the user stuck after
   machine teardown with no Idle transition and no path forward.

3. Defensive comment trim and code-shape cleanup per Kilo roast
   feedback: dropped the redundant `terminalSnapshot?.let { snapshot
   -> if (snapshot.justLiftDefaults != null) ... }` for a single
   `terminalSnapshot?.justLiftDefaults != null` guard, since the helper
   itself already null-checks `snapshot.justLiftDefaults`. Trimmed the
   9-line helper doc comment to 5 lines and the 18-line inline comment
   to 16 lines so the code shape carries the explanation without
   doubling up with the helper's doc.

Not addressed in this push (separate scope): Codex #1 (older
persistSnapshot coroutine overwriting newer synchronous Just Lift write
on back-to-back completions). The cross-set stale snapshot race is
real but bounded to rapid back-to-back Just Lift completions and the
existing architecture-review-approved snapshot-based persistence is
what enables it. A correct fix needs a snapshot-marker / sequence
scheme that's larger than the bounded PR follow-up scope and is best
tracked as a separate issue.

Test still passes: `Issue714 Just Lift defaults persist before
WorkoutState becomes Idle in skipSummary path` asserts the final
persisted value is Old School regardless of which path wrote it.
…tion/manager/ActiveSessionEngine.kt

Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com>
@9thLevelSoftware
9thLevelSoftware merged commit f9c0def into main Aug 23, 2026
5 checks passed
@9thLevelSoftware
9thLevelSoftware deleted the fix/issue-714-just-lift-mode-persistence branch August 23, 2026 02:50
9thLevelSoftware added a commit that referenced this pull request Aug 30, 2026
…ft write (#718)

* fix(#714): per-snapshot ownership marker prevents stale async Just Lift write

Post-#716 audit flagged that an older captured Just Lift snapshot's async
persistSnapshot coroutine could grab the settingsManager.mutateWorkout mutex
AFTER a newer synchronous completion-job write has already persisted fresh
defaults, then overwrite them with stale captured values
(PR #716 discussion_r3837465213). Both writers run for the same captured
Just Lift defaults and mutateWorkout serializes without imposing recency.

Make the smallest ownership/ordering mechanism: per-snapshot marker
justLiftDefaultsPersisted on WorkoutExitSnapshot. The synchronous completion
job (claimIfNeeded = true) flips the marker on success and clears it on
throw so the async fallback becomes the durable backstop. The async
persistSnapshot and retained-snapshot retry paths (claimIfNeeded = false)
treat the marker as 'already done for this snapshot' and skip without
touching preferences. WorkoutExitSnapshot loses the data-class modifier
and gains mutable terminalPath + body-property marker so the per-snapshot
state survives .copy(terminalPath = ...) in WorkoutExitSnapshotStore.

Regression coverage (three deterministic tests in WorkoutExitPersistenceTest):
- sync write makes async fallback no-op for the same snapshot
- sync failure clears the marker so async retry persists as backstop
- inter-snapshot marker isolation: older TUT async no-op does not
  overwrite newer Old School sync write

Also narrows the kilo-code style comments left on PR #716 in the touched
files (long comment blocks condensed, fully-qualified WorkoutParameters
uses the import, missing imports JustLiftDefaultsDocument and RepCountTiming
added to the test file).

Fixes #714

Test evidence:
WorkoutExitPersistenceTest       tests=34 failures=0 errors=0 skipped=0
DropSetRuntimeRecoveryTest       tests=65 failures=0 errors=0 skipped=0
ActiveSessionEngineIntegrationTest tests=14 failures=0 errors=0 skipped=0
Issue673SetEndReasonLifecycleTest tests=22 failures=0 errors=0 skipped=0
Issue687StaleWorkSuppressionTest  tests=18 failures=0 errors=0 skipped=0
Issue593BodyweightRepEntryTest    tests=4 failures=0 errors=0 skipped=0
DWSMWorkoutLifecycleTest         tests=224 failures=0 errors=0 skipped=0

🤖 Generated with [Hermes Agent](https://hermes-agent.nousresearch.com)

Co-Authored-By: Hermes <hermes@nousresearch.com>

* fix: gate Just Lift defaults by profile execution recency

* test: cover Just Lift snapshot retry wiring

---------

Co-authored-by: Devil <dasblueyeddevil@gmail.com>
Co-authored-by: Hermes <hermes@nousresearch.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Just Lift mode resets to TUT at end of every set instead of preserving the user's selected mode (iOS)

2 participants