Better sync: defer hot paths, rebase before park - #19
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
Inbound feed and reconcile now try optimistic rebase onto remote current before parking conflict siblings, and the daemon holds hot-file work in memory with settle-based mtime gating on both push and feed directions. Co-authored-by: Cursor <cursoragent@cursor.com>
usergenic
left a comment
There was a problem hiding this comment.
Code Review
Verdict: APPROVE WITH RESERVATIONS
Well-structured decomposition of the sync convergence logic into shared helpers with a clean decision/effect split; the settle gate and deferred map are the right abstractions. A few control-flow gaps deserve attention before merge.
0 critical · 3 important · 2 minor
| if (result === "parked" || decision.action === "noop") { | ||
| // noop here would mean ignored; occupied diverge shouldn't noop. | ||
| if (result === "parked") { | ||
| return { path, verdict: "conflict", detail: "rebase failed; parked remote sibling" }; | ||
| } | ||
| } | ||
| if (decision.action === "rebase" || result === "applied") { | ||
| return { path, verdict: "rebase", detail: "local bytes put onto remote current" }; | ||
| } | ||
| if (decision.action === "adopt") { | ||
| return { path, verdict: "adopt", detail: "content matches remote; provenance repaired" }; | ||
| } | ||
| return { path, verdict: "conflict", detail: "could not converge occupied path" }; |
There was a problem hiding this comment.
(important) convergeOccupied returns incorrect "conflict" verdict when decideInbound returns noop
If decideInbound returns noop (e.g. the divergence resolves between the index scan and this call, or the file becomes $sync: ignore in a race), effectInbound returns noop, and the function falls through all conditions to line 368, returning { verdict: "conflict", detail: "could not converge occupied path" }. This misreports a benign noop as a conflict. Consider handling the result === "noop" case explicitly — either return { verdict: "clean" } or at minimum log it separately so it does not show up as a conflict in the reconcile report.
There was a problem hiding this comment.
Fixed in 93ec11e — noop now returns clean/ignored instead of conflict.
| try { | ||
| remote = await client.docs.get_version(repo, entry.ref.version_id); | ||
| } catch { | ||
| return { done: true, result: "noop" }; | ||
| } |
There was a problem hiding this comment.
(important) Bare catch permanently drops deferred entries on transient errors
This catch block swallows all errors — network timeouts, auth failures, server errors — not just doc_not_found. The resulting { done: true, result: "noop" } tells the daemon to permanently remove the deferred entry, silently abandoning convergence for that path. Consider only catching KernelError with doc_not_found code (consistent with the pattern at lines 269-274 above) and re-throwing everything else so transient failures trigger the daemon's existing defer retry error log+continue rather than a silent drop.
There was a problem hiding this comment.
Fixed in 93ec11e — only doc_not_found is caught; transient errors propagate to defer retry.
| if (err instanceof KernelError && err.code === "create_conflict") { | ||
| // A doc appeared at this path since the index scan; treat the occupied | ||
| // path as a conflict rather than clobbering it. | ||
| const current = (err.data as { current_version_id?: string }).current_version_id; | ||
| if (current) await parkConflict(client, store, repo, path, current); | ||
| // A doc appeared at this path since the index scan; rebase or park. | ||
| await convergeOccupied(client, store, repo, path); | ||
| return; |
There was a problem hiding this comment.
(important) pushCreate discards the SyncAction returned by convergeOccupied
convergeOccupied returns a SyncAction indicating whether it rebased, adopted, or parked a sibling, but pushCreate is void and discards it. The caller at line 246 then unconditionally reports { verdict: "push", detail: "local creation" } regardless of the actual outcome. This means the reconcile report will say "push" when the real action was a rebase or conflict park, which can mislead operators reviewing sync behavior.
There was a problem hiding this comment.
Fixed in 93ec11e — pushCreate returns SyncAction; callers propagate converge outcomes.
| park = await client.docs.get(repo, path); | ||
| } catch { | ||
| // Keep get_version result. | ||
| } |
There was a problem hiding this comment.
(minor) Bare catch in rebase fallback swallows non-retrieval errors
This catch-all is meant to handle the case where docs.get fails because the doc was deleted or moved, falling back to the get_version result. But it also swallows network errors, auth failures, etc. — parking a potentially stale version instead of surfacing the problem. Consider narrowing to KernelError with doc_not_found for consistency with the error-handling pattern elsewhere in this file.
There was a problem hiding this comment.
Fixed in 93ec11e — docs.get fallback only catches doc_not_found.
| // Fast path: bytes already match — may still need provenance repair. | ||
| if (intr.computed_hash === ref.content_hash) { | ||
| // Bytes already present (our own push echoing back, or a vault copy). | ||
| map?.set(ref.path, { version_id: ref.version_id, content_hash: ref.content_hash }); | ||
| // Repair provenance only if the embedded version lags. | ||
| if (intr.version === ref.version_id) return false; | ||
| if (versionAtOrAhead(intr.version, ref.version_id)) return false; | ||
| const v = await client.docs.get_version(repo, ref.version_id); | ||
| await store.write(ref.path, renderMaterialized(v), { preserveMtime: true }); | ||
| return true; | ||
| } | ||
| // Local is at or ahead of this ref (echo of our push, or a replay of an | ||
| // older version). Never clobber and never park a sibling of something we | ||
| // already have — including when the user has typed more since (dirty). | ||
| if (versionAtOrAhead(intr.version, ref.version_id)) return false; | ||
| if (isDirty(intr)) { | ||
| // A local edit collides with a *newer* incoming version → conflict, not | ||
| // overwrite. Park the remote as an ignored sibling; keep local bytes. | ||
| const v = await client.docs.get_version(repo, ref.version_id); | ||
| await store.write(withVersionSuffix(ref.path, ref.version_id), renderIgnoredSibling(v)); | ||
| log(`feed conflict\t${ref.path}`); | ||
| return true; | ||
| opts.map?.set(ref.path, { version_id: ref.version_id, content_hash: ref.content_hash }); | ||
| if (intr.version === ref.version_id) return "noop"; | ||
| if (versionAtOrAhead(intr.version, ref.version_id)) return "noop"; | ||
| const hot = await pathIsHot(store, ref.path, settleMs, nowMs); | ||
| const v = await client.docs.get_version(opts.repo, ref.version_id); | ||
| const decision = decideInbound({ | ||
| localText: existing, | ||
| remote: v, | ||
| hot, | ||
| canDefer, | ||
| }); | ||
| const result = await effectInbound(client, store, opts.repo, ref.path, decision, { | ||
| deferred: opts.deferred, | ||
| ref, | ||
| map: opts.map, | ||
| nowMs, | ||
| }); | ||
| if (result === "deferred") return "deferred"; | ||
| if (result === "noop") return "noop"; | ||
| log(`feed adopt\t${ref.path}`); | ||
| return "applied"; | ||
| } |
There was a problem hiding this comment.
(minor) Hash-equal fast path re-parses intrinsics via decideInbound
When computed_hash === ref.content_hash and provenance needs repair, the code fetches the full version and routes through decideInbound, which re-reads intrinsics and re-checks the hash condition that was already confirmed at line 154. This is correct but redundant on the echo-suppression hot path (every feed poll). A direct materializeAt with preserveMtime (the adopt action) would be equivalent and skip the re-parse, since the only possible decideInbound outcome here is adopt or defer.
There was a problem hiding this comment.
Fixed in 93ec11e — cold hash-equal path uses materializeAt directly; hot still defers.
Return accurate SyncActions from pushCreate, map noop convergence to clean, narrow deferred/rebase catch blocks to doc_not_found, and streamline the hash-equal feed adopt path. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Review follow-up pushed in 93ec11e:
CI typecheck failure is pre-existing on |
Matches the rename case so chokidar/debounce latency in a busy vitest worker does not flake at the default 8s deadline. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
docs/better-sync.planand archives completed design docs intodocs/archive/.docs.putonto remote current before creating a conflict sibling.--settle): hot files are skipped on push/feed; inbound work is held in an in-memory deferred map and retried on later polls (cursor still advances).converge.ts/hot-path.tshelpers used by feed, reconcile, push, and the daemon.Test plan
npx vitest run test/sync-*.test.ts(81 tests)mrplex sync <vault> --settle 3000while editing a note; confirm no sibling appears mid-edit and convergence happens after save settlesMade with Cursor