Skip to content

refactor(ssh): main decides when to upload SSH workspaces; downloads go through main (core C5d) - #26320

Closed
Jinwoo-H wants to merge 13 commits into
topo-c5c-sleep-wakefrom
topo-c5d-ssh-import-export
Closed

Jinwoo-H wants to merge 13 commits into
topo-c5c-sleep-wakefrom
topo-c5d-ssh-import-export

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 25 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1177 $\color{#cf222e}{\Huge{\mathbf{−}}}$​995 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​182
Prod 18 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​683 $\color{#cf222e}{\Huge{\mathbf{−}}}$​424 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​259

ELI5

When you use an SSH workspace on two computers, each Orca uploads its terminal layout to the SSH host so the other one can pick it up. Until now the window (renderer) decided when to upload, after every save. Since the earlier core steps the window also copies main's layout, so a layout the window just downloaded from the host would come back through main and get uploaded again, and the other computer would download it again: an echo. This step moves the upload decision into main (the part of Orca that stores sessions) and has the window hand every download to main in one call, so main can tell "a change made here" from "a change we just downloaded".

This is core step C5d of the terminal layout refactor (C5 is split into a..d). The core steps are reviewed as stacked PRs and merged together as one unit, so main never gets a half-finished version. Stacked on C5a (#26307).

What Changed

Main decides when to upload (new src/main/ipc/remote-workspace-export.ts). For each SSH host, main remembers what it and the host last agreed the host's workspace holds: the host's revision, its observation token, and the exported workspace. After any session write, main rebuilds the export for each agreed, connected host and uploads only if it differs from that agreement. The upload body, the revision/token check before uploading, and the per-host queue are today's code moved out of the old remoteWorkspace:setForConnectedTargets handler, unchanged. After a successful upload the agreement moves to the new revision; a "the host moved on" reply drops it until the window's next download.

Downloads go through main (remoteWorkspace:importPeerTopology). The window still runs today's merge (remote-workspace-session-merge.ts, unchanged). It now sends the fields the merge rewrote for that host's own ssh:<targetId> partition only, together with the download's revision, observation token and outcome, in one call. Main commits them through the topology commit module (importPeerTopology, traced like the other commits) into that partition alone, so a download can never touch local or other-host rows or roll back a main write the window has not received yet; the merge's changes to other worktrees reach main through the window's normal save. Main then records the agreement as what it now holds, so neither the import nor the window saving it back causes an upload. Outcome conflict (the window could not place every host tab) removes the agreement, so that host gets no uploads until a full download agrees again — the same protection use-app-session-persistence.ts gave before.

First connect to an empty host. The window used to upload its own copy directly (remote-workspace-target-sync.ts, the second upload path the plan refresh found). Now it reports the revision-0 download to main with nothing to import. Main agrees on the host's empty copy, so anything this computer has for the host is uploaded once, and a computer with nothing for it uploads nothing, as before.

Upload results reach the window as a push (remoteWorkspace:pushStatus). The window applies them as before: sync status, and acknowledging pending layout edits the host now holds. It ignores a report while it is mid-download, in conflict, or looking at a different host observation.

Deleted: the window's upload chain in use-app-session-persistence.ts (about 150 lines), the seed upload, and the setForConnectedTargets IPC. patchWorkspaceSessionByHost returns one promise instead of { localWrite, written }, since nothing orders an upload after the local write any more.

Not changed: what is sent to the SSH host. It is the same replace-session patch, with the same session shape and the same revision check, so older hosts and older clients on the other end see no difference. remote-workspace-target-session-export.ts (which #26098 changes) is only called, not edited; the one file both PRs touch is a two-line test edit in workspace-session-ssh-partition-ownership.test.ts.

Why

The plan's rule for the SSH echo (R11, D2) is that uploads follow this computer's own changes, never a download or the mirror copy of one. Tagging writes would need a "this write came from an import" flag threaded through the store. Comparing against the last agreement needs no flag: the import sets the agreement, so its write, and any save-back of the same content, compare equal; a split made here doesn't. It also covers main's own writes (for example a PTY binding), which the window-triggered upload only caught when the window next saved. It keeps today's merge semantics, as B2 requires; the additive merge arrives with B3.

Rejected: keeping the seed upload in the window (it is a second upload path, and the brief removes it), and keeping uploads in the window behind a "skip after import" guard (that guard would be window state racing main's writes).

Linked Issue

STA-9417 (fixed by the core as a unit).

Visual Proof

N/A. Nothing on screen changes; this step changes which process decides when to upload an SSH workspace.

Testing

  • pnpm tc, pnpm run check:code-quality:changed, audit:anti-slop, check:max-lines-ratchet and check:reliability-gates pass. CI unit shards 1–5 pass (shard 4 after one rerun of an unrelated agent-launch flake that passes locally 3/3).
  • Full local pnpm test: every file that fails on this machine also fails on C5a (bun/node:sqlite setup), except two that the second commit fixes: the session-patch boundary ratchet drops main's import handler (the import now writes through the commit module), and direct-ssh-snapshot-parking.unit.test.ts passes the new import seam. So the first commit alone fails those two tests; the pair passes.
  • Blocking, two computers and one SSH host (remote-workspace-two-desktops.test.ts). Each computer is its own copy of main's modules (own caches and client id), against an in-memory host with revisions. A split on computer A uploads exactly once; B's download of it, and B saving it back, upload nothing; then a split on B uploads exactly once and A's download uploads nothing. A computer whose download was a conflict uploads nothing. Mutation-checked: making the import agree on nothing fails both tests; turning the conflict off fails the conflict test.
  • remote-workspace.test.ts, remote-workspace-patch-queue.test.ts, ssh-host-partition-session-export.test.ts and remote-workspace-stale-resync.test.ts now drive main's trigger: one catalog read and one ownership check per worktree for all hosts, uploads only to agreed connected hosts, revision/token rejection before and while queued, reset-relay retry, independent hosts in parallel, the empty-host first connect with and without tabs, and an invalid download recording nothing.
  • Window: remote-workspace-snapshot-import-commit.test.ts (the merge goes to main with its download, only the merged fields, conflict when tabs can't be placed) and remote-workspace-push-status-event.test.ts (layout-edit acknowledgement, localized fallbacks, error, ignored mid-download/conflict/other host). The deleted remote-workspace-unplaced-upload-suppression.test.tsx covered the window's upload chain; its guarantees are now tested in main.
  • Import scope: ssh-host-partition-session-export.test.ts (real store) shows an import leaves the local and other-host partitions byte-identical, including a main-written local tab the window never saw; remote-workspace-snapshot-import-commit.test.ts shows the window sends none of its local rows (fails if the unsplit merge is sent). ssh-docker-reconnect-pane-restore, ssh-lost-kill-tab-resurrection (2), ssh-reattach-home-partition, ssh-reconnect-tab-destruction pass after the change.
  • Docker SSH e2e (background launch): ssh-cold-activation-restore, ssh-cold-hydration-gap-tab-seeding, ssh-emptied-worktree-reactivation, ssh-reconnect-tab-destruction, ssh-restart-tab-accumulation — 8 passed.
  • Parity (pnpm run test:e2e:terminal-layout-parity -- --base 80d45095d2ff --head HEAD, background launch): all 9 scenarios identical to main.

Known gaps:

  • The window still applies the merge to its own store (presentation, focus, reconnect plans) after main has committed it, so it still writes terminal fields for an import; the values match what main publishes. Removing that write is B2-12 work.
  • If the window's save-back of an import differs from what main committed (for example hydration drops an invalid active-tab pointer), that difference uploads once. The unit test covers an identical save-back only.
  • A change made here while a download is in flight is absorbed into the download's agreement and uploads with the next local change, by the plan's "never after an import" rule.
  • Older relays and shutdown (degree only). A relay shutting down kills its PTYs; main retires those panes and exports. This PR's relay refuses patches from the start of shutdown; an older relay does not, so the retirement can land on it and its snapshot loses those tabs. Main cannot tell these exits from ordinary ones (the relay sends no shutdown notice before them), so there is no desktop-side guard. The same race existed before this PR through the window's debounced upload; main exporting immediately only widens the window.
  • Export cost. After each write burst (one microtask), main re-projects and compares the session for every agreed, connected host. Scoping it to writes that touch a host's worktrees would need the write notification to carry which partition changed, and the projection also reads the local partition and repo catalog, so that is new plumbing rather than a small change; left as is.

Jinwoo-H and others added 5 commits October 7, 2026 16:29
…es; imports go through main

The window no longer uploads to SSH relays. Main exports a host's workspace after any
session write whose share for that host differs from what the two last agreed on, and
reports each export back to the window as a push-status event. The window's snapshot
import now commits its merge through main in one call (remoteWorkspace:importPeerTopology)
with the pull's revision, host observation and outcome; that sets the agreement, so an
import and the window's save-back of it never export, and a conflicted pull keeps the host
out of exports until a whole pull agrees again. The first-connect seed of an empty host is
main's export too.

Co-Authored-By: Claude <noreply@anthropic.com>
…parking test passes the import seam

Co-Authored-By: Claude <noreply@anthropic.com>
…he unavailable retry

A pull that lands while an export is in flight supersedes its outcome, so main
no longer sends that outcome to the window, where a late stale result could
flip a freshly synced target to conflict. Rename the export test driver to the
*-test-harness.ts name its neighbours use, which also drops its ratchet row.
… save-back did

The window's import outcome gains 'kept-local' when its merge keeps tabs or
pending layout edits the host has not seen (and on a never-written host). Main
then agrees on the host's own copy instead of what it now holds, so that state
uploads once; a plain import still agrees on this desktop's state and never
exports back. Replaces the revision-0 special case in main.
@Jinwoo-H
Jinwoo-H changed the base branch from topo-c5a-pending-panes to topo-c5c-sleep-wake October 7, 2026 22:38
Jinwoo-H and others added 8 commits October 7, 2026 22:13
…ade on

After a relay restart, main kept the pre-drop agreement and the matching cached host
observation, so the first session write after reconnect exported this desktop's view
(the dead PTYs' tabs already retired) before the window's pull ran, and the pull then
imported the empty host. An agreement now records the connection it was reached on and
exports only while that connection is the target's live one; the reconnect pull agrees
anew. The push-status event carries only the revision and observation it ran under.

Co-Authored-By: Claude <noreply@anthropic.com>
A relay's shutdown kills its PTYs and reports their exits; main retires those panes and
exports immediately, so the patch could land on the dying relay and erase the tabs every
client restores from its snapshot after reconnecting. The workspace store refuses patches
from the start of shutdown (answering unavailable) and reopens if the shutdown defers.

Co-Authored-By: Claude <noreply@anthropic.com>
The SSH import sent the merge split across every partition, and main patched each one straight
into the store, so a pull could overwrite local and other-host rows with the window's copy —
tabs main hadn't created, and main writes the window hadn't received yet rolled back. The window
now sends only the `ssh:<targetId>` slice, and main commits it through the topology commit module
(`importPeerTopology`, traced like the other commits) into that partition alone. Presentation
fields of other worktrees reach main through the window's normal save, as before.

Co-Authored-By: Claude <noreply@anthropic.com>
@Jinwoo-H

Jinwoo-H commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing: the layout rework is being redesigned so the runtime holds the only copy of the layout and every window/client is a view. This branch is kept for reference.

@Jinwoo-H Jinwoo-H closed this Oct 8, 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