Finding #2: Revert to Recommendation is a real engine inverse (unisonRiRevert) + first-class changedFromDefault#12
Merged
Conversation
"Revert to Unison's Recommendation" only deleted the Swift override; the OCaml direction (set by unisonRiSet*) was left forced, so sync used the reverted-away choice while the badge cleared. Fix it with the exact per-row inverse already in the vendored blob — no blob/patch/source change. Engine inverse. Add `unison_bridge_ri_revert`, which applies upstream `unisonRiRevert` (`diff.direction <- diff.default_direction`). Because every setter — including Force Older/Newer via `Recon.setDirection` — mutates only `diff.direction`, and `default_direction` is immutable after the scan, this is the exact, total inverse of all six actions (First/Second/Skip/Merge/Force). It follows the structured mutation contract: resolves the revert, direction, and changed-from-default callbacks before mutating; INVALID for a bad row/missing callback; FAILED_DIRTY for any raise once the revert began (incl. either readback), routed to engineBecameUncertain. ri_set/revert share one helper and now also return `changedFromDefault`. changedFromDefault is first-class. Added to the C/Swift state item, populated transactionally during scan and Ignore emission and updated by every direction action's result. A missing accessor fails the whole publication and a raise in a mutation is DIRTY, so callback failure is never conflated with `false`. It is carried (not recomputed in Swift) because a skip-requested Conflict renders identically to a default Conflict yet must read as changed — only the engine's structural compare gets that right. Revert rewrite. `revertSelectionAction` reverts each selected modified leaf in OCaml, resyncs the Swift direction + changedFromDefault, clears the visual override, and redraws leaves + ancestors. FAILED_DIRTY stops the batch (earlier successes stay synced) and enters restart-required. Gated by the reconciliation action gate. Enablement (extracted `RowSelectionRules.isRevertible`) covers a row that diverges from the default (changedFromDefault — plain First/Second/Merge, which leave no override) OR carries a Force/Skip override (so a Force that equals the default is still revertible to clear its badge). Tests: six-actions-then-revert restores the exact recommendation; skip over an original conflict is detected as changed and reverts to the original conflict; force incl. result-equal-to-default; merge; invalid-row no-op; exception injection at the revert/direction/changed readback stages; multi-row success + injected per-row failure; changedFromDefault carried through scan and Ignore reindexing; and the isRevertible enablement predicate. Corrected the misleading Revert/clearOverrides comments; updated TODO.md to describe the real engine transition. Debug + Release build green; full suite 528 tests, 0 failures, 1 skipped; no new Release test hooks; vendored blob byte-identical (a57f5c4e). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s, harden tests Review follow-ups on the Revert engine-inverse work. StateItem invariant: `changedFromDefault` now has NO default in the initializer (a silent false on a real row would hide a divergence and mis-gate Revert) — every construction site states it explicitly. Removed the direction-only `with(direction:)`; a direction change must always supply the matching `changedFromDefault` atomically via `with(direction:changedFromDefault:)`. Added tests that `with(progress:)` preserves a true flag and the direction updater changes both together. Removed the obsolete `RowSelectionRules.clearOverrides` helper and its tests — unused, and it preserved a Swift-only "drop the override" API that could recreate the original Revert defect (clearing the badge without reverting the engine). Hardened bridge tests: the Force fixture now sets deterministic distinct mtimes (both.txt) and an equal-mtime conflict row (both-eq.txt); every action asserts its returned direction AND changedFromDefault before reverting, so a no-op mutation can't pass; added an explicit action-equals-default case (Second on a default----> row) and a deterministic Force-equals-default case (equal mtimes → engine ignores Older/Newer); kept the skip-over-original-conflict structural test; renamed the multi-row test to state it exercises per-row building blocks, not the private window batch loop; added a consolidated engine-level Revert smoke (mixed actions, revert, unrelated row untouched, post-revert not revertible, restored direction == what sync propagates). Docs: state-emission accounting corrected to nine callbacks per row (six string duplications); removed the "modified badge" claim for changedFromDefault (it gates Revert eligibility). Debug + Release build green; full suite 526 tests, 0 failures, 1 skipped; app launches cleanly standalone; no new Release test hooks; vendored blob byte-identical (a57f5c4e). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Low-severity test-doc/assertion corrections; no runtime change. - test_k: fix the doc — the deterministic Force-equals-default case is the EQUAL-mtime conflict row (both-eq.txt, engine ignores Older/Newer), not a one-sided row (Force on a one-sided row does change direction). - test_q: match its Force claim — apply a plain direction to only-a, Force to the distinct-mtime conflict (both.txt), and Skip to the equal-mtime conflict (both-eq.txt); leave only-b untargeted; revert all three and assert restored directions + changedFromDefault == false. - test_q: drop the tautological only-b Swift-snapshot assertion; state that no operation targeted that row and that cross-row isolation comes from the indexed bridge implementation + the independent per-row tests, not a live readback here. - test_q: describe post-Revert eligibility as SPLIT evidence — engine returns changedFromDefault==false; the pure predicate returns false with no override; code inspection confirms the window removes the override after a successful Revert (not observed by this test). - test_q: state explicitly it does NOT drive the private window controller or observe menu greying/badges. Debug + Release build green; full suite 526 tests, 0 failures, 1 skipped; no new Release test hooks; vendored blob byte-identical (a57f5c4e). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bcourbage
marked this pull request as ready for review
July 20, 2026 03:45
bcourbage
added a commit
that referenced
this pull request
Jul 20, 2026
Lifecycle-correction pass for PR #18 (SSH probe safety). 1. Cancel the probe in leaveSession (the common session-leave path), so BOTH window-close and the programmatic Stop-during-scan path tear it down. Previously Stop-during-scan detached the window delegate before closing, so handleWindowClosed (which held the cancel) never fired and the probe leaked. Removed the duplicate cancel from handleWindowClosed. 2. runVersionCheckIfNeeded supersedes the old probe BEFORE the unison_bridge_get_version() guard, so a replacement open invalidates the previous session's probe even if the version lookup then fails. 3. Executor checks cancellation BEFORE launch (never spawns an already-abandoned child) and immediately AFTER Process.run(). 4. Deterministic termination. New ProbeCanceller fires a registered teardown (SIGTERM) SYNCHRONOUSLY inside cancel() rather than on a later background poll tick; applicationWillTerminate waits (bounded) on Handle.waitUntilFinished so the app doesn't exit mid-teardown. The wait loop checks cancellation first so a SIGTERM'd child reports .cancelled, not .exited(signal). 5. Active-probe state is cleared on completion only when the delivering probe is still current (a newer, superseding probe is never clobbered). 6. Corrected teardown claims: the deadline is measured from just after launch (not "launch + I/O + exit"); the final SIGKILL reap-wait result is not asserted; terminating ssh does not prove its ProxyCommand/remote descendants are reaped. 7. Lifecycle tests: cancel-before-launch never launches; teardown fires synchronously on cancel; late registration fires immediately; cancel is idempotent (teardown once); wait wakes on cancel / times out; shutdown waitUntilFinished completes after cancel. Debug build + Release compile succeed; full suite 576 tests, 0 failures (1 pre-existing env skip). Swift-only; vendored blob a57f5c4e and vendor/ patches/ untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bcourbage
added a commit
that referenced
this pull request
Jul 20, 2026
This was referenced Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reconciliation correctness (review Finding #2). "Revert to Unison's Recommendation" previously deleted only the Swift
rowOverridesentry; the OCaml direction (set byunisonRiSet*) stayed forced, so synchronization used the reverted-away choice while the badge cleared. This makes Revert a genuine per-row engine inverse, using the exact reset already present in the vendored blob — no blob/patch/upstream-source change (blob staysa57f5c4e).Why no rescan fallback
Upstream
uimacbridge.mlalready registersunisonRiRevert(diff.direction <- diff.default_direction) andchangedFromDefault— both already in the current blob. Every setter, incl. Force Older/Newer viaRecon.setDirection, mutates onlydiff.direction;default_directionis set once at reconcile time and never reassigned. So resetting todefault_directionis the exact, total inverse of all six actions — a coordinator-owned rescan (which would re-run update detection and discard every other row's choices, expansion/selection, and possibly the row set) is unnecessary and strictly worse.Implementation
unison_bridge_ri_revert— shares one helper with the six setters; structured mutation contract (INVALID before mutation / OK / FAILED_DIRTY on any raise once revert began, incl. either readback →engineBecameUncertain). ri_set and revert also returnchangedFromDefault.changedFromDefaultfirst-class on the C/Swift state item — populated transactionally during scan + Ignore emission (nine callbacks/row, six string dups), updated by every action's result. Never conflates callback failure withfalse(a raise fails the publication or is DIRTY). Carried from the engine because a skip-requested Conflict renders identically to a default Conflict yet must read as changed (structural compare). It has no initializer default, so every construction site states it explicitly.changedFromDefault, clears the override, redraws leaves/ancestors; FAILED_DIRTY stops the batch (earlier successes stay synced) → restart-required; gated by the reconciliation action gate.RowSelectionRules.isRevertible) covers rows that diverge from the default (changedFromDefault— plain First/Second/Merge leave no override) OR carry a Force/Skip override (so a Force equal to the default is still revertible to clear its badge). The obsolete Swift-onlyclearOverrideshelper (which could recreate the original defect) was removed.Tests & evidence
Engine round-trip: every action → Revert restores the exact recommendation with
changedFromDefault == false(each action's returned direction + flag asserted before reverting, so no-op mutations can't pass); action-equals-default and a deterministic Force-equals-default (equal-mtime conflict, engine ignores Older/Newer); Force-changes (distinct mtimes); skip-over-original-conflict via structural comparison; invalid-row no-op; exception injection at the revert/direction/changed readback stages; per-row multi-row success + injected failure;changedFromDefaultcarried through scan and Ignore reindexing;isRevertiblepredicate; and a consolidated engine-level Revert smoke.Post-Revert eligibility is established as split evidence: the engine returns
changedFromDefault == false; the pure predicate returns false with no override; and code inspection confirms the window removes the override after a successful Revert.Verification
a57f5c4e);vendor//patches/untouched;docs/manual-test-step2b.mduntracked.Automation limitation (retained)
The window-level Revert batch orchestration (stop-on-DIRTY keeping earlier rows synced, override removal, redraw) lives in the private
@MainActor ReconcileWindowControllerand is not automatable in this suite: the tests do not drive the window controller batch loop, observe visual badges, or observe menu greying. Those are covered by code inspection plus the automated bridge/engine tests and the pure predicate tests. The GUI gestures (folder multi-select, badge/menu rendering) were not automated.Out of scope
Scan/core cancellation, other review findings, release work, unrelated cleanup.
🤖 Generated with Claude Code