fix(op-log): count syncTimeSpent as touching the time fields on the receiver - #10076
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
Conflict-disjoint merging for deferred time-tracking writes needs a safe semantic path and regression coverage.
Pull request overview
Updates operation-log capture so deferred writes extract entityChanges consistently with direct writes.
Changes:
- Removes deferred-write special casing.
- Adds regression coverage for direct/deferred payload parity.
- Updates related comments.
File summaries
| File | Summary |
|---|---|
src/app/op-log/capture/operation-log.effects.ts |
Extracts entity changes for all writes. |
src/app/op-log/capture/operation-log.effects.spec.ts |
Verifies direct and deferred payload parity. |
Review details
Suppressed comments (1)
src/app/op-log/capture/operation-log.effects.ts:253
- This makes deferred
[TimeTracking] Sync time spentoperations participate in the conflict-disjoint-merge path, not just the Android consumer path.conflict-disjoint-merge.util.tsexplicitly falls back toentityChangesforsyncTimeSpent, and_tryCreateDisjointMergeOp()can then synthesize a TASK patch from{ taskId, date, duration };lwwUpdateMetaReducerapplies that map as task fields instead of replaying the additive time action. The existing special case only exempts time-vs-time conflicts, so a deferred time delta concurrent with a normal task edit can now produce an invalid/non-semantic merged update (direct writes already have the same exposure). Please exclude this action from field-level merging or add a semantic merge path, and add a regression covering the deferred conflict; the PR description's claim that conflict handling is unaffected is not accurate.
const entityChanges = this.operationCaptureService.extractEntityChanges(action);
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Preview Deployment
Branch: Deployed with Cloudflare Pages |
|
The Copilot finding on Fixed in PR description corrected — the earlier "conflict handling unaffected" line was wrong. Whether time-vs-edit should commute (apply both) rather than LWW is left as a maintainer call; happy to split commit 2 into its own PR if preferred. |
9e79a15 to
40ec60f
Compare
|
🔧 Needs changes – labelled Thanks for the PR! Commit 1 is exactly what #8758 asks for and the parity spec is the right proof. Commit 2 fixes a real master bug (the synthesized Blocking
Nits
Automated review pass (Claude Code). Anything unclear or wrong – say so and I'll take a look. |
|
@johannesjo addressed in 909cc3f. Confirmed the regression: the no-pending caller reads the same predicate as "apply both", so the line 252 exclusion sent a retained Changes:
Full |
|
Note on the red Tests job on 909cc3f: the unit suites all pass (the op-log Vitest/Karma runs are green in the log). The single failure is the e2e case |
|
Filed the underlying analysis as #10146 and #10147 so it doesn't live only in this thread — please read #10146 before doing more work here, because it argues against the approach in the second commit. Short version: the The actual root cause is upstream of the predicate: changes: { timeSpent, timeSpentOnDay }Two things to carry over if you take that route:
Thanks for digging into this one — the diagnosis that something is wrong around |
909cc3f to
b4ed8b7
Compare
|
@johannesjo read #10146 and #10147 and took that route. The second commit is replaced (force-pushed, now b4ed8b7 on top of the unchanged commit 1); the actionType exclusion inside Declaration. Consequences on the two paths.
Specs. All use the real captured payload shape, not the bare-payload fixture at line 7414. In the If you would rather carry absolute values by having the time-tracking effect put the post-state into the |
|
🔧 Needs changes – labelled Thanks for the updates. Re-checked the 2 item(s) from the earlier review at
I've flagged a question for the maintainer about the approach below; you may want to wait for that before reworking the details. Still blocking
Should fix before mergeTwo more readers on the new client treat the declared keys as an absolute write, and the delta values now break them where the old junk keys were merely ignored:
All three are the same root cause: Automated follow-up pass (Claude Code). Anything unclear or wrong – say so and I'll take a look. |
|
Thanks for working through this, @GabeSilvaDev. I need to correct my earlier guidance: asking you to put Let's take the receiver-side approach from your Concretely:
The earlier objection was to blanket exclusion from the shared predicate; it does not rule out this scoped receiver mapping. This remains a bounded correction: pending conflicts still fall back to LWW, which can discard a change. Preserving both sides there is separate work, not a prerequisite for this fix. Let's handle the existing journal/Flip issue separately: additive operations should be represented as The mixed-version finding is supported by the released-code path and predicate/synthesizer probe; it has not yet been reproduced through a live mixed-version sync. I'll correct the suggested fixes in #10146 and #10147 as well. |
…eceiver super-productivity#10146 super-productivity#10147 A syncTimeSpent op is an additive delta on timeSpent/timeSpentOnDay, but its captured entityChanges carry the delta's arguments ({ taskId, date, duration }, direct writes) or nothing (deferred writes). Read as field changes, the disjointness test in isDisjointMergeEligible could never see an overlap with an absolute write of the fields the delta mutates: - no-pending path: a retained absolute timeSpentOnDay write crossing a remote syncTimeSpent was applied on both sides in arrival order and the two clients settled on different values (super-productivity#10146); - pending path: a disjoint local edit crossing a remote syncTimeSpent got a synthesized merge that wrote taskId/date/duration onto the task and rejected the delta, dropping the tracked time (super-productivity#10147). The predicate now derives the touched fields for a syncTimeSpent op from its ACTION TYPE (sideNonNoiseKeys), so a non-time edit keeps commuting with it and an absolute time write falls to LWW. The mapping is receiver side only: the captured payload is unchanged, and mergeChangedFields still returns the wire changes, so no reader ever sees the delta's values under task-field names. Released clients keep reading the wire shape they already know. A delta still cannot be expressed as a merged patch, so _tryCreateDisjointMergeOp refuses additive time ops up front and falls back to whole-entity LWW. removeTimeSpent is listed there explicitly: it is a second clamping delta whose extraction merely happens to be opaque. Specs use production-captured payloads in both wire forms and pin the outgoing shapes (direct { taskId, date, duration }, deferred []). They cover non-time edits vs absolute time edits on the no-pending path with resulting task state and order independence, the pending synthesis guard with the local-win snapshot keeping the whole time history when applied through lwwUpdateMetaReducer, and removeTimeSpent.
b4ed8b7 to
8c987d8
Compare
|
Reworked as the scoped receiver fix in 8c987d8 (single commit, rebased on 19.1.0). Point by point:
Mutation check: the 8 new behavioural specs fail with util/service at master. Journal/Flip left for the separate |
|
@johannesjo ready for re-review: 8c987d8 is the scoped receiver-side fix from your last comment (single commit, rebased on 19.1.0, CI green). |
|
✅ Ready to merge – no blocking issues at Thanks for the updates. Re-checked the 3 item(s) from the earlier review at
The new coverage is the right shape: production-captured payloads in both wire forms, the no-pending crossing checked for resulting state and convergence in both arrival orders, and the pending-path local-win snapshot applied through the production Automated follow-up pass (Claude Code). Anything unclear or wrong – say so and I'll take a look. |
…orm-improvements-jhp6x2
* origin/master: (37 commits)
test(e2e): wait for hydration before adding task in packaged smoke
fix(lint): pin conflict-resolution cap at its size when ratcheted
feat(video): add reels, landing hero and sharp recorder on video-kit
chore(lint): turn every lint warning into an error or a ratchet
fix(i18n): restore {{errors}} in PLUGINS.VALIDATION_FAILED and fail on dropped placeholders #10006 (#10075)
ci(windows): pin SignPath connector-url to the v2 endpoint
feat(tasks): add task priorities (#10128)
fix(gitlab): URL-encode the issue search term #9907 (#10103)
fix(gitea): Add API paging (#9628)
feat(config): hot-reload styles.css (#9972)
fix(op-log): count syncTimeSpent as touching the time fields on the receiver #10146 #10147 (#10076)
fix(tasks): start the Shift+click range at the focused task #10143 (#10158)
fix(project): Display custom snack notification when settings update is canceled (#10088)
fix(android): stop mobile sidebar's nav-list from double-applying the top safe-area inset (#10206)
Add Caffeine Tracker plugin to community plugins (#10134)
chore(deps)(deps): bump signpath/github-action-submit-signing-request (#10109)
chore(deps)(deps): bump the github-actions-minor group with 8 updates (#10184)
refactor(capture): share one importer for android and ios captures
fix(sync): classify intra-batch oversized-clock retries as duplicates
fix(android): keep quick-add tasks until they are persisted
...
# Conflicts:
# docs/wiki/3.05-Web-App-vs-Desktop.md
# ios/App/App.xcodeproj/project.pbxproj
# ios/App/App/App.entitlements
# ios/App/App/CustomViewController.swift
…eceiver super-productivity#10146 super-productivity#10147 (super-productivity#10076) A syncTimeSpent op is an additive delta on timeSpent/timeSpentOnDay, but its captured entityChanges carry the delta's arguments ({ taskId, date, duration }, direct writes) or nothing (deferred writes). Read as field changes, the disjointness test in isDisjointMergeEligible could never see an overlap with an absolute write of the fields the delta mutates: - no-pending path: a retained absolute timeSpentOnDay write crossing a remote syncTimeSpent was applied on both sides in arrival order and the two clients settled on different values (super-productivity#10146); - pending path: a disjoint local edit crossing a remote syncTimeSpent got a synthesized merge that wrote taskId/date/duration onto the task and rejected the delta, dropping the tracked time (super-productivity#10147). The predicate now derives the touched fields for a syncTimeSpent op from its ACTION TYPE (sideNonNoiseKeys), so a non-time edit keeps commuting with it and an absolute time write falls to LWW. The mapping is receiver side only: the captured payload is unchanged, and mergeChangedFields still returns the wire changes, so no reader ever sees the delta's values under task-field names. Released clients keep reading the wire shape they already know. A delta still cannot be expressed as a merged patch, so _tryCreateDisjointMergeOp refuses additive time ops up front and falls back to whole-entity LWW. removeTimeSpent is listed there explicitly: it is a second clamping delta whose extraction merely happens to be opaque. Specs use production-captured payloads in both wire forms and pin the outgoing shapes (direct { taskId, date, duration }, deferred []). They cover non-time edits vs absolute time edits on the no-pending path with resulting task state and order independence, the pending synthesis guard with the local-win snapshot keeping the whole time history when applied through lwwUpdateMetaReducer, and removeTimeSpent.
The 4827 pin was measured before super-productivity#10076 (0115a41, +11 lines) landed underneath c94de8d, so the file was already 4838 lines at the commit that introduced the ratchet. This corrects the baseline; the file has not grown since.
Related: #10146, #10147 (receiver-side fix). #8758 (deferred writes emit
entityChanges: []) is deliberately left open, see "Deferred" below.Change
A
syncTimeSpentop is an additive delta ontimeSpent/timeSpentOnDay, but its capturedentityChangescarry the delta's arguments ({ taskId, date, duration }for direct writes) or nothing ([]for deferred writes). Read as field changes,isDisjointMergeEligiblecould never see an overlap with an absolute write of the fields the delta mutates:timeSpentOnDaywrite crossing a remotesyncTimeSpentwas applied on both sides in arrival order, and the two clients settled on different values;syncTimeSpentgot a synthesized merge that wrotetaskId/date/durationonto the task and rejected the delta, dropping the tracked time.conflict-disjoint-merge.util.tsisDisjointMergeEligiblenow builds each side's touched-field set throughsideNonNoiseKeys, which counts asyncTimeSpentop as touchingtimeSpent/timeSpentOnDayderived from its action type. Everything else is read exactly as before (opaque ops still make the side ineligible). Effect: a non-time edit keeps commuting with the delta on the no-pending path; an absolute time write falls to LWW.The mapping is receiver-side only.
mergeChangedFieldsstill returns the wireentityChanges, so neither_tryCreateDisjointMergeOp,_createLocalMultiReconciliationOpsnor the journal diff ever sees the delta's values under task-field names.isAdditiveTimeOp(syncTimeSpent+removeTimeSpent) is exported for the synthesizer guard below.conflict-resolution.service.ts_tryCreateDisjointMergeOprefuses additive time ops up front, before the predicate, and falls back to whole-entity LWW.removeTimeSpentis included explicitly: it is a second clamping delta whose extraction merely happens to be opaque today.Wire shape
Unchanged.
_captureTaskTimeSyncFromActionstill emits{ taskId, date, duration }; deferred writes still emitentityChanges: []. Both are pinned by spec. No schema bump.Deferred
The earlier commit that ran deferred writes through the extractor is dropped. Even with the old argument names, it would expose previously opaque ops to the dropped-delta / junk-field merge on released clients that carry the disjoint merge but not this guard. #8758 stays open until that path is safe on the fleet.
Specs
All time-op fixtures are built from production-captured payloads (
OperationCaptureService.extractEntityChanges) in both wire forms: the legacy direct{ taskId, date, duration }and the empty deferred[].conflict-disjoint-merge.util.spec.ts: delta vs non-time edit eligible (both forms); delta vs absolutetimeSpentOnDay/timeSpentnot eligible;removeTimeSpentnot eligible;mergeChangedFieldsdoes not surface the mapped fields;isAdditiveTimeOp.conflict-resolution.service.spec.ts(no-pendingcheckOpForConflicts): remote delta vs retained title edit → no conflict; vs retained absolutetimeSpentOnDay→ conflict (both forms).conflict-resolution.disjoint-merge.spec.ts(pending path): pendingisDoneedit vs remote delta (both forms) → no synthesized patch, no op carriestaskId/date/duration, journal notmerged; the local-win snapshot applied through the productionlwwUpdateMetaReduceron the other client keeps the wholetimeSpentOnDayhistory. PendingremoveTimeSpentvs remote title → no synthesized patch.task-time-sync-crossing.integration.spec.ts(new): resulting state and convergence. Non-time edit + delta applied in both orders viaconvertOpToAction+taskReducerreach the identical task (history + delta, edit kept). Absolute time write + delta is order-dependent (DAYends as the delta in one order and0in the other), which is why the predicate routes it through LWW. Pins the outgoing payload.operation-capture.service.spec.ts/operation-log.effects.spec.ts: outgoing shapes pinned (direct{ taskId, date, duration }, deferred[], extractor not called for deferred writes).Mutation check: with the util/service reverted to
masterthe 8 new behavioural specs fail;src/app/op-log/**4127/4129 green (2 pre-existing skips) after rebasing on 19.1.0.Bounds
Pending conflicts still fall back to LWW, which can discard one side (the delta or the edit). That is the pre-existing behaviour for every opaque op; preserving both sides there is separate work. The journal / Flip exposure for additive ops (
kind: 'action'diffs) is also separate, as discussed.