Repository navigation
SSH host session partitions are written but never read — tab lists round-trip as empty and remote sync permanently deletes them #12721
Description
Summary
SSH-owned worktree session state is persisted into per-host ssh:<targetId> partitions (workspaceSessionsByHostId in main-process persistence) by main-process runtime flows, but no reader ever consults those partitions:
- Boot hydration (
fetchWorkspaceSessionWithRuntimeHostOwnersinsrc/renderer/src/lib/workspace-session-host-persistence.ts) fetches the local partition plusruntime:*partitions only —listKnownRuntimeHostIdsfiltersparseExecutionHostId(...)?.kind === 'runtime', sossh:partitions are skipped. - The remote-workspace push fallback (
remoteWorkspace:setForConnectedTargetsinsrc/main/ipc/remote-workspace.ts, called with nosessionargument by the renderer's debounced session-write subscriber) exports fromstore.getWorkspaceSession()— the local partition only.
The writers disagree about ownership: renderer persistence maps SSH worktrees to the local partition (buildHostIdByWorktreeId, with an explicit comment that partitioning them out "would double-own that data"), while the main-process runtime maps them to the ssh: partition (tryGetWorkspaceSessionHostIdForWorktree in orca-runtime.ts; #12671 recently routed more retirement writes there). Any worktree whose freshest (or only) tab list ends up on the ssh: side becomes invisible.
Impact — permanent, self-sustaining tab deletion
Invisible is not just cosmetic, because the remote-workspace round trip treats "empty" as authoritative:
- The debounced push exports the target's worktrees from the local partition → worktrees whose tabs live only in the
ssh:partition are uploaded as explicit empty tab lists to the remote snapshot (~/.orca/sessions/<ns>.jsonon the SSH host). - On the next launch,
syncAfterConnectpulls that snapshot andmergeDirectSshRemoteWorkspaceSessionreplaces all worktrees of the target scope with the remote lists — the empty entries delete the local tabs. - The wiped state is pushed back up, keeping the snapshot poisoned. Every subsequent launch repeats the wipe.
Observed on a real workspace (macOS client, Debian SSH target): the local partition held tabsByWorktree entries with 0 tabs for three worktrees, while workspaceSessionsByHostId['ssh:<targetId>'] in the same orca-data.json held the real lists (10, 3 and 3 tabs, with layouts and relay PTY bindings); the box-side snapshot was frozen at a stale revision with explicit [] for exactly those worktrees. Result: those worktrees' tabs were destroyed on every app start, while one worktree whose tabs happened to live in the local partition survived every time.
Repro sketch
- Connect an SSH target with a repo and open tabs in one of its worktrees.
- Cause a main-process runtime write for that worktree so its session state lands in the
ssh:partition (pane retirement / windowless PTY handoff paths usegetWorkspaceSessionHostIdForWorktree), and get the local partition entry emptied (any of the merge/sweep paths that rewritetabsByWorktree). - Restart the app twice. First start uploads the empty list to the target snapshot; every start from then on wipes the worktree's tabs, even after the user re-creates them (the recreated tabs survive only until the next pull).
Root cause
Two writers use two different worktree→partition owner maps, and both readers only follow one of them. Whatever data lands on the unread side is not "unknown" to the readers — it is round-tripped as absence, which the remote-authoritative merge converts into deletion.
Suggested direction
Read-side adoption (no writer changes): boot hydration and the export fallback treat a populated ssh:-partition entry for a worktree whose local entry holds no tabs as recoverable state instead of absence — see linked PR. The writer-side ownership disagreement itself is tracked separately (it deserves its own decision rather than being folded into a data-loss fix).
Environment
- Reproduced on v1.4.168 (installed) and traced in current
mainsource. - Related: fix(runtime): retire an exited pane's surface in its owning host partition (STA-3463) #12671 (routed more runtime retirement writes into the owning
ssh:partition and documents the stale-partition resurrection pathology), [Bug]: Closed SSH terminal tabs resurrect: reattach re-creates persisted tabs, erases close tombstones, and auto-runs claude --resume on old sessions #12447 (deletion tombstones for the remote snapshot merge).
Activity
Turtle-Hwan commentedon Aug 19, 2026
Still present on 1.4.182 — with a diagram, and two consequences worth adding
I hit this from the symptom side and traced it back to the chain described here.
Confirming it unchanged at
main, and adding what it does downstream.What it looks like before you know the cause
Three symptoms that seem unrelated, but are all this one bug:
snapshot — not once.
live processes, all writing one transcript file.
The cause in one sentence
Two writers disagree about where SSH session state lives, and the uploader reads the one
that is not being kept current.
The renderer deliberately keeps SSH worktrees in the
localpartition(
workspace-session-host-persistence.ts:172-177: "partitioning them too would double-ownthat data"), while the main-process runtime routes the same worktrees to the
ssh:partition. Whichever wrote last wins — and the uploader never looks at the
ssh:side.Because the UI reads the partition that is current, nothing looks wrong locally. The
divergence is only visible in the file on the remote host.
What that produced here
One SSH target, six worktrees. Calling two of them A and B:
workspaceSession(local partition)workspaceSessionsByHostId["ssh:<id>"]~/.orca/sessions/<ns>.jsonSo the snapshot the host restores from carried only stale entries, and never carried the
live ones. Symptom 1 and symptom 2, from the same read.
The chain, confirmed in source
src/renderer/src/app-shell/use-app-session-persistence.ts:90callssetForConnectedTargets({ hydratedTargetIds })with nosession— one line afterpatchWorkspaceSessionByHost(...)has already computed the correct per-host slices.src/main/ipc/remote-workspace.ts:138—args.session ?? store.getWorkspaceSession().src/main/persistence/loading-store/store.ts:2771-2781—resolveHostId(undefined)returns
LOCAL_EXECUTION_HOST_ID, so the fallback resolves to the local blob. Theparameter is optional, so no type error guards this.
exportRemoteWorkspaceSessionfilters worktrees viaisTargetWorktree, which selectswithin a session but cannot change which partition was read.
Step 1 is the part that surprised me: the correctly partitioned data exists at the call
site and is simply not passed.
Two consequences
You cannot repair it by editing either file while the app is running. Editing the host
snapshot is reverted within one revision (
rev N->rev N+1, same ids back). Editing thelocal partition is reverted at the next launch, because the in-memory session is
authoritative and is flushed on quit. Only an edit made with the app fully quit survives.
Pruning the stale entries alone makes it worse — this is symptom 3. After removing them
from
workspaceSessionand relaunching, the app did not reattach to the live relay PTYs. Itcreated a fresh tab per conversation and ran
claude --resume <same-id>in each. Terminalcount went 9 -> 16, and three sessions each ended up with three live processes writing one
transcript:
Killing the extra processes did not help — the surviving tab relaunched
--resumewithinseconds. Only closing the tab stuck. So whenever the wrong partition is projected, this
issue is an upstream cause of the duplicate-agent reports (#12699, #13716).
Where this stands
#12722 looks like the fix and is still open (mergeable, updated yesterday); #12339 covers
the adjacent pull-side resurrection. This issue has no labels and no comments since it was
filed on Aug 5.
The failure mode silently drops tab lists and can fan one agent session out into several
processes writing a single transcript, so it would be good to see #12722 land — or to know
what it is waiting on. The reproduction is stable here and I am happy to test a build.
fix(ssh): a reattach may bind a pane but must never create one (stabl…
mixedb commentedon Sep 1, 2026
Additional confirmation on a different client platform: Windows desktop client (current auto-updated build as of 2026-09-01), direct-SSH target on Debian 12.
Symptoms match the root cause described here, with one difference in severity: for us the affected worktree card does not even open an empty workspace. Clicking it is a complete no-op (no tabs, no status dot, no error), while every card without an agent session restores normally. The relay log on the host shows the relaunched client handshaking and being accepted, and then zero PTY attach traffic when the dead card is clicked, so the click dies entirely client-side before any reattach is attempted.
Repro is 100% for us:
Reproduced three times, including after deleting and recreating the worktree under a different workspace base path. The agent process itself survives on the host under the detached relay every time (verified via the host process table); only the client-side tab state is lost, consistent with the empty-list round-trip described in this issue.
Happy to provide client logs if useful. Looking forward to #12722.
5 remaining items
test(session): pin the empty-base-row gap for a contested id
fix(session): give an SSH workspace one owning partition so its tabs …
fix(session): give an SSH workspace one owning partition so its tabs …
test(GAP-03): red tests for offline-client tab resurrection on reconnect
fix(GAP-03): stop reconnect from re-adopting server-closed tabs
test(GAP-03): Docker cross-site offline-reconnect tab resurrection ha…
test(GAP-03): fix Docker harness fault-injection so it actually exerc…
fix(ssh): a reattach may bind a pane but must never create one (stabl…