Skip to content

SSH host session partitions are written but never read — tab lists round-trip as empty and remote sync permanently deletes them #12721

Closed

Description

@robertnisipeanu

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 (fetchWorkspaceSessionWithRuntimeHostOwners in src/renderer/src/lib/workspace-session-host-persistence.ts) fetches the local partition plus runtime:* partitions only — listKnownRuntimeHostIds filters parseExecutionHostId(...)?.kind === 'runtime', so ssh: partitions are skipped.
  • The remote-workspace push fallback (remoteWorkspace:setForConnectedTargets in src/main/ipc/remote-workspace.ts, called with no session argument by the renderer's debounced session-write subscriber) exports from store.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:

  1. 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>.json on the SSH host).
  2. On the next launch, syncAfterConnect pulls that snapshot and mergeDirectSshRemoteWorkspaceSession replaces all worktrees of the target scope with the remote lists — the empty entries delete the local tabs.
  3. 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

  1. Connect an SSH target with a repo and open tabs in one of its worktrees.
  2. Cause a main-process runtime write for that worktree so its session state lands in the ssh: partition (pane retirement / windowless PTY handoff paths use getWorkspaceSessionHostIdForWorktree), and get the local partition entry emptied (any of the merge/sweep paths that rewrite tabsByWorktree).
  3. 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

Activity

  1. self-assigned this
    on Aug 5, 2026
  2. Turtle-Hwan commented on Aug 19, 2026

    @Turtle-Hwan
    Contributor

    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:

    1. Tabs you closed come back on every relaunch, forever. Some of mine were 14 days old.
    2. Tabs you actually use are never saved. A whole worktree's tabs never reached the host
      snapshot — not once.
    3. One conversation ends up running as several processes. Same agent session id, three
      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.

    renderer  --writes-->  workspaceSession                      ("local" partition)
                           ^
                           +-- uploader reads ONLY this --> host ~/.orca/sessions/<ns>.json
    
    main runtime --writes-->  workspaceSessionsByHostId["ssh:<id>"]
                              ^
                              +-- the UI reads this, so the screen looks fine
    

    The renderer deliberately keeps SSH worktrees in the local partition
    (workspace-session-host-persistence.ts:172-177: "partitioning them too would double-own
    that 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:

    store what it held
    workspaceSession (local partition) 8 tabs for worktree A — 6 with no PTY, created 6-14 days earlier; 2 live
    workspaceSessionsByHostId["ssh:<id>"] 25 tabs over 6 worktrees, including every live tab of worktree B
    host ~/.orca/sessions/<ns>.json an exact copy of the local partition: A's 6 dead + 2 live, and zero tabs for B

    So 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

    1. src/renderer/src/app-shell/use-app-session-persistence.ts:90 calls
      setForConnectedTargets({ hydratedTargetIds }) with no session — one line after
      patchWorkspaceSessionByHost(...) has already computed the correct per-host slices.
    2. src/main/ipc/remote-workspace.ts:138 — args.session ?? store.getWorkspaceSession().
    3. src/main/persistence/loading-store/store.ts:2771-2781 — resolveHostId(undefined)
      returns LOCAL_EXECUTION_HOST_ID, so the fallback resolves to the local blob. The
      parameter is optional, so no type error guards this.
    4. exportRemoteWorkspaceSession filters worktrees via isTargetWorktree, which selects
      within 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 the
    local 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 workspaceSession and relaunching, the app did not reattach to the live relay PTYs. It
    created a fresh tab per conversation and ran claude --resume <same-id> in each. Terminal
    count went 9 -> 16, and three sessions each ended up with three live processes writing one
    transcript:

    S1 -> pids [a, b, c]
    S2 -> pids [a, b, c]
    S3 -> pids [a, b, c]
    

    Killing the extra processes did not help — the surviving tab relaunched --resume within
    seconds. 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.

  3. mixedb commented on Sep 1, 2026

    @mixedb

    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:

    1. Create an SSH worktree, launch Claude Code in its terminal, exchange at least one message.
    2. Quit and relaunch the client (an auto-update restart triggers it as well).
    3. The worktree's card is dead on arrival as described above.

    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.

  4. 5 remaining items

  5. added a commit that references this issue on Sep 17, 2026
    0e3b71f
  6. added a commit that references this issue on Sep 20, 2026
    a24e8da
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions