From 343eed58db242008082eaec4646fce5d0317f3e7 Mon Sep 17 00:00:00 2001 From: m4air Date: Sun, 16 Aug 2026 21:41:32 -0700 Subject: [PATCH] Remap SSH leases per execution host, not globally SSH lease leaf IDs are now remapped within their execution host partition, preventing silently empty tabs when the same tab name exists on multiple machines after restart. Also preserve folder workspace paths exactly as provided without trimming whitespace. --- ...sistence-host-partitioned-sessions.test.ts | 152 +++++++-- ...orktree-meta-and-folder-workspaces.test.ts | 23 ++ src/main/persistence/loading-store/store.ts | 10 +- .../folder-workspace-operations.ts | 5 +- .../workspace-pane-normalization.ts | 60 ++-- sta-4540-ship-hold.html | 318 ++++++++++++++++++ 6 files changed, 513 insertions(+), 55 deletions(-) create mode 100644 sta-4540-ship-hold.html diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index c9721b68d83..0dfa23f90bb 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -70,6 +70,27 @@ describe('Store host-partitioned workspace sessions', () => { activeRepoId }) + const makeLegacyPaneHostSession = (repoId: string, ptyId: string): WorkspaceSessionState => { + const worktreeId = `${repoId}::/worktree` + return { + ...getDefaultWorkspaceSession(), + activeRepoId: repoId, + activeWorktreeId: worktreeId, + activeTabId: 'tab-shared', + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'tab-shared', worktreeId, ptyId })] + }, + terminalLayoutsByTabId: { + 'tab-shared': { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': ptyId } + } + } + } + } + const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({ ...getDefaultWorkspaceSession(), activeRepoId: 'repo-1', @@ -171,41 +192,14 @@ describe('Store host-partitioned workspace sessions', () => { schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), workspaceSessionsByHostId: { - 'ssh:host-b': { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo-ssh', - activeWorktreeId: 'repo-ssh::/worktree', - activeTabId: 'tab-ssh', - tabsByWorktree: { - 'repo-ssh::/worktree': [ - { - id: 'tab-ssh', - worktreeId: 'repo-ssh::/worktree', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty' - } - ] - }, - terminalLayoutsByTabId: { - 'tab-ssh': { - root: { type: 'leaf', leafId: 'pane:1' }, - activeLeafId: 'pane:1', - expandedLeafId: null, - ptyIdsByLeafId: { 'pane:1': 'remote-pty' } - } - } - } + 'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty') }, sshRemotePtyLeases: [ { targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'repo-ssh::/worktree', - tabId: 'tab-ssh', + tabId: 'tab-shared', leafId: 'pane:1', state: 'detached', createdAt: 1, @@ -216,13 +210,111 @@ describe('Store host-partitioned workspace sessions', () => { const store = await createStore() - const root = store.getWorkspaceSession('ssh:host-b').terminalLayoutsByTabId['tab-ssh']?.root + const root = store.getWorkspaceSession('ssh:ssh-1').terminalLayoutsByTabId['tab-shared']?.root const leafId = root?.type === 'leaf' ? root.leafId : null expect(leafId && isTerminalLeafId(leafId)).toBe(true) // The lease follows the partition's rewritten leaf, not the legacy `pane:1`. expect(store.getSshRemotePtyLeases('ssh-1')[0]?.leafId).toBe(leafId) }) + it('remaps legacy SSH leases within their execution-host partition', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('local-repo'), + workspaceSessionsByHostId: { + 'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'), + 'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b') + }, + sshRemotePtyLeases: [ + { + targetId: 'host-a', + ptyId: 'pty-a', + worktreeId: 'repo-a::/worktree', + tabId: 'tab-shared', + leafId: 'pane:1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + }, + { + targetId: 'host-b', + ptyId: 'pty-b', + worktreeId: 'repo-b::/worktree', + tabId: 'tab-shared', + leafId: 'pane:1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + const store = await createStore() + const rootA = store.getWorkspaceSession('ssh:host-a').terminalLayoutsByTabId['tab-shared']?.root + const rootB = store.getWorkspaceSession('ssh:host-b').terminalLayoutsByTabId['tab-shared']?.root + const leafA = rootA?.type === 'leaf' ? rootA.leafId : null + const leafB = rootB?.type === 'leaf' ? rootB.leafId : null + + expect(leafA && isTerminalLeafId(leafA)).toBe(true) + expect(leafB && isTerminalLeafId(leafB)).toBe(true) + expect(leafA).not.toBe(leafB) + expect(store.getSshRemotePtyLeases('host-a')[0]?.leafId).toBe(leafA) + expect(store.getSshRemotePtyLeases('host-b')[0]?.leafId).toBe(leafB) + }) + + it('repairs a stable SSH lease leaf copied from another host partition', async () => { + const leafA = '11111111-1111-4111-8111-111111111111' + const leafB = '22222222-2222-4222-8222-222222222222' + const makeStableHostSession = ( + repoId: string, + ptyId: string, + leafId: string + ): WorkspaceSessionState => { + const worktreeId = `${repoId}::/worktree` + return { + ...getDefaultWorkspaceSession(), + activeRepoId: repoId, + activeWorktreeId: worktreeId, + activeTabId: 'tab-shared', + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'tab-shared', worktreeId, ptyId })] + }, + terminalLayoutsByTabId: { + 'tab-shared': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + } + } + } + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('local-repo'), + workspaceSessionsByHostId: { + 'ssh:host-a': makeStableHostSession('repo-a', 'pty-a', leafA), + 'ssh:host-b': makeStableHostSession('repo-b', 'pty-b', leafB) + }, + sshRemotePtyLeases: [ + { + targetId: 'host-b', + ptyId: 'pty-b', + worktreeId: 'repo-b::/worktree', + tabId: 'tab-shared', + leafId: leafA, + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + const store = await createStore() + + expect(store.getSshRemotePtyLeases('host-b')[0]?.leafId).toBe(leafB) + }) + it('isolates writes: setting host A does not mutate host B or local', async () => { const store = await createStore() diff --git a/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts b/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts index 19019a314cb..f72956fb20b 100644 --- a/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts +++ b/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts @@ -287,6 +287,29 @@ describe('Store', () => { ]) }) + it('persists the exact folder workspace path provided on create and update', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ + projectGroupId: group.id, + folderPath: '/workspace/platform ' + }) + + expect(workspace.folderPath).toBe('/workspace/platform ') + expect( + store.updateFolderWorkspace(workspace.id, { folderPath: '/workspace/platform-next ' }) + ?.folderPath + ).toBe('/workspace/platform-next ') + + store.flush() + const restored = await createStore() + expect(restored.getFolderWorkspace(workspace.id)?.folderPath).toBe('/workspace/platform-next ') + }) + it('round-trips Jira item and source context for repo-less folder workspaces', async () => { const store = await createStore() const group = store.createProjectGroup({ diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 4fd97e3214d..f72adf2f2d7 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -301,7 +301,8 @@ import { normalizePersistedPaneIdentityState, normalizeWorkspaceSessionPaneIdentities, remapAcknowledgedAgentPaneKeys, - remapSshRemotePtyLeaseLeafIds + remapSshRemotePtyLeaseLeafIds, + type WorkspaceSessionPaneIdentityRemap } from '../restoring-sessions/workspace-pane-normalization' import { mergeProjectHostSetupCompatibilityState, @@ -2880,10 +2881,13 @@ export class Store { registerPersistedPaneKeyAlias(entry) } session = normalized.session + const remapsByHostId = new Map([ + [LOCAL_EXECUTION_HOST_ID, normalized] + ]) const remappedLeases = remapSshRemotePtyLeaseLeafIds( this.state.sshRemotePtyLeases ?? [], - normalized.leafIdByInputLeafIdByTabId, - normalized.leafIdByPtyIdByTabId + remapsByHostId, + new Set(this.getWorkspaceSessionHostIds()) ) if (remappedLeases.changed) { this.state.sshRemotePtyLeases = remappedLeases.leases diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts index feef3ebf3e0..05783b7b5c2 100644 --- a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -66,10 +66,9 @@ export class FolderWorkspacePersistenceOperations { const group = (this.state.projectGroups ?? []).find( (entry) => entry.id === input.projectGroupId ) - // Why trim: the guard below accepts a padded path, so persist the same value it validated. const folderPath = typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 - ? input.folderPath.trim() + ? input.folderPath : group?.parentPath if (!group || !folderPath) { throw new Error('Folder-backed project group not found.') @@ -138,7 +137,7 @@ export class FolderWorkspacePersistenceOperations { workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name) } if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { - workspace.folderPath = updates.folderPath.trim() + workspace.folderPath = updates.folderPath } if (updates.linkedTask !== undefined) { workspace.linkedTask = normalizeWorkspaceLinkedItem(updates.linkedTask) diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index df1bf25f036..137a2e3bace 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -2,6 +2,11 @@ import type { LegacyPaneKeyAliasEntry, PersistedState } from '../../../shared/pe import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { registerLegacyPaneKeyAliasesForTab } from './pane-identity-migration' @@ -77,27 +82,46 @@ export function normalizeWorkspaceSessionPaneIdentities( } } +export type WorkspaceSessionPaneIdentityRemap = { + leafIdByInputLeafIdByTabId: Map> + leafIdByPtyIdByTabId: Map> +} + export function remapSshRemotePtyLeaseLeafIds( leases: SshRemotePtyLease[], - leafIdByInputLeafIdByTabId: Map>, - leafIdByPtyIdByTabId: Map> + remapsByHostId: ReadonlyMap, + hostIdsWithWorkspaceSessions: ReadonlySet = new Set(remapsByHostId.keys()) ): { leases: SshRemotePtyLease[]; changed: boolean } { let changed = false const nextLeases = leases.map((lease) => { - if (lease.leafId === undefined || isTerminalLeafId(lease.leafId)) { + if (lease.leafId === undefined) { return lease } + const hostId = toSshExecutionHostId(lease.targetId) + // Legacy unpartitioned state kept SSH panes in local; only fall back when this host has no partition. + const remap = + remapsByHostId.get(hostId) ?? + (hostIdsWithWorkspaceSessions.has(hostId) + ? undefined + : remapsByHostId.get(LOCAL_EXECUTION_HOST_ID)) const remappedLeafId = lease.tabId - ? leafIdByInputLeafIdByTabId.get(lease.tabId)?.get(lease.leafId) + ? remap?.leafIdByInputLeafIdByTabId.get(lease.tabId)?.get(lease.leafId) : undefined const leafIdForPty = lease.tabId - ? leafIdByPtyIdByTabId.get(lease.tabId)?.get(lease.ptyId) + ? remap?.leafIdByPtyIdByTabId.get(lease.tabId)?.get(lease.ptyId) : undefined - changed = true const nextLeafId = remappedLeafId ?? leafIdForPty if (nextLeafId) { + if (nextLeafId === lease.leafId) { + return lease + } + changed = true return { ...lease, leafId: nextLeafId } } + if (isTerminalLeafId(lease.leafId)) { + return lease + } + changed = true const next = { ...lease } // Why: unmatched legacy leaf ids are ambiguous after migration; don't re-persist them as durable pane identity. delete next.leafId @@ -106,8 +130,8 @@ export function remapSshRemotePtyLeaseLeafIds( return { leases: nextLeases, changed } } -/** Combines per-tab leaf maps from separate host partitions; an already-mapped tab keeps its mapping. */ -function mergeLeafIdMapsByTabId( +/** Acknowledgement keys lack host metadata, so an already-mapped tab keeps its mapping. */ +function mergeAcknowledgementLeafIdMapsByTabId( target: Map>, source: Map> ): Map> { @@ -126,8 +150,10 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {}) - let leafIdByInputLeafIdByTabId = normalizedSession.leafIdByInputLeafIdByTabId - let leafIdByPtyIdByTabId = normalizedSession.leafIdByPtyIdByTabId + let acknowledgementLeafIdByInputLeafIdByTabId = normalizedSession.leafIdByInputLeafIdByTabId + const remapsByHostId = new Map([ + [LOCAL_EXECUTION_HOST_ID, normalizedSession] + ]) const hostSessionLegacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] // Why: SSH/runtime hosts keep their own session blob, and their legacy leaves need the same UUID // rewrite — otherwise their leases and read markers still point at `pane:1` after migration. @@ -145,14 +171,11 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { } const normalizedHostSession = normalizeWorkspaceSessionPaneIdentities(hostSession, {}) normalizedHostSessions[hostId] = normalizedHostSession.session - leafIdByInputLeafIdByTabId = mergeLeafIdMapsByTabId( - leafIdByInputLeafIdByTabId, + remapsByHostId.set(hostId, normalizedHostSession) + acknowledgementLeafIdByInputLeafIdByTabId = mergeAcknowledgementLeafIdMapsByTabId( + acknowledgementLeafIdByInputLeafIdByTabId, normalizedHostSession.leafIdByInputLeafIdByTabId ) - leafIdByPtyIdByTabId = mergeLeafIdMapsByTabId( - leafIdByPtyIdByTabId, - normalizedHostSession.leafIdByPtyIdByTabId - ) for (const entry of normalizedHostSession.legacyPaneKeyAliasEntries) { hostSessionLegacyPaneKeyAliasEntries.push(entry) } @@ -161,8 +184,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { } const remappedLeases = remapSshRemotePtyLeaseLeafIds( state.sshRemotePtyLeases ?? [], - leafIdByInputLeafIdByTabId, - leafIdByPtyIdByTabId + remapsByHostId ) const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([ @@ -173,7 +195,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { ]) const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( state.ui?.acknowledgedAgentsByPaneKey, - leafIdByInputLeafIdByTabId + acknowledgementLeafIdByInputLeafIdByTabId ) const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( state.migrationUnsupportedPtyEntries ?? [], diff --git a/sta-4540-ship-hold.html b/sta-4540-ship-hold.html new file mode 100644 index 00000000000..f67c4c578b5 --- /dev/null +++ b/sta-4540-ship-hold.html @@ -0,0 +1,318 @@ + + + + + + STA-4540 · Ship or hold + + + +
+
+ Default · ship + No UI change + STA-4540 · restart reconnect +
+ +
+

After restart, the same SSH tab can come back empty while the remote session is still running.

+

Silent. Same tab title. No toast. They think the work died. It didn’t — Orca just reattached the wrong pane.

+
+ Problem +

You leave Claude · auth-fix mid-edit on staging, restart Orca, reopen that same tab — body is a blank $.

+ Fix +

On restart, wire that tab back to staging’s still-running session — not to the first other machine that had the same old pane slot.

+
+
Ship before the next build that includes yesterday’s rewrite. Persistence only; the empty-tab failure is already in that rewrite.
+
+ +
+

Does / does not

+
+
+

Does

+
    +
  • Same SSH tab comes back with the command or agent you left running.
  • +
  • Two machines no longer steal each other’s reconnects.
  • +
  • A folder workspace keeps the exact path you set, including a trailing space.
  • +
+
+
+

Does not

+
    +
  • Change any button, tab, toast, or reconnect UI.
  • +
  • Put a session back if this build already restarted once and emptied the tab.
  • +
  • Move, rename, or rescan folders on disk.
  • +
+
+
+
+ +
+

One window

+

Orca keeps remote terminals alive across quit. Restart is supposed to reopen the same tab onto that still-running session — not start a new shell.

+
+
+
1 · Beforethe session they meant to keep
+
+
staging
+
+ Claude · auth-fix + zsh +
+
+
Claude
+
Editing src/auth.ts — adding the login redirect.
+
Checking the session cookie path next.
+
+
+

They are looking at this tab. Work is mid-flight. They quit Orca on purpose; staging should keep the session.

+
+ +
+
2 · Restartold check says yes
+
+
staging
+
+ Claude · auth-fix + zsh +
+
+
Reconnecting…
+
+
Same tab name?Yes
+
Same old pane slot as a rewrite we just did?Yes ← replacement
+
Same machine as that rewrite?Never asked
+
+
+
+

Same window, same title. Yesterday’s rewrite treats another machine’s new pane as “the” replacement. No toast.

+
+ +
+
3 · Endingsame tab, two bodies
+
+
+
Wrong · now
+
+
Claude · auth-fix
+
$
+
empty shell
+
+
+
+
Right · this change
+
+
Claude · auth-fix
+
Editing src/auth.ts — adding the login redirect.
+
+
+
+

If this looked like a new tab, it would be the wrong bug. The title never changes.

+
+
+
+ +
+

Why the old check is the wrong question

+
+

Same picture as above: staging’s Claude · auth-fix is being matched against a rewrite that may belong to another machine.

+
+
+ Tab still called Claude · auth-fix?
Surface check — always true here
+ Yes +
+
+ Did yesterday’s change rewrite old pane slots on every machine?
Earlier change — keep this
+ Yes +
+
+ If the old pane slot matches a rewrite, is that the replacement?
Earlier change — this is the wrong YES
+ Yes +
+
+ Is that rewrite from the same machine as this tab?
This change — the question it never asked
+ Never asked +
+
+

Yesterday also started trimming folder paths on save. This change puts the given path back, untrimmed. Not a second story — just stop rewriting what the user set.

+
+
+ +
+

Who pays

+
+
+

Benefits

+

Anyone with more than one SSH machine, or a leftover local copy of an SSH tab, who has not restarted on yesterday’s rewrite yet. Their next quit/reopen keeps the running session.

+
+
+

Pays

+

If someone already restarted on that rewrite, this does not refill the empty tab. The still-running remote process stays orphaned. No UI risk; the leftover empty tab is a follow-up, not a reason to hold the stop-the-bleed.

+
+
+
+ +
+

Decision traps

+
+
+ This is diagnostics only. + False +
+
+ Already-rewritten empty tabs will heal themselves. + False +
+
+ Folder-path trim is the user-facing failure. + False +
+
+
+ +
+

Call

+
+
+

Ship · default

+

Persistence-only correction of yesterday’s rewrite. Stops the silent empty-tab failure for the next restart. No UI. Land before that rewrite ships in a build people actually quit.

+
+
+

Hold

+

Only if you need this same PR to also revive tabs that already restarted empty. That is extra work, not this diff.

+
+
+

Wrong tool

+

Not a reconnect-UI or SSH-relay change. Restart already aims this tab; it is aimed at the wrong machine’s pane.

+
+
+
+ +

Local decision page. Not product UI. Yesterday = host-wide pane rewrite + merged replacement table + folder-path trim. This = per-machine replacement + persist the path as given.

+
+ +