Files
orca/docs/reference/ssh-session-partition-move.md
T
Neil 22aaebf4fe fix(session): read the ssh partition a release before writing to it
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.
2026-09-10 17:35:17 -07:00

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. mergeDirectSshRemoteWorkspaceSession can only refuse to delete tabs this client actually holds — hydrating them out of ssh:<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:

  1. Every previously shipped build reads SSH session state out of local alone, and never enumerates ssh:* partitions.
  2. A client that has moved the rows therefore looks empty for every SSH workspace on the older build — including unsaved dirtyDraftContent.
  3. That build's publish then omits the workspace, and the relay applies workspace.patch / replace-session as 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. parseWorkspaceSessionsByHostId keeps any valid non-local host id, in the older build too, so the ssh:<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 as unverifiable.
  • 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 base tabsByWorktree row is exactly what makes workspacesTheBaseOwns refuse to adopt, so this silently disables the adoption repair it ships alongside.
  • Leave the legacy local copy 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.

  • clientWorkspaceSessionWritePartitionHostId in src/shared/workspace-session-partition-owner.ts maps ssh:* back to local. Two call sites: sessionPartitionHostFor (workspace-session-host-contention.ts) and the routing tail of buildHostSessionRouting (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.

  1. Delete clientWorkspaceSessionWritePartitionHostId and both call sites; they revert to workspaceSessionPartitionHostId.
  2. 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 the local / SSH_HOST_ID expectations.
    • workspace-session-host-contention.test.ts — "keeps an SSH claimant out of the rotating runtime partition": LOCAL_EXECUTION_HOST_ID → SSH_HOST. Keep the not.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 a SSH_HOST write.
  3. 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."
  4. Fix, or accept with eyes open, the swallowed partition write in patchWorkspaceSessionByHost: the awaited local patch is what removes these rows from local, while the partition write meant to receive them is void-ed with a console.warn. Today that only risks runtime:* rows; from N+1 a swallowed rejection loses an SSH workspace's tabs and unsaved drafts outright.