The SSH partition move has two halves, and only one of them is the fix. Reading both partitions IS the repair for #12721: the remote merge can only refuse to delete tabs this client actually holds, and hydrating them out of `ssh:<targetId>` is what arms that defence. Moving the write is cleanup that collapses the #12723 double-ownership. Shipping both at once is what a downgrade cannot survive. Every previously shipped build reads SSH session state out of `local` alone and never enumerates `ssh:*`, so a client that has moved the rows looks empty to the older build -- unsaved editor drafts included -- and that build's publish then omits the workspace, which the relay applies as a wholesale replace-session overwrite. Exposure is launch-and-quit, not 'use an SSH workspace': routing reads the persisted repo catalog, so an offline target still moves on the quit checkpoint. So this release reads `ssh:<targetId>` and keeps writing SSH state where every shipped build looks for it. The four destination assertions now say `local` and each names the file that flips them; the invariants around them -- draft survival, tombstone semantics, contested-id rules, and an SSH claimant staying out of the rotating runtime partition -- are asserted destination-independently and hold in both releases. docs/reference/ssh-session-partition-move.md carries the argument and the N+1 checklist, including the fleet condition that gates it.
6.2 KiB
Moving SSH workspace session state into its own partition
A worktree's durable session state (terminal tabs, editor files including unsaved drafts, browser
workspaces, tab groups, layouts, visit recency) is stored per execution host. local is the legacy
single blob; runtime:* and ssh:* each own a partition under workspaceSessionsByHostId.
SSH worktrees were split across two of these: the renderer wrote local, the main-process runtime
read-modify-wrote ssh:<targetId> (#12723). Neither reader reunited them, so whatever landed on
the unread side round-tripped as absence, and the replace-session upload converted absence
into deletion on the host (#12721, #18173).
The repair moves SSH state into ssh:<targetId> on both sides. It ships in two releases, and
the ordering is load-bearing.
Why two releases
Both halves are needed for the end state, but only one of them is the fix:
- Reading both partitions is the repair.
mergeDirectSshRemoteWorkspaceSessioncan only refuse to delete tabs this client actually holds — hydrating them out ofssh:<targetId>is what arms that defence. With the read alone, an older client's empty publish no longer deletes anything and this client republishes the real list. - Moving the write is cleanup. It collapses the double-ownership so one workspace stops being written twice.
Shipping the write move in the same release is what a downgrade cannot survive:
- Every previously shipped build reads SSH session state out of
localalone, and never enumeratesssh:*partitions. - A client that has moved the rows therefore looks empty for every SSH workspace on the older
build — including unsaved
dirtyDraftContent. - That build's publish then omits the workspace, and the relay applies
workspace.patch/replace-sessionas a wholesale snapshot overwrite (src/relay/workspace-session-handler.ts), so the host snapshot forgets it too.
Exposure is launch-and-quit, not "use an SSH workspace". Routing derives from the persisted
repo catalog (buildRepoHostById over state.repos), so an offline target with no active
multiplexer still moves on the shutdown checkpoint.
What is and is not lost
Measured, so the severity is not overstated:
- Client-side this is invisibility, not destruction.
parseWorkspaceSessionsByHostIdkeeps any valid non-localhost id, in the older build too, so thessh:<targetId>partition survives the downgrade on disk and returns on re-upgrade. - Running remote agents are not reaped. There is no path from a lost host-snapshot entry to a
pty.kill. - The older build publishes an omission, not
path: []. It is not the authoritative-empty-list shape of #12721, and the next pull reads omission asunverifiable. - The unrecoverable residue is therefore only what lived in the host snapshot and never in this
client's
ssh:*partition — tabs created by another paired device, or minted host-side while this client was away.
Rejected alternatives
- Dual-write a compatibility copy into
local. A populated basetabsByWorktreerow is exactly what makesworkspacesTheBaseOwnsrefuse to adopt, so this silently disables the adoption repair it ships alongside. - Leave the legacy
localcopy behind instead of replacing it. Same trap, same reason. - A marker the older build would happen to honour. Every input to its publish gate is host- or
connection-derived (
hydratedTargetIds,expectedRevision,hostObservationToken,getActiveMultiplexer); nothing on local disk reaches it. And the export inverts: making a worktree fail to resolve excludes it rather than withholding the publish, which produces the damaging empty export.
Release N (this change)
Reads ssh:<targetId>, keeps writing SSH state to local.
clientWorkspaceSessionWritePartitionHostIdinsrc/shared/workspace-session-partition-owner.tsmapsssh:*back tolocal. Two call sites:sessionPartitionHostFor(workspace-session-host-contention.ts) and the routing tail ofbuildHostSessionRouting(workspace-session-host-persistence.ts).- The main-process runtime is unchanged — it already wrote
ssh:<targetId>before any of this.
Release N+1 checklist
Fleet condition: do not ship until release N is broadly adopted. N+1's safety is exactly that a
user downgrading from it lands on a build that already reads ssh:*. Any still-running build older
than N has the full exposure above.
- Delete
clientWorkspaceSessionWritePartitionHostIdand both call sites; they revert toworkspaceSessionPartitionHostId. - Flip these four assertions, each of which carries a pointer to this file:
workspace-session-ssh-partition-round-trip.test.ts— "routes the reunited workspace to the partition every shipped build reads":LOCAL_EXECUTION_HOST_ID→SSH_HOST_ID, and rename it back to "…to the partition that owns it".workspace-session-ssh-partition-round-trip.test.ts— "writes an SSH workspace emptied by this build into the partition it writes to": swap thelocal/SSH_HOST_IDexpectations.workspace-session-host-contention.test.ts— "keeps an SSH claimant out of the rotating runtime partition":LOCAL_EXECUTION_HOST_ID→SSH_HOST. Keep thenot.toBe(RUNTIME_HOST)assertion; it is the invariant and holds in both releases.workspace-session-host-contention.test.ts— "does not strand the runtime co-claimant when the SSH row is written": the SSH row moves from the local write (hostId === undefined) to aSSH_HOSTwrite.
- Release-note line: "SSH workspace session state now persists in its own store partition. Downgrading below <release N> after this update will hide SSH workspaces' tabs and editor state until you upgrade again."
- Fix, or accept with eyes open, the swallowed partition write in
patchWorkspaceSessionByHost: the awaitedlocalpatch is what removes these rows fromlocal, while the partition write meant to receive them isvoid-ed with aconsole.warn. Today that only risksruntime:*rows; from N+1 a swallowed rejection loses an SSH workspace's tabs and unsaved drafts outright.