Skip to content

feat(wsl-mover): retain moved source rows, reconcile them on start, and plan delta moves (D1b-8) - #31401

Closed
nwparker wants to merge 2 commits into
nwparker/wsl-mover-d1b-7f-browser-loginsfrom
nwparker/wsl-mover-d1b-8-retain-reconcile
Closed

nwparker wants to merge 2 commits into
nwparker/wsl-mover-d1b-7f-browser-loginsfrom
nwparker/wsl-mover-d1b-8-retain-reconcile

Conversation

@nwparker

@nwparker nwparker commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1614 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1614
Prod 29 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1327 $\color{#cf222e}{\Huge{\mathbf{−}}}$​30 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1297

Stacked on #31232 (D1b-7e). For owner review only; please do not merge. Dark-gated behind the internal WSL hosts gate (ORCA_INTERNAL_WSL_HOSTS); with the gate closed, nothing new runs.

ELI5

When a project moves from "Windows reaching into WSL" to "the WSL host itself", Orca keeps the old Windows-side copy of the project's records, hidden, as a safety net. This PR makes that safety net hold up. Each kept record gets a small label saying which move took it. If you downgrade Orca and come back, the labels are put back and the records are hidden again, and nothing is lost. A later move into the same WSL distro only takes projects that are new.

What Changed

Before: a committed move hid its projects' local records. Nothing marked those records, nothing checked them after a downgrade, and no step finished a move by setting it to retained. A second move into the same distro had no planning step: nothing worked out what it should take, chained it to the earlier move, or refused it while another move was still running.

After:

  • Retain. Once a move's desktop rewrite is finished (client-rewritten), its local rows are marked orcadRetained: {migrationId, environmentId}. The marks are saved to disk first, then the journal records the move as retained. Nothing is deleted. The rows stay hidden by the same visibleRepos / visibleFolderWorkspaces filters SSH uses (through D1b-7a's withoutWslMovedRepos).
  • Reconcile on start. This extends what reconcileManagedOrcadSshTargets does for SSH. A pass runs at startup and after every journal change. It:
    • puts back marks an older build stripped;
    • compares each retained row's identity (id, Linux path and name) with what its move took, and stamps sourceChangedAt if an older build changed it;
    • removes marks that no committed move explains, e.g. after "Use on this computer again". With an unreadable journal it changes nothing, and it never starts a move on its own.
  • Delta move.
    • planWslMove works out what a move into a distro would take: only projects no earlier move of that distro took. It also checks what the host holds now, so a project the host still has after "Use on this computer again" is not taken twice. generation keeps increasing even past a released move.
    • journalWslMove records the move at prepared, chained to the current head by supersedesMigrationId. The journal itself refuses a second move while one is in flight, or one planned against a head that has since changed.
    • Rows edited in an older build, or that another distro's host now owns, are listed as changed. They are never moved again.
  • Marks never leave the desktop. The shared catalog collector that builds a move's list of projects now copies rows without orcadRetained. For SSH this changes no bytes, because SSH rows are never marked.
  • Small hardening. A manifest whose WSL scope lists the same id twice is now refused, on both the desktop and the host (assertLocalWslScopeMatchesPayload).

Shared paths touched:

  • Session persistence: Repo / FolderWorkspace types, repo hydration, folder-workspace normalization, and a new store method setWslMoveRetainedMarks.
  • Startup: main-process-ready-foundation.ts.
  • Migration catalog collection: orcad-source-catalog.ts.
  • Runtime lists and the sidebar are not changed. Hiding still comes from the journal through D1b-7a's filters. The new optional field does reach renderer, web and mobile clients through repo lists, and old clients ignore it.

How this relates to other work:

Why

  • Why keep a mark if the journal already hides the rows? The journal lives outside the profile, so hiding survives a downgrade without it. The mark records on the row itself which move took it and whether it changed since. The D4+1 cleanup needs that to tell retained rows apart without reading the journal. An older build drops unknown folder-workspace fields, but repo rows keep them. So the pass compares identity every time instead of relying on whether the mark is missing.
  • Why the move's manifest is the baseline: a crash and a downgrade between commit and retain could change the rows before a later snapshot was taken. The manifest records what the host actually received, the same reasoning SSH's retention uses.
  • Why edited rows are not moved again: the host refuses ids it already holds, so including them would make the whole stage fail, new projects too. SSH's delta move makes the same choice (notReflected).

Owner questions

Each one ships with the conservative default:

  1. Rows edited in an older build: they are flagged (sourceChangedAt) and listed, never moved again, and "Use on this computer again" is the remedy. Do we want a host-side update, or a "keep the host's version" action like SSH has?
  2. Flagged rows stay hidden, because the WSL host owns the project. SSH shows changed rows again instead. Keep WSL hidden?
  3. Hiding comes from the journal, not the mark. The mark is a per-row record, carries the change flag, and is what the D4 cleanup would use. Is that the intended role, or should the mark take part in hiding?

Linked Issue

N/A (maintainer stack, tracker row D1b-8).

Visual Proof

N/A: no UI. The mover is dark-gated, and D1b-9 adds the move UI.

Testing

  • Automated tests added
  • wsl-move-retention.test.ts (real SQLite, listed in vitest-sqlite-runtime-files.mjs):
    • downgrade then re-upgrade: marks stripped and a row renamed by an "older build", then re-hidden, re-marked and flagged, with no row lost;
    • SSH and folder workspaces are untouched with no journal;
    • an unreadable journal keeps every mark, and a closed gate does nothing;
    • a release removes the marks, and a refused release (wsl_move_superseded) keeps them;
    • a save that fails leaves the move at client-rewritten;
    • a delta move gets its own marks;
    • the listener retains a move once its journal entry changes.
  • wsl-move-delta-plan.test.ts:
    • first move, delta chain, and the third move finds nothing new;
    • a second move is refused while one is in flight, including a plan made before the first was journaled;
    • a stale head is refused;
    • edited, removed and out-of-scope rows are listed, not taken;
    • projects the host holds after a release are not taken again, and generation keeps increasing;
    • rows owned by another distro, and duplicate ids, are handled;
    • the user's selection is respected.
  • orcad-retained-marker.test.ts.
  • pnpm tc, the neighbour suites (moving-wsl-projects, shared/wsl-move, shared/orcad-migration, main/ssh, main/orcad, catalog and folder persistence), check:code-quality:changed and the ratchet scripts all pass locally on macOS. The only local failure was orcad-forked-child-paths.test.ts, which fails the same way on the base (macOS /var vs /private/var temp-path symlink).

Review

An adversarial review agent found no HIGH and two MEDIUM issues, both fixed:

  • The marks' save step now has a time limit and no longer waits for other unrelated writes to settle, so a busy profile can't block retention.
  • Planning now takes what the host holds and its highest generation, so a delta move after a release doesn't re-take projects the host still has.

These LOW findings are also fixed:

  • The journal listener compares the profile folder path case-insensitively on Windows.
  • Retention always saves before setting retained.
  • Duplicate scope ids are refused.
  • Projects owned by another distro's move are listed, not taken.
  • A project whose path left the distro is reported as changed, not removed.
  • A mark naming a move that never took the row is removed.
  • Journal reads that only scan no longer copy every record.
  • Manifests never carry the mark.

Agent skill upstream boundary

  • Not applicable.

Notes

  • Cross-platform: path checks reuse wslUncPathToLinux and the existing WSL path rewriter, and the profile-folder comparison follows the fence's case rule.
  • SSH: SSH rows and the host's own same-id rows are never marked or matched (isLocalWslMoveRow), and SSH manifests are byte-identical.
  • Folder workspaces are covered alongside repos.
  • Compatibility: the field is optional, and malformed marks are ignored.

@nwparker
nwparker force-pushed the nwparker/wsl-mover-d1b-7e-orchestration-history branch from b8bf5c6 to 05e3553 Compare October 21, 2026 05:17
@nwparker
nwparker force-pushed the nwparker/wsl-mover-d1b-8-retain-reconcile branch from 5d0884e to 5e909a6 Compare October 21, 2026 05:18
@nwparker
nwparker force-pushed the nwparker/wsl-mover-d1b-8-retain-reconcile branch from e0640d7 to b9722fe Compare October 21, 2026 07:46
@nwparker
nwparker changed the base branch from nwparker/wsl-mover-d1b-7e-orchestration-history to nwparker/wsl-mover-d1b-7f-browser-logins October 21, 2026 07:47
@nwparker

Copy link
Copy Markdown
Contributor Author

Contained in the dark landing PR #34059 (its head commit is an ancestor of the landing branch). Closing in favour of it; branch kept.

@nwparker nwparker closed this Oct 26, 2026
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.

1 participant