Skip to content

[Bug]: Closed SSH terminal tabs resurrect on every app launch from stale legacy workspaceSession.tabsByWorktree, which host-scoped close never prunes #22503

Closed

Description

@practicaldev101

Operating system

Linux

Orca version

1.4.188

Details

Operating system

Linux (Fedora 43, x86_64). Desktop client connected to a remote Linux host over SSH (Orca relay).

Orca version

1.4.188

Details

Summary

On every launch of Orca, terminal tabs for SSH worktrees come back even though I closed them days or weeks earlier. Most of them come back as Claude agent tabs, and Orca resumes their old sessions.

The source is the legacy local workspaceSession in orca-data.json. It still has stale tabsByWorktree entries for SSH worktrees. When I close a tab, Orca removes it from the host-scoped session (workspaceSessionsByHostId[<ssh host>]) but not from the legacy local record. On the next startup the legacy tabs are merged back into the host-scoped session, and Orca spawns a fresh PTY for each one.

This looks related to #12447, but it is a different path. In #12447 a failed pty.kill leaves a live remote PTY, and reconnecting reattaches it. Here every previous PTY for the resurrected tabs was already terminated or expired. Orca rebuilds the tab from the persisted legacy record and spawns a new PTY.

Evidence (from ~/.config/orca/profiles/local-default/orca-data.json)

For one SSH worktree (<repoId>::/home/<user>/<repo>):

  • workspaceSession.tabsByWorktree[<wt>] (legacy, local) had 11 tabs. They were created between Aug 24 and Sep 16, and all had ptyId: null. Most had launchAgent: "claude" and an aiVaultTitle of the form { agent: "claude", sessionId, title }.

  • workspaceSessionsByHostId["ssh:<hostId>"].tabsByWorktree[<wt>] had 18 tabs. It contained all 11 legacy tab ids, each with a new ptyId, plus the 7 tabs I actually use.

  • The createdAt of the re-created host-scoped tabs matched my app launch times exactly (Sep 21 14:22, Sep 21 16:53, Sep 22 20:33).

  • sshRemotePtyLeases for those tab ids shows the pattern: each tab has 3 to 13 leases over time. Every older lease is terminated or expired, and each launch adds a new lease with a new PTY. Example for one tab:

    09-14 14:28 pty-564 terminated
    09-16 23:24 pty-630 terminated
    09-18 01:07 pty-707 expired
    09-21 14:22 pty-738 detached   <- new PTY spawned on app launch
    

    So no old PTY was still alive to reattach. The tab was rebuilt from the persisted record.

  • 22 SSH worktrees across 2 SSH repos had leftover legacy tabsByWorktree entries (36 tabs in total). Local, non-SSH worktrees did not.

  • sshRemotePtyLeases had grown to 738 entries (638 terminated, 66 expired).

How to reproduce

I have not reduced this to a minimal repro. Based on the persisted state, the sequence looks like this:

  1. Have an SSH worktree whose tabs were first saved in the legacy local workspaceSession. That was possible before the host-scoped workspaceSessionsByHostId existed, or before the worktree was migrated to it.
  2. Open terminal or agent tabs there, then close them from the tab bar.
  3. Quit Orca and launch it again.
  4. The closed tabs reappear. Agent tabs auto-resume their old sessions.
  5. Closing them again only fixes the host-scoped session. They come back on the next launch.

Expected

  • Closing a tab in an SSH worktree removes it from every persisted session source, including the legacy workspaceSession.tabsByWorktree, unifiedTabs, tabGroups[].tabOrder, terminalLayoutsByTabId, remoteSessionIdsByTabId and sleepingAgentSessionsByPaneKey.
  • Alternatively: once a worktree has a host-scoped session, the startup merge treats that session as the source of truth and does not re-import legacy tabs for it. A migration could also drop the legacy entries for SSH worktrees once and for all.
  • The startup restore should check close tombstones before recreating a tab and spawning a PTY for it.

Workaround

With Orca fully quit, I removed the SSH worktrees' terminal tabs from the legacy workspaceSession. I only touched these keys: tabsByWorktree, the terminal entries in unifiedTabs, tabGroups[].tabOrder/activeTabId, terminalLayoutsByTabId, remoteSessionIdsByTabId, sleepingAgentSessionsByPaneKey and terminalPtyIncarnationsByPaneKey. I left workspaceSessionsByHostId untouched.

After relaunching, I closed the ghost tabs one last time. Orca rewrote the file, the legacy entries stayed empty, and the host-scoped session kept only the tabs I had left open. On the next quit and relaunch, none of the closed tabs came back. That confirms the legacy local record was what re-created them.

Activity

  1. AmethystLiang commented on Sep 23, 2026

    @AmethystLiang
    Contributor

    thanks for reporting. looking into it

  2. tlgnkl commented on Sep 27, 2026

    @tlgnkl

    Same bug on macOS → macOS SSH host, 1.4.212. Code references are checked against main @ 27b823f (1.4.214). Below are the code path, a second channel that the original report doesn't cover, and why close tombstones can't stop it.

    The rule that makes the stale copy win

    adoptStrandedHostPartitionSession removes a workspace from adoptable whenever the base (local) holds at least one terminal tab for it. The comment at L46-L49 calls that copy "the live copy the user is looking at". For a profile with leftover local rows it is the stale one. The whole ssh:<target> row set for that workspace is then ignored: tabs, unifiedTabs, tabGroups, and closedTerminalTabTombstonesByTabId.

    This function is called from two places:

    1. Boot read (renderer): workspace-session-host-hydration.ts#L222-L243. This is the path the original report describes.
    2. Remote snapshot upload (main): the debounced writer calls remoteWorkspace:setForConnectedTargets without a session (use-app-session-persistence.ts#L156-L160). So main builds it from disk through persistedSessionForTarget, which runs the same adoption with local as the base (remote-workspace.ts#L238-L242). The result is sent as a replace-session (relay/workspace-session-handler.ts#L136-L139).

    So the stale local copy isn't only restored at boot. It is also written into the host snapshot (~/.orca/sessions/<ns>.json) on every sync. That explains two things. Editing the host snapshot by hand is undone within seconds. And the ghosts can come back after a plain relay reconnect, with no app restart: on one night about 46 empty shells were spawned after a reconnect.

    Evidence: the per-worktree snapshot is exactly the local copy wherever local is non-empty

    Read-only comparison. Orca was running, and the host snapshot was at revision 14384, written a minute earlier:

    worktree local tabs ssh: tabs host snapshot tabs snapshot equals local tabs tombstoned in ssh:
    repo-a 30 16 30 local 30
    repo-b 31 17 31 local 31
    repo-c-branch 1 51 1 local 0
    repo-c 0 2 2 ssh –
    repo-c-mobile 0 5 5 ssh –
    repo-c-flow 0 9 9 ssh –
    repo-c-artifacts 0 1 1 ssh –
    • local and ssh: share no tab ids for SSH worktrees. All 62 local rows have ptyId: null.
    • 61 of the 62 local rows are tombstoned in the ssh: partition. I closed them on Sep 26 with orca terminal close --tab, after they had resurrected and filled the relay's 50-PTY cap. They are still in local, and still in the host snapshot.
    • Only 4 of the 79 tabs in the host snapshot belong to terminals that orca terminal list shows as live (10 remote terminals, relay ptys=5).
    • The local rows are not only pre-migration leftovers. Their createdAt values run from Sep 9 to Sep 26, with 11 on Sep 22 and 14 on Sep 23 (both days with an app restart or relay reconnect). None of them ever reached the ssh: partition. Something in 1.4.207–1.4.212 still writes new SSH-worktree tabs into local. I could not pin down which write does it. A candidate: boot sets contestedPrimaryHostBySessionKey[key] = 'local' for every key the base kept (extractContestedHostSessionEntries records the uncontested single-partition case too, and adoption only overrides adopted ids). The write routing then honours that local owner until the SSH catalog claims the worktree (workspace-session-host-persistence.ts#L175-L182). Any tab created or re-created during that window after boot or reconnect lands in local.

    Why close tombstones (#16752) can't stop it

    • Not loaded. closedTerminalTabTombstonesByTabId is surfaceTombstoneKeyed (ownership table). Adoption copies those records only for adoptable workspaces (L367-L375). For a base-owned workspace, the renderer boots with the stale tabs and none of their tombstones. local itself has 0 tombstones.
    • Overridden. Even when a tombstone is present, isSuppressedByClose ignores it for any id in the current store (!currentTabsById.has(tabId)). After a boot that restored the stale rows, every ghost counts as "live locally".
    • Never retired, then expires. A tombstone retires only when the host stops listing the tab (closed-terminal-tab-tombstones.ts#L91-L101). The host keeps listing it, because main keeps uploading local. So the tombstones sit there until the 30-day TTL (L28-L31), and after that nothing suppresses the 61 tabs even in a running session.

    Consequence: the relay PTY cap

    Each resurrected row gets a fresh shell on the host. On Sep 26 that was 45 empty zsh plus 5 agents = 50, and new terminals failed with Maximum number of PTY sessions reached (50) (relay/pty-handler.ts#L1627-L1629). Killing the shells on the host doesn't help: Orca respawns them within seconds while the tab exists. The only recovery that doesn't lose running agents is closing the tabs one by one (orca terminal close --tab). The rows stay in local, so the next restart brings back all 62, which is more than the cap.

    Suggested direction

    • Once a workspace's ssh:<target> partition has rows, treat it as authoritative. local rows should only fill gaps (for example, never a tab id the ssh: side has tombstoned). Or do what the partition-owner comment says will happen (workspace-session-partition-owner.ts#L16-L19) and delete SSH-worktree rows from local after the one-time fold, so the second copy stops existing.
    • Build the upload in persistedSessionForTarget from the ssh: partition, never from local. The renderer could also pass its in-memory session so that main publishes what the user sees.
    • Adopt tombstones independently of tab rows, and check them before the "live locally" exemption when the live tab came from boot restore rather than a user action.

    The same adoption rule also loses live agent tabs on restart while their processes keep running on the host. I filed that side as #23390.

    Happy to test a build against this profile. It reproduces deterministically for me.

  3. OrcaWin commented on Oct 9, 2026

    @OrcaWin
    Collaborator

    Thanks for the detailed write-up, it made this much easier to track down. This should be fixed by #26098, now on main: at startup, a workspace that lives on an SSH host now takes that host's session as the source of truth, so leftover local tab rows are dropped instead of being merged back in, and they're no longer uploaded to the host either. #26088 also stops relay reattach from leaving new local leftovers behind. If closed SSH tabs still come back on a build that includes it, please reopen with details and we'll take another look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingos:linuxIssues affecting Linux

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions