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.

+
+ +