fix(#714): per-snapshot ownership marker prevents stale async Just Lift write - #718
Conversation
…ft 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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a79afceea
ℹ️ 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".
Code Review Roast 🔥Verdict: 1 Issue Found | Recommendation: Add back one integration test before merge Overview
Issue Details (click to expand)
VerdictComment only — the refactor is sound. The profile-wide execution gate correctly prevents older async writes from clobbering newer sync writes, and the new design is materially simpler than the per-snapshot marker approach. Correctness / Safety FindingsNo correctness or safety findings. Ponytail Review
Ponytail net: -5 lines (Boolean return removal) + ~200 lines (one ported integration test) = net +195. Suggested Minimal Patch
Final Merge GuidanceCan merge after one integration test is ported back. The guard-level tests are necessary but not sufficient — the wiring between the engine and the guard is the integration risk the PR was supposed to close. 🏆 Best part: The profile-wide execution gate is genuinely simpler than the per-snapshot marker. One mutex, one map, one comparison. The previous design had a marker that had to survive 💀 Worst part: Deleting the integration tests while claiming "red-green verified" is a coverage gap dressed as a verification claim. The new tests verify the guard's logic, not the engine's wiring to the guard. 📊 Overall: Like replacing a Rube Goldberg machine with a light switch — cleaner, simpler, and harder to break. Just don't forget to test that the switch is actually wired to the light. Fix these issues in Kilo Cloud Previous Review Summaries (2 snapshots, latest commit e200b33)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit e200b33)Verdict: 1 Issue Found | Recommendation: Add back one integration test before merge Overview
Issue Details (click to expand)
VerdictComment only — the refactor is sound. The profile-wide execution gate correctly prevents older async writes from clobbering newer sync writes, and the new design is materially simpler than the per-snapshot marker approach. Correctness / Safety FindingsNo correctness or safety findings. Ponytail Review
Ponytail net: -5 lines (Boolean return removal) + ~200 lines (one ported integration test) = net +195. Suggested Minimal Patch
Final Merge GuidanceCan merge after one integration test is ported back. The guard-level tests are necessary but not sufficient — the wiring between the engine and the guard is the integration risk the PR was supposed to close. 🏆 Best part: The profile-wide execution gate is genuinely simpler than the per-snapshot marker. One mutex, one map, one comparison. The previous design had a marker that had to survive 💀 Worst part: Deleting the integration tests while claiming "red-green verified" is a coverage gap dressed as a verification claim. The new tests verify the guard's logic, not the engine's wiring to the guard. 📊 Overall: Like replacing a Rube Goldberg machine with a light switch — cleaner, simpler, and harder to break. Just don't forget to test that the switch is actually wired to the light. Fix these issues in Kilo Cloud Previous review (commit 1a79afc)Verdict: 6 Issues Found | Recommendation: Address test gap before merge; rest is optional cleanup Overview
Issue Details (click to expand)
VerdictComment only — the fix logic is sound (per-snapshot marker prevents same-snapshot duplicate writes, and the invariant that sync runs before its own async for each snapshot handles the cross-snapshot case). The warning is a test coverage gap, not a code bug. Correctness / Safety FindingsNo correctness or safety findings. The Ponytail Review
Ponytail net: -50 lines. Suggested Minimal Patch
Final Merge GuidanceCan merge after the test gap is closed (reorder test 3). The Ponytail cleanup is optional — the current code works, it's just verbose. 🏆 Best part: The per-snapshot marker design is actually clever — it's the smallest mechanism that solves the problem without introducing a global lock or sequence number. The 💀 Worst part: The third test claims to catch a regression it can't actually catch. Red-green verification only works if the test exercises the failing path. 📊 Overall: Like a seatbelt that's installed correctly but not actually buckled. Files Reviewed (3 files)
Reviewed by minimax-m3 · Input: 45.8K · Output: 8.1K · Cached: 650.2K Review guidance: REVIEW.md from base branch |
Summary
Fix Project Phoenix issue #714 (post-#716 P1 follow-up): an older captured Just Lift snapshots async
persistSnapshotcoroutine could grab thesettingsManager.mutateWorkout(profileId)mutex AFTER a newer synchronous completion-job write had already persisted fresh defaults, then clobber them with stale captured values.See PR #716 review thread
discussion_r3837465213. PR #716 (commitf9c0defa) added the Just Lift defaults capture + persist pipeline; this PR closes the remaining P1 ordering regression identified by the post-merge audit.Root cause
Both writers serialise through the same
settingsManager.mutateWorkoutmutex, butmutateWorkoutdoes not impose recency. When two Just Lift sets complete before the firstpersistSnapshotreaches its Just Lift write, the second synchronous write flips preferences to Old School, then the older async coroutine resumes and re-writes TUT over the fresh value.Fix — smallest ownership/ordering mechanism
Per-snapshot ownership marker on
WorkoutExitSnapshot:WorkoutExitSnapshotgains a@Volatile var justLiftDefaultsPersisted: Boolean = falsebody field (withmarkJustLiftDefaultsPersisted()/clearJustLiftDefaultsPersisted()helpers) so the marker survives the existinggetOrCapture { .copy(terminalPath = …) }hot path that re-issues a snapshot for an already-captured key.WorkoutExitSnapshotloses thedata classmodifier; the two.copy(terminalPath = …)callsites inWorkoutExitSnapshotStoremutate the existingterminalPathin place instead. No other classes or equals/hashCode usages are affected (equality ofWorkoutExitSnapshotis not used outside the lease-keyed map).persistCapturedJustLiftDefaultsSnapshot(snapshot, claimIfNeeded)now distinguishes the synchronous completion-job write (the canonical owner; flips the marker on success, clears on throw) from the asyncpersistSnapshot/ retained-snapshot retry paths (skip if the marker is already true, otherwise write — preserving the durable backstop the test plan requires).handleSetCompletion→claimIfNeeded = true(with the existing try/catch wrapper).persistSnapshot→claimIfNeeded = false.The synchronous failure path explicitly clears the marker so the async retry can take ownership. Process-recovery retry paths (
retryRetainedWorkoutExitPersistence/retryWorkoutExitPersistence) re-use the async path and inherit the same fallback semantics.Scope
ActiveSessionEngine.kt(helper + two call sites, plus narrowed comments addressing PR fix: persist Just Lift mode through automatic completion snapshot (#714) #716 style-review threads within the issue-714 touched region),WorkoutExitSnapshot.kt(data class → class + marker + body),WorkoutExitPersistenceTest.kt(the pre-existing test file for issue [Bug]: Just Lift mode resets to TUT at end of every set instead of preserving the user's selected mode (iOS) #714).PR #716 review-thread cleanup
discussion_r3837465213(the audits open P1) — fixed by this commit.discussion_r3837465215(failure containment: continue completion when saving Just Lift defaults fails) — already addressed by PR fix: persist Just Lift mode through automatic completion snapshot (#714) #71652a80788(try/catch at line 11283-11294 ofActiveSessionEngine.kt); verified intact and will be resolved with an explanation.discussion_r3837511295(pre-summary ordering: persist defaults before publishing the summary) — already addressed by the same52a80788fix (write happens before theWorkoutState.Idleflip /SetSummarypublish in the synchronous completion job); verified intact and will be resolved with an explanation.3837532484,3837532489,3837532491,3837560397,3837582668,3837451173,3837532493): narrowed where they fall within the issue-714 touched files (comment-block collapse, fully-qualifiedWorkoutParametersuses the existing import, addedJustLiftDefaultsDocument/RepCountTimingimports to the test file).Regression evidence
Three new deterministic tests in
WorkoutExitPersistenceTest(Issue714 successful synchronous write makes the async fallback path no-op for the same snapshot,Issue714 sync failure preserves the async fallback so the snapshot can still persist,Issue714 interleaved completion — older async TUT snapshots late write does not overwrite newer Old School sync write). Red-green verified by temporarily reverting the marker check: tests fail before the fix, pass after the fix.Test evidence (
./gradlew :shared:testAndroidHostTest -Pskip.supabase.check=true)Fixes #714
🤖 Generated with Hermes Agent
Co-Authored-By: Hermes hermes@nousresearch.com