From b07bba61ab0e3a23da4ac8edee185b225a84668d Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Wed, 5 Aug 2026 15:46:37 +0300 Subject: [PATCH 01/20] fix(session): read ssh host partitions so stranded tabs stop round-tripping as deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main-process runtime flows persist SSH-owned worktree session state into ssh: partitions (workspaceSessionsByHostId), while renderer persistence keeps SSH worktrees in the local partition. Neither boot hydration (local + runtime:* partitions only) nor the remote-workspace export fallback (local partition only) ever reads an ssh: partition, so a worktree whose only populated tab list lives there renders empty at boot and is uploaded to the target's remote workspace snapshot as an empty tab list. The remote-authoritative pull merge then deletes those tabs on every client — permanently, on every launch, since the emptied state is pushed right back. Fix, on the read side only (no writer changes): - shared/workspace-session-partition-adoption.ts: adoption of orphaned partition entries — a worktree whose base session holds no tabs takes the partition's populated entry plus the tab-scoped records of exactly the adopted tabs. Populated base entries always win; tabs fenced by a surface tombstone on either side stay retired; nothing is ever removed from the base. - Renderer boot hydration reads the ssh partitions of known SSH repos and completes each adoption as a move: the local partition durably gains every adopted field (patch built from a fresh read, awaited) before the source partition sheds the adopted entries, so tabs the user later closes cannot be re-adopted. - The remoteWorkspace:setForConnectedTargets fallback adopts per target before exporting, read-only: the renderer owns the local partition and replaces its worktree map wholesale on every debounced write, so a store-side move here would be silently undone with the only durable copy already pruned. The move belongs to boot hydration. Co-Authored-By: Claude Fable 5 --- src/main/ipc/remote-workspace.test.ts | 73 +++++- src/main/ipc/remote-workspace.ts | 21 +- ...workspace-session-host-persistence.test.ts | 86 +++++++ .../lib/workspace-session-host-persistence.ts | 13 +- ...rkspace-session-ssh-partition-hydration.ts | 94 ++++++++ ...rkspace-session-partition-adoption.test.ts | 200 ++++++++++++++++ .../workspace-session-partition-adoption.ts | 217 ++++++++++++++++++ 7 files changed, 700 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts create mode 100644 src/shared/workspace-session-partition-adoption.test.ts create mode 100644 src/shared/workspace-session-partition-adoption.ts diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 6da587ac86cc..3ade653b5f66 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -152,9 +152,11 @@ describe('remoteWorkspace:setForConnectedTargets', () => { const muxByTargetId = new Map }>() const getRepoMock = vi.fn() const getWorkspaceSessionMock = vi.fn() + const patchWorkspaceSessionMock = vi.fn() const store = { getRepo: getRepoMock, - getWorkspaceSession: getWorkspaceSessionMock + getWorkspaceSession: getWorkspaceSessionMock, + patchWorkspaceSession: patchWorkspaceSessionMock } as unknown as Store beforeEach(() => { @@ -174,6 +176,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { getRepoMock.mockReset() getWorkspaceSessionMock.mockReset() getWorkspaceSessionMock.mockReturnValue(baseSession) + patchWorkspaceSessionMock.mockReset() getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -296,4 +299,72 @@ describe('remoteWorkspace:setForConnectedTargets', () => { }) ) }) + + it('exports tabs stranded in the target ssh partition when the local partition is empty', async () => { + const worktreeId = 'repo-target-1::/repo' + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => { + if (hostId === 'ssh:target-1') { + return { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: 'stranded-tab', + title: 'Stranded shell', + ptyId: null, + worktreeId + } as never + ] + } + } + } + return { ...baseSession, tabsByWorktree: { [worktreeId]: [] } } + }) + + await callSetForConnectedTargets({ hydratedTargetIds: ['target-1'] }) + + expect(getWorkspaceSessionMock).toHaveBeenCalledWith('ssh:target-1') + expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + tabsByWorktreePath: { + '/repo': [expect.objectContaining({ id: 'stranded-tab' })] + } + }) + }) + }) + ) + // Adoption on this path is read-only: the renderer owns the local + // partition, so a store-side move here would be undone by its next write. + expect(patchWorkspaceSessionMock).not.toHaveBeenCalled() + }) + + it('does not consult ssh partitions when an explicit session argument is provided', async () => { + const worktreeId = 'repo-target-1::/repo' + await callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-explicit', title: 'Shell', ptyId: null, worktreeId } as never] + } + }, + hydratedTargetIds: ['target-1'] + }) + + expect(getWorkspaceSessionMock).not.toHaveBeenCalled() + expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + tabsByWorktreePath: { + '/repo': [expect.objectContaining({ id: 'tab-explicit' })] + } + }) + }) + }) + ) + }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 713fe1b8a171..eae3facbfdba 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -16,6 +16,8 @@ import type { import type { SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { adoptOrphanedWorkspaceSessionPartition } from '../../shared/workspace-session-partition-adoption' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' @@ -419,9 +421,26 @@ export function registerRemoteWorkspaceHandlers( (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) ) ?? [] - const workspaceSession = args.session ?? store.getWorkspaceSession() + const fallbackSession = args.session ?? store.getWorkspaceSession() const results = await Promise.all( targets.map(async (target) => { + // Why: the local-partition fallback cannot see session state the + // runtime persisted into the target's ssh partition; exporting + // without it round-trips those worktrees as empty, and the next + // remote-authoritative pull deletes their tabs on every client. + // Adoption here is deliberately read-only: the renderer owns the + // local partition and replaces its worktree map wholesale on every + // debounced write, so a store-side move would be silently undone — + // with the only durable copy already pruned. The move happens at + // boot hydration, where the adopted tabs enter the renderer's own + // state; deliberate closes are fenced by surface tombstones, which + // adoption honors. + const workspaceSession = args.session + ? args.session + : adoptOrphanedWorkspaceSessionPartition( + fallbackSession, + store.getWorkspaceSession(toSshExecutionHostId(target.id)) + ).session // Why: each target has its own revision stream. Keep same-target // writes queued, but do not let one slow relay block others. const session = exportSessionForTarget(store, target.id, workspaceSession) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index d86fc3ba927d..7ab533db3d42 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -175,6 +175,92 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) }) + it('adopts tabs stranded in an ssh host partition and moves them to the local partition', async () => { + const worktreeId = 'ssh-repo::/home/user/wt' + const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } + const strandedTab = { + id: 'stranded-tab', + ptyId: null, + worktreeId, + title: 'Stranded', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [strandedTab] }, + terminalLayoutsByTabId: { 'stranded-tab': { root: null } as never } + } + } + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [] } + } + }) + const patch = vi.fn().mockResolvedValue(undefined) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) + + expect(get).toHaveBeenCalledWith('ssh:conn-1') + expect(read.session.tabsByWorktree[worktreeId]).toEqual([strandedTab]) + expect(read.session.terminalLayoutsByTabId['stranded-tab']).toBeDefined() + // Adopted data is not runtime-owned; the owner map must stay untouched. + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({}) + await vi.waitFor(() => { + expect(patch).toHaveBeenCalledTimes(2) + }) + // First write lands the adopted tabs in the local partition, then the ssh + // partition sheds them so a later read cannot adopt the same tabs twice. + expect(patch.mock.calls[0]).toEqual([ + expect.objectContaining({ + tabsByWorktree: expect.objectContaining({ [worktreeId]: [strandedTab] }) + }) + ]) + expect(patch.mock.calls[1]).toEqual([ + expect.objectContaining({ tabsByWorktree: {} }), + 'ssh:conn-1' + ]) + }) + + it('keeps locally populated worktrees over a stale ssh partition copy', async () => { + const worktreeId = 'ssh-repo::/home/user/wt' + const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } + const liveTab = { + id: 'live-tab', + ptyId: null, + worktreeId, + title: 'Live', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 2 + } + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [{ ...liveTab, id: 'stale-tab', title: 'Stale' }] + } + } + } + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [liveTab] } + } + }) + const patch = vi.fn().mockResolvedValue(undefined) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) + + expect(read.session.tabsByWorktree[worktreeId]).toEqual([liveTab]) + expect(patch).not.toHaveBeenCalled() + }) + it('routes restored runtime folder workspace patches back to the runtime host', async () => { const folderKey = folderWorkspaceKey('folder-1') const patch = vi.fn().mockResolvedValue(undefined) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 445ef2917e42..2408c47c4336 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -17,6 +17,7 @@ import { indexWorkspaceRuntimeHostOwnership, type WorkspaceRuntimeOwnerProjection } from './workspace-runtime-host-ownership' +import { adoptStrandedSshHostPartitions } from './workspace-session-ssh-partition-hydration' export type HostPersistenceState = { repos: readonly Pick[] @@ -309,7 +310,7 @@ export async function fetchWorkspaceSessionFromHosts( } export async function fetchWorkspaceSessionWithRuntimeHostOwners( - api: Pick, + api: Pick & Partial>, repos: readonly Pick[], additionalRuntimeHostIds: readonly ExecutionHostId[] = [] ): Promise { @@ -331,8 +332,16 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( } }) ) + // Why: ssh partitions stay out of `slices` — SSH worktrees are local-owned + // here, so an ssh slice must never feed the runtime-owner map or win a blind + // last-wins merge; it is only a source for orphan adoption. + const session = await adoptStrandedSshHostPartitions( + api, + repos, + mergeWorkspaceSessionsFromHosts(slices) + ) return { - session: mergeWorkspaceSessionsFromHosts(slices), + session, runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices) } } diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts new file mode 100644 index 000000000000..10d188b99bbd --- /dev/null +++ b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts @@ -0,0 +1,94 @@ +import type { Repo, WorkspaceSessionPatch, WorkspaceSessionState } from '../../../shared/types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { + adoptOrphanedWorkspaceSessionPartition, + buildAdoptedWorkspaceSessionOwnerPatch, + pruneAdoptedWorkspaceSessionPartitionEntries +} from '../../../shared/workspace-session-partition-adoption' + +type SshPartitionHydrationApi = { + get: (hostId?: ExecutionHostId) => Promise + patch?: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise +} + +/** Collect the distinct SSH hosts owning any persisted repo. */ +export function listKnownSshHostIds( + repos: readonly Pick[] +): ExecutionHostId[] { + const hostIds = new Set() + for (const repo of repos) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'ssh') { + hostIds.add(parsed.id) + } + } + return [...hostIds] +} + +/** Boot-time recovery for session state stranded in `ssh:` host partitions. + * + * SSH worktrees are local-owned in renderer persistence, but main-process + * runtime flows persist their session state under `ssh:` partitions + * that ordinary hydration never reads. Any worktree whose only populated tab + * list lives there renders empty — and the remote-workspace round trip then + * deletes it everywhere. This reads those partitions, adopts orphaned entries + * into the unified session, and completes each adoption as a move: the local + * partition gains the adopted state first, then the source partition sheds it + * so the same tabs cannot be re-adopted after the user closes them. */ +export async function adoptStrandedSshHostPartitions( + api: SshPartitionHydrationApi, + repos: readonly Pick[], + merged: WorkspaceSessionState +): Promise { + const sshSlices: [ExecutionHostId, WorkspaceSessionState][] = [] + await Promise.all( + listKnownSshHostIds(repos).map(async (hostId) => { + try { + sshSlices.push([hostId, await api.get(hostId)]) + } catch (err) { + console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) + } + }) + ) + let session = merged + for (const [hostId, slice] of sshSlices) { + const adoption = adoptOrphanedWorkspaceSessionPartition(session, slice) + if (adoption.session === session) { + continue + } + session = adoption.session + if (!api.patch) { + continue + } + try { + // Why: both patches replace whole fields, so each is built from a fresh + // read taken just before the write — a boot-time snapshot would clobber + // any write that landed on the partition in between. The owner (local) + // partition persists every adopted field before the source sheds them, + // so a crash between the two writes cannot strand the adopted tabs with + // no persisted copy on either side. + const ownerPatch = buildAdoptedWorkspaceSessionOwnerPatch( + await api.get(), + session, + adoption.adoptedTabIdsByWorktreeId + ) + if (ownerPatch) { + await api.patch(ownerPatch) + } + const prune = pruneAdoptedWorkspaceSessionPartitionEntries( + await api.get(hostId), + adoption.adoptedTabIdsByWorktreeId + ) + if (prune) { + await api.patch(prune, hostId) + } + } catch (err) { + console.warn(`[session] adopting orphaned ssh partition ${hostId} failed:`, err) + } + } + return session +} diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts new file mode 100644 index 000000000000..4c9e2c16d936 --- /dev/null +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from './constants' +import type { TerminalTab, WorkspaceSessionState } from './types' +import { + adoptOrphanedWorkspaceSessionPartition, + buildAdoptedWorkspaceSessionOwnerPatch, + pruneAdoptedWorkspaceSessionPartitionEntries +} from './workspace-session-partition-adoption' + +const WORKTREE_ID = 'repo-1::/srv/wt' +const OTHER_WORKTREE_ID = 'repo-1::/srv/other-wt' + +function tab(id: string, worktreeId = WORKTREE_ID): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(overrides: Partial): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), ...overrides } +} + +describe('adoptOrphanedWorkspaceSessionPartition', () => { + it('adopts a populated partition entry when the base holds no tabs for the worktree', () => { + const base = session({ tabsByWorktree: { [WORKTREE_ID]: [] } }) + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] }, + terminalLayoutsByTabId: { 'tab-1': { root: null } as never }, + activeTabIdByWorktree: { [WORKTREE_ID]: 'tab-1' }, + lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 42 }, + defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE_ID]: true } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + + expect(adoption.adoptedTabIdsByWorktreeId).toEqual({ [WORKTREE_ID]: ['tab-1'] }) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(adoption.session.terminalLayoutsByTabId['tab-1']).toBeDefined() + expect(adoption.session.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('tab-1') + expect(adoption.session.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBe(42) + expect(adoption.session.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE_ID]).toBe(true) + }) + + it('adopts when the base has no entry at all for the worktree', () => { + const base = session({}) + const partition = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] } }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + + expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + + it('keeps the base entry when it already holds tabs, even if the partition differs', () => { + const base = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('live-tab')] } }) + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-tab-1'), tab('stale-tab-2')] } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + + expect(adoption.session).toBe(base) + expect(adoption.adoptedTabIdsByWorktreeId).toEqual({}) + }) + + it('returns the base identically when there is nothing to adopt', () => { + const base = session({ tabsByWorktree: { [WORKTREE_ID]: [] } }) + + expect(adoptOrphanedWorkspaceSessionPartition(base, session({})).session).toBe(base) + expect(adoptOrphanedWorkspaceSessionPartition(base, null).session).toBe(base) + }) + + it('never removes base entries for worktrees the partition does not populate', () => { + const base = session({ + tabsByWorktree: { [OTHER_WORKTREE_ID]: [tab('kept', OTHER_WORKTREE_ID)] } + }) + const partition = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] } }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + + expect(adoption.session.tabsByWorktree[OTHER_WORKTREE_ID]).toHaveLength(1) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + + it('skips tabs retired by a surface tombstone on either side', () => { + const tombstone = { + worktreeId: WORKTREE_ID, + parentTabId: 'retired-tab', + leafId: 'leaf-1', + ptyId: 'pty-1', + incarnationId: 'inc-1', + retiredAt: 5 + } + const base = session({ + terminalSurfaceTombstonesByPaneKey: { 'retired-tab:leaf-1': tombstone } + }) + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('retired-tab'), tab('live-tab')] } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + + expect(adoption.adoptedTabIdsByWorktreeId).toEqual({ [WORKTREE_ID]: ['live-tab'] }) + }) + + it('only carries tab-scoped records of adopted tabs and never overwrites base records', () => { + const baseLayout = { root: null } as never + const base = session({ + tabsByWorktree: { [OTHER_WORKTREE_ID]: [tab('base-tab', OTHER_WORKTREE_ID)] }, + terminalLayoutsByTabId: { 'base-tab': baseLayout } + }) + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] }, + terminalLayoutsByTabId: { + 'tab-1': { root: null } as never, + 'base-tab': { root: { stale: true } } as never, + 'unrelated-tab': { root: null } as never + }, + remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'unrelated-tab': 'session-2' } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + + expect(adoption.session.terminalLayoutsByTabId['base-tab']).toBe(baseLayout) + expect(adoption.session.terminalLayoutsByTabId['tab-1']).toBeDefined() + expect(adoption.session.terminalLayoutsByTabId['unrelated-tab']).toBeUndefined() + expect(adoption.session.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'session-1' }) + }) +}) + +describe('buildAdoptedWorkspaceSessionOwnerPatch', () => { + it('returns null when nothing was adopted', () => { + expect(buildAdoptedWorkspaceSessionOwnerPatch(session({}), session({}), {})).toBeNull() + }) + + it('lands every adopted field on top of a fresh owner read without dropping newer entries', () => { + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] }, + terminalLayoutsByTabId: { 'tab-1': { root: null } as never }, + remoteSessionIdsByTabId: { 'tab-1': 'session-1' }, + activeTabIdByWorktree: { [WORKTREE_ID]: 'tab-1' }, + lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 42 }, + defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE_ID]: true } + }) + const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), partition) + // A concurrent write landed on the owner partition after adoption started. + const freshOwner = session({ + tabsByWorktree: { [OTHER_WORKTREE_ID]: [tab('new-tab', OTHER_WORKTREE_ID)] } + }) + + const patch = buildAdoptedWorkspaceSessionOwnerPatch( + freshOwner, + adoption.session, + adoption.adoptedTabIdsByWorktreeId + ) + + expect(patch?.tabsByWorktree?.[WORKTREE_ID]).toHaveLength(1) + expect(patch?.tabsByWorktree?.[OTHER_WORKTREE_ID]).toHaveLength(1) + expect(patch?.terminalLayoutsByTabId?.['tab-1']).toBeDefined() + expect(patch?.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'session-1' }) + expect(patch?.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('tab-1') + expect(patch?.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBe(42) + expect(patch?.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE_ID]).toBe(true) + }) +}) + +describe('pruneAdoptedWorkspaceSessionPartitionEntries', () => { + it('returns null when nothing was adopted', () => { + expect(pruneAdoptedWorkspaceSessionPartitionEntries(session({}), {})).toBeNull() + }) + + it('drops only the adopted worktree entries and adopted tab records', () => { + const partition = session({ + tabsByWorktree: { + [WORKTREE_ID]: [tab('tab-1')], + [OTHER_WORKTREE_ID]: [tab('kept-tab', OTHER_WORKTREE_ID)] + }, + terminalLayoutsByTabId: { + 'tab-1': { root: null } as never, + 'kept-tab': { root: null } as never + }, + remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'kept-tab': 'session-2' } + }) + + const patch = pruneAdoptedWorkspaceSessionPartitionEntries(partition, { + [WORKTREE_ID]: ['tab-1'] + }) + + expect(patch).not.toBeNull() + expect(Object.keys(patch?.tabsByWorktree ?? {})).toEqual([OTHER_WORKTREE_ID]) + expect(Object.keys(patch?.terminalLayoutsByTabId ?? {})).toEqual(['kept-tab']) + expect(patch?.remoteSessionIdsByTabId).toEqual({ 'kept-tab': 'session-2' }) + }) +}) diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts new file mode 100644 index 000000000000..0bf50a3fdd8f --- /dev/null +++ b/src/shared/workspace-session-partition-adoption.ts @@ -0,0 +1,217 @@ +import type { TerminalTab, WorkspaceSessionPatch, WorkspaceSessionState } from './types' + +/** + * Adopt terminal-session state that exists only in another host partition. + * + * The main-process runtime persists SSH-owned worktree session state into + * `ssh:` partitions (persistence.ts workspaceSessionsByHostId), while + * renderer persistence keeps SSH worktrees in the 'local' partition + * (workspace-session-host-persistence.ts buildHostIdByWorktreeId). Readers that + * only consult one side see the other side's worktrees as empty — and both the + * boot hydration merge and the remote-workspace export treat "empty" as + * authoritative, which turns the invisible copy into a permanent deletion once + * a remote snapshot round-trips it. + * + * Adoption is the read-side bridge: a worktree whose base entry holds no tabs + * takes the partition's populated entry, together with the tab-scoped records + * of exactly the adopted tabs. Entries the base already populates always win — + * the partition copy may be a stale fork. Tabs fenced by a surface tombstone on + * either side stay retired. Nothing is ever removed from the base. + * + * The adopted field set deliberately mirrors the terminal-session surface of + * remote-workspace-session-projection.ts — when a worktree/tab-scoped field is + * added there it must be considered here too. + */ + +export type WorkspaceSessionPartitionAdoption = { + session: WorkspaceSessionState + /** Tab ids adopted per worktree; empty when the base was returned unchanged. */ + adoptedTabIdsByWorktreeId: Record +} + +function tombstonedTabIds(session: WorkspaceSessionState): Set { + const tabIds = new Set() + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + tabIds.add(tombstone.parentTabId) + } + return tabIds +} + +function pickTabKeyed( + record: Record | undefined, + tabIds: ReadonlySet +): Record { + return Object.fromEntries(Object.entries(record ?? {}).filter(([tabId]) => tabIds.has(tabId))) +} + +function adoptWorktreeKeyed( + base: Record | undefined, + partition: Record | undefined, + worktreeIds: readonly string[] +): Record { + const adopted = { ...base } + for (const worktreeId of worktreeIds) { + if (Object.hasOwn(adopted, worktreeId)) { + continue + } + const entry = partition?.[worktreeId] + if (entry !== undefined) { + adopted[worktreeId] = entry + } + } + return adopted +} + +export function adoptOrphanedWorkspaceSessionPartition( + base: WorkspaceSessionState, + partition: WorkspaceSessionState | null | undefined +): WorkspaceSessionPartitionAdoption { + if (!partition) { + return { session: base, adoptedTabIdsByWorktreeId: {} } + } + const retiredTabIds = new Set([...tombstonedTabIds(base), ...tombstonedTabIds(partition)]) + const adoptedTabsByWorktreeId: Record = {} + const adoptedTabIdsByWorktreeId: Record = {} + for (const [worktreeId, tabs] of Object.entries(partition.tabsByWorktree ?? {})) { + if ((base.tabsByWorktree?.[worktreeId] ?? []).length > 0) { + continue + } + const liveTabs = (tabs ?? []).filter((tab) => !retiredTabIds.has(tab.id)) + if (liveTabs.length === 0) { + continue + } + adoptedTabsByWorktreeId[worktreeId] = liveTabs + adoptedTabIdsByWorktreeId[worktreeId] = liveTabs.map((tab) => tab.id) + } + const adoptedWorktreeIds = Object.keys(adoptedTabsByWorktreeId) + if (adoptedWorktreeIds.length === 0) { + return { session: base, adoptedTabIdsByWorktreeId: {} } + } + const adoptedTabIds = new Set(Object.values(adoptedTabIdsByWorktreeId).flat()) + + const session: WorkspaceSessionState = { + ...base, + tabsByWorktree: { ...base.tabsByWorktree, ...adoptedTabsByWorktreeId }, + // Why: base entries spread last so an id collision never lets a stale + // partition record replace live state; only records of adopted tabs enter. + terminalLayoutsByTabId: { + ...pickTabKeyed(partition.terminalLayoutsByTabId, adoptedTabIds), + ...base.terminalLayoutsByTabId + }, + activeTabIdByWorktree: adoptWorktreeKeyed( + base.activeTabIdByWorktree, + partition.activeTabIdByWorktree, + adoptedWorktreeIds + ), + lastVisitedAtByWorktreeId: adoptWorktreeKeyed( + base.lastVisitedAtByWorktreeId, + partition.lastVisitedAtByWorktreeId, + adoptedWorktreeIds + ), + defaultTerminalTabsAppliedByWorktreeId: adoptWorktreeKeyed( + base.defaultTerminalTabsAppliedByWorktreeId, + partition.defaultTerminalTabsAppliedByWorktreeId, + adoptedWorktreeIds + ) + } + const adoptedRemoteSessionIds = pickTabKeyed(partition.remoteSessionIdsByTabId, adoptedTabIds) + if (Object.keys(adoptedRemoteSessionIds).length > 0 || base.remoteSessionIdsByTabId) { + session.remoteSessionIdsByTabId = { + ...adoptedRemoteSessionIds, + ...base.remoteSessionIdsByTabId + } + } + return { session, adoptedTabIdsByWorktreeId } +} + +/** + * Build the owner-partition patch that lands every adopted field durably. + * `ownerSession` must be a fresh read of the owning partition — the patch + * replaces whole fields, so building it from a stale snapshot would clobber + * writes that landed in between. Returns null when nothing was adopted. + */ +export function buildAdoptedWorkspaceSessionOwnerPatch( + ownerSession: WorkspaceSessionState, + adoptedSession: WorkspaceSessionState, + adoptedTabIdsByWorktreeId: Record +): WorkspaceSessionPatch | null { + const adoptedWorktreeIds = Object.keys(adoptedTabIdsByWorktreeId) + if (adoptedWorktreeIds.length === 0) { + return null + } + const adoptedTabIds = new Set(Object.values(adoptedTabIdsByWorktreeId).flat()) + const adoptedTabsByWorktree = Object.fromEntries( + adoptedWorktreeIds.map((worktreeId) => [ + worktreeId, + adoptedSession.tabsByWorktree[worktreeId] ?? [] + ]) + ) + const patch: WorkspaceSessionPatch = { + tabsByWorktree: { ...ownerSession.tabsByWorktree, ...adoptedTabsByWorktree }, + terminalLayoutsByTabId: { + ...ownerSession.terminalLayoutsByTabId, + ...pickTabKeyed(adoptedSession.terminalLayoutsByTabId, adoptedTabIds) + } + } + const adoptedRemoteSessionIds = pickTabKeyed( + adoptedSession.remoteSessionIdsByTabId, + adoptedTabIds + ) + if (Object.keys(adoptedRemoteSessionIds).length > 0) { + patch.remoteSessionIdsByTabId = { + ...ownerSession.remoteSessionIdsByTabId, + ...adoptedRemoteSessionIds + } + } + const scalarFields = [ + 'activeTabIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' + ] as const + for (const field of scalarFields) { + ;(patch as Record)[field] = adoptWorktreeKeyed( + ownerSession[field] as Record | undefined, + adoptedSession[field] as Record | undefined, + adoptedWorktreeIds + ) + } + return patch +} + +/** + * Build the partition patch that completes an adoption as a move. Leaving the + * adopted entries behind would re-adopt them on every read once the base's own + * copy empties again — resurrecting tabs the user deliberately closed. + * Returns null when nothing was adopted. Only the adopted worktree entries and + * the adopted tabs' records are dropped; everything else stays untouched. + */ +export function pruneAdoptedWorkspaceSessionPartitionEntries( + partition: WorkspaceSessionState, + adoptedTabIdsByWorktreeId: Record +): WorkspaceSessionPatch | null { + const adoptedWorktreeIds = new Set(Object.keys(adoptedTabIdsByWorktreeId)) + if (adoptedWorktreeIds.size === 0) { + return null + } + const adoptedTabIds = new Set(Object.values(adoptedTabIdsByWorktreeId).flat()) + const patch: WorkspaceSessionPatch = { + tabsByWorktree: Object.fromEntries( + Object.entries(partition.tabsByWorktree ?? {}).filter( + ([worktreeId]) => !adoptedWorktreeIds.has(worktreeId) + ) + ), + terminalLayoutsByTabId: Object.fromEntries( + Object.entries(partition.terminalLayoutsByTabId ?? {}).filter( + ([tabId]) => !adoptedTabIds.has(tabId) + ) + ) + } + if (partition.remoteSessionIdsByTabId) { + patch.remoteSessionIdsByTabId = Object.fromEntries( + Object.entries(partition.remoteSessionIdsByTabId).filter( + ([tabId]) => !adoptedTabIds.has(tabId) + ) + ) + } + return patch +} From 954fe5b18f14297e436aba7982c40013a35695a7 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Thu, 6 Aug 2026 15:41:49 +0300 Subject: [PATCH 02/20] fix(session): scope the partition adoption move to what each side still owns Session patches replace whole record fields, so the adoption move has to decide what it may overwrite from the partitions' current contents rather than from the boot snapshot it started with. - Boot hydration reads the owner partition before adopting and folds it into the base, taking both its entries and its surface tombstones. Adoption's base-wins rule then leaves a worktree the owner repopulated alone, and a pane retired since the snapshot still fences its tab out. - buildAdoptedWorkspaceSessionOwnerPatch drops every adopted worktree the owner already populates and reports the tab ids it does land, so the hydrated session and the owner partition agree on what moved. - The source partition sheds exactly those landed tabs plus the ones a tombstone retired. A tab written to an adopted worktree after the adoption read stays behind instead of going down with the whole entry. Co-Authored-By: Claude Fable 5 --- ...workspace-session-host-persistence.test.ts | 165 +++++++++++++++++- ...rkspace-session-ssh-partition-hydration.ts | 71 ++++++-- ...rkspace-session-partition-adoption.test.ts | 109 +++++++++++- .../workspace-session-partition-adoption.ts | 84 ++++++--- 4 files changed, 392 insertions(+), 37 deletions(-) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index 7ab533db3d42..23ee30f43121 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../../shared/constants' -import type { WorkspaceSessionState } from '../../../shared/types' +import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { buildHostIdByWorktreeId, @@ -13,6 +13,19 @@ import { type HostPersistenceState } from './workspace-session-host-persistence' +function sessionTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + describe('fetchWorkspaceSessionFromHosts', () => { it('reads saved runtime host partitions before runtime repos are loaded', async () => { const worktreeId = 'remote-repo::/srv/remote-wt' @@ -261,6 +274,156 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(patch).not.toHaveBeenCalled() }) + it('leaves both copies in place when a local write populates the worktree mid-adoption', async () => { + const worktreeId = 'ssh-repo::/home/user/wt' + const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } + const strandedTab = sessionTab('stranded-tab', worktreeId) + const concurrentTab = sessionTab('written-by-local', worktreeId) + let localReads = 0 + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [strandedTab] } + } + } + localReads += 1 + // The boot read sees the worktree empty; a concurrent writer populates it + // before the adoption move re-reads the owner partition. + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: localReads > 1 ? [concurrentTab] : [] } + } + }) + const patch = vi.fn().mockResolvedValue(undefined) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) + + // The hydrated session must carry the concurrent write, not the adoption + // snapshot — the first debounced write replaces this field wholesale. + expect(read.session.tabsByWorktree[worktreeId]).toEqual([concurrentTab]) + expect(patch).not.toHaveBeenCalled() + }) + + it('keeps a tab the ssh partition gained after the adoption read', async () => { + const worktreeId = 'ssh-repo::/home/user/wt' + const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } + const strandedTab = sessionTab('stranded-tab', worktreeId) + const concurrentTab = sessionTab('written-by-runtime', worktreeId) + let sshReads = 0 + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + sshReads += 1 + // The runtime adds a tab to the same worktree between the adoption read + // and the prune read. + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: sshReads > 1 ? [strandedTab, concurrentTab] : [strandedTab] + } + } + } + return { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [] } } + }) + const patch = vi.fn().mockResolvedValue(undefined) + + await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) + + await vi.waitFor(() => { + expect(patch).toHaveBeenCalledTimes(2) + }) + expect(patch.mock.calls[1]).toEqual([ + expect.objectContaining({ tabsByWorktree: { [worktreeId]: [concurrentTab] } }), + 'ssh:conn-1' + ]) + }) + + it('honors a tombstone the owner partition gained after the boot read', async () => { + const worktreeId = 'ssh-repo::/home/user/wt' + const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } + const strandedTab = sessionTab('stranded-tab', worktreeId) + let localReads = 0 + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [strandedTab] } + } + } + localReads += 1 + // The pane is retired between the boot read and the adoption move. + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [] }, + ...(localReads > 1 + ? { + terminalSurfaceTombstonesByPaneKey: { + 'stranded-tab:leaf-1': { + worktreeId, + parentTabId: 'stranded-tab', + leafId: 'leaf-1', + ptyId: 'pty-1', + incarnationId: 'inc-1', + retiredAt: 5 + } + } + } + : {}) + } + }) + const patch = vi.fn().mockResolvedValue(undefined) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) + + expect(read.session.tabsByWorktree[worktreeId]).toEqual([]) + expect(patch).not.toHaveBeenCalled() + }) + + it('scopes each ssh host adoption to its own partition', async () => { + const worktreeA = 'repo-a::/home/user/wt-a' + const worktreeB = 'repo-b::/home/user/wt-b' + const repos = [ + { id: 'repo-a', connectionId: 'conn-1', executionHostId: null }, + { id: 'repo-b', connectionId: 'conn-2', executionHostId: null } + ] + const tabA = sessionTab('tab-a', worktreeA) + const tabB = sessionTab('tab-b', worktreeB) + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeA]: [tabA] }, + // A tombstone in one host's partition must not retire another's tab. + terminalSurfaceTombstonesByPaneKey: { + 'tab-b:leaf-1': { + worktreeId: worktreeB, + parentTabId: 'tab-b', + leafId: 'leaf-1', + ptyId: 'pty-1', + incarnationId: 'inc-1', + retiredAt: 5 + } + } + } + } + if (hostId === 'ssh:conn-2') { + return { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeB]: [tabB] } } + } + return getDefaultWorkspaceSession() + }) + const patch = vi.fn().mockResolvedValue(undefined) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, repos) + + expect(read.session.tabsByWorktree[worktreeA]).toEqual([tabA]) + expect(read.session.tabsByWorktree[worktreeB]).toEqual([tabB]) + const prunes = patch.mock.calls.filter(([, hostId]) => hostId !== undefined) + expect(prunes).toEqual([ + [expect.objectContaining({ tabsByWorktree: {} }), 'ssh:conn-1'], + [expect.objectContaining({ tabsByWorktree: {} }), 'ssh:conn-2'] + ]) + }) + it('routes restored runtime folder workspace patches back to the runtime host', async () => { const folderKey = folderWorkspaceKey('folder-1') const patch = vi.fn().mockResolvedValue(undefined) diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts index 10d188b99bbd..2150a52a42bc 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts @@ -56,32 +56,43 @@ export async function adoptStrandedSshHostPartitions( ) let session = merged for (const [hostId, slice] of sshSlices) { - const adoption = adoptOrphanedWorkspaceSessionPartition(session, slice) - if (adoption.session === session) { - continue - } + // Why: the boot snapshot can predate writes that landed on the owner + // partition. Folding a fresh owner read in first keeps the hydrated session + // and the owner partition agreeing on which worktrees are still empty and + // which tabs are retired. Adopting into a worktree the owner repopulated + // would hand the renderer a copy the owner patch refuses to persist, and + // the first debounced write would push that stale copy back over the live + // one. + const ownerSession = api.patch ? await readOwnerPartition(api, hostId) : null + const adoption = adoptOrphanedWorkspaceSessionPartition( + ownerSession ? foldOwnerPartition(session, ownerSession) : session, + slice + ) session = adoption.session - if (!api.patch) { + if (!api.patch || !ownerSession) { continue } try { - // Why: both patches replace whole fields, so each is built from a fresh - // read taken just before the write — a boot-time snapshot would clobber - // any write that landed on the partition in between. The owner (local) + // Why: both patches replace whole fields, so each is built from a read + // taken after the boot snapshot — a boot-time snapshot would clobber any + // write that landed on the partition in between. The owner (local) // partition persists every adopted field before the source sheds them, // so a crash between the two writes cannot strand the adopted tabs with - // no persisted copy on either side. + // no persisted copy on either side. The source sheds exactly what the + // owner patch landed, plus the tabs a tombstone retired. const ownerPatch = buildAdoptedWorkspaceSessionOwnerPatch( - await api.get(), + ownerSession, session, adoption.adoptedTabIdsByWorktreeId ) - if (ownerPatch) { - await api.patch(ownerPatch) + if (!ownerPatch) { + continue } + await api.patch(ownerPatch.patch) const prune = pruneAdoptedWorkspaceSessionPartitionEntries( await api.get(hostId), - adoption.adoptedTabIdsByWorktreeId + ownerPatch.landedTabIdsByWorktreeId, + adoption.retiredTabIds ) if (prune) { await api.patch(prune, hostId) @@ -92,3 +103,37 @@ export async function adoptStrandedSshHostPartitions( } return session } + +/** Merge a fresh owner read into the boot snapshot: adopt the entries the + * snapshot lacks, and take the owner's tombstones so a pane retired since the + * snapshot still fences its tab out of the adoption. */ +function foldOwnerPartition( + session: WorkspaceSessionState, + ownerSession: WorkspaceSessionState +): WorkspaceSessionState { + const folded = adoptOrphanedWorkspaceSessionPartition(session, ownerSession).session + if (!ownerSession.terminalSurfaceTombstonesByPaneKey) { + return folded + } + return { + ...folded, + terminalSurfaceTombstonesByPaneKey: { + ...folded.terminalSurfaceTombstonesByPaneKey, + ...ownerSession.terminalSurfaceTombstonesByPaneKey + } + } +} + +/** Read the owner (local) partition, or null when it is unreadable — a failed + * read only costs this host its durable move, never the boot. */ +async function readOwnerPartition( + api: SshPartitionHydrationApi, + hostId: ExecutionHostId +): Promise { + try { + return await api.get() + } catch (err) { + console.warn(`[session] skipping adoption move for ${hostId}, owner read failed:`, err) + return null + } +} diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index 4c9e2c16d936..1f003424b660 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -154,12 +154,14 @@ describe('buildAdoptedWorkspaceSessionOwnerPatch', () => { tabsByWorktree: { [OTHER_WORKTREE_ID]: [tab('new-tab', OTHER_WORKTREE_ID)] } }) - const patch = buildAdoptedWorkspaceSessionOwnerPatch( + const owner = buildAdoptedWorkspaceSessionOwnerPatch( freshOwner, adoption.session, adoption.adoptedTabIdsByWorktreeId ) + const patch = owner?.patch + expect(owner?.landedTabIdsByWorktreeId).toEqual({ [WORKTREE_ID]: ['tab-1'] }) expect(patch?.tabsByWorktree?.[WORKTREE_ID]).toHaveLength(1) expect(patch?.tabsByWorktree?.[OTHER_WORKTREE_ID]).toHaveLength(1) expect(patch?.terminalLayoutsByTabId?.['tab-1']).toBeDefined() @@ -168,6 +170,50 @@ describe('buildAdoptedWorkspaceSessionOwnerPatch', () => { expect(patch?.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBe(42) expect(patch?.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE_ID]).toBe(true) }) + + it('keeps an owner entry a concurrent write populated after adoption started', () => { + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-tab')] }, + terminalLayoutsByTabId: { 'stale-tab': { root: null } as never }, + remoteSessionIdsByTabId: { 'stale-tab': 'session-1' }, + activeTabIdByWorktree: { [WORKTREE_ID]: 'stale-tab' } + }) + const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), partition) + const freshOwner = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('live-tab')] } }) + + const owner = buildAdoptedWorkspaceSessionOwnerPatch( + freshOwner, + adoption.session, + adoption.adoptedTabIdsByWorktreeId + ) + + expect(owner).toBeNull() + }) + + it('lands only the worktrees the fresh owner read left empty', () => { + const partition = session({ + tabsByWorktree: { + [WORKTREE_ID]: [tab('stale-tab')], + [OTHER_WORKTREE_ID]: [tab('orphan-tab', OTHER_WORKTREE_ID)] + }, + activeTabIdByWorktree: { [WORKTREE_ID]: 'stale-tab' }, + lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 42 } + }) + const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), partition) + const freshOwner = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('live-tab')] } }) + + const owner = buildAdoptedWorkspaceSessionOwnerPatch( + freshOwner, + adoption.session, + adoption.adoptedTabIdsByWorktreeId + ) + + expect(owner?.landedTabIdsByWorktreeId).toEqual({ [OTHER_WORKTREE_ID]: ['orphan-tab'] }) + expect(owner?.patch.tabsByWorktree?.[WORKTREE_ID]).toEqual([tab('live-tab')]) + expect(owner?.patch.tabsByWorktree?.[OTHER_WORKTREE_ID]).toHaveLength(1) + expect(owner?.patch.activeTabIdByWorktree?.[WORKTREE_ID]).toBeUndefined() + expect(owner?.patch.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBeUndefined() + }) }) describe('pruneAdoptedWorkspaceSessionPartitionEntries', () => { @@ -197,4 +243,65 @@ describe('pruneAdoptedWorkspaceSessionPartitionEntries', () => { expect(Object.keys(patch?.terminalLayoutsByTabId ?? {})).toEqual(['kept-tab']) expect(patch?.remoteSessionIdsByTabId).toEqual({ 'kept-tab': 'session-2' }) }) + + it('retains a tab a concurrent write added to an adopted worktree', () => { + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1'), tab('written-during-adoption')] }, + terminalLayoutsByTabId: { + 'tab-1': { root: null } as never, + 'written-during-adoption': { root: null } as never + }, + remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'written-during-adoption': 'session-3' } + }) + + const patch = pruneAdoptedWorkspaceSessionPartitionEntries(partition, { + [WORKTREE_ID]: ['tab-1'] + }) + + expect(patch?.tabsByWorktree?.[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'written-during-adoption' + ]) + expect(Object.keys(patch?.terminalLayoutsByTabId ?? {})).toEqual(['written-during-adoption']) + expect(patch?.remoteSessionIdsByTabId).toEqual({ 'written-during-adoption': 'session-3' }) + }) + + it('sheds retired tabs of an adopted worktree along with the adopted ones', () => { + const partition = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1'), tab('retired-tab')] }, + terminalLayoutsByTabId: { + 'tab-1': { root: null } as never, + 'retired-tab': { root: null } as never + }, + remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'retired-tab': 'session-2' } + }) + + const patch = pruneAdoptedWorkspaceSessionPartitionEntries( + partition, + { [WORKTREE_ID]: ['tab-1'] }, + ['retired-tab'] + ) + + expect(patch?.tabsByWorktree).toEqual({}) + expect(patch?.terminalLayoutsByTabId).toEqual({}) + expect(patch?.remoteSessionIdsByTabId).toEqual({}) + }) + + it('keeps a retired tab that belongs to a worktree the adoption did not touch', () => { + const partition = session({ + tabsByWorktree: { + [WORKTREE_ID]: [tab('tab-1')], + [OTHER_WORKTREE_ID]: [tab('retired-tab', OTHER_WORKTREE_ID)] + }, + terminalLayoutsByTabId: { 'retired-tab': { root: null } as never } + }) + + const patch = pruneAdoptedWorkspaceSessionPartitionEntries( + partition, + { [WORKTREE_ID]: ['tab-1'] }, + ['retired-tab'] + ) + + expect(patch?.tabsByWorktree?.[OTHER_WORKTREE_ID]).toHaveLength(1) + expect(Object.keys(patch?.terminalLayoutsByTabId ?? {})).toEqual(['retired-tab']) + }) }) diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index 0bf50a3fdd8f..5441c695a139 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -27,6 +27,8 @@ export type WorkspaceSessionPartitionAdoption = { session: WorkspaceSessionState /** Tab ids adopted per worktree; empty when the base was returned unchanged. */ adoptedTabIdsByWorktreeId: Record + /** Tab ids fenced by a tombstone on either side — retired, never adopted. */ + retiredTabIds: string[] } function tombstonedTabIds(session: WorkspaceSessionState): Set { @@ -67,7 +69,7 @@ export function adoptOrphanedWorkspaceSessionPartition( partition: WorkspaceSessionState | null | undefined ): WorkspaceSessionPartitionAdoption { if (!partition) { - return { session: base, adoptedTabIdsByWorktreeId: {} } + return { session: base, adoptedTabIdsByWorktreeId: {}, retiredTabIds: [] } } const retiredTabIds = new Set([...tombstonedTabIds(base), ...tombstonedTabIds(partition)]) const adoptedTabsByWorktreeId: Record = {} @@ -85,7 +87,7 @@ export function adoptOrphanedWorkspaceSessionPartition( } const adoptedWorktreeIds = Object.keys(adoptedTabsByWorktreeId) if (adoptedWorktreeIds.length === 0) { - return { session: base, adoptedTabIdsByWorktreeId: {} } + return { session: base, adoptedTabIdsByWorktreeId: {}, retiredTabIds: [...retiredTabIds] } } const adoptedTabIds = new Set(Object.values(adoptedTabIdsByWorktreeId).flat()) @@ -121,25 +123,44 @@ export function adoptOrphanedWorkspaceSessionPartition( ...base.remoteSessionIdsByTabId } } - return { session, adoptedTabIdsByWorktreeId } + return { session, adoptedTabIdsByWorktreeId, retiredTabIds: [...retiredTabIds] } +} + +export type AdoptedWorkspaceSessionOwnerPatch = { + patch: WorkspaceSessionPatch + /** Tab ids the patch lands, per worktree — the exact set the source may shed. */ + landedTabIdsByWorktreeId: Record } /** * Build the owner-partition patch that lands every adopted field durably. * `ownerSession` must be a fresh read of the owning partition — the patch * replaces whole fields, so building it from a stale snapshot would clobber - * writes that landed in between. Returns null when nothing was adopted. + * writes that landed in between. Worktrees the fresh read already populates + * drop out of the patch, so only what this patch lands may later be pruned + * from the source. Returns null when nothing is left to land. */ export function buildAdoptedWorkspaceSessionOwnerPatch( ownerSession: WorkspaceSessionState, adoptedSession: WorkspaceSessionState, adoptedTabIdsByWorktreeId: Record -): WorkspaceSessionPatch | null { - const adoptedWorktreeIds = Object.keys(adoptedTabIdsByWorktreeId) +): AdoptedWorkspaceSessionOwnerPatch | null { + // Why: base-wins holds against the fresh read too — a concurrent write can + // populate an adopted worktree after the boot snapshot, and this patch + // replaces the whole field, so keeping the snapshot would drop those tabs. + const adoptedWorktreeIds = Object.keys(adoptedTabIdsByWorktreeId).filter( + (worktreeId) => (ownerSession.tabsByWorktree?.[worktreeId] ?? []).length === 0 + ) if (adoptedWorktreeIds.length === 0) { return null } - const adoptedTabIds = new Set(Object.values(adoptedTabIdsByWorktreeId).flat()) + const landedTabIdsByWorktreeId = Object.fromEntries( + adoptedWorktreeIds.map((worktreeId) => [ + worktreeId, + adoptedTabIdsByWorktreeId[worktreeId] ?? [] + ]) + ) + const adoptedTabIds = new Set(Object.values(landedTabIdsByWorktreeId).flat()) const adoptedTabsByWorktree = Object.fromEntries( adoptedWorktreeIds.map((worktreeId) => [ worktreeId, @@ -175,41 +196,60 @@ export function buildAdoptedWorkspaceSessionOwnerPatch( adoptedWorktreeIds ) } - return patch + return { patch, landedTabIdsByWorktreeId } } /** * Build the partition patch that completes an adoption as a move. Leaving the - * adopted entries behind would re-adopt them on every read once the base's own + * adopted tabs behind would re-adopt them on every read once the base's own * copy empties again — resurrecting tabs the user deliberately closed. - * Returns null when nothing was adopted. Only the adopted worktree entries and - * the adopted tabs' records are dropped; everything else stays untouched. + * `partition` must be a fresh read of the source. Returns null when nothing was + * adopted. An adopted worktree sheds the tabs the owner took plus the ones a + * tombstone retired; every other tab stays, including one a concurrent write + * added to that worktree after the adoption read. */ export function pruneAdoptedWorkspaceSessionPartitionEntries( partition: WorkspaceSessionState, - adoptedTabIdsByWorktreeId: Record + adoptedTabIdsByWorktreeId: Record, + retiredTabIds: readonly string[] = [] ): WorkspaceSessionPatch | null { - const adoptedWorktreeIds = new Set(Object.keys(adoptedTabIdsByWorktreeId)) - if (adoptedWorktreeIds.size === 0) { + if (Object.keys(adoptedTabIdsByWorktreeId).length === 0) { return null } - const adoptedTabIds = new Set(Object.values(adoptedTabIdsByWorktreeId).flat()) + const shed = new Set([...Object.values(adoptedTabIdsByWorktreeId).flat(), ...retiredTabIds]) + const droppedTabIds = new Set() + const tabsByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(partition.tabsByWorktree ?? {})) { + if (!Object.hasOwn(adoptedTabIdsByWorktreeId, worktreeId)) { + tabsByWorktree[worktreeId] = tabs + continue + } + // Why: a tab the source gained after the adoption read was never moved — + // dropping the whole entry would delete it with no copy on the owner side. + const retained: TerminalTab[] = [] + for (const tab of tabs ?? []) { + if (shed.has(tab.id)) { + droppedTabIds.add(tab.id) + } else { + retained.push(tab) + } + } + if (retained.length > 0) { + tabsByWorktree[worktreeId] = retained + } + } const patch: WorkspaceSessionPatch = { - tabsByWorktree: Object.fromEntries( - Object.entries(partition.tabsByWorktree ?? {}).filter( - ([worktreeId]) => !adoptedWorktreeIds.has(worktreeId) - ) - ), + tabsByWorktree, terminalLayoutsByTabId: Object.fromEntries( Object.entries(partition.terminalLayoutsByTabId ?? {}).filter( - ([tabId]) => !adoptedTabIds.has(tabId) + ([tabId]) => !droppedTabIds.has(tabId) ) ) } if (partition.remoteSessionIdsByTabId) { patch.remoteSessionIdsByTabId = Object.fromEntries( Object.entries(partition.remoteSessionIdsByTabId).filter( - ([tabId]) => !adoptedTabIds.has(tabId) + ([tabId]) => !droppedTabIds.has(tabId) ) ) } From 7ff1d8dc7fa1a05062bf9966ce0b9dc7a4956e8c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 05:18:17 -0400 Subject: [PATCH 03/20] fix(session): adopt ssh partitions atomically --- config/reliability-gates.jsonc | 23 ++- src/main/ipc/remote-workspace.ts | 12 +- src/main/ipc/session.test.ts | 34 +++ src/main/ipc/session.ts | 4 + src/main/persistence.test.ts | 89 ++++++++ src/main/persistence.ts | 41 ++++ src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + ...workspace-session-host-persistence.test.ts | 193 +++--------------- .../lib/workspace-session-host-persistence.ts | 3 +- ...rkspace-session-ssh-partition-hydration.ts | 112 +--------- src/renderer/src/web/web-preload-api.ts | 1 + ...rkspace-session-partition-adoption.test.ts | 83 -------- .../workspace-session-partition-adoption.ts | 106 +--------- .../ssh-session-partition-adoption.spec.ts | 189 +++++++++++++++++ 15 files changed, 419 insertions(+), 473 deletions(-) create mode 100644 src/main/ipc/session.test.ts create mode 100644 tests/e2e/ssh-session-partition-adoption.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3993a178f383..2c9b2b08db9c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5874,7 +5874,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, eager six-terminal remount after renderer reload, and two full restarts after a live session is stranded in its SSH partition. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, and WSL remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -5897,6 +5897,7 @@ "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_SSH_TARGET_HOST= SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-session-partition-adoption.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ @@ -5948,6 +5949,7 @@ "src/shared/ssh-types.test.ts", "tests/e2e/terminal-cold-activation-deferral.spec.ts", "tests/e2e/ssh-cold-activation-restore.spec.ts", + "tests/e2e/ssh-session-partition-adoption.spec.ts", "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ @@ -6242,6 +6244,14 @@ "repo and worktree setup fails closed unless the exact direct SSH host and complete authority are returned", "a reconnected terminal accepts input and writes a proof file visible inside the Linux SSH target" ] + }, + { + "file": "tests/e2e/ssh-session-partition-adoption.spec.ts", + "assertions": [ + "a live Git terminal stranded in an SSH partition retains its exact tab and PTY identity across two full app restarts", + "the atomic boot adoption prunes the source partition and remote export remains non-empty", + "the restored shell retains its environment and writes proof through a non-loopback Linux SSH target" + ] } ], "evidenceRuns": [ @@ -6316,6 +6326,15 @@ "result": "passed", "durationSeconds": 12.5, "summary": "One Electron journey passed on the final implementation after exact-authority hydration; six restored SSH terminal managers remounted after renderer reload and remote input reached the Linux target." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_SSH_TARGET_HOST= SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-session-partition-adoption.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 19.4, + "summary": "A headed macOS client created a live terminal in a throwaway Docker Linux Git repository over non-loopback SSH, moved its persisted tab into the SSH host partition, and completed two full restarts with the same tab and PTY identities, a pruned source partition, a non-empty remote snapshot, and a shell-environment proof file." } ], "runtimeBudget": { @@ -6332,7 +6351,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." + "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. Startup adoption adds one sequential in-memory main-process transaction per known SSH target, while remote export adds one in-memory partition overlay per target only when no explicit session is supplied; neither path adds polling, subprocesses, listeners, or provider calls. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." }, "promotionCriteria": [ "Use deterministic fake providers for failure and unknown-liveness cases.", diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index eae3facbfdba..88ea19d5ca42 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -424,17 +424,7 @@ export function registerRemoteWorkspaceHandlers( const fallbackSession = args.session ?? store.getWorkspaceSession() const results = await Promise.all( targets.map(async (target) => { - // Why: the local-partition fallback cannot see session state the - // runtime persisted into the target's ssh partition; exporting - // without it round-trips those worktrees as empty, and the next - // remote-authoritative pull deletes their tabs on every client. - // Adoption here is deliberately read-only: the renderer owns the - // local partition and replaces its worktree map wholesale on every - // debounced write, so a store-side move would be silently undone — - // with the only durable copy already pruned. The move happens at - // boot hydration, where the adopted tabs enter the renderer's own - // state; deliberate closes are fenced by surface tombstones, which - // adoption honors. + // Boot owns persistence; export only overlays stranded SSH state. const workspaceSession = args.session ? args.session : adoptOrphanedWorkspaceSessionPartition( diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts new file mode 100644 index 000000000000..479ad40a16ca --- /dev/null +++ b/src/main/ipc/session.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, onMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock, on: onMock } +})) + +import { registerSessionHandlers } from './session' + +describe('registerSessionHandlers', () => { + beforeEach(() => { + handleMock.mockClear() + onMock.mockClear() + }) + + it('routes atomic ssh partition adoption through the store', () => { + const expected = { tabsByWorktree: {} } + const store = { + adoptSshWorkspaceSessionPartition: vi.fn(() => expected) + } + registerSessionHandlers(store as never) + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'session:adopt-ssh-partition' + )?.[1] as (event: unknown, hostId: string) => unknown + + expect(handler({}, 'ssh:target-1')).toBe(expected) + expect(store.adoptSshWorkspaceSessionPartition).toHaveBeenCalledOnce() + expect(store.adoptSshWorkspaceSessionPartition).toHaveBeenCalledWith('ssh:target-1') + }) +}) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 98015aa64a7c..62ffd8111397 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -18,6 +18,10 @@ export function registerSessionHandlers(store: Store): void { store.patchWorkspaceSession(args, hostId) }) + ipcMain.handle('session:adopt-ssh-partition', (_event, hostId: string) => { + return store.adoptSshWorkspaceSessionPartition(hostId) + }) + ipcMain.handle('session:flush', () => { // Why: durable lifecycle RPCs must propagate disk failures instead of // returning success through Store.flush(), which intentionally only logs. diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 0e507090c1f2..ff3a6441dd90 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -11815,6 +11815,95 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') }) + it('atomically moves stranded ssh tabs into the local partition', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'repo-1::/worktree': [] } + }) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + source.tabsByWorktree['repo-1::/worktree'] + ) + expect(adopted.terminalLayoutsByTabId['tab-1']).toEqual(source.terminalLayoutsByTabId['tab-1']) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('finishes cleanup when an earlier move left the same tab in both partitions', async () => { + const store = await createStore() + const session = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(session) + store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + session.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('keeps populated local state and removes the rejected ssh fork', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@local-pty') + const source = makeBoundHostSession('ssh:ssh-1@@stale-pty') + source.tabsByWorktree['repo-1::/worktree'][0].id = 'stale-tab' + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + local.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('ignores non-ssh adoption requests without mutating their partition', async () => { + const store = await createStore() + const local = makeBoundHostSession('local-pty') + const runtime = makeBoundHostSession('runtime:env-1@@runtime-pty') + store.setWorkspaceSession(local) + store.setWorkspaceSession(runtime, 'runtime:env-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('runtime:env-1') + + expect(adopted).toEqual(local) + expect(store.getWorkspaceSession('runtime:env-1')).toEqual(runtime) + }) + + it('prunes tombstoned ssh tabs without resurrecting them locally', async () => { + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + writeDataFile({ + schemaVersion: 1, + workspaceSession: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'repo-1::/worktree': [] }, + terminalSurfaceTombstonesByPaneKey: { + [`tab-1:${TEST_LEAF_1}`]: { + worktreeId: 'repo-1::/worktree', + parentTabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@remote-pty', + incarnationId: 'inc-1', + retiredAt: 1 + } + } + }, + workspaceSessionsByHostId: { 'ssh:ssh-1': source } + }) + const store = await createStore() + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual([]) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + it('preserves and enforces equal repo-id topology authority independently per host', async () => { const store = await createStore() const worktreeId = 'duplicate::/worktree' diff --git a/src/main/persistence.ts b/src/main/persistence.ts index c2e36d3ce9dc..13572081e4b9 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -135,6 +135,10 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' +import { + adoptOrphanedWorkspaceSessionPartition, + pruneAdoptedWorkspaceSessionPartitionEntries +} from '../shared/workspace-session-partition-adoption' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -6278,6 +6282,43 @@ export class Store { return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() } + adoptSshWorkspaceSessionPartition(hostId: string): WorkspaceSessionState { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'ssh' || !this.state.workspaceSessionsByHostId?.[hostId]) { + return this.getWorkspaceSession() + } + const owner = this.getWorkspaceSession() + const source = this.getWorkspaceSession(hostId) + const adoption = adoptOrphanedWorkspaceSessionPartition(owner, source) + const movedTabIdsByWorktreeId: Record = {} + for (const [worktreeId, sourceTabs] of Object.entries(source.tabsByWorktree ?? {})) { + if (sourceTabs.length > 0) { + // Local is authoritative once it either accepts or rejects this stale fork. + movedTabIdsByWorktreeId[worktreeId] = sourceTabs.map((tab) => tab.id) + } + } + const sourcePatch = pruneAdoptedWorkspaceSessionPartitionEntries( + source, + movedTabIdsByWorktreeId, + adoption.retiredTabIds + ) + if (adoption.session === owner && !sourcePatch) { + return owner + } + + if (adoption.session !== owner) { + this.setLocalWorkspaceSession(adoption.session) + } + if (sourcePatch) { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [hostId]: { ...source, ...sourcePatch } + } + this.scheduleSave() + } + return this.getWorkspaceSession() + } + /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { return ( diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1cae8cd2359c..627d6598ef1e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2742,6 +2742,7 @@ export type PreloadApi = { get: (hostId?: ExecutionHostId) => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + adoptSshPartition: (hostId: `ssh:${string}`) => Promise flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 179ce5ecc2a1..105f91c3c789 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3015,6 +3015,7 @@ const api = { get: (hostId) => ipcRenderer.invoke('session:get', hostId), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + adoptSshPartition: (hostId) => ipcRenderer.invoke('session:adopt-ssh-partition', hostId), flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index 23ee30f43121..37ffa6059b00 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -201,42 +201,23 @@ describe('fetchWorkspaceSessionFromHosts', () => { sortOrder: 0, createdAt: 1 } - const get = vi.fn(async (hostId?: string): Promise => { - if (hostId === 'ssh:conn-1') { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeId]: [strandedTab] }, - terminalLayoutsByTabId: { 'stranded-tab': { root: null } as never } - } - } - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeId]: [] } - } - }) - const patch = vi.fn().mockResolvedValue(undefined) + const local = { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [] } } + const adopted = { + ...local, + tabsByWorktree: { [worktreeId]: [strandedTab] }, + terminalLayoutsByTabId: { 'stranded-tab': { root: null } as never } + } + const get = vi.fn().mockResolvedValue(local) + const adoptSshPartition = vi.fn().mockResolvedValue(adopted) - const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, [ + sshRepo + ]) - expect(get).toHaveBeenCalledWith('ssh:conn-1') + expect(adoptSshPartition).toHaveBeenCalledWith('ssh:conn-1') expect(read.session.tabsByWorktree[worktreeId]).toEqual([strandedTab]) expect(read.session.terminalLayoutsByTabId['stranded-tab']).toBeDefined() - // Adopted data is not runtime-owned; the owner map must stay untouched. expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({}) - await vi.waitFor(() => { - expect(patch).toHaveBeenCalledTimes(2) - }) - // First write lands the adopted tabs in the local partition, then the ssh - // partition sheds them so a later read cannot adopt the same tabs twice. - expect(patch.mock.calls[0]).toEqual([ - expect.objectContaining({ - tabsByWorktree: expect.objectContaining({ [worktreeId]: [strandedTab] }) - }) - ]) - expect(patch.mock.calls[1]).toEqual([ - expect.objectContaining({ tabsByWorktree: {} }), - 'ssh:conn-1' - ]) }) it('keeps locally populated worktrees over a stale ssh partition copy', async () => { @@ -266,120 +247,12 @@ describe('fetchWorkspaceSessionFromHosts', () => { tabsByWorktree: { [worktreeId]: [liveTab] } } }) - const patch = vi.fn().mockResolvedValue(undefined) - - const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [sshRepo]) expect(read.session.tabsByWorktree[worktreeId]).toEqual([liveTab]) - expect(patch).not.toHaveBeenCalled() - }) - - it('leaves both copies in place when a local write populates the worktree mid-adoption', async () => { - const worktreeId = 'ssh-repo::/home/user/wt' - const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } - const strandedTab = sessionTab('stranded-tab', worktreeId) - const concurrentTab = sessionTab('written-by-local', worktreeId) - let localReads = 0 - const get = vi.fn(async (hostId?: string): Promise => { - if (hostId === 'ssh:conn-1') { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeId]: [strandedTab] } - } - } - localReads += 1 - // The boot read sees the worktree empty; a concurrent writer populates it - // before the adoption move re-reads the owner partition. - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeId]: localReads > 1 ? [concurrentTab] : [] } - } - }) - const patch = vi.fn().mockResolvedValue(undefined) - - const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) - - // The hydrated session must carry the concurrent write, not the adoption - // snapshot — the first debounced write replaces this field wholesale. - expect(read.session.tabsByWorktree[worktreeId]).toEqual([concurrentTab]) - expect(patch).not.toHaveBeenCalled() }) - it('keeps a tab the ssh partition gained after the adoption read', async () => { - const worktreeId = 'ssh-repo::/home/user/wt' - const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } - const strandedTab = sessionTab('stranded-tab', worktreeId) - const concurrentTab = sessionTab('written-by-runtime', worktreeId) - let sshReads = 0 - const get = vi.fn(async (hostId?: string): Promise => { - if (hostId === 'ssh:conn-1') { - sshReads += 1 - // The runtime adds a tab to the same worktree between the adoption read - // and the prune read. - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [worktreeId]: sshReads > 1 ? [strandedTab, concurrentTab] : [strandedTab] - } - } - } - return { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [] } } - }) - const patch = vi.fn().mockResolvedValue(undefined) - - await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) - - await vi.waitFor(() => { - expect(patch).toHaveBeenCalledTimes(2) - }) - expect(patch.mock.calls[1]).toEqual([ - expect.objectContaining({ tabsByWorktree: { [worktreeId]: [concurrentTab] } }), - 'ssh:conn-1' - ]) - }) - - it('honors a tombstone the owner partition gained after the boot read', async () => { - const worktreeId = 'ssh-repo::/home/user/wt' - const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } - const strandedTab = sessionTab('stranded-tab', worktreeId) - let localReads = 0 - const get = vi.fn(async (hostId?: string): Promise => { - if (hostId === 'ssh:conn-1') { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeId]: [strandedTab] } - } - } - localReads += 1 - // The pane is retired between the boot read and the adoption move. - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeId]: [] }, - ...(localReads > 1 - ? { - terminalSurfaceTombstonesByPaneKey: { - 'stranded-tab:leaf-1': { - worktreeId, - parentTabId: 'stranded-tab', - leafId: 'leaf-1', - ptyId: 'pty-1', - incarnationId: 'inc-1', - retiredAt: 5 - } - } - } - : {}) - } - }) - const patch = vi.fn().mockResolvedValue(undefined) - - const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, [sshRepo]) - - expect(read.session.tabsByWorktree[worktreeId]).toEqual([]) - expect(patch).not.toHaveBeenCalled() - }) - - it('scopes each ssh host adoption to its own partition', async () => { + it('moves known ssh partitions in stable host order', async () => { const worktreeA = 'repo-a::/home/user/wt-a' const worktreeB = 'repo-b::/home/user/wt-b' const repos = [ @@ -388,40 +261,22 @@ describe('fetchWorkspaceSessionFromHosts', () => { ] const tabA = sessionTab('tab-a', worktreeA) const tabB = sessionTab('tab-b', worktreeB) - const get = vi.fn(async (hostId?: string): Promise => { - if (hostId === 'ssh:conn-1') { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeA]: [tabA] }, - // A tombstone in one host's partition must not retire another's tab. - terminalSurfaceTombstonesByPaneKey: { - 'tab-b:leaf-1': { - worktreeId: worktreeB, - parentTabId: 'tab-b', - leafId: 'leaf-1', - ptyId: 'pty-1', - incarnationId: 'inc-1', - retiredAt: 5 - } - } - } - } - if (hostId === 'ssh:conn-2') { - return { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeB]: [tabB] } } + const get = vi.fn().mockResolvedValue(getDefaultWorkspaceSession()) + const adoptSshPartition = vi.fn(async (hostId: string): Promise => { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: + hostId === 'ssh:conn-1' + ? { [worktreeA]: [tabA] } + : { [worktreeA]: [tabA], [worktreeB]: [tabB] } } - return getDefaultWorkspaceSession() }) - const patch = vi.fn().mockResolvedValue(undefined) - const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, patch }, repos) + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, repos) expect(read.session.tabsByWorktree[worktreeA]).toEqual([tabA]) expect(read.session.tabsByWorktree[worktreeB]).toEqual([tabB]) - const prunes = patch.mock.calls.filter(([, hostId]) => hostId !== undefined) - expect(prunes).toEqual([ - [expect.objectContaining({ tabsByWorktree: {} }), 'ssh:conn-1'], - [expect.objectContaining({ tabsByWorktree: {} }), 'ssh:conn-2'] - ]) + expect(adoptSshPartition.mock.calls).toEqual([['ssh:conn-1'], ['ssh:conn-2']]) }) it('routes restored runtime folder workspace patches back to the runtime host', async () => { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 2408c47c4336..cc41901572b8 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -34,6 +34,7 @@ export type HostPersistenceState = { type SessionApi = { get: (hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + adoptSshPartition?: (hostId: `ssh:${string}`) => Promise setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } @@ -310,7 +311,7 @@ export async function fetchWorkspaceSessionFromHosts( } export async function fetchWorkspaceSessionWithRuntimeHostOwners( - api: Pick & Partial>, + api: Pick, repos: readonly Pick[], additionalRuntimeHostIds: readonly ExecutionHostId[] = [] ): Promise { diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts index 2150a52a42bc..d203d9133422 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts @@ -1,18 +1,14 @@ -import type { Repo, WorkspaceSessionPatch, WorkspaceSessionState } from '../../../shared/types' +import type { Repo, WorkspaceSessionState } from '../../../shared/types' import { getRepoExecutionHostId, parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { - adoptOrphanedWorkspaceSessionPartition, - buildAdoptedWorkspaceSessionOwnerPatch, - pruneAdoptedWorkspaceSessionPartitionEntries -} from '../../../shared/workspace-session-partition-adoption' +import { adoptOrphanedWorkspaceSessionPartition } from '../../../shared/workspace-session-partition-adoption' type SshPartitionHydrationApi = { get: (hostId?: ExecutionHostId) => Promise - patch?: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + adoptSshPartition?: (hostId: `ssh:${string}`) => Promise } /** Collect the distinct SSH hosts owning any persisted repo. */ @@ -29,111 +25,21 @@ export function listKnownSshHostIds( return [...hostIds] } -/** Boot-time recovery for session state stranded in `ssh:` host partitions. - * - * SSH worktrees are local-owned in renderer persistence, but main-process - * runtime flows persist their session state under `ssh:` partitions - * that ordinary hydration never reads. Any worktree whose only populated tab - * list lives there renders empty — and the remote-workspace round trip then - * deletes it everywhere. This reads those partitions, adopts orphaned entries - * into the unified session, and completes each adoption as a move: the local - * partition gains the adopted state first, then the source partition sheds it - * so the same tabs cannot be re-adopted after the user closes them. */ +/** Recover SSH tabs through the main process's atomic partition move. */ export async function adoptStrandedSshHostPartitions( api: SshPartitionHydrationApi, repos: readonly Pick[], merged: WorkspaceSessionState ): Promise { - const sshSlices: [ExecutionHostId, WorkspaceSessionState][] = [] - await Promise.all( - listKnownSshHostIds(repos).map(async (hostId) => { - try { - sshSlices.push([hostId, await api.get(hostId)]) - } catch (err) { - console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) - } - }) - ) let session = merged - for (const [hostId, slice] of sshSlices) { - // Why: the boot snapshot can predate writes that landed on the owner - // partition. Folding a fresh owner read in first keeps the hydrated session - // and the owner partition agreeing on which worktrees are still empty and - // which tabs are retired. Adopting into a worktree the owner repopulated - // would hand the renderer a copy the owner patch refuses to persist, and - // the first debounced write would push that stale copy back over the live - // one. - const ownerSession = api.patch ? await readOwnerPartition(api, hostId) : null - const adoption = adoptOrphanedWorkspaceSessionPartition( - ownerSession ? foldOwnerPartition(session, ownerSession) : session, - slice - ) - session = adoption.session - if (!api.patch || !ownerSession) { - continue - } + for (const hostId of listKnownSshHostIds(repos)) { try { - // Why: both patches replace whole fields, so each is built from a read - // taken after the boot snapshot — a boot-time snapshot would clobber any - // write that landed on the partition in between. The owner (local) - // partition persists every adopted field before the source sheds them, - // so a crash between the two writes cannot strand the adopted tabs with - // no persisted copy on either side. The source sheds exactly what the - // owner patch landed, plus the tabs a tombstone retired. - const ownerPatch = buildAdoptedWorkspaceSessionOwnerPatch( - ownerSession, - session, - adoption.adoptedTabIdsByWorktreeId - ) - if (!ownerPatch) { - continue - } - await api.patch(ownerPatch.patch) - const prune = pruneAdoptedWorkspaceSessionPartitionEntries( - await api.get(hostId), - ownerPatch.landedTabIdsByWorktreeId, - adoption.retiredTabIds - ) - if (prune) { - await api.patch(prune, hostId) - } + session = api.adoptSshPartition + ? await api.adoptSshPartition(hostId as `ssh:${string}`) + : adoptOrphanedWorkspaceSessionPartition(session, await api.get(hostId)).session } catch (err) { - console.warn(`[session] adopting orphaned ssh partition ${hostId} failed:`, err) + console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) } } return session } - -/** Merge a fresh owner read into the boot snapshot: adopt the entries the - * snapshot lacks, and take the owner's tombstones so a pane retired since the - * snapshot still fences its tab out of the adoption. */ -function foldOwnerPartition( - session: WorkspaceSessionState, - ownerSession: WorkspaceSessionState -): WorkspaceSessionState { - const folded = adoptOrphanedWorkspaceSessionPartition(session, ownerSession).session - if (!ownerSession.terminalSurfaceTombstonesByPaneKey) { - return folded - } - return { - ...folded, - terminalSurfaceTombstonesByPaneKey: { - ...folded.terminalSurfaceTombstonesByPaneKey, - ...ownerSession.terminalSurfaceTombstonesByPaneKey - } - } -} - -/** Read the owner (local) partition, or null when it is unreadable — a failed - * read only costs this host its durable move, never the boot. */ -async function readOwnerPartition( - api: SshPartitionHydrationApi, - hostId: ExecutionHostId -): Promise { - try { - return await api.get() - } catch (err) { - console.warn(`[session] skipping adoption move for ${hostId}, owner read failed:`, err) - return null - } -} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 77f9a40dfdbb..ecc6513187d0 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -790,6 +790,7 @@ function createWebPreloadApi(): Partial { }) ) }, + adoptSshPartition: async () => getStoredWorkspaceSession(), // localStorage writes synchronously, so there is no deferred web flush. flush: async () => {}, readTerminalScrollback: () => null, diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index 1f003424b660..48897abf508a 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -3,7 +3,6 @@ import { getDefaultWorkspaceSession } from './constants' import type { TerminalTab, WorkspaceSessionState } from './types' import { adoptOrphanedWorkspaceSessionPartition, - buildAdoptedWorkspaceSessionOwnerPatch, pruneAdoptedWorkspaceSessionPartitionEntries } from './workspace-session-partition-adoption' @@ -134,88 +133,6 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { }) }) -describe('buildAdoptedWorkspaceSessionOwnerPatch', () => { - it('returns null when nothing was adopted', () => { - expect(buildAdoptedWorkspaceSessionOwnerPatch(session({}), session({}), {})).toBeNull() - }) - - it('lands every adopted field on top of a fresh owner read without dropping newer entries', () => { - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] }, - terminalLayoutsByTabId: { 'tab-1': { root: null } as never }, - remoteSessionIdsByTabId: { 'tab-1': 'session-1' }, - activeTabIdByWorktree: { [WORKTREE_ID]: 'tab-1' }, - lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 42 }, - defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE_ID]: true } - }) - const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), partition) - // A concurrent write landed on the owner partition after adoption started. - const freshOwner = session({ - tabsByWorktree: { [OTHER_WORKTREE_ID]: [tab('new-tab', OTHER_WORKTREE_ID)] } - }) - - const owner = buildAdoptedWorkspaceSessionOwnerPatch( - freshOwner, - adoption.session, - adoption.adoptedTabIdsByWorktreeId - ) - const patch = owner?.patch - - expect(owner?.landedTabIdsByWorktreeId).toEqual({ [WORKTREE_ID]: ['tab-1'] }) - expect(patch?.tabsByWorktree?.[WORKTREE_ID]).toHaveLength(1) - expect(patch?.tabsByWorktree?.[OTHER_WORKTREE_ID]).toHaveLength(1) - expect(patch?.terminalLayoutsByTabId?.['tab-1']).toBeDefined() - expect(patch?.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'session-1' }) - expect(patch?.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('tab-1') - expect(patch?.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBe(42) - expect(patch?.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE_ID]).toBe(true) - }) - - it('keeps an owner entry a concurrent write populated after adoption started', () => { - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('stale-tab')] }, - terminalLayoutsByTabId: { 'stale-tab': { root: null } as never }, - remoteSessionIdsByTabId: { 'stale-tab': 'session-1' }, - activeTabIdByWorktree: { [WORKTREE_ID]: 'stale-tab' } - }) - const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), partition) - const freshOwner = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('live-tab')] } }) - - const owner = buildAdoptedWorkspaceSessionOwnerPatch( - freshOwner, - adoption.session, - adoption.adoptedTabIdsByWorktreeId - ) - - expect(owner).toBeNull() - }) - - it('lands only the worktrees the fresh owner read left empty', () => { - const partition = session({ - tabsByWorktree: { - [WORKTREE_ID]: [tab('stale-tab')], - [OTHER_WORKTREE_ID]: [tab('orphan-tab', OTHER_WORKTREE_ID)] - }, - activeTabIdByWorktree: { [WORKTREE_ID]: 'stale-tab' }, - lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 42 } - }) - const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), partition) - const freshOwner = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('live-tab')] } }) - - const owner = buildAdoptedWorkspaceSessionOwnerPatch( - freshOwner, - adoption.session, - adoption.adoptedTabIdsByWorktreeId - ) - - expect(owner?.landedTabIdsByWorktreeId).toEqual({ [OTHER_WORKTREE_ID]: ['orphan-tab'] }) - expect(owner?.patch.tabsByWorktree?.[WORKTREE_ID]).toEqual([tab('live-tab')]) - expect(owner?.patch.tabsByWorktree?.[OTHER_WORKTREE_ID]).toHaveLength(1) - expect(owner?.patch.activeTabIdByWorktree?.[WORKTREE_ID]).toBeUndefined() - expect(owner?.patch.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBeUndefined() - }) -}) - describe('pruneAdoptedWorkspaceSessionPartitionEntries', () => { it('returns null when nothing was adopted', () => { expect(pruneAdoptedWorkspaceSessionPartitionEntries(session({}), {})).toBeNull() diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index 5441c695a139..da1eed9ab18d 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -1,27 +1,6 @@ import type { TerminalTab, WorkspaceSessionPatch, WorkspaceSessionState } from './types' -/** - * Adopt terminal-session state that exists only in another host partition. - * - * The main-process runtime persists SSH-owned worktree session state into - * `ssh:` partitions (persistence.ts workspaceSessionsByHostId), while - * renderer persistence keeps SSH worktrees in the 'local' partition - * (workspace-session-host-persistence.ts buildHostIdByWorktreeId). Readers that - * only consult one side see the other side's worktrees as empty — and both the - * boot hydration merge and the remote-workspace export treat "empty" as - * authoritative, which turns the invisible copy into a permanent deletion once - * a remote snapshot round-trips it. - * - * Adoption is the read-side bridge: a worktree whose base entry holds no tabs - * takes the partition's populated entry, together with the tab-scoped records - * of exactly the adopted tabs. Entries the base already populates always win — - * the partition copy may be a stale fork. Tabs fenced by a surface tombstone on - * either side stay retired. Nothing is ever removed from the base. - * - * The adopted field set deliberately mirrors the terminal-session surface of - * remote-workspace-session-projection.ts — when a worktree/tab-scoped field is - * added there it must be considered here too. - */ +/** Adopt orphaned terminal state while preserving base authority and tombstones. */ export type WorkspaceSessionPartitionAdoption = { session: WorkspaceSessionState @@ -126,88 +105,7 @@ export function adoptOrphanedWorkspaceSessionPartition( return { session, adoptedTabIdsByWorktreeId, retiredTabIds: [...retiredTabIds] } } -export type AdoptedWorkspaceSessionOwnerPatch = { - patch: WorkspaceSessionPatch - /** Tab ids the patch lands, per worktree — the exact set the source may shed. */ - landedTabIdsByWorktreeId: Record -} - -/** - * Build the owner-partition patch that lands every adopted field durably. - * `ownerSession` must be a fresh read of the owning partition — the patch - * replaces whole fields, so building it from a stale snapshot would clobber - * writes that landed in between. Worktrees the fresh read already populates - * drop out of the patch, so only what this patch lands may later be pruned - * from the source. Returns null when nothing is left to land. - */ -export function buildAdoptedWorkspaceSessionOwnerPatch( - ownerSession: WorkspaceSessionState, - adoptedSession: WorkspaceSessionState, - adoptedTabIdsByWorktreeId: Record -): AdoptedWorkspaceSessionOwnerPatch | null { - // Why: base-wins holds against the fresh read too — a concurrent write can - // populate an adopted worktree after the boot snapshot, and this patch - // replaces the whole field, so keeping the snapshot would drop those tabs. - const adoptedWorktreeIds = Object.keys(adoptedTabIdsByWorktreeId).filter( - (worktreeId) => (ownerSession.tabsByWorktree?.[worktreeId] ?? []).length === 0 - ) - if (adoptedWorktreeIds.length === 0) { - return null - } - const landedTabIdsByWorktreeId = Object.fromEntries( - adoptedWorktreeIds.map((worktreeId) => [ - worktreeId, - adoptedTabIdsByWorktreeId[worktreeId] ?? [] - ]) - ) - const adoptedTabIds = new Set(Object.values(landedTabIdsByWorktreeId).flat()) - const adoptedTabsByWorktree = Object.fromEntries( - adoptedWorktreeIds.map((worktreeId) => [ - worktreeId, - adoptedSession.tabsByWorktree[worktreeId] ?? [] - ]) - ) - const patch: WorkspaceSessionPatch = { - tabsByWorktree: { ...ownerSession.tabsByWorktree, ...adoptedTabsByWorktree }, - terminalLayoutsByTabId: { - ...ownerSession.terminalLayoutsByTabId, - ...pickTabKeyed(adoptedSession.terminalLayoutsByTabId, adoptedTabIds) - } - } - const adoptedRemoteSessionIds = pickTabKeyed( - adoptedSession.remoteSessionIdsByTabId, - adoptedTabIds - ) - if (Object.keys(adoptedRemoteSessionIds).length > 0) { - patch.remoteSessionIdsByTabId = { - ...ownerSession.remoteSessionIdsByTabId, - ...adoptedRemoteSessionIds - } - } - const scalarFields = [ - 'activeTabIdByWorktree', - 'lastVisitedAtByWorktreeId', - 'defaultTerminalTabsAppliedByWorktreeId' - ] as const - for (const field of scalarFields) { - ;(patch as Record)[field] = adoptWorktreeKeyed( - ownerSession[field] as Record | undefined, - adoptedSession[field] as Record | undefined, - adoptedWorktreeIds - ) - } - return { patch, landedTabIdsByWorktreeId } -} - -/** - * Build the partition patch that completes an adoption as a move. Leaving the - * adopted tabs behind would re-adopt them on every read once the base's own - * copy empties again — resurrecting tabs the user deliberately closed. - * `partition` must be a fresh read of the source. Returns null when nothing was - * adopted. An adopted worktree sheds the tabs the owner took plus the ones a - * tombstone retired; every other tab stays, including one a concurrent write - * added to that worktree after the adoption read. - */ +/** Remove only tabs the owner transaction accepted or rejected. */ export function pruneAdoptedWorkspaceSessionPartitionEntries( partition: WorkspaceSessionState, adoptedTabIdsByWorktreeId: Record, diff --git a/tests/e2e/ssh-session-partition-adoption.spec.ts b/tests/e2e/ssh-session-partition-adoption.spec.ts new file mode 100644 index 000000000000..4e560cca5473 --- /dev/null +++ b/tests/e2e/ssh-session-partition-adoption.spec.ts @@ -0,0 +1,189 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { getDefaultWorkspaceSession } from '../../src/shared/constants' +import type { WorkspaceSessionState } from '../../src/shared/types' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + execDockerSshRelayTargetCommand, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { createRestartSession } from './helpers/orca-restart' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +type PersistedSessionData = { + workspaceSession: WorkspaceSessionState + workspaceSessionsByHostId?: Record +} + +function projectSessionForWorktree( + session: WorkspaceSessionState, + worktreeId: string +): WorkspaceSessionState { + const source = getDefaultWorkspaceSession() + const tabs = session.tabsByWorktree[worktreeId] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + source.tabsByWorktree = { [worktreeId]: tabs } + source.terminalLayoutsByTabId = Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + source.activeTabIdByWorktree = { + [worktreeId]: session.activeTabIdByWorktree?.[worktreeId] ?? null + } + source.lastVisitedAtByWorktreeId = { + [worktreeId]: session.lastVisitedAtByWorktreeId?.[worktreeId] ?? 0 + } + source.defaultTerminalTabsAppliedByWorktreeId = { + [worktreeId]: session.defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] ?? false + } + if (session.remoteSessionIdsByTabId) { + source.remoteSessionIdsByTabId = Object.fromEntries( + Object.entries(session.remoteSessionIdsByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + } + return source +} + +function strandSessionInSshPartition( + userDataDir: string, + hostId: `ssh:${string}`, + worktreeId: string, + session: WorkspaceSessionState +): void { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedSessionData + const source = projectSessionForWorktree(session, worktreeId) + const tabIds = new Set(source.tabsByWorktree[worktreeId].map((tab) => tab.id)) + data.workspaceSession.tabsByWorktree[worktreeId] = [] + for (const tabId of tabIds) { + delete data.workspaceSession.terminalLayoutsByTabId[tabId] + delete data.workspaceSession.remoteSessionIdsByTabId?.[tabId] + } + delete data.workspaceSession.activeTabIdByWorktree?.[worktreeId] + delete data.workspaceSession.lastVisitedAtByWorktreeId?.[worktreeId] + delete data.workspaceSession.defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] + data.workspaceSessionsByHostId = { + ...data.workspaceSessionsByHostId, + [hostId]: source + } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) +} + +async function readTabIdentity( + page: Page, + worktreeId: string +): Promise<{ id: string; ptyId: string | null }> { + return page.evaluate((id) => { + const tab = window.__store?.getState().tabsByWorktree[id]?.[0] + if (!tab) { + throw new Error(`No tab restored for ${id}`) + } + return { id: tab.id, ptyId: tab.ptyId } + }, worktreeId) +} + +test.describe('SSH session partition adoption', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH adoption uses POSIX SSH tooling.') + + test('round-trips stranded tabs without remote deletion @headful', async (// oxlint-disable-next-line no-empty-pattern -- The test owns each Electron launch. + {}, testInfo) => { + test.setTimeout(360_000) + const restart = createRestartSession(testInfo) + let target: DockerSshRelayTarget | null = null + let app: ElectronApplication | null = null + try { + target = startDockerSshRelayTarget(testInfo) + const first = await restart.launch() + app = first.app + await waitForSessionReady(first.page) + const remote = await connectDockerSshRelayTarget(first.page, target) + await expect.poll(() => waitForActiveWorktree(first.page)).toBe(remote.worktreeId) + await waitForActiveTerminalManager(first.page, 60_000) + const ptyId = await waitForActivePanePtyId(first.page, 60_000) + const identity = await readTabIdentity(first.page, remote.worktreeId) + expect(identity.ptyId).toBe(ptyId) + + const token = `SSH_PARTITION_${Date.now()}` + const proofPath = `/tmp/orca-ssh-partition-${Date.now()}` + await focusActiveTerminalInput(first.page) + await first.page.keyboard.type( + `export ORCA_PARTITION_TOKEN=${token}; printf '%s' "$ORCA_PARTITION_TOKEN" > ${proofPath}` + ) + await first.page.keyboard.press('Enter') + await expect + .poll(() => execDockerSshRelayTargetCommand(target!, `cat ${proofPath}`)) + .toBe(token) + + const persisted = await first.page.evaluate(() => window.api.session.get()) + await restart.close(first.app) + app = null + const hostId = `ssh:${encodeURIComponent(remote.targetId)}` as const + strandSessionInSshPartition(restart.userDataDir, hostId, remote.worktreeId, persisted) + + const second = await restart.launch() + app = second.app + await waitForSessionReady(second.page, 60_000) + await expect + .poll(() => waitForActiveWorktree(second.page), { timeout: 60_000 }) + .toBe(remote.worktreeId) + await waitForActiveTerminalManager(second.page, 60_000) + expect(await readTabIdentity(second.page, remote.worktreeId)).toEqual(identity) + expect(await second.page.evaluate((id) => window.api.session.get(id), hostId)).toMatchObject({ + tabsByWorktree: {} + }) + expect( + await second.page.evaluate( + async ({ targetId, worktreePath }) => { + const snapshot = await window.api.remoteWorkspace.get({ targetId }) + return snapshot?.session.tabsByWorktreePath[worktreePath]?.map((tab) => tab.id) ?? [] + }, + { targetId: remote.targetId, worktreePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH } + ) + ).toContain(identity.id) + await waitForActivePanePtyId(second.page, 60_000) + await focusActiveTerminalInput(second.page) + await second.page.keyboard.type(`printf '%s' "$ORCA_PARTITION_TOKEN" > ${proofPath}.restored`) + await second.page.keyboard.press('Enter') + await expect + .poll(() => execDockerSshRelayTargetCommand(target!, `cat ${proofPath}.restored`)) + .toBe(token) + + await restart.close(second.app) + app = null + const third = await restart.launch() + app = third.app + await waitForSessionReady(third.page, 60_000) + await expect + .poll(() => waitForActiveWorktree(third.page), { timeout: 60_000 }) + .toBe(remote.worktreeId) + expect(await readTabIdentity(third.page, remote.worktreeId)).toEqual(identity) + expect(await third.page.evaluate((id) => window.api.session.get(id), hostId)).toMatchObject({ + tabsByWorktree: {} + }) + } finally { + if (app) { + await restart.close(app) + } + await restart.dispose() + cleanupDockerSshRelayTarget(target) + } + }) +}) From 68e0c223cac35592cef1c8448c053539f11cc771 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 06:07:59 -0400 Subject: [PATCH 04/20] fix(session): reconcile ssh partition provenance durably --- config/reliability-gates.jsonc | 31 +- src/main/ipc/session.test.ts | 8 +- src/main/ipc/session.ts | 2 +- src/main/persistence.test.ts | 208 +++++++++ src/main/persistence.ts | 70 ++-- src/preload/api-types.ts | 2 +- ...workspace-session-host-persistence.test.ts | 48 ++- .../lib/workspace-session-host-persistence.ts | 2 +- ...rkspace-session-ssh-partition-hydration.ts | 10 +- ...rkspace-session-partition-adoption.test.ts | 362 ++++++++-------- .../workspace-session-partition-adoption.ts | 395 ++++++++++++------ .../workspace-session-partition-provenance.ts | 267 ++++++++++++ 12 files changed, 1061 insertions(+), 344 deletions(-) create mode 100644 src/shared/workspace-session-partition-provenance.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2c9b2b08db9c..cc274f25f684 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5887,6 +5887,7 @@ "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", @@ -5902,6 +5903,11 @@ ], "testFiles": [ "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", + "src/shared/workspace-session-partition-adoption.test.ts", + "src/main/persistence.test.ts", + "src/main/ipc/session.test.ts", + "src/renderer/src/lib/workspace-session-host-persistence.test.ts", + "src/main/ipc/remote-workspace.test.ts", "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "src/renderer/src/lib/resolved-worktree-execution-host.test.ts", "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts", @@ -5953,6 +5959,29 @@ "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ + { + "file": "src/shared/workspace-session-partition-adoption.test.ts", + "assertions": [ + "topology revisions select terminal membership authority while SSH incarnation provenance selects newer pane bindings", + "folder-only workspaces retain tab groups, layouts, files, and provider-session identity", + "old tombstones cannot retire a newer SSH incarnation and reconciliation is idempotent" + ] + }, + { + "file": "src/main/persistence.test.ts", + "assertions": [ + "one transaction enumerates every persisted SSH partition and preserves non-SSH partitions", + "the durable owner flush precedes complete source pruning and both failure phases remain retryable", + "a sync-flushed SSH binding and incarnation survive the crash window before renderer adoption" + ] + }, + { + "file": "src/renderer/src/lib/workspace-session-host-persistence.test.ts", + "assertions": [ + "startup delegates persisted SSH enumeration to main even with no loaded repository", + "durability failure rejects startup adoption instead of being folded into an empty inventory" + ] + }, { "file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "assertions": [ @@ -6351,7 +6380,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. Startup adoption adds one sequential in-memory main-process transaction per known SSH target, while remote export adds one in-memory partition overlay per target only when no explicit session is supplied; neither path adds polling, subprocesses, listeners, or provider calls. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." + "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. Startup adoption enumerates the in-memory persisted partition map once, reconciles each SSH partition in stable host order, and performs exactly two durable flushes total: owner first, then source pruning. Remote export adds one in-memory partition overlay per target only when no explicit session is supplied; neither path adds polling, subprocesses, listeners, or provider calls. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." }, "promotionCriteria": [ "Use deterministic fake providers for failure and unknown-liveness cases.", diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts index 479ad40a16ca..9dd2f2f5b5fe 100644 --- a/src/main/ipc/session.test.ts +++ b/src/main/ipc/session.test.ts @@ -17,7 +17,7 @@ describe('registerSessionHandlers', () => { onMock.mockClear() }) - it('routes atomic ssh partition adoption through the store', () => { + it('routes authoritative SSH partition enumeration through the store', () => { const expected = { tabsByWorktree: {} } const store = { adoptSshWorkspaceSessionPartition: vi.fn(() => expected) @@ -25,10 +25,10 @@ describe('registerSessionHandlers', () => { registerSessionHandlers(store as never) const handler = handleMock.mock.calls.find( ([channel]) => channel === 'session:adopt-ssh-partition' - )?.[1] as (event: unknown, hostId: string) => unknown + )?.[1] as (event: unknown, hostId?: string) => unknown - expect(handler({}, 'ssh:target-1')).toBe(expected) + expect(handler({})).toBe(expected) expect(store.adoptSshWorkspaceSessionPartition).toHaveBeenCalledOnce() - expect(store.adoptSshWorkspaceSessionPartition).toHaveBeenCalledWith('ssh:target-1') + expect(store.adoptSshWorkspaceSessionPartition).toHaveBeenCalledWith(undefined) }) }) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 62ffd8111397..6b610baea977 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -18,7 +18,7 @@ export function registerSessionHandlers(store: Store): void { store.patchWorkspaceSession(args, hostId) }) - ipcMain.handle('session:adopt-ssh-partition', (_event, hostId: string) => { + ipcMain.handle('session:adopt-ssh-partition', (_event, hostId?: string) => { return store.adoptSshWorkspaceSessionPartition(hostId) }) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ff3a6441dd90..1ab9514dcd64 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -11904,6 +11904,214 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) }) + it('enumerates every persisted SSH partition including folder-only projects', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('ssh-folder') + const folderSession = makeBoundHostSession('ssh:ssh-folder@@folder-pty') + folderSession.tabsByWorktree = { + [folderKey]: [ + { + ...folderSession.tabsByWorktree['repo-1::/worktree'][0], + worktreeId: folderKey + } + ] + } + folderSession.openFilesByWorktree = { + [folderKey]: [ + { + filePath: '/srv/folder/a.ts', + relativePath: 'a.ts', + worktreeId: folderKey, + language: 'typescript' + } + ] + } + store.setWorkspaceSession(folderSession, 'ssh:ssh-folder') + store.setWorkspaceSession(makeBoundHostSession('ssh:ssh-repo@@repo-pty'), 'ssh:ssh-repo') + store.setWorkspaceSession(makeHostSession('runtime-repo'), 'runtime:env-a') + const flush = vi.spyOn(store, 'flushOrThrow') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(flush).toHaveBeenCalledTimes(2) + expect(adopted.tabsByWorktree[folderKey]).toHaveLength(1) + expect(adopted.openFilesByWorktree?.[folderKey]?.[0]?.relativePath).toBe('a.ts') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'runtime:env-a']) + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') + }) + + it('rolls back owner and sources when the durable owner flush fails', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('owner flush failed') + }) + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('owner flush failed') + flush.mockRestore() + + expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:ssh-1']) + }) + + it('keeps the durable owner and retryable sources when source pruning fails', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const durableFlush = store.flushOrThrow.bind(store) + const flush = vi + .spyOn(store, 'flushOrThrow') + .mockImplementationOnce(durableFlush) + .mockImplementationOnce(() => { + throw new Error('source prune failed') + }) + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') + flush.mockRestore() + + expect(store.getWorkspaceSession().tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSession.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']?.tabsByWorktree).toEqual( + source.tabsByWorktree + ) + }) + + it('converges idempotently when retrying after a source-prune failure', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const durableFlush = store.flushOrThrow.bind(store) + const flush = vi + .spyOn(store, 'flushOrThrow') + .mockImplementationOnce(durableFlush) + .mockImplementationOnce(() => { + throw new Error('source prune failed') + }) + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') + flush.mockRestore() + const beforeRetry = structuredClone(store.getWorkspaceSession()) + + const afterRetry = store.adoptSshWorkspaceSessionPartition() + + expect(afterRetry).toEqual(beforeRetry) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().tabsByWorktree).toEqual(beforeRetry.tabsByWorktree) + expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId).toEqual( + beforeRetry.terminalLayoutsByTabId + ) + expect(reloaded.getWorkspaceSessionHostIds()).toEqual(['local']) + }) + + it('reconciles a newer SSH binding, incarnation, and sleeping provider identity', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@old-pty') + local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } + local.sleepingAgentSessionsByPaneKey = { + [`tab-1:${TEST_LEAF_1}`]: { + paneKey: `tab-1:${TEST_LEAF_1}`, + tabId: 'tab-1', + worktreeId: 'repo-1::/worktree', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-old' }, + prompt: 'old', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + const source = makeBoundHostSession('ssh:ssh-1@@new-pty') + source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-new' } + source.sleepingAgentSessionsByPaneKey = { + [`tab-1:${TEST_LEAF_1}`]: { + ...local.sleepingAgentSessionsByPaneKey[`tab-1:${TEST_LEAF_1}`], + providerSession: { key: 'session_id', id: 'provider-new' }, + prompt: 'new', + capturedAt: 2, + updatedAt: 2 + } + } + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']?.[0]?.ptyId).toBe('ssh:ssh-1@@new-pty') + expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( + 'ssh:ssh-1@@new-pty' + ) + expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') + expect( + adopted.sleepingAgentSessionsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]?.providerSession.id + ).toBe('provider-new') + }) + + it('preserves a sync-flushed SSH incarnation across the adoption crash window', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@old-pty') + local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } + const source = structuredClone(local) + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + expect( + store.persistPtyBinding( + { + worktreeId: 'repo-1::/worktree', + tabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@new-pty', + incarnationId: 'inc-new', + expectedBinding: { ptyId: 'ssh:ssh-1@@old-pty', incarnationId: 'inc-old' } + }, + 'ssh:ssh-1' + ) + ).toBe(true) + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( + 'ssh:ssh-1@@new-pty' + ) + expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') + expect(adopted.terminalTopologyRevisionByRepoId?.['repo-1']).toBe(1) + }) + + it('removes all SSH source metadata only after the owner is durable', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-1' } + source.terminalTopologyRevisionByRepoId = { 'repo-1': 3 } + source.tabGroups = { + 'repo-1::/worktree': [ + { + id: 'group-1', + worktreeId: 'repo-1::/worktree', + activeTabId: 'tab-1', + tabOrder: ['tab-1'] + } + ] + } + store.setWorkspaceSession(source, 'ssh:ssh-1') + + store.adoptSshWorkspaceSessionPartition() + + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']).toBeUndefined() + expect(persisted.workspaceSession.terminalPtyIncarnationsByPaneKey).toEqual({ + [`tab-1:${TEST_LEAF_1}`]: 'inc-1' + }) + expect(persisted.workspaceSession.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 3 }) + expect(persisted.workspaceSession.tabGroups?.['repo-1::/worktree']?.[0]?.id).toBe('group-1') + }) + it('preserves and enforces equal repo-id topology authority independently per host', async () => { const store = await createStore() const worktreeId = 'duplicate::/worktree' diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 13572081e4b9..e4aca4c261ed 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -135,10 +135,7 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' -import { - adoptOrphanedWorkspaceSessionPartition, - pruneAdoptedWorkspaceSessionPartitionEntries -} from '../shared/workspace-session-partition-adoption' +import { adoptOrphanedWorkspaceSessionPartition } from '../shared/workspace-session-partition-adoption' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -6282,41 +6279,48 @@ export class Store { return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() } - adoptSshWorkspaceSessionPartition(hostId: string): WorkspaceSessionState { - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind !== 'ssh' || !this.state.workspaceSessionsByHostId?.[hostId]) { + adoptSshWorkspaceSessionPartition(hostId?: string): WorkspaceSessionState { + if (hostId && parseExecutionHostId(hostId)?.kind !== 'ssh') { return this.getWorkspaceSession() } - const owner = this.getWorkspaceSession() - const source = this.getWorkspaceSession(hostId) - const adoption = adoptOrphanedWorkspaceSessionPartition(owner, source) - const movedTabIdsByWorktreeId: Record = {} - for (const [worktreeId, sourceTabs] of Object.entries(source.tabsByWorktree ?? {})) { - if (sourceTabs.length > 0) { - // Local is authoritative once it either accepts or rejects this stale fork. - movedTabIdsByWorktreeId[worktreeId] = sourceTabs.map((tab) => tab.id) - } - } - const sourcePatch = pruneAdoptedWorkspaceSessionPartitionEntries( - source, - movedTabIdsByWorktreeId, - adoption.retiredTabIds - ) - if (adoption.session === owner && !sourcePatch) { - return owner + const originalOwner = this.getWorkspaceSession() + const originalPartitions = this.state.workspaceSessionsByHostId + const sshPartitions = Object.entries(originalPartitions ?? {}) + .filter(([partitionHostId, session]) => + Boolean(session && parseExecutionHostId(partitionHostId)?.kind === 'ssh') + ) + .sort(([left], [right]) => left.localeCompare(right)) as [string, WorkspaceSessionState][] + if (sshPartitions.length === 0) { + return originalOwner } - if (adoption.session !== owner) { - this.setLocalWorkspaceSession(adoption.session) + let owner = originalOwner + for (const [, source] of sshPartitions) { + owner = adoptOrphanedWorkspaceSessionPartition(owner, source).session } - if (sourcePatch) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: { ...source, ...sourcePatch } - } - this.scheduleSave() + this.state.workspaceSession = owner + try { + this.flushOrThrow() + } catch (error) { + this.state.workspaceSession = originalOwner + this.state.workspaceSessionsByHostId = originalPartitions + throw error + } + + this.state.workspaceSessionsByHostId = Object.fromEntries( + Object.entries(originalPartitions ?? {}).filter( + ([partitionHostId]) => parseExecutionHostId(partitionHostId)?.kind !== 'ssh' + ) + ) + try { + this.flushOrThrow() + } catch (error) { + // Why: phase one is already durable; keep its owner plus retryable sources. + this.state.workspaceSession = owner + this.state.workspaceSessionsByHostId = originalPartitions + throw error } - return this.getWorkspaceSession() + return owner } /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 627d6598ef1e..596fb7e80c16 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2742,7 +2742,7 @@ export type PreloadApi = { get: (hostId?: ExecutionHostId) => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise - adoptSshPartition: (hostId: `ssh:${string}`) => Promise + adoptSshPartition: (hostId?: `ssh:${string}`) => Promise flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index 37ffa6059b00..42bd4ef4bc9b 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -214,7 +214,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { sshRepo ]) - expect(adoptSshPartition).toHaveBeenCalledWith('ssh:conn-1') + expect(adoptSshPartition).toHaveBeenCalledWith() expect(read.session.tabsByWorktree[worktreeId]).toEqual([strandedTab]) expect(read.session.terminalLayoutsByTabId['stranded-tab']).toBeDefined() expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({}) @@ -252,7 +252,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(read.session.tabsByWorktree[worktreeId]).toEqual([liveTab]) }) - it('moves known ssh partitions in stable host order', async () => { + it('delegates persisted SSH partition enumeration to one main transaction', async () => { const worktreeA = 'repo-a::/home/user/wt-a' const worktreeB = 'repo-b::/home/user/wt-b' const repos = [ @@ -262,21 +262,47 @@ describe('fetchWorkspaceSessionFromHosts', () => { const tabA = sessionTab('tab-a', worktreeA) const tabB = sessionTab('tab-b', worktreeB) const get = vi.fn().mockResolvedValue(getDefaultWorkspaceSession()) - const adoptSshPartition = vi.fn(async (hostId: string): Promise => { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: - hostId === 'ssh:conn-1' - ? { [worktreeA]: [tabA] } - : { [worktreeA]: [tabA], [worktreeB]: [tabB] } + const adoptSshPartition = vi.fn( + async (hostId?: `ssh:${string}`): Promise => { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: + hostId === 'ssh:conn-1' + ? { [worktreeA]: [tabA] } + : { [worktreeA]: [tabA], [worktreeB]: [tabB] } + } } - }) + ) const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, repos) expect(read.session.tabsByWorktree[worktreeA]).toEqual([tabA]) expect(read.session.tabsByWorktree[worktreeB]).toEqual([tabB]) - expect(adoptSshPartition.mock.calls).toEqual([['ssh:conn-1'], ['ssh:conn-2']]) + expect(adoptSshPartition.mock.calls).toEqual([[]]) + }) + + it('runs main partition enumeration when no SSH repository is loaded', async () => { + const folderKey = folderWorkspaceKey('ssh-folder') + const folderTab = sessionTab('folder-tab', folderKey) + const get = vi.fn().mockResolvedValue(getDefaultWorkspaceSession()) + const adoptSshPartition = vi.fn().mockResolvedValue({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [folderKey]: [folderTab] } + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, []) + + expect(adoptSshPartition).toHaveBeenCalledWith() + expect(read.session.tabsByWorktree[folderKey]).toEqual([folderTab]) + }) + + it('fails closed when the durable main adoption transaction rejects', async () => { + const get = vi.fn().mockResolvedValue(getDefaultWorkspaceSession()) + const adoptSshPartition = vi.fn().mockRejectedValue(new Error('disk unavailable')) + + await expect( + fetchWorkspaceSessionWithRuntimeHostOwners({ get, adoptSshPartition }, []) + ).rejects.toThrow('disk unavailable') }) it('routes restored runtime folder workspace patches back to the runtime host', async () => { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index cc41901572b8..fc3295cd7c38 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -34,7 +34,7 @@ export type HostPersistenceState = { type SessionApi = { get: (hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise - adoptSshPartition?: (hostId: `ssh:${string}`) => Promise + adoptSshPartition?: (hostId?: `ssh:${string}`) => Promise setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts index d203d9133422..0bca1a3cc350 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts @@ -8,7 +8,7 @@ import { adoptOrphanedWorkspaceSessionPartition } from '../../../shared/workspac type SshPartitionHydrationApi = { get: (hostId?: ExecutionHostId) => Promise - adoptSshPartition?: (hostId: `ssh:${string}`) => Promise + adoptSshPartition?: (hostId?: `ssh:${string}`) => Promise } /** Collect the distinct SSH hosts owning any persisted repo. */ @@ -31,12 +31,14 @@ export async function adoptStrandedSshHostPartitions( repos: readonly Pick[], merged: WorkspaceSessionState ): Promise { + if (api.adoptSshPartition) { + // Main enumerates persisted partitions, including folder-only SSH projects. + return api.adoptSshPartition() + } let session = merged for (const hostId of listKnownSshHostIds(repos)) { try { - session = api.adoptSshPartition - ? await api.adoptSshPartition(hostId as `ssh:${string}`) - : adoptOrphanedWorkspaceSessionPartition(session, await api.get(hostId)).session + session = adoptOrphanedWorkspaceSessionPartition(session, await api.get(hostId)).session } catch (err) { console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) } diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index 48897abf508a..a1564b0fd29e 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from './constants' -import type { TerminalTab, WorkspaceSessionState } from './types' -import { - adoptOrphanedWorkspaceSessionPartition, - pruneAdoptedWorkspaceSessionPartitionEntries -} from './workspace-session-partition-adoption' +import type { TerminalLayoutSnapshot, TerminalTab, WorkspaceSessionState } from './types' +import { folderWorkspaceKey } from './workspace-scope' +import { adoptOrphanedWorkspaceSessionPartition } from './workspace-session-partition-adoption' const WORKTREE_ID = 'repo-1::/srv/wt' -const OTHER_WORKTREE_ID = 'repo-1::/srv/other-wt' +const FOLDER_KEY = folderWorkspaceKey('folder-1') +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF_ID}` -function tab(id: string, worktreeId = WORKTREE_ID): TerminalTab { +function tab(id: string, worktreeId = WORKTREE_ID, ptyId: string | null = null): TerminalTab { return { id, - ptyId: null, + ptyId, worktreeId, title: id, customTitle: null, @@ -26,199 +26,235 @@ function session(overrides: Partial): WorkspaceSessionSta return { ...getDefaultWorkspaceSession(), ...overrides } } +function layout(ptyId: string | null): TerminalLayoutSnapshot { + return { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [LEAF_ID]: ptyId } : undefined + } +} + describe('adoptOrphanedWorkspaceSessionPartition', () => { - it('adopts a populated partition entry when the base holds no tabs for the worktree', () => { - const base = session({ tabsByWorktree: { [WORKTREE_ID]: [] } }) - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] }, - terminalLayoutsByTabId: { 'tab-1': { root: null } as never }, - activeTabIdByWorktree: { [WORKTREE_ID]: 'tab-1' }, - lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 42 }, - defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE_ID]: true } + it('adopts complete folder-only state without a repository entry', () => { + const source = session({ + activeWorkspaceKey: FOLDER_KEY, + activeWorktreeId: FOLDER_KEY, + activeTabId: 'tab-1', + tabsByWorktree: { [FOLDER_KEY]: [tab('tab-1', FOLDER_KEY, 'ssh:one@@pty-1')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-1') }, + openFilesByWorktree: { + [FOLDER_KEY]: [ + { + filePath: '/srv/folder/a.ts', + relativePath: 'a.ts', + worktreeId: FOLDER_KEY, + language: 'typescript' + } + ] + }, + activeFileIdByWorktree: { [FOLDER_KEY]: '/srv/folder/a.ts' }, + tabGroups: { + [FOLDER_KEY]: [ + { id: 'group-1', worktreeId: FOLDER_KEY, activeTabId: 'tab-1', tabOrder: ['tab-1'] } + ] + }, + tabGroupLayouts: { [FOLDER_KEY]: { type: 'leaf', groupId: 'group-1' } }, + activeGroupIdByWorktree: { [FOLDER_KEY]: 'group-1' }, + activeTabIdByWorktree: { [FOLDER_KEY]: 'tab-1' }, + lastVisitedAtByWorktreeId: { [FOLDER_KEY]: 42 }, + defaultTerminalTabsAppliedByWorktreeId: { [FOLDER_KEY]: true } }) - const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) - - expect(adoption.adoptedTabIdsByWorktreeId).toEqual({ [WORKTREE_ID]: ['tab-1'] }) - expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - expect(adoption.session.terminalLayoutsByTabId['tab-1']).toBeDefined() - expect(adoption.session.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('tab-1') - expect(adoption.session.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBe(42) - expect(adoption.session.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE_ID]).toBe(true) - }) - - it('adopts when the base has no entry at all for the worktree', () => { - const base = session({}) - const partition = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] } }) - - const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) - - expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - }) - - it('keeps the base entry when it already holds tabs, even if the partition differs', () => { - const base = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('live-tab')] } }) - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('stale-tab-1'), tab('stale-tab-2')] } + const adoption = adoptOrphanedWorkspaceSessionPartition(session({}), source) + + expect(adoption.reconciledWorktreeIds).toEqual([FOLDER_KEY]) + expect(adoption.session.tabsByWorktree[FOLDER_KEY]?.[0]?.ptyId).toBe('ssh:one@@pty-1') + expect(adoption.session.activeWorkspaceKey).toBe(FOLDER_KEY) + expect(adoption.session.activeWorktreeId).toBe(FOLDER_KEY) + expect(adoption.session.openFilesByWorktree?.[FOLDER_KEY]?.[0]?.relativePath).toBe('a.ts') + expect(adoption.session.tabGroups?.[FOLDER_KEY]?.[0]?.id).toBe('group-1') + expect(adoption.session.tabGroupLayouts?.[FOLDER_KEY]).toEqual({ + type: 'leaf', + groupId: 'group-1' }) - - const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) - - expect(adoption.session).toBe(base) - expect(adoption.adoptedTabIdsByWorktreeId).toEqual({}) - }) - - it('returns the base identically when there is nothing to adopt', () => { - const base = session({ tabsByWorktree: { [WORKTREE_ID]: [] } }) - - expect(adoptOrphanedWorkspaceSessionPartition(base, session({})).session).toBe(base) - expect(adoptOrphanedWorkspaceSessionPartition(base, null).session).toBe(base) + expect(adoption.session.lastVisitedAtByWorktreeId?.[FOLDER_KEY]).toBe(42) }) - it('never removes base entries for worktrees the partition does not populate', () => { + it('uses a newer SSH topology revision as terminal membership authority', () => { const base = session({ - tabsByWorktree: { [OTHER_WORKTREE_ID]: [tab('kept', OTHER_WORKTREE_ID)] } + tabsByWorktree: { [WORKTREE_ID]: [tab('local-tab', WORKTREE_ID, 'local-pty')] }, + terminalLayoutsByTabId: { 'local-tab': layout('local-pty') }, + terminalTopologyRevisionByRepoId: { 'repo-1': 3 }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'local-group', + worktreeId: WORKTREE_ID, + activeTabId: 'local-tab', + tabOrder: ['local-tab'] + } + ] + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-1')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-1') }, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'ssh-group', + worktreeId: WORKTREE_ID, + activeTabId: 'tab-1', + tabOrder: ['tab-1'] + } + ] + } }) - const partition = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] } }) - const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) - expect(adoption.session.tabsByWorktree[OTHER_WORKTREE_ID]).toHaveLength(1) - expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([WORKTREE_ID]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'tab-1' + ]) + expect(adoption.session.terminalLayoutsByTabId['local-tab']).toBeUndefined() + expect(adoption.session.tabGroups?.[WORKTREE_ID]?.[0]?.id).toBe('ssh-group') + expect(adoption.session.terminalTopologyRevisionByRepoId?.['repo-1']).toBe(4) }) - it('skips tabs retired by a surface tombstone on either side', () => { - const tombstone = { - worktreeId: WORKTREE_ID, - parentTabId: 'retired-tab', - leafId: 'leaf-1', - ptyId: 'pty-1', - incarnationId: 'inc-1', - retiredAt: 5 - } + it('keeps newer local membership when the SSH topology revision is stale', () => { const base = session({ - terminalSurfaceTombstonesByPaneKey: { 'retired-tab:leaf-1': tombstone } + tabsByWorktree: { [WORKTREE_ID]: [tab('local-tab')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 8 } }) - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('retired-tab'), tab('live-tab')] } + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-tab')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 7 } }) - const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) - expect(adoption.adoptedTabIdsByWorktreeId).toEqual({ [WORKTREE_ID]: ['live-tab'] }) + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'local-tab' + ]) }) - it('only carries tab-scoped records of adopted tabs and never overwrites base records', () => { - const baseLayout = { root: null } as never + it('uses SSH incarnation provenance for a newer binding and provider session', () => { const base = session({ - tabsByWorktree: { [OTHER_WORKTREE_ID]: [tab('base-tab', OTHER_WORKTREE_ID)] }, - terminalLayoutsByTabId: { 'base-tab': baseLayout } + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@old')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@old') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-old' }, + remoteSessionIdsByTabId: { 'tab-1': 'ssh:one@@old' }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-old' }, + prompt: 'old', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } }) - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1')] }, - terminalLayoutsByTabId: { - 'tab-1': { root: null } as never, - 'base-tab': { root: { stale: true } } as never, - 'unrelated-tab': { root: null } as never - }, - remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'unrelated-tab': 'session-2' } + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' }, + remoteSessionIdsByTabId: { 'tab-1': 'ssh:one@@new' }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-new' }, + prompt: 'new', + state: 'working', + capturedAt: 2, + updatedAt: 2 + } + } }) - const adoption = adoptOrphanedWorkspaceSessionPartition(base, partition) + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) - expect(adoption.session.terminalLayoutsByTabId['base-tab']).toBe(baseLayout) - expect(adoption.session.terminalLayoutsByTabId['tab-1']).toBeDefined() - expect(adoption.session.terminalLayoutsByTabId['unrelated-tab']).toBeUndefined() - expect(adoption.session.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'session-1' }) - }) -}) - -describe('pruneAdoptedWorkspaceSessionPartitionEntries', () => { - it('returns null when nothing was adopted', () => { - expect(pruneAdoptedWorkspaceSessionPartitionEntries(session({}), {})).toBeNull() + expect(adoption.sourceAuthoritativePaneKeys).toEqual([PANE_KEY]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@new') + expect(adoption.session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe( + 'ssh:one@@new' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-new') + expect(adoption.session.remoteSessionIdsByTabId?.['tab-1']).toBe('ssh:one@@new') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.providerSession.id).toBe( + 'provider-new' + ) }) - it('drops only the adopted worktree entries and adopted tab records', () => { - const partition = session({ - tabsByWorktree: { - [WORKTREE_ID]: [tab('tab-1')], - [OTHER_WORKTREE_ID]: [tab('kept-tab', OTHER_WORKTREE_ID)] - }, - terminalLayoutsByTabId: { - 'tab-1': { root: null } as never, - 'kept-tab': { root: null } as never - }, - remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'kept-tab': 'session-2' } + it('keeps a local incarnation when the SSH binding revision is older', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@local')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@local') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-local' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 5 } }) - - const patch = pruneAdoptedWorkspaceSessionPartitionEntries(partition, { - [WORKTREE_ID]: ['tab-1'] + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@stale')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@stale') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-stale' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 } }) - expect(patch).not.toBeNull() - expect(Object.keys(patch?.tabsByWorktree ?? {})).toEqual([OTHER_WORKTREE_ID]) - expect(Object.keys(patch?.terminalLayoutsByTabId ?? {})).toEqual(['kept-tab']) - expect(patch?.remoteSessionIdsByTabId).toEqual({ 'kept-tab': 'session-2' }) - }) - - it('retains a tab a concurrent write added to an adopted worktree', () => { - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1'), tab('written-during-adoption')] }, - terminalLayoutsByTabId: { - 'tab-1': { root: null } as never, - 'written-during-adoption': { root: null } as never - }, - remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'written-during-adoption': 'session-3' } - }) + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) - const patch = pruneAdoptedWorkspaceSessionPartitionEntries(partition, { - [WORKTREE_ID]: ['tab-1'] - }) - - expect(patch?.tabsByWorktree?.[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ - 'written-during-adoption' - ]) - expect(Object.keys(patch?.terminalLayoutsByTabId ?? {})).toEqual(['written-during-adoption']) - expect(patch?.remoteSessionIdsByTabId).toEqual({ 'written-during-adoption': 'session-3' }) + expect(adoption.sourceAuthoritativePaneKeys).toEqual([]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@local') + expect(adoption.session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe( + 'ssh:one@@local' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-local') }) - it('sheds retired tabs of an adopted worktree along with the adopted ones', () => { - const partition = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1'), tab('retired-tab')] }, - terminalLayoutsByTabId: { - 'tab-1': { root: null } as never, - 'retired-tab': { root: null } as never - }, - remoteSessionIdsByTabId: { 'tab-1': 'session-1', 'retired-tab': 'session-2' } + it('does not let an old tombstone retire a newer SSH incarnation', () => { + const base = session({ + terminalSurfaceTombstonesByPaneKey: { + [PANE_KEY]: { + worktreeId: WORKTREE_ID, + parentTabId: 'tab-1', + leafId: LEAF_ID, + ptyId: 'ssh:one@@old', + incarnationId: 'inc-old', + retiredAt: 1 + } + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' } }) - const patch = pruneAdoptedWorkspaceSessionPartitionEntries( - partition, - { [WORKTREE_ID]: ['tab-1'] }, - ['retired-tab'] - ) + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) - expect(patch?.tabsByWorktree).toEqual({}) - expect(patch?.terminalLayoutsByTabId).toEqual({}) - expect(patch?.remoteSessionIdsByTabId).toEqual({}) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(adoption.session.terminalSurfaceTombstonesByPaneKey?.[PANE_KEY]).toBeUndefined() }) - it('keeps a retired tab that belongs to a worktree the adoption did not touch', () => { - const partition = session({ - tabsByWorktree: { - [WORKTREE_ID]: [tab('tab-1')], - [OTHER_WORKTREE_ID]: [tab('retired-tab', OTHER_WORKTREE_ID)] - }, - terminalLayoutsByTabId: { 'retired-tab': { root: null } as never } + it('is idempotent after provenance reconciliation', () => { + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 2 } }) + const first = adoptOrphanedWorkspaceSessionPartition(session({}), source).session + const second = adoptOrphanedWorkspaceSessionPartition(first, source).session - const patch = pruneAdoptedWorkspaceSessionPartitionEntries( - partition, - { [WORKTREE_ID]: ['tab-1'] }, - ['retired-tab'] - ) - - expect(patch?.tabsByWorktree?.[OTHER_WORKTREE_ID]).toHaveLength(1) - expect(Object.keys(patch?.terminalLayoutsByTabId ?? {})).toEqual(['retired-tab']) + expect(second).toEqual(first) }) }) diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index da1eed9ab18d..7cca594d4d2d 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -1,155 +1,300 @@ -import type { TerminalTab, WorkspaceSessionPatch, WorkspaceSessionState } from './types' - -/** Adopt orphaned terminal state while preserving base authority and tombstones. */ +import type { WorkspaceSessionState } from './types' +import { + chooseSleepingRecord, + collectTerminalProvenance, + collectWorkspaceKeys, + layoutLeafCount, + mergeBrowserHistory, + mergeTerminalLayout, + mergeTopologyRevisions, + mergeUnique, + mergeWorkspaceRecord, + paneLeafId, + paneTabId, + sourceOwnsTerminalMembership +} from './workspace-session-partition-provenance' export type WorkspaceSessionPartitionAdoption = { session: WorkspaceSessionState - /** Tab ids adopted per worktree; empty when the base was returned unchanged. */ - adoptedTabIdsByWorktreeId: Record - /** Tab ids fenced by a tombstone on either side — retired, never adopted. */ - retiredTabIds: string[] + reconciledWorktreeIds: string[] + sourceAuthoritativeWorktreeIds: string[] + sourceAuthoritativePaneKeys: string[] } -function tombstonedTabIds(session: WorkspaceSessionState): Set { - const tabIds = new Set() - for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { - tabIds.add(tombstone.parentTabId) +export function adoptOrphanedWorkspaceSessionPartition( + base: WorkspaceSessionState, + source: WorkspaceSessionState | null | undefined +): WorkspaceSessionPartitionAdoption { + if (!source) { + return { + session: base, + reconciledWorktreeIds: [], + sourceAuthoritativeWorktreeIds: [], + sourceAuthoritativePaneKeys: [] + } + } + const sourceKeys = collectWorkspaceKeys(source) + const sourceAuthority = new Set( + [...sourceKeys].filter((key) => sourceOwnsTerminalMembership(base, source, key)) + ) + const tabsByWorktree = mergeWorkspaceRecord( + base.tabsByWorktree, + source.tabsByWorktree, + sourceKeys, + sourceAuthority + ) ?? { ...base.tabsByWorktree } + const { liveTabIds, touchedTabIds, sourceWorkspaceKeyByTabId, sourcePaneAuthority } = + collectTerminalProvenance(base, source, tabsByWorktree, sourceKeys, sourceAuthority) + const sourcePaneAuthoritativeTabIds = new Set( + [...sourcePaneAuthority].map((paneKey) => paneTabId(paneKey)) + ) + for (const key of sourceKeys) { + if (sourceAuthority.has(key)) { + continue + } + const sourceTabsById = new Map( + (source.tabsByWorktree[key] ?? []).map((tab) => [tab.id, tab] as const) + ) + tabsByWorktree[key] = (tabsByWorktree[key] ?? []).map((tab) => { + const sourceTab = sourceTabsById.get(tab.id) + return sourceTab?.ptyId && sourcePaneAuthoritativeTabIds.has(tab.id) + ? { ...tab, ptyId: sourceTab.ptyId } + : tab + }) } - return tabIds -} - -function pickTabKeyed( - record: Record | undefined, - tabIds: ReadonlySet -): Record { - return Object.fromEntries(Object.entries(record ?? {}).filter(([tabId]) => tabIds.has(tabId))) -} -function adoptWorktreeKeyed( - base: Record | undefined, - partition: Record | undefined, - worktreeIds: readonly string[] -): Record { - const adopted = { ...base } - for (const worktreeId of worktreeIds) { - if (Object.hasOwn(adopted, worktreeId)) { + const terminalLayoutsByTabId = { ...base.terminalLayoutsByTabId } + const remoteSessionIdsByTabId = { ...base.remoteSessionIdsByTabId } + for (const tabId of touchedTabIds) { + if (!liveTabIds.has(tabId)) { + delete terminalLayoutsByTabId[tabId] + delete remoteSessionIdsByTabId[tabId] continue } - const entry = partition?.[worktreeId] - if (entry !== undefined) { - adopted[worktreeId] = entry + const sourceLayout = source.terminalLayoutsByTabId[tabId] + const sourceWorkspaceKey = sourceWorkspaceKeyByTabId.get(tabId) + terminalLayoutsByTabId[tabId] = + sourceWorkspaceKey && sourceAuthority.has(sourceWorkspaceKey) + ? (sourceLayout ?? terminalLayoutsByTabId[tabId]) + : (mergeTerminalLayout( + terminalLayoutsByTabId[tabId], + sourceLayout, + tabId, + sourcePaneAuthority + ) ?? terminalLayoutsByTabId[tabId]) + if ( + source.remoteSessionIdsByTabId?.[tabId] !== undefined && + ((sourceWorkspaceKey && sourceAuthority.has(sourceWorkspaceKey)) || + remoteSessionIdsByTabId[tabId] === undefined || + sourcePaneAuthoritativeTabIds.has(tabId)) + ) { + remoteSessionIdsByTabId[tabId] = source.remoteSessionIdsByTabId[tabId] } } - return adopted -} -export function adoptOrphanedWorkspaceSessionPartition( - base: WorkspaceSessionState, - partition: WorkspaceSessionState | null | undefined -): WorkspaceSessionPartitionAdoption { - if (!partition) { - return { session: base, adoptedTabIdsByWorktreeId: {}, retiredTabIds: [] } - } - const retiredTabIds = new Set([...tombstonedTabIds(base), ...tombstonedTabIds(partition)]) - const adoptedTabsByWorktreeId: Record = {} - const adoptedTabIdsByWorktreeId: Record = {} - for (const [worktreeId, tabs] of Object.entries(partition.tabsByWorktree ?? {})) { - if ((base.tabsByWorktree?.[worktreeId] ?? []).length > 0) { - continue + const terminalPtyIncarnationsByPaneKey = { ...base.terminalPtyIncarnationsByPaneKey } + for (const [paneKey, incarnationId] of Object.entries( + source.terminalPtyIncarnationsByPaneKey ?? {} + )) { + if (sourcePaneAuthority.has(paneKey)) { + terminalPtyIncarnationsByPaneKey[paneKey] = incarnationId } - const liveTabs = (tabs ?? []).filter((tab) => !retiredTabIds.has(tab.id)) - if (liveTabs.length === 0) { - continue + } + for (const paneKey of Object.keys(terminalPtyIncarnationsByPaneKey)) { + if (touchedTabIds.has(paneTabId(paneKey)) && !liveTabIds.has(paneTabId(paneKey))) { + delete terminalPtyIncarnationsByPaneKey[paneKey] } - adoptedTabsByWorktreeId[worktreeId] = liveTabs - adoptedTabIdsByWorktreeId[worktreeId] = liveTabs.map((tab) => tab.id) } - const adoptedWorktreeIds = Object.keys(adoptedTabsByWorktreeId) - if (adoptedWorktreeIds.length === 0) { - return { session: base, adoptedTabIdsByWorktreeId: {}, retiredTabIds: [...retiredTabIds] } + + const terminalSurfaceTombstonesByPaneKey = { + ...base.terminalSurfaceTombstonesByPaneKey + } + for (const [paneKey, tombstone] of Object.entries( + source.terminalSurfaceTombstonesByPaneKey ?? {} + )) { + const liveIncarnation = terminalPtyIncarnationsByPaneKey[paneKey] + if (!liveIncarnation || liveIncarnation === tombstone.incarnationId) { + terminalSurfaceTombstonesByPaneKey[paneKey] = tombstone + } + } + for (const paneKey of sourcePaneAuthority) { + const tombstone = terminalSurfaceTombstonesByPaneKey[paneKey] + const sourceIncarnation = source.terminalPtyIncarnationsByPaneKey?.[paneKey] + const sourceWorkspaceKey = sourceWorkspaceKeyByTabId.get(paneTabId(paneKey)) + const sourcePtyId = + source.terminalLayoutsByTabId[paneTabId(paneKey)]?.ptyIdsByLeafId?.[paneLeafId(paneKey)] + if ( + tombstone && + ((sourceIncarnation && tombstone.incarnationId !== sourceIncarnation) || + (!sourceIncarnation && + sourceWorkspaceKey && + sourceAuthority.has(sourceWorkspaceKey) && + sourcePtyId && + sourcePtyId !== tombstone.ptyId)) + ) { + delete terminalSurfaceTombstonesByPaneKey[paneKey] + } } - const adoptedTabIds = new Set(Object.values(adoptedTabIdsByWorktreeId).flat()) - const session: WorkspaceSessionState = { - ...base, - tabsByWorktree: { ...base.tabsByWorktree, ...adoptedTabsByWorktreeId }, - // Why: base entries spread last so an id collision never lets a stale - // partition record replace live state; only records of adopted tabs enter. - terminalLayoutsByTabId: { - ...pickTabKeyed(partition.terminalLayoutsByTabId, adoptedTabIds), - ...base.terminalLayoutsByTabId - }, - activeTabIdByWorktree: adoptWorktreeKeyed( - base.activeTabIdByWorktree, - partition.activeTabIdByWorktree, - adoptedWorktreeIds - ), - lastVisitedAtByWorktreeId: adoptWorktreeKeyed( - base.lastVisitedAtByWorktreeId, - partition.lastVisitedAtByWorktreeId, - adoptedWorktreeIds - ), - defaultTerminalTabsAppliedByWorktreeId: adoptWorktreeKeyed( - base.defaultTerminalTabsAppliedByWorktreeId, - partition.defaultTerminalTabsAppliedByWorktreeId, - adoptedWorktreeIds - ) + const retiredTabIds = new Set() + for (const [paneKey, tombstone] of Object.entries(terminalSurfaceTombstonesByPaneKey)) { + const incarnation = terminalPtyIncarnationsByPaneKey[paneKey] + const layout = terminalLayoutsByTabId[tombstone.parentTabId] + if ( + (!incarnation || incarnation === tombstone.incarnationId) && + layoutLeafCount(layout?.root ?? null) <= 1 + ) { + retiredTabIds.add(tombstone.parentTabId) + } } - const adoptedRemoteSessionIds = pickTabKeyed(partition.remoteSessionIdsByTabId, adoptedTabIds) - if (Object.keys(adoptedRemoteSessionIds).length > 0 || base.remoteSessionIdsByTabId) { - session.remoteSessionIdsByTabId = { - ...adoptedRemoteSessionIds, - ...base.remoteSessionIdsByTabId + for (const [workspaceKey, tabs] of Object.entries(tabsByWorktree)) { + tabsByWorktree[workspaceKey] = tabs.filter((tab) => !retiredTabIds.has(tab.id)) + } + for (const tabId of retiredTabIds) { + liveTabIds.delete(tabId) + delete terminalLayoutsByTabId[tabId] + delete remoteSessionIdsByTabId[tabId] + for (const paneKey of Object.keys(terminalPtyIncarnationsByPaneKey)) { + if (paneTabId(paneKey) === tabId) { + delete terminalPtyIncarnationsByPaneKey[paneKey] + } } } - return { session, adoptedTabIdsByWorktreeId, retiredTabIds: [...retiredTabIds] } -} -/** Remove only tabs the owner transaction accepted or rejected. */ -export function pruneAdoptedWorkspaceSessionPartitionEntries( - partition: WorkspaceSessionState, - adoptedTabIdsByWorktreeId: Record, - retiredTabIds: readonly string[] = [] -): WorkspaceSessionPatch | null { - if (Object.keys(adoptedTabIdsByWorktreeId).length === 0) { - return null - } - const shed = new Set([...Object.values(adoptedTabIdsByWorktreeId).flat(), ...retiredTabIds]) - const droppedTabIds = new Set() - const tabsByWorktree: Record = {} - for (const [worktreeId, tabs] of Object.entries(partition.tabsByWorktree ?? {})) { - if (!Object.hasOwn(adoptedTabIdsByWorktreeId, worktreeId)) { - tabsByWorktree[worktreeId] = tabs + const sleepingAgentSessionsByPaneKey = { ...base.sleepingAgentSessionsByPaneKey } + for (const [paneKey, record] of Object.entries(source.sleepingAgentSessionsByPaneKey ?? {})) { + if (!sourceKeys.has(record.worktreeId)) { continue } - // Why: a tab the source gained after the adoption read was never moved — - // dropping the whole entry would delete it with no copy on the owner side. - const retained: TerminalTab[] = [] - for (const tab of tabs ?? []) { - if (shed.has(tab.id)) { - droppedTabIds.add(tab.id) - } else { - retained.push(tab) - } + const tombstone = terminalSurfaceTombstonesByPaneKey[paneKey] + if (!tombstone || tombstone.incarnationId !== terminalPtyIncarnationsByPaneKey[paneKey]) { + sleepingAgentSessionsByPaneKey[paneKey] = chooseSleepingRecord( + sleepingAgentSessionsByPaneKey[paneKey], + record + ) } - if (retained.length > 0) { - tabsByWorktree[worktreeId] = retained + } + for (const [paneKey, record] of Object.entries(sleepingAgentSessionsByPaneKey)) { + if (sourceKeys.has(record.worktreeId) && record.tabId && retiredTabIds.has(record.tabId)) { + delete sleepingAgentSessionsByPaneKey[paneKey] + } + } + + const browserPagesByWorkspace = { ...base.browserPagesByWorkspace } + for (const [workspaceId, pages] of Object.entries(source.browserPagesByWorkspace ?? {})) { + if ( + browserPagesByWorkspace[workspaceId] === undefined || + pages.some((page) => sourceAuthority.has(page.worktreeId)) + ) { + browserPagesByWorkspace[workspaceId] = pages } } - const patch: WorkspaceSessionPatch = { + + const session: WorkspaceSessionState = { + ...source, + ...base, + activeRepoId: base.activeRepoId ?? source.activeRepoId, + activeWorkspaceKey: base.activeWorkspaceKey ?? source.activeWorkspaceKey, + activeWorkspaceExecutionHostId: + base.activeWorkspaceExecutionHostId ?? source.activeWorkspaceExecutionHostId, + activeWorktreeId: base.activeWorktreeId ?? source.activeWorktreeId, + activeTabId: base.activeTabId ?? source.activeTabId, tabsByWorktree, - terminalLayoutsByTabId: Object.fromEntries( - Object.entries(partition.terminalLayoutsByTabId ?? {}).filter( - ([tabId]) => !droppedTabIds.has(tabId) - ) - ) + terminalLayoutsByTabId, + openFilesByWorktree: mergeWorkspaceRecord( + base.openFilesByWorktree, + source.openFilesByWorktree, + sourceKeys, + sourceAuthority + ), + activeFileIdByWorktree: mergeWorkspaceRecord( + base.activeFileIdByWorktree, + source.activeFileIdByWorktree, + sourceKeys, + sourceAuthority + ), + browserTabsByWorktree: mergeWorkspaceRecord( + base.browserTabsByWorktree, + source.browserTabsByWorktree, + sourceKeys, + sourceAuthority + ), + browserPagesByWorkspace, + browserUrlHistory: mergeBrowserHistory(base.browserUrlHistory, source.browserUrlHistory), + markdownFrontmatterVisible: { + ...source.markdownFrontmatterVisible, + ...base.markdownFrontmatterVisible + }, + activeBrowserTabIdByWorktree: mergeWorkspaceRecord( + base.activeBrowserTabIdByWorktree, + source.activeBrowserTabIdByWorktree, + sourceKeys, + sourceAuthority + ), + activeTabTypeByWorktree: mergeWorkspaceRecord( + base.activeTabTypeByWorktree, + source.activeTabTypeByWorktree, + sourceKeys, + sourceAuthority + ), + activeTabIdByWorktree: mergeWorkspaceRecord( + base.activeTabIdByWorktree, + source.activeTabIdByWorktree, + sourceKeys, + sourceAuthority + ), + unifiedTabs: mergeWorkspaceRecord( + base.unifiedTabs, + source.unifiedTabs, + sourceKeys, + sourceAuthority + ), + tabGroups: mergeWorkspaceRecord(base.tabGroups, source.tabGroups, sourceKeys, sourceAuthority), + tabGroupLayouts: mergeWorkspaceRecord( + base.tabGroupLayouts, + source.tabGroupLayouts, + sourceKeys, + sourceAuthority + ), + activeGroupIdByWorktree: mergeWorkspaceRecord( + base.activeGroupIdByWorktree, + source.activeGroupIdByWorktree, + sourceKeys, + sourceAuthority + ), + lastVisitedAtByWorktreeId: mergeWorkspaceRecord( + base.lastVisitedAtByWorktreeId, + source.lastVisitedAtByWorktreeId, + sourceKeys, + sourceAuthority + ), + defaultTerminalTabsAppliedByWorktreeId: mergeWorkspaceRecord( + base.defaultTerminalTabsAppliedByWorktreeId, + source.defaultTerminalTabsAppliedByWorktreeId, + sourceKeys, + sourceAuthority + ), + activeWorktreeIdsOnShutdown: mergeUnique( + base.activeWorktreeIdsOnShutdown, + source.activeWorktreeIdsOnShutdown + ), + activeConnectionIdsAtShutdown: mergeUnique( + base.activeConnectionIdsAtShutdown, + source.activeConnectionIdsAtShutdown + ), + remoteSessionIdsByTabId, + sleepingAgentSessionsByPaneKey, + terminalPtyIncarnationsByPaneKey, + terminalTopologyRevisionByRepoId: mergeTopologyRevisions( + base.terminalTopologyRevisionByRepoId, + source.terminalTopologyRevisionByRepoId + ), + terminalSurfaceTombstonesByPaneKey } - if (partition.remoteSessionIdsByTabId) { - patch.remoteSessionIdsByTabId = Object.fromEntries( - Object.entries(partition.remoteSessionIdsByTabId).filter( - ([tabId]) => !droppedTabIds.has(tabId) - ) - ) + return { + session, + reconciledWorktreeIds: [...sourceKeys], + sourceAuthoritativeWorktreeIds: [...sourceAuthority], + sourceAuthoritativePaneKeys: [...sourcePaneAuthority] } - return patch } diff --git a/src/shared/workspace-session-partition-provenance.ts b/src/shared/workspace-session-partition-provenance.ts new file mode 100644 index 000000000000..470850e67c56 --- /dev/null +++ b/src/shared/workspace-session-partition-provenance.ts @@ -0,0 +1,267 @@ +import type { SleepingAgentSessionRecord } from './agent-session-resume' +import type { BrowserHistoryEntry, TerminalLayoutSnapshot, WorkspaceSessionState } from './types' +import { parseWorkspaceKey } from './workspace-scope' +import { getRepoIdFromWorktreeId } from './worktree-id' + +const WORKTREE_RECORD_FIELDS = [ + 'tabsByWorktree', + 'openFilesByWorktree', + 'activeFileIdByWorktree', + 'browserTabsByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts', + 'activeGroupIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +export function paneTabId(paneKey: string): string { + const separator = paneKey.indexOf(':') + return separator < 0 ? paneKey : paneKey.slice(0, separator) +} + +export function paneLeafId(paneKey: string): string { + const separator = paneKey.indexOf(':') + return separator < 0 ? '' : paneKey.slice(separator + 1) +} + +function repoIdForWorkspaceKey(key: string): string | null { + const scope = parseWorkspaceKey(key) + if (scope?.type === 'folder') { + return null + } + return getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : key) +} + +function sourceBindingRevisionIsCurrent( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + workspaceKey: string | undefined +): boolean { + if (!workspaceKey) { + return false + } + const repoId = repoIdForWorkspaceKey(workspaceKey) + if (!repoId) { + return true + } + return ( + (source.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) >= + (base.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + ) +} + +export function collectWorkspaceKeys(session: WorkspaceSessionState): Set { + const keys = new Set() + for (const field of WORKTREE_RECORD_FIELDS) { + for (const key of Object.keys((session[field] as Record | undefined) ?? {})) { + keys.add(key) + } + } + for (const key of session.activeWorktreeIdsOnShutdown ?? []) { + keys.add(key) + } + if (session.activeWorktreeId) { + keys.add(session.activeWorktreeId) + } + for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) { + for (const page of pages) { + keys.add(page.worktreeId) + } + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + keys.add(record.worktreeId) + } + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + keys.add(tombstone.worktreeId) + } + return keys +} + +export function collectTerminalProvenance( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + tabsByWorktree: WorkspaceSessionState['tabsByWorktree'], + sourceKeys: ReadonlySet, + sourceAuthority: ReadonlySet +): { + liveTabIds: Set + touchedTabIds: Set + sourceWorkspaceKeyByTabId: Map + sourcePaneAuthority: Set +} { + const liveTabIds = new Set( + Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + const touchedTabIds = new Set( + [...sourceKeys].flatMap((key) => [ + ...(base.tabsByWorktree[key] ?? []).map((tab) => tab.id), + ...(source.tabsByWorktree[key] ?? []).map((tab) => tab.id) + ]) + ) + const sourceWorkspaceKeyByTabId = new Map() + for (const key of sourceKeys) { + for (const tab of source.tabsByWorktree[key] ?? []) { + sourceWorkspaceKeyByTabId.set(tab.id, key) + } + } + const sourcePaneAuthority = new Set() + for (const [paneKey, incarnationId] of Object.entries( + source.terminalPtyIncarnationsByPaneKey ?? {} + )) { + const baseTombstone = base.terminalSurfaceTombstonesByPaneKey?.[paneKey] + if ( + liveTabIds.has(paneTabId(paneKey)) && + sourceBindingRevisionIsCurrent( + base, + source, + sourceWorkspaceKeyByTabId.get(paneTabId(paneKey)) + ) && + (!baseTombstone || baseTombstone.incarnationId !== incarnationId) + ) { + sourcePaneAuthority.add(paneKey) + } + } + for (const key of sourceAuthority) { + for (const tab of source.tabsByWorktree[key] ?? []) { + for (const leafId of Object.keys( + source.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + )) { + sourcePaneAuthority.add(`${tab.id}:${leafId}`) + } + } + } + return { liveTabIds, touchedTabIds, sourceWorkspaceKeyByTabId, sourcePaneAuthority } +} + +export function mergeWorkspaceRecord( + base: Record | undefined, + source: Record | undefined, + sourceKeys: ReadonlySet, + sourceAuthority: ReadonlySet +): Record | undefined { + if (!base && !source) { + return undefined + } + const merged = { ...base } + for (const key of sourceKeys) { + const value = source?.[key] + if (value !== undefined && (sourceAuthority.has(key) || merged[key] === undefined)) { + merged[key] = value + } + } + return merged +} + +export function sourceOwnsTerminalMembership( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + workspaceKey: string +): boolean { + const baseTabs = base.tabsByWorktree[workspaceKey] ?? [] + const sourceTabs = source.tabsByWorktree[workspaceKey] ?? [] + const repoId = repoIdForWorkspaceKey(workspaceKey) + if (!repoId) { + return sourceTabs.length > 0 && baseTabs.length === 0 + } + const baseRevision = base.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + const sourceRevision = source.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + return ( + sourceRevision > baseRevision || + (sourceRevision === baseRevision && sourceTabs.length > 0 && baseTabs.length === 0) + ) +} + +function layoutContainsLeaf(root: TerminalLayoutSnapshot['root'], leafId: string): boolean { + if (!root) { + return false + } + return root.type === 'leaf' + ? root.leafId === leafId + : layoutContainsLeaf(root.first, leafId) || layoutContainsLeaf(root.second, leafId) +} + +export function layoutLeafCount(root: TerminalLayoutSnapshot['root']): number { + if (!root) { + return 0 + } + return root.type === 'leaf' ? 1 : layoutLeafCount(root.first) + layoutLeafCount(root.second) +} + +export function mergeTerminalLayout( + base: TerminalLayoutSnapshot | undefined, + source: TerminalLayoutSnapshot | undefined, + tabId: string, + sourcePaneAuthority: ReadonlySet +): TerminalLayoutSnapshot | undefined { + if (!base || !source) { + return base ?? source + } + const ptyIdsByLeafId = { ...base.ptyIdsByLeafId } + for (const [leafId, ptyId] of Object.entries(source.ptyIdsByLeafId ?? {})) { + const paneKey = `${tabId}:${leafId}` + if ( + (sourcePaneAuthority.has(paneKey) || ptyIdsByLeafId[leafId] === undefined) && + layoutContainsLeaf(base.root, leafId) + ) { + ptyIdsByLeafId[leafId] = ptyId + } + } + const merged: TerminalLayoutSnapshot = { ...source, ...base, ptyIdsByLeafId } + if (source.buffersByLeafId || base.buffersByLeafId) { + merged.buffersByLeafId = { ...source.buffersByLeafId, ...base.buffersByLeafId } + } + if (source.scrollbackRefsByLeafId || base.scrollbackRefsByLeafId) { + merged.scrollbackRefsByLeafId = { + ...source.scrollbackRefsByLeafId, + ...base.scrollbackRefsByLeafId + } + } + if (source.titlesByLeafId || base.titlesByLeafId) { + merged.titlesByLeafId = { ...source.titlesByLeafId, ...base.titlesByLeafId } + } + return merged +} + +export function mergeTopologyRevisions( + base: Record | undefined, + source: Record | undefined +): Record { + const merged = { ...base } + for (const [repoId, revision] of Object.entries(source ?? {})) { + merged[repoId] = Math.max(merged[repoId] ?? 0, revision) + } + return merged +} + +export function chooseSleepingRecord( + base: SleepingAgentSessionRecord | undefined, + source: SleepingAgentSessionRecord +): SleepingAgentSessionRecord { + return !base || source.updatedAt >= base.updatedAt ? source : base +} + +export function mergeUnique( + values: readonly string[] | undefined, + incoming: readonly string[] | undefined +): string[] { + return [...new Set([...(values ?? []), ...(incoming ?? [])])] +} + +export function mergeBrowserHistory( + base: readonly BrowserHistoryEntry[] | undefined, + source: readonly BrowserHistoryEntry[] | undefined +): BrowserHistoryEntry[] { + const entries = new Map(source?.map((entry) => [entry.normalizedUrl, entry])) + for (const entry of base ?? []) { + const current = entries.get(entry.normalizedUrl) + if (!current || entry.lastVisitedAt >= current.lastVisitedAt) { + entries.set(entry.normalizedUrl, entry) + } + } + return [...entries.values()].sort((left, right) => right.lastVisitedAt - left.lastVisitedAt) +} From d5e9f46e802cb202f5c0ccde00fff2fb90447532 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 06:43:57 -0400 Subject: [PATCH 05/20] fix(session): preserve ssh partition authority bundles --- config/reliability-gates.jsonc | 12 +- src/main/ipc/remote-workspace.test.ts | 52 +++++ src/main/ipc/remote-workspace.ts | 6 +- src/main/ipc/session.test.ts | 7 +- src/main/persistence.test.ts | 108 +++++++++- src/main/persistence.ts | 36 +++- ...workspace-session-host-persistence.test.ts | 32 +++ .../lib/workspace-session-host-persistence.ts | 13 +- ...rkspace-session-ssh-partition-hydration.ts | 5 - ...rkspace-session-partition-adoption.test.ts | 81 ++++++++ .../workspace-session-partition-adoption.ts | 196 ++++++------------ .../workspace-session-partition-authority.ts | 124 +++++++++++ .../workspace-session-partition-provenance.ts | 113 +++------- ...workspace-session-partition-state-merge.ts | 120 +++++++++++ 14 files changed, 663 insertions(+), 242 deletions(-) create mode 100644 src/shared/workspace-session-partition-authority.ts create mode 100644 src/shared/workspace-session-partition-state-merge.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cc274f25f684..703bd3c91811 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5962,7 +5962,8 @@ { "file": "src/shared/workspace-session-partition-adoption.test.ts", "assertions": [ - "topology revisions select terminal membership authority while SSH incarnation provenance selects newer pane bindings", + "topology revisions select terminal membership authority while each authoritative pane moves its PTY, incarnation, layout fields, and provider session as one bundle", + "equal-revision pane conflicts remain unmerged instead of pairing stale PTY identity with newer provider metadata", "folder-only workspaces retain tab groups, layouts, files, and provider-session identity", "old tombstones cannot retire a newer SSH incarnation and reconciliation is idempotent" ] @@ -5971,7 +5972,9 @@ "file": "src/main/persistence.test.ts", "assertions": [ "one transaction enumerates every persisted SSH partition and preserves non-SSH partitions", + "equal-revision repository and folder conflicts preserve every ambiguous source while reconciled sources prune independently", "the durable owner flush precedes complete source pruning and both failure phases remain retryable", + "qualified adoption scopes to one SSH partition and frozen persistence rejects before reporting durable success", "a sync-flushed SSH binding and incarnation survive the crash window before renderer adoption" ] }, @@ -5979,9 +5982,16 @@ "file": "src/renderer/src/lib/workspace-session-host-persistence.test.ts", "assertions": [ "startup delegates persisted SSH enumeration to main even with no loaded repository", + "freshly adopted local and SSH state merges back with every runtime-host slice", "durability failure rejects startup adoption instead of being folded into an empty inventory" ] }, + { + "file": "src/main/ipc/remote-workspace.test.ts", + "assertions": [ + "duplicate repository ids resolve through the exact SSH target host and export only that target's adopted state" + ] + }, { "file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "assertions": [ diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 3ade653b5f66..d983b16f6284 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -341,6 +341,58 @@ describe('remoteWorkspace:setForConnectedTargets', () => { expect(patchWorkspaceSessionMock).not.toHaveBeenCalled() }) + it('qualifies duplicate repository ids by SSH target during export', async () => { + const worktreeId = 'duplicate::/repo' + getRepoMock.mockImplementation((repoId: string, hostId?: string) => { + if (repoId !== 'duplicate' || !hostId?.startsWith('ssh:')) { + return undefined + } + return { + id: repoId, + path: '/repo', + displayName: 'Duplicate', + badgeColor: 'blue', + addedAt: 1, + connectionId: hostId.slice(4), + executionHostId: hostId + } as never + }) + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => ({ + ...baseSession, + tabsByWorktree: + hostId === 'ssh:target-1' || hostId === 'ssh:target-2' + ? { + [worktreeId]: [ + { + id: `${hostId}-tab`, + title: hostId, + ptyId: null, + worktreeId + } as never + ] + } + : { [worktreeId]: [] } + })) + + await callSetForConnectedTargets({ hydratedTargetIds: ['target-1', 'target-2'] }) + + for (const targetId of ['target-1', 'target-2']) { + expect(getRepoMock).toHaveBeenCalledWith('duplicate', `ssh:${targetId}`) + expect(requestByTargetId.get(targetId)).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + tabsByWorktreePath: { + '/repo': [expect.objectContaining({ id: `ssh:${targetId}-tab` })] + } + }) + }) + }) + ) + } + }) + it('does not consult ssh partitions when an explicit session argument is provided', async () => { const worktreeId = 'repo-target-1::/repo' await callSetForConnectedTargets({ diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 88ea19d5ca42..9261d05d07de 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -222,9 +222,9 @@ function getExplicitHydratedTargetIds(value: unknown): Set | null { return new Set(value) } -function targetForWorktree(store: Store, worktreeId: string): string | null { +function targetForWorktree(store: Store, worktreeId: string, targetId: string): string | null { const repoId = getRepoIdFromWorktreeId(worktreeId) - return store.getRepo(repoId)?.connectionId ?? null + return store.getRepo(repoId, toSshExecutionHostId(targetId))?.connectionId ?? null } function exportSessionForTarget( @@ -233,7 +233,7 @@ function exportSessionForTarget( session: WorkspaceSessionState ): RemoteWorkspaceSession { return exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId) === targetId + isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId, targetId) === targetId }) } diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts index 9dd2f2f5b5fe..35d7e60dcd23 100644 --- a/src/main/ipc/session.test.ts +++ b/src/main/ipc/session.test.ts @@ -28,7 +28,10 @@ describe('registerSessionHandlers', () => { )?.[1] as (event: unknown, hostId?: string) => unknown expect(handler({})).toBe(expected) - expect(store.adoptSshWorkspaceSessionPartition).toHaveBeenCalledOnce() - expect(store.adoptSshWorkspaceSessionPartition).toHaveBeenCalledWith(undefined) + expect(handler({}, 'ssh:target-1')).toBe(expected) + expect(store.adoptSshWorkspaceSessionPartition.mock.calls).toEqual([ + [undefined], + ['ssh:target-1'] + ]) }) }) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 1ab9514dcd64..3d27a21062ec 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -11847,7 +11847,7 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) }) - it('keeps populated local state and removes the rejected ssh fork', async () => { + it('keeps populated local state and preserves an equal-revision SSH fork', async () => { const store = await createStore() const local = makeBoundHostSession('ssh:ssh-1@@local-pty') const source = makeBoundHostSession('ssh:ssh-1@@stale-pty') @@ -11860,7 +11860,7 @@ describe('Store host-partitioned workspace sessions', () => { expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( local.tabsByWorktree['repo-1::/worktree'] ) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) }) it('ignores non-ssh adoption requests without mutating their partition', async () => { @@ -11940,6 +11940,101 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') }) + it('preserves equal-revision repo conflicts in both SSH source partitions', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } + right.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } + right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toBeUndefined() + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + + it('does not choose a folder tab between conflicting SSH sources', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('shared-folder') + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.tabsByWorktree = { + [folderKey]: [{ ...left.tabsByWorktree['repo-1::/worktree'][0], worktreeId: folderKey }] + } + right.tabsByWorktree = { + [folderKey]: [ + { + ...right.tabsByWorktree['repo-1::/worktree'][0], + id: 'right-tab', + worktreeId: folderKey + } + ] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree[folderKey]).toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + }) + + it('prunes only the source whose state reconciled without ambiguity', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('ambiguous-folder') + const local = getDefaultWorkspaceSession() + local.tabsByWorktree[folderKey] = [ + makeTerminalTab({ id: 'local-folder-tab', worktreeId: folderKey }) + ] + const accepted = makeBoundHostSession('ssh:accepted@@accepted-pty') + const retained = makeBoundHostSession('ssh:retained@@retained-pty') + retained.tabsByWorktree = { + [folderKey]: [makeTerminalTab({ id: 'remote-folder-tab', worktreeId: folderKey })] + } + store.setWorkspaceSession(local) + store.setWorkspaceSession(accepted, 'ssh:accepted') + store.setWorkspaceSession(retained, 'ssh:retained') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(adopted.tabsByWorktree[folderKey]?.[0]?.id).toBe('local-folder-tab') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:retained']) + }) + + it('scopes a qualified adoption request to exactly one SSH partition', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + right.tabsByWorktree = { + 'repo-2::/worktree': [makeTerminalTab({ id: 'right-tab', worktreeId: 'repo-2::/worktree' })] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:left') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(adopted.tabsByWorktree['repo-2::/worktree']).toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:right']) + }) + + it('rejects adoption before mutation when persistence writes are frozen', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(source, 'ssh:ssh-1') + store.flushOrThrow() + store.freezeWrites() + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('writes are frozen') + expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + }) + it('rolls back owner and sources when the durable owner flush fails', async () => { const store = await createStore() const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') @@ -12023,19 +12118,20 @@ describe('Store host-partitioned workspace sessions', () => { providerSession: { key: 'session_id', id: 'provider-old' }, prompt: 'old', state: 'working', - capturedAt: 1, - updatedAt: 1 + capturedAt: 9, + updatedAt: 9 } } const source = makeBoundHostSession('ssh:ssh-1@@new-pty') + source.terminalTopologyRevisionByRepoId = { 'repo-1': 1 } source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-new' } source.sleepingAgentSessionsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: { ...local.sleepingAgentSessionsByPaneKey[`tab-1:${TEST_LEAF_1}`], providerSession: { key: 'session_id', id: 'provider-new' }, prompt: 'new', - capturedAt: 2, - updatedAt: 2 + capturedAt: 1, + updatedAt: 1 } } store.setWorkspaceSession(local) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e4aca4c261ed..62c535d2ee5c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -136,6 +136,7 @@ import { } from '../shared/constants' import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' import { adoptOrphanedWorkspaceSessionPartition } from '../shared/workspace-session-partition-adoption' +import { findAmbiguousWorkspaceSessionKeys } from '../shared/workspace-session-partition-authority' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -4157,6 +4158,9 @@ export class Store { } flushOrThrow(): void { + if (this.writesFrozen) { + throw new Error('Cannot synchronously flush while persistence writes are frozen') + } if (this.quitFlushStarted) { throw new Error('Cannot synchronously flush after final persistence has started') } @@ -4342,8 +4346,11 @@ export class Store { return this.state.repos.length } - getRepo(id: string): Repo | undefined { - const repo = this.state.repos.find((r) => r.id === id) + getRepo(id: string, hostId?: ExecutionHostId): Repo | undefined { + const repo = this.state.repos.find( + (candidate) => + candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) + ) return repo ? this.hydrateRepo(repo) : undefined } @@ -6287,16 +6294,33 @@ export class Store { const originalPartitions = this.state.workspaceSessionsByHostId const sshPartitions = Object.entries(originalPartitions ?? {}) .filter(([partitionHostId, session]) => - Boolean(session && parseExecutionHostId(partitionHostId)?.kind === 'ssh') + Boolean( + session && + parseExecutionHostId(partitionHostId)?.kind === 'ssh' && + (!hostId || partitionHostId === hostId) + ) ) .sort(([left], [right]) => left.localeCompare(right)) as [string, WorkspaceSessionState][] if (sshPartitions.length === 0) { return originalOwner } + const preservedWorkspaceKeys = findAmbiguousWorkspaceSessionKeys( + sshPartitions.map(([, source]) => source) + ) + const prunableHostIds = new Set() let owner = originalOwner - for (const [, source] of sshPartitions) { - owner = adoptOrphanedWorkspaceSessionPartition(owner, source).session + for (const [partitionHostId, source] of sshPartitions) { + const adoption = adoptOrphanedWorkspaceSessionPartition(owner, source, { + preserveWorkspaceKeys: preservedWorkspaceKeys + }) + owner = adoption.session + if (adoption.ambiguousWorktreeIds.length === 0) { + prunableHostIds.add(partitionHostId) + } + } + if (prunableHostIds.size === 0) { + return originalOwner } this.state.workspaceSession = owner try { @@ -6309,7 +6333,7 @@ export class Store { this.state.workspaceSessionsByHostId = Object.fromEntries( Object.entries(originalPartitions ?? {}).filter( - ([partitionHostId]) => parseExecutionHostId(partitionHostId)?.kind !== 'ssh' + ([partitionHostId]) => !prunableHostIds.has(partitionHostId) ) ) try { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index 42bd4ef4bc9b..32e2aae4fc9c 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -220,6 +220,38 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({}) }) + it('merges freshly adopted SSH state back with runtime host slices', async () => { + const sshWorktreeId = 'ssh-repo::/home/user/ssh-wt' + const runtimeWorktreeId = 'runtime-repo::/srv/runtime-wt' + const sshTab = sessionTab('ssh-tab', sshWorktreeId) + const runtimeTab = sessionTab('runtime-tab', runtimeWorktreeId) + const local = getDefaultWorkspaceSession() + const get = vi.fn(async (hostId?: string): Promise => { + return hostId === 'runtime:env-1' + ? { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [runtimeWorktreeId]: [runtimeTab] } + } + : local + }) + const adoptSshPartition = vi.fn().mockResolvedValue({ + ...local, + tabsByWorktree: { [sshWorktreeId]: [sshTab] } + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + { get, adoptSshPartition }, + [{ connectionId: 'ssh-1', executionHostId: null }], + ['runtime:env-1'] + ) + + expect(read.session.tabsByWorktree[sshWorktreeId]).toEqual([sshTab]) + expect(read.session.tabsByWorktree[runtimeWorktreeId]).toEqual([runtimeTab]) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ + [runtimeWorktreeId]: 'runtime:env-1' + }) + }) + it('keeps locally populated worktrees over a stale ssh partition copy', async () => { const worktreeId = 'ssh-repo::/home/user/wt' const sshRepo = { id: 'ssh-repo', connectionId: 'conn-1', executionHostId: null } diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index fc3295cd7c38..cf820a62ac5e 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -336,11 +336,14 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( // Why: ssh partitions stay out of `slices` — SSH worktrees are local-owned // here, so an ssh slice must never feed the runtime-owner map or win a blind // last-wins merge; it is only a source for orphan adoption. - const session = await adoptStrandedSshHostPartitions( - api, - repos, - mergeWorkspaceSessionsFromHosts(slices) - ) + if (api.adoptSshPartition) { + // Main enumerates persisted partitions, including folder-only SSH projects. + slices[LOCAL_EXECUTION_HOST_ID] = await api.adoptSshPartition() + } + const merged = mergeWorkspaceSessionsFromHosts(slices) + const session = api.adoptSshPartition + ? merged + : await adoptStrandedSshHostPartitions(api, repos, merged) return { session, runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices) diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts index 0bca1a3cc350..5699730a9e7e 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts @@ -8,7 +8,6 @@ import { adoptOrphanedWorkspaceSessionPartition } from '../../../shared/workspac type SshPartitionHydrationApi = { get: (hostId?: ExecutionHostId) => Promise - adoptSshPartition?: (hostId?: `ssh:${string}`) => Promise } /** Collect the distinct SSH hosts owning any persisted repo. */ @@ -31,10 +30,6 @@ export async function adoptStrandedSshHostPartitions( repos: readonly Pick[], merged: WorkspaceSessionState ): Promise { - if (api.adoptSshPartition) { - // Main enumerates persisted partitions, including folder-only SSH projects. - return api.adoptSshPartition() - } let session = merged for (const hostId of listKnownSshHostIds(repos)) { try { diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index a1564b0fd29e..7844934f92aa 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -148,6 +148,7 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@old') }, terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-old' }, remoteSessionIdsByTabId: { 'tab-1': 'ssh:one@@old' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 1 }, sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { paneKey: PANE_KEY, @@ -167,6 +168,7 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' }, remoteSessionIdsByTabId: { 'tab-1': 'ssh:one@@new' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 2 }, sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { paneKey: PANE_KEY, @@ -196,6 +198,85 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { ) }) + it('moves an older provider record with its authoritative SSH incarnation', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@old')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@old') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-old' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-wrong' }, + prompt: 'wrong', + state: 'working', + capturedAt: 9, + updatedAt: 9 + } + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@new')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@new') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-new' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 5 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + ...base.sleepingAgentSessionsByPaneKey![PANE_KEY], + providerSession: { key: 'session_id', id: 'provider-new' }, + prompt: 'new', + capturedAt: 1, + updatedAt: 1 + } + } + }) + + const adopted = adoptOrphanedWorkspaceSessionPartition(base, source).session + + expect(adopted.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-new') + expect(adopted.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.providerSession.id).toBe( + 'provider-new' + ) + }) + + it('preserves equal-revision pane bundles instead of combining their provenance', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@base')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@base') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-base' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 3 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:two@@source')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:two@@source') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-source' }, + terminalTopologyRevisionByRepoId: { 'repo-1': 3 }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-source' }, + prompt: 'source', + state: 'working', + capturedAt: 10, + updatedAt: 10 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.ambiguousWorktreeIds).toEqual([WORKTREE_ID]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@base') + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-base') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeUndefined() + }) + it('keeps a local incarnation when the SSH binding revision is older', () => { const base = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@local')] }, diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index 7cca594d4d2d..a928fe0a1b24 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -1,17 +1,14 @@ import type { WorkspaceSessionState } from './types' +import { workspaceTerminalAuthority } from './workspace-session-partition-authority' +import { mergeAdoptedWorkspaceSessionState } from './workspace-session-partition-state-merge' import { - chooseSleepingRecord, collectTerminalProvenance, collectWorkspaceKeys, layoutLeafCount, - mergeBrowserHistory, mergeTerminalLayout, - mergeTopologyRevisions, - mergeUnique, mergeWorkspaceRecord, paneLeafId, - paneTabId, - sourceOwnsTerminalMembership + paneTabId } from './workspace-session-partition-provenance' export type WorkspaceSessionPartitionAdoption = { @@ -19,36 +16,62 @@ export type WorkspaceSessionPartitionAdoption = { reconciledWorktreeIds: string[] sourceAuthoritativeWorktreeIds: string[] sourceAuthoritativePaneKeys: string[] + ambiguousWorktreeIds: string[] +} + +type WorkspaceSessionPartitionAdoptionOptions = { + preserveWorkspaceKeys?: ReadonlySet } export function adoptOrphanedWorkspaceSessionPartition( base: WorkspaceSessionState, - source: WorkspaceSessionState | null | undefined + source: WorkspaceSessionState | null | undefined, + options: WorkspaceSessionPartitionAdoptionOptions = {} ): WorkspaceSessionPartitionAdoption { if (!source) { return { session: base, reconciledWorktreeIds: [], sourceAuthoritativeWorktreeIds: [], - sourceAuthoritativePaneKeys: [] + sourceAuthoritativePaneKeys: [], + ambiguousWorktreeIds: [] } } const sourceKeys = collectWorkspaceKeys(source) + const baseKeys = collectWorkspaceKeys(base) + const authorityByWorkspaceKey = new Map( + [...sourceKeys].map((key) => [ + key, + workspaceTerminalAuthority(base, source, key, { + base: baseKeys.has(key), + source: true + }) + ]) + ) + const ambiguousWorktreeIds = new Set( + [...sourceKeys].filter( + (key) => + options.preserveWorkspaceKeys?.has(key) || authorityByWorkspaceKey.get(key) === 'ambiguous' + ) + ) const sourceAuthority = new Set( - [...sourceKeys].filter((key) => sourceOwnsTerminalMembership(base, source, key)) + [...sourceKeys].filter( + (key) => !ambiguousWorktreeIds.has(key) && authorityByWorkspaceKey.get(key) === 'source' + ) ) + const reconciledKeys = new Set([...sourceKeys].filter((key) => !ambiguousWorktreeIds.has(key))) const tabsByWorktree = mergeWorkspaceRecord( base.tabsByWorktree, source.tabsByWorktree, - sourceKeys, + reconciledKeys, sourceAuthority ) ?? { ...base.tabsByWorktree } const { liveTabIds, touchedTabIds, sourceWorkspaceKeyByTabId, sourcePaneAuthority } = - collectTerminalProvenance(base, source, tabsByWorktree, sourceKeys, sourceAuthority) + collectTerminalProvenance(base, source, tabsByWorktree, reconciledKeys, sourceAuthority) const sourcePaneAuthoritativeTabIds = new Set( [...sourcePaneAuthority].map((paneKey) => paneTabId(paneKey)) ) - for (const key of sourceKeys) { + for (const key of reconciledKeys) { if (sourceAuthority.has(key)) { continue } @@ -93,10 +116,11 @@ export function adoptOrphanedWorkspaceSessionPartition( } const terminalPtyIncarnationsByPaneKey = { ...base.terminalPtyIncarnationsByPaneKey } - for (const [paneKey, incarnationId] of Object.entries( - source.terminalPtyIncarnationsByPaneKey ?? {} - )) { - if (sourcePaneAuthority.has(paneKey)) { + for (const paneKey of sourcePaneAuthority) { + const incarnationId = source.terminalPtyIncarnationsByPaneKey?.[paneKey] + if (incarnationId === undefined) { + delete terminalPtyIncarnationsByPaneKey[paneKey] + } else { terminalPtyIncarnationsByPaneKey[paneKey] = incarnationId } } @@ -112,6 +136,9 @@ export function adoptOrphanedWorkspaceSessionPartition( for (const [paneKey, tombstone] of Object.entries( source.terminalSurfaceTombstonesByPaneKey ?? {} )) { + if (!sourceAuthority.has(tombstone.worktreeId)) { + continue + } const liveIncarnation = terminalPtyIncarnationsByPaneKey[paneKey] if (!liveIncarnation || liveIncarnation === tombstone.incarnationId) { terminalSurfaceTombstonesByPaneKey[paneKey] = tombstone @@ -162,139 +189,42 @@ export function adoptOrphanedWorkspaceSessionPartition( } const sleepingAgentSessionsByPaneKey = { ...base.sleepingAgentSessionsByPaneKey } - for (const [paneKey, record] of Object.entries(source.sleepingAgentSessionsByPaneKey ?? {})) { - if (!sourceKeys.has(record.worktreeId)) { - continue - } + for (const paneKey of sourcePaneAuthority) { + const record = source.sleepingAgentSessionsByPaneKey?.[paneKey] const tombstone = terminalSurfaceTombstonesByPaneKey[paneKey] - if (!tombstone || tombstone.incarnationId !== terminalPtyIncarnationsByPaneKey[paneKey]) { - sleepingAgentSessionsByPaneKey[paneKey] = chooseSleepingRecord( - sleepingAgentSessionsByPaneKey[paneKey], - record - ) + if ( + record && + (!tombstone || tombstone.incarnationId !== terminalPtyIncarnationsByPaneKey[paneKey]) + ) { + sleepingAgentSessionsByPaneKey[paneKey] = record + } else { + delete sleepingAgentSessionsByPaneKey[paneKey] } } for (const [paneKey, record] of Object.entries(sleepingAgentSessionsByPaneKey)) { - if (sourceKeys.has(record.worktreeId) && record.tabId && retiredTabIds.has(record.tabId)) { + if (reconciledKeys.has(record.worktreeId) && record.tabId && retiredTabIds.has(record.tabId)) { delete sleepingAgentSessionsByPaneKey[paneKey] } } - const browserPagesByWorkspace = { ...base.browserPagesByWorkspace } - for (const [workspaceId, pages] of Object.entries(source.browserPagesByWorkspace ?? {})) { - if ( - browserPagesByWorkspace[workspaceId] === undefined || - pages.some((page) => sourceAuthority.has(page.worktreeId)) - ) { - browserPagesByWorkspace[workspaceId] = pages - } - } - - const session: WorkspaceSessionState = { - ...source, - ...base, - activeRepoId: base.activeRepoId ?? source.activeRepoId, - activeWorkspaceKey: base.activeWorkspaceKey ?? source.activeWorkspaceKey, - activeWorkspaceExecutionHostId: - base.activeWorkspaceExecutionHostId ?? source.activeWorkspaceExecutionHostId, - activeWorktreeId: base.activeWorktreeId ?? source.activeWorktreeId, - activeTabId: base.activeTabId ?? source.activeTabId, + const session = mergeAdoptedWorkspaceSessionState({ + base, + source, + reconciledKeys, + sourceAuthority, + ambiguousWorktreeIds, tabsByWorktree, terminalLayoutsByTabId, - openFilesByWorktree: mergeWorkspaceRecord( - base.openFilesByWorktree, - source.openFilesByWorktree, - sourceKeys, - sourceAuthority - ), - activeFileIdByWorktree: mergeWorkspaceRecord( - base.activeFileIdByWorktree, - source.activeFileIdByWorktree, - sourceKeys, - sourceAuthority - ), - browserTabsByWorktree: mergeWorkspaceRecord( - base.browserTabsByWorktree, - source.browserTabsByWorktree, - sourceKeys, - sourceAuthority - ), - browserPagesByWorkspace, - browserUrlHistory: mergeBrowserHistory(base.browserUrlHistory, source.browserUrlHistory), - markdownFrontmatterVisible: { - ...source.markdownFrontmatterVisible, - ...base.markdownFrontmatterVisible - }, - activeBrowserTabIdByWorktree: mergeWorkspaceRecord( - base.activeBrowserTabIdByWorktree, - source.activeBrowserTabIdByWorktree, - sourceKeys, - sourceAuthority - ), - activeTabTypeByWorktree: mergeWorkspaceRecord( - base.activeTabTypeByWorktree, - source.activeTabTypeByWorktree, - sourceKeys, - sourceAuthority - ), - activeTabIdByWorktree: mergeWorkspaceRecord( - base.activeTabIdByWorktree, - source.activeTabIdByWorktree, - sourceKeys, - sourceAuthority - ), - unifiedTabs: mergeWorkspaceRecord( - base.unifiedTabs, - source.unifiedTabs, - sourceKeys, - sourceAuthority - ), - tabGroups: mergeWorkspaceRecord(base.tabGroups, source.tabGroups, sourceKeys, sourceAuthority), - tabGroupLayouts: mergeWorkspaceRecord( - base.tabGroupLayouts, - source.tabGroupLayouts, - sourceKeys, - sourceAuthority - ), - activeGroupIdByWorktree: mergeWorkspaceRecord( - base.activeGroupIdByWorktree, - source.activeGroupIdByWorktree, - sourceKeys, - sourceAuthority - ), - lastVisitedAtByWorktreeId: mergeWorkspaceRecord( - base.lastVisitedAtByWorktreeId, - source.lastVisitedAtByWorktreeId, - sourceKeys, - sourceAuthority - ), - defaultTerminalTabsAppliedByWorktreeId: mergeWorkspaceRecord( - base.defaultTerminalTabsAppliedByWorktreeId, - source.defaultTerminalTabsAppliedByWorktreeId, - sourceKeys, - sourceAuthority - ), - activeWorktreeIdsOnShutdown: mergeUnique( - base.activeWorktreeIdsOnShutdown, - source.activeWorktreeIdsOnShutdown - ), - activeConnectionIdsAtShutdown: mergeUnique( - base.activeConnectionIdsAtShutdown, - source.activeConnectionIdsAtShutdown - ), remoteSessionIdsByTabId, sleepingAgentSessionsByPaneKey, terminalPtyIncarnationsByPaneKey, - terminalTopologyRevisionByRepoId: mergeTopologyRevisions( - base.terminalTopologyRevisionByRepoId, - source.terminalTopologyRevisionByRepoId - ), terminalSurfaceTombstonesByPaneKey - } + }) return { session, - reconciledWorktreeIds: [...sourceKeys], + reconciledWorktreeIds: [...reconciledKeys], sourceAuthoritativeWorktreeIds: [...sourceAuthority], - sourceAuthoritativePaneKeys: [...sourcePaneAuthority] + sourceAuthoritativePaneKeys: [...sourcePaneAuthority], + ambiguousWorktreeIds: [...ambiguousWorktreeIds] } } diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts new file mode 100644 index 000000000000..34529e89e649 --- /dev/null +++ b/src/shared/workspace-session-partition-authority.ts @@ -0,0 +1,124 @@ +import type { WorkspaceSessionState } from './types' +import { parseWorkspaceKey } from './workspace-scope' +import { getRepoIdFromWorktreeId } from './worktree-id' +import { + collectWorkspaceKeys, + paneTabId, + WORKTREE_RECORD_FIELDS +} from './workspace-session-partition-provenance' + +export function repoIdForWorkspaceKey(key: string): string | null { + const scope = parseWorkspaceKey(key) + if (scope?.type === 'folder') { + return null + } + return getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : key) +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, entry: unknown) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return entry + } + return Object.fromEntries( + Object.entries(entry as Record).sort(([left], [right]) => + left.localeCompare(right) + ) + ) + }) +} + +function workspaceBundle(session: WorkspaceSessionState, workspaceKey: string): unknown { + const tabs = session.tabsByWorktree[workspaceKey] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + return { + workspaceRecords: Object.fromEntries( + WORKTREE_RECORD_FIELDS.map((field) => [field, session[field]?.[workspaceKey]]) + ), + layouts: Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabIds.has(tabId)) + ), + remoteSessions: Object.fromEntries( + Object.entries(session.remoteSessionIdsByTabId ?? {}).filter(([tabId]) => tabIds.has(tabId)) + ), + incarnations: Object.fromEntries( + Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => + tabIds.has(paneTabId(paneKey)) + ) + ), + sleepingAgents: Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( + ([, record]) => record.worktreeId === workspaceKey + ) + ), + tombstones: Object.fromEntries( + Object.entries(session.terminalSurfaceTombstonesByPaneKey ?? {}).filter( + ([, tombstone]) => tombstone.worktreeId === workspaceKey + ) + ), + browserPages: Object.fromEntries( + Object.entries(session.browserPagesByWorkspace ?? {}).filter(([, pages]) => + pages.some((page) => page.worktreeId === workspaceKey) + ) + ) + } +} + +export type WorkspaceTerminalAuthority = 'base' | 'source' | 'equivalent' | 'ambiguous' + +export function workspaceTerminalAuthority( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + workspaceKey: string, + presence?: { base: boolean; source: boolean } +): WorkspaceTerminalAuthority { + const repoId = repoIdForWorkspaceKey(workspaceKey) + const baseRevision = repoId ? (base.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) : 0 + const sourceRevision = repoId ? (source.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) : 0 + if (sourceRevision !== baseRevision) { + return sourceRevision > baseRevision ? 'source' : 'base' + } + const baseHasKey = presence?.base ?? collectWorkspaceKeys(base).has(workspaceKey) + const sourceHasKey = presence?.source ?? collectWorkspaceKeys(source).has(workspaceKey) + if (sourceHasKey && !baseHasKey) { + return 'source' + } + if (baseHasKey && !sourceHasKey) { + return 'base' + } + const baseTabs = base.tabsByWorktree[workspaceKey] ?? [] + const sourceTabs = source.tabsByWorktree[workspaceKey] ?? [] + if (sourceTabs.length > 0 && baseTabs.length === 0) { + return 'source' + } + if (baseTabs.length > 0 && sourceTabs.length === 0) { + return 'base' + } + return canonicalJson(workspaceBundle(base, workspaceKey)) === + canonicalJson(workspaceBundle(source, workspaceKey)) + ? 'equivalent' + : 'ambiguous' +} + +export function findAmbiguousWorkspaceSessionKeys( + sources: readonly WorkspaceSessionState[] +): Set { + const ambiguous = new Set() + const keysBySource = sources.map(collectWorkspaceKeys) + for (let left = 0; left < sources.length; left += 1) { + for (let right = left + 1; right < sources.length; right += 1) { + const keys = new Set([...keysBySource[left], ...keysBySource[right]]) + for (const key of keys) { + if ( + workspaceTerminalAuthority(sources[left], sources[right], key, { + base: keysBySource[left].has(key), + source: keysBySource[right].has(key) + }) === 'ambiguous' + ) { + ambiguous.add(key) + } + } + } + } + return ambiguous +} diff --git a/src/shared/workspace-session-partition-provenance.ts b/src/shared/workspace-session-partition-provenance.ts index 470850e67c56..4f2405d3bb27 100644 --- a/src/shared/workspace-session-partition-provenance.ts +++ b/src/shared/workspace-session-partition-provenance.ts @@ -1,9 +1,6 @@ -import type { SleepingAgentSessionRecord } from './agent-session-resume' import type { BrowserHistoryEntry, TerminalLayoutSnapshot, WorkspaceSessionState } from './types' -import { parseWorkspaceKey } from './workspace-scope' -import { getRepoIdFromWorktreeId } from './worktree-id' -const WORKTREE_RECORD_FIELDS = [ +export const WORKTREE_RECORD_FIELDS = [ 'tabsByWorktree', 'openFilesByWorktree', 'activeFileIdByWorktree', @@ -29,32 +26,6 @@ export function paneLeafId(paneKey: string): string { return separator < 0 ? '' : paneKey.slice(separator + 1) } -function repoIdForWorkspaceKey(key: string): string | null { - const scope = parseWorkspaceKey(key) - if (scope?.type === 'folder') { - return null - } - return getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : key) -} - -function sourceBindingRevisionIsCurrent( - base: WorkspaceSessionState, - source: WorkspaceSessionState, - workspaceKey: string | undefined -): boolean { - if (!workspaceKey) { - return false - } - const repoId = repoIdForWorkspaceKey(workspaceKey) - if (!repoId) { - return true - } - return ( - (source.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) >= - (base.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) - ) -} - export function collectWorkspaceKeys(session: WorkspaceSessionState): Set { const keys = new Set() for (const field of WORKTREE_RECORD_FIELDS) { @@ -116,11 +87,7 @@ export function collectTerminalProvenance( const baseTombstone = base.terminalSurfaceTombstonesByPaneKey?.[paneKey] if ( liveTabIds.has(paneTabId(paneKey)) && - sourceBindingRevisionIsCurrent( - base, - source, - sourceWorkspaceKeyByTabId.get(paneTabId(paneKey)) - ) && + sourceAuthority.has(sourceWorkspaceKeyByTabId.get(paneTabId(paneKey)) ?? '') && (!baseTombstone || baseTombstone.incarnationId !== incarnationId) ) { sourcePaneAuthority.add(paneKey) @@ -135,6 +102,11 @@ export function collectTerminalProvenance( } } } + for (const [paneKey, record] of Object.entries(source.sleepingAgentSessionsByPaneKey ?? {})) { + if (sourceAuthority.has(record.worktreeId)) { + sourcePaneAuthority.add(paneKey) + } + } return { liveTabIds, touchedTabIds, sourceWorkspaceKeyByTabId, sourcePaneAuthority } } @@ -157,25 +129,6 @@ export function mergeWorkspaceRecord( return merged } -export function sourceOwnsTerminalMembership( - base: WorkspaceSessionState, - source: WorkspaceSessionState, - workspaceKey: string -): boolean { - const baseTabs = base.tabsByWorktree[workspaceKey] ?? [] - const sourceTabs = source.tabsByWorktree[workspaceKey] ?? [] - const repoId = repoIdForWorkspaceKey(workspaceKey) - if (!repoId) { - return sourceTabs.length > 0 && baseTabs.length === 0 - } - const baseRevision = base.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - const sourceRevision = source.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - return ( - sourceRevision > baseRevision || - (sourceRevision === baseRevision && sourceTabs.length > 0 && baseTabs.length === 0) - ) -} - function layoutContainsLeaf(root: TerminalLayoutSnapshot['root'], leafId: string): boolean { if (!root) { return false @@ -201,28 +154,33 @@ export function mergeTerminalLayout( if (!base || !source) { return base ?? source } - const ptyIdsByLeafId = { ...base.ptyIdsByLeafId } - for (const [leafId, ptyId] of Object.entries(source.ptyIdsByLeafId ?? {})) { - const paneKey = `${tabId}:${leafId}` - if ( - (sourcePaneAuthority.has(paneKey) || ptyIdsByLeafId[leafId] === undefined) && - layoutContainsLeaf(base.root, leafId) - ) { - ptyIdsByLeafId[leafId] = ptyId + const merged: TerminalLayoutSnapshot = { ...source, ...base } + const paneFields = [ + 'ptyIdsByLeafId', + 'buffersByLeafId', + 'scrollbackRefsByLeafId', + 'titlesByLeafId' + ] as const + for (const field of paneFields) { + const values = { ...base[field] } + const sourceValues = source[field] + for (const leafId of Object.keys(values)) { + if (sourcePaneAuthority.has(`${tabId}:${leafId}`) && sourceValues?.[leafId] === undefined) { + delete values[leafId] + } } - } - const merged: TerminalLayoutSnapshot = { ...source, ...base, ptyIdsByLeafId } - if (source.buffersByLeafId || base.buffersByLeafId) { - merged.buffersByLeafId = { ...source.buffersByLeafId, ...base.buffersByLeafId } - } - if (source.scrollbackRefsByLeafId || base.scrollbackRefsByLeafId) { - merged.scrollbackRefsByLeafId = { - ...source.scrollbackRefsByLeafId, - ...base.scrollbackRefsByLeafId + for (const [leafId, value] of Object.entries(sourceValues ?? {})) { + const paneKey = `${tabId}:${leafId}` + if ( + (sourcePaneAuthority.has(paneKey) || values[leafId] === undefined) && + layoutContainsLeaf(base.root, leafId) + ) { + values[leafId] = value + } + } + if (Object.keys(values).length > 0 || base[field] || source[field]) { + merged[field] = values } - } - if (source.titlesByLeafId || base.titlesByLeafId) { - merged.titlesByLeafId = { ...source.titlesByLeafId, ...base.titlesByLeafId } } return merged } @@ -238,13 +196,6 @@ export function mergeTopologyRevisions( return merged } -export function chooseSleepingRecord( - base: SleepingAgentSessionRecord | undefined, - source: SleepingAgentSessionRecord -): SleepingAgentSessionRecord { - return !base || source.updatedAt >= base.updatedAt ? source : base -} - export function mergeUnique( values: readonly string[] | undefined, incoming: readonly string[] | undefined diff --git a/src/shared/workspace-session-partition-state-merge.ts b/src/shared/workspace-session-partition-state-merge.ts new file mode 100644 index 000000000000..b61c4cf3f7d1 --- /dev/null +++ b/src/shared/workspace-session-partition-state-merge.ts @@ -0,0 +1,120 @@ +import type { WorkspaceSessionState } from './types' +import { repoIdForWorkspaceKey } from './workspace-session-partition-authority' +import { + mergeBrowserHistory, + mergeTopologyRevisions, + mergeUnique, + mergeWorkspaceRecord +} from './workspace-session-partition-provenance' + +type PartitionStateMerge = { + base: WorkspaceSessionState + source: WorkspaceSessionState + reconciledKeys: ReadonlySet + sourceAuthority: ReadonlySet + ambiguousWorktreeIds: ReadonlySet + tabsByWorktree: WorkspaceSessionState['tabsByWorktree'] + terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] + remoteSessionIdsByTabId: NonNullable + sleepingAgentSessionsByPaneKey: NonNullable< + WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] + > + terminalPtyIncarnationsByPaneKey: NonNullable< + WorkspaceSessionState['terminalPtyIncarnationsByPaneKey'] + > + terminalSurfaceTombstonesByPaneKey: NonNullable< + WorkspaceSessionState['terminalSurfaceTombstonesByPaneKey'] + > +} + +export function mergeAdoptedWorkspaceSessionState( + input: PartitionStateMerge +): WorkspaceSessionState { + const { base, source, reconciledKeys, sourceAuthority, ambiguousWorktreeIds } = input + const browserPagesByWorkspace = { ...base.browserPagesByWorkspace } + for (const [workspaceId, pages] of Object.entries(source.browserPagesByWorkspace ?? {})) { + if ( + (browserPagesByWorkspace[workspaceId] === undefined && + pages.every((page) => reconciledKeys.has(page.worktreeId))) || + pages.some((page) => sourceAuthority.has(page.worktreeId)) + ) { + browserPagesByWorkspace[workspaceId] = pages + } + } + const mergeRecord = ( + baseRecord: Record | undefined, + sourceRecord: Record | undefined + ) => mergeWorkspaceRecord(baseRecord, sourceRecord, reconciledKeys, sourceAuthority) + + return { + ...source, + ...base, + activeRepoId: + base.activeRepoId ?? (ambiguousWorktreeIds.size === 0 ? source.activeRepoId : null), + activeWorkspaceKey: + base.activeWorkspaceKey ?? + (ambiguousWorktreeIds.size === 0 ? source.activeWorkspaceKey : null), + activeWorkspaceExecutionHostId: + base.activeWorkspaceExecutionHostId ?? + (ambiguousWorktreeIds.size === 0 ? source.activeWorkspaceExecutionHostId : null), + activeWorktreeId: + base.activeWorktreeId ?? (ambiguousWorktreeIds.size === 0 ? source.activeWorktreeId : null), + activeTabId: base.activeTabId ?? (ambiguousWorktreeIds.size === 0 ? source.activeTabId : null), + tabsByWorktree: input.tabsByWorktree, + terminalLayoutsByTabId: input.terminalLayoutsByTabId, + openFilesByWorktree: mergeRecord(base.openFilesByWorktree, source.openFilesByWorktree), + activeFileIdByWorktree: mergeRecord(base.activeFileIdByWorktree, source.activeFileIdByWorktree), + browserTabsByWorktree: mergeRecord(base.browserTabsByWorktree, source.browserTabsByWorktree), + browserPagesByWorkspace, + browserUrlHistory: mergeBrowserHistory(base.browserUrlHistory, source.browserUrlHistory), + markdownFrontmatterVisible: { + ...(ambiguousWorktreeIds.size === 0 ? source.markdownFrontmatterVisible : {}), + ...base.markdownFrontmatterVisible + }, + activeBrowserTabIdByWorktree: mergeRecord( + base.activeBrowserTabIdByWorktree, + source.activeBrowserTabIdByWorktree + ), + activeTabTypeByWorktree: mergeRecord( + base.activeTabTypeByWorktree, + source.activeTabTypeByWorktree + ), + activeTabIdByWorktree: mergeRecord(base.activeTabIdByWorktree, source.activeTabIdByWorktree), + unifiedTabs: mergeRecord(base.unifiedTabs, source.unifiedTabs), + tabGroups: mergeRecord(base.tabGroups, source.tabGroups), + tabGroupLayouts: mergeRecord(base.tabGroupLayouts, source.tabGroupLayouts), + activeGroupIdByWorktree: mergeRecord( + base.activeGroupIdByWorktree, + source.activeGroupIdByWorktree + ), + lastVisitedAtByWorktreeId: mergeRecord( + base.lastVisitedAtByWorktreeId, + source.lastVisitedAtByWorktreeId + ), + defaultTerminalTabsAppliedByWorktreeId: mergeRecord( + base.defaultTerminalTabsAppliedByWorktreeId, + source.defaultTerminalTabsAppliedByWorktreeId + ), + activeWorktreeIdsOnShutdown: mergeUnique( + base.activeWorktreeIdsOnShutdown, + source.activeWorktreeIdsOnShutdown?.filter((key) => reconciledKeys.has(key)) + ), + activeConnectionIdsAtShutdown: mergeUnique( + base.activeConnectionIdsAtShutdown, + source.activeConnectionIdsAtShutdown + ), + remoteSessionIdsByTabId: input.remoteSessionIdsByTabId, + sleepingAgentSessionsByPaneKey: input.sleepingAgentSessionsByPaneKey, + terminalPtyIncarnationsByPaneKey: input.terminalPtyIncarnationsByPaneKey, + terminalTopologyRevisionByRepoId: mergeTopologyRevisions( + base.terminalTopologyRevisionByRepoId, + Object.fromEntries( + Object.entries(source.terminalTopologyRevisionByRepoId ?? {}).filter( + ([repoId]) => + ![...ambiguousWorktreeIds].some((key) => repoIdForWorkspaceKey(key) === repoId) + ) + ) + ), + terminalSurfaceTombstonesByPaneKey: input.terminalSurfaceTombstonesByPaneKey + } +} From a7b8caa7232e70c1530487a93999e21aad9a66be Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 07:34:37 -0400 Subject: [PATCH 06/20] fix(session): fail closed across ssh authority domains --- config/reliability-gates.jsonc | 5 +- src/main/ipc/remote-workspace.test.ts | 66 +++++++++++++++++++ src/main/ipc/remote-workspace.ts | 51 +++++++++++--- src/main/persistence.test.ts | 29 +++++++- src/main/persistence.ts | 4 +- src/preload/api-types.ts | 1 + .../remote-workspace-target-sync.test.ts | 5 +- .../src/hooks/remote-workspace-target-sync.ts | 2 + .../workspace-session-partition-authority.ts | 33 ++++++++-- 9 files changed, 176 insertions(+), 20 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 703bd3c91811..c947977b883b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5972,7 +5972,7 @@ "file": "src/main/persistence.test.ts", "assertions": [ "one transaction enumerates every persisted SSH partition and preserves non-SSH partitions", - "equal-revision repository and folder conflicts preserve every ambiguous source while reconciled sources prune independently", + "equal- or unequal-revision duplicate repository keys preserve non-empty local state and every ambiguous SSH source while reconciled sources prune independently", "the durable owner flush precedes complete source pruning and both failure phases remain retryable", "qualified adoption scopes to one SSH partition and frozen persistence rejects before reporting durable success", "a sync-flushed SSH binding and incarnation survive the crash window before renderer adoption" @@ -5989,7 +5989,8 @@ { "file": "src/main/ipc/remote-workspace.test.ts", "assertions": [ - "duplicate repository ids resolve through the exact SSH target host and export only that target's adopted state" + "duplicate repository ids resolve through the exact SSH target host and export only that target's adopted state", + "conflicting local and target partitions skip replacement exports, and explicit sessions require matching target provenance" ] }, { diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index d983b16f6284..5dc4fca06990 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -227,6 +227,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { async function callSetForConnectedTargets(args: { session?: WorkspaceSessionState + sessionTargetId?: string hydratedTargetIds?: unknown }): Promise { const handler = handlers.get('remoteWorkspace:setForConnectedTargets') @@ -249,9 +250,19 @@ describe('remoteWorkspace:setForConnectedTargets', () => { expect(getActiveMultiplexerMock).not.toHaveBeenCalled() }) + it('does not export an explicit session without target provenance', async () => { + await expect( + callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] }) + ).resolves.toEqual([]) + + expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + expect(getActiveMultiplexerMock).not.toHaveBeenCalled() + }) + it('writes only to explicitly hydrated connected targets', async () => { const result = await callSetForConnectedTargets({ session: baseSession, + sessionTargetId: 'target-1', hydratedTargetIds: ['target-1', 'missing-target'] }) @@ -393,6 +404,39 @@ describe('remoteWorkspace:setForConnectedTargets', () => { } }) + it('fails closed when local and target partitions conflict on one workspace key', async () => { + const worktreeId = 'duplicate::/repo' + getRepoMock.mockImplementation((repoId: string, hostId?: string) => + repoId === 'duplicate' && hostId === 'ssh:target-2' + ? ({ id: repoId, connectionId: 'target-2', executionHostId: hostId } as never) + : undefined + ) + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => ({ + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: hostId === 'ssh:target-2' ? 'target-b-tab' : 'target-a-tab', + title: hostId ?? 'local', + ptyId: null, + worktreeId + } as never + ] + }, + terminalTopologyRevisionByRepoId: { duplicate: 4 } + })) + + await expect(callSetForConnectedTargets({ hydratedTargetIds: ['target-2'] })).resolves.toEqual( + [] + ) + + expect(getWorkspaceSessionMock).toHaveBeenCalledWith('ssh:target-2') + expect(requestByTargetId.get('target-2')).not.toHaveBeenCalledWith( + 'workspace.patch', + expect.anything() + ) + }) + it('does not consult ssh partitions when an explicit session argument is provided', async () => { const worktreeId = 'repo-target-1::/repo' await callSetForConnectedTargets({ @@ -402,6 +446,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { [worktreeId]: [{ id: 'tab-explicit', title: 'Shell', ptyId: null, worktreeId } as never] } }, + sessionTargetId: 'target-1', hydratedTargetIds: ['target-1'] }) @@ -419,4 +464,25 @@ describe('remoteWorkspace:setForConnectedTargets', () => { }) ) }) + + it('does not export a target-qualified explicit session through a conflicting target', async () => { + const worktreeId = 'duplicate::/repo' + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { id: 'target-a-tab', title: 'Target A', ptyId: null, worktreeId } as never + ] + } + }, + sessionTargetId: 'target-1', + hydratedTargetIds: ['target-2'] + }) + ).resolves.toEqual([]) + + expect(getRepoMock).not.toHaveBeenCalled() + expect(requestByTargetId.get('target-2')).toBeUndefined() + }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 9261d05d07de..ed445236b902 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -18,6 +18,10 @@ import type { WorkspaceSessionState } from '../../shared/types' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' import { toSshExecutionHostId } from '../../shared/execution-host' import { adoptOrphanedWorkspaceSessionPartition } from '../../shared/workspace-session-partition-adoption' +import { + findAmbiguousWorkspaceSessionKeys, + workspaceSessionBundlesEquivalent +} from '../../shared/workspace-session-partition-authority' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' @@ -407,30 +411,61 @@ export function registerRemoteWorkspaceHandlers( ipcMain.handle( 'remoteWorkspace:setForConnectedTargets', - async (_event, args: { session?: WorkspaceSessionState; hydratedTargetIds?: unknown }) => { + async ( + _event, + args: { + session?: WorkspaceSessionState + sessionTargetId?: unknown + hydratedTargetIds?: unknown + } + ) => { const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds) if (!hydratedTargetIds) { // Why: an omitted hydration set used to broadcast one session to every // SSH target, overwriting unrelated remote workspace snapshots. return [] } + const sessionTargetId = + typeof args.sessionTargetId === 'string' && args.sessionTargetId.length > 0 + ? args.sessionTargetId + : null + if (args.session && (!sessionTargetId || !hydratedTargetIds.has(sessionTargetId))) { + return [] + } const targets = getSshConnectionStore() ?.listTargets() .filter( - (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) + (target) => + hydratedTargetIds.has(target.id) && + (!args.session || target.id === sessionTargetId) && + getActiveMultiplexer(target.id) ) ?? [] const fallbackSession = args.session ?? store.getWorkspaceSession() const results = await Promise.all( targets.map(async (target) => { // Boot owns persistence; export only overlays stranded SSH state. - const workspaceSession = args.session - ? args.session - : adoptOrphanedWorkspaceSessionPartition( - fallbackSession, - store.getWorkspaceSession(toSshExecutionHostId(target.id)) - ).session + let workspaceSession = args.session + if (!workspaceSession) { + const targetPartition = store.getWorkspaceSession(toSshExecutionHostId(target.id)) + const ambiguousKeys = findAmbiguousWorkspaceSessionKeys([ + fallbackSession, + targetPartition + ]) + const hasPopulatedLocalConflict = [...ambiguousKeys].some( + (key) => + (fallbackSession.tabsByWorktree[key]?.length ?? 0) > 0 && + !workspaceSessionBundlesEquivalent(fallbackSession, targetPartition, key) + ) + if (hasPopulatedLocalConflict) { + return null + } + workspaceSession = adoptOrphanedWorkspaceSessionPartition( + fallbackSession, + targetPartition + ).session + } // Why: each target has its own revision stream. Keep same-target // writes queued, but do not let one slow relay block others. const session = exportSessionForTarget(store, target.id, workspaceSession) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 3d27a21062ec..618438d87428 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -11907,15 +11907,17 @@ describe('Store host-partitioned workspace sessions', () => { it('enumerates every persisted SSH partition including folder-only projects', async () => { const store = await createStore() const folderKey = folderWorkspaceKey('ssh-folder') - const folderSession = makeBoundHostSession('ssh:ssh-folder@@folder-pty') + const boundFolderSession = makeBoundHostSession('ssh:ssh-folder@@folder-pty') + const folderSession = getDefaultWorkspaceSession() folderSession.tabsByWorktree = { [folderKey]: [ { - ...folderSession.tabsByWorktree['repo-1::/worktree'][0], + ...boundFolderSession.tabsByWorktree['repo-1::/worktree'][0], worktreeId: folderKey } ] } + folderSession.terminalLayoutsByTabId = boundFolderSession.terminalLayoutsByTabId folderSession.openFilesByWorktree = { [folderKey]: [ { @@ -11957,6 +11959,27 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) }) + it('keeps non-empty local state and unequal-revision duplicate SSH sources partitioned', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.terminalTopologyRevisionByRepoId = { 'repo-1': 2 } + right.terminalTopologyRevisionByRepoId = { 'repo-1': 7 } + right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' + store.setWorkspaceSession(structuredClone(left)) + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + left.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + it('does not choose a folder tab between conflicting SSH sources', async () => { const store = await createStore() const folderKey = folderWorkspaceKey('shared-folder') @@ -11991,7 +12014,7 @@ describe('Store host-partitioned workspace sessions', () => { makeTerminalTab({ id: 'local-folder-tab', worktreeId: folderKey }) ] const accepted = makeBoundHostSession('ssh:accepted@@accepted-pty') - const retained = makeBoundHostSession('ssh:retained@@retained-pty') + const retained = getDefaultWorkspaceSession() retained.tabsByWorktree = { [folderKey]: [makeTerminalTab({ id: 'remote-folder-tab', worktreeId: folderKey })] } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 62c535d2ee5c..58d388e8f64d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -136,7 +136,7 @@ import { } from '../shared/constants' import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' import { adoptOrphanedWorkspaceSessionPartition } from '../shared/workspace-session-partition-adoption' -import { findAmbiguousWorkspaceSessionKeys } from '../shared/workspace-session-partition-authority' +import { findCrossHostWorkspaceSessionKeyCollisions } from '../shared/workspace-session-partition-authority' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -6305,7 +6305,7 @@ export class Store { return originalOwner } - const preservedWorkspaceKeys = findAmbiguousWorkspaceSessionKeys( + const preservedWorkspaceKeys = findCrossHostWorkspaceSessionKeyCollisions( sshPartitions.map(([, source]) => source) ) const prunableHostIds = new Set() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 596fb7e80c16..ab3c0c0d0b06 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2751,6 +2751,7 @@ export type PreloadApi = { get: (args: { targetId: string }) => Promise setForConnectedTargets: (args: { session?: WorkspaceSessionState + sessionTargetId?: string hydratedTargetIds?: string[] }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> listEnabledConnectedTargets: () => Promise diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 985f54e535a9..6720ac0989d2 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -212,7 +212,10 @@ describe('createRemoteWorkspaceTargetSync', () => { expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() expect(harness.setForConnectedTargets).toHaveBeenCalledWith( - expect.objectContaining({ hydratedTargetIds: ['target-a'] }) + expect.objectContaining({ + sessionTargetId: 'target-a', + hydratedTargetIds: ['target-a'] + }) ) }) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 79bc3d9690ef..3602a3f7e673 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -24,6 +24,7 @@ type RemoteWorkspaceApi = { get: (args: { targetId: string }) => Promise setForConnectedTargets: (args: { session?: WorkspaceSessionState + sessionTargetId?: string hydratedTargetIds?: string[] }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> } @@ -200,6 +201,7 @@ export function createRemoteWorkspaceTargetSync( } const results = await deps.remoteWorkspace.setForConnectedTargets({ session: buildWorkspaceSessionPayload(deps.store.getState()), + sessionTargetId: authority.targetId, hydratedTargetIds: [authority.targetId] }) if (!deps.isPreparationTokenCurrent(token)) { diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts index 34529e89e649..55476152b0d0 100644 --- a/src/shared/workspace-session-partition-authority.ts +++ b/src/shared/workspace-session-partition-authority.ts @@ -64,6 +64,17 @@ function workspaceBundle(session: WorkspaceSessionState, workspaceKey: string): } } +export function workspaceSessionBundlesEquivalent( + base: WorkspaceSessionState, + source: WorkspaceSessionState, + workspaceKey: string +): boolean { + return ( + canonicalJson(workspaceBundle(base, workspaceKey)) === + canonicalJson(workspaceBundle(source, workspaceKey)) + ) +} + export type WorkspaceTerminalAuthority = 'base' | 'source' | 'equivalent' | 'ambiguous' export function workspaceTerminalAuthority( @@ -94,10 +105,7 @@ export function workspaceTerminalAuthority( if (baseTabs.length > 0 && sourceTabs.length === 0) { return 'base' } - return canonicalJson(workspaceBundle(base, workspaceKey)) === - canonicalJson(workspaceBundle(source, workspaceKey)) - ? 'equivalent' - : 'ambiguous' + return workspaceSessionBundlesEquivalent(base, source, workspaceKey) ? 'equivalent' : 'ambiguous' } export function findAmbiguousWorkspaceSessionKeys( @@ -122,3 +130,20 @@ export function findAmbiguousWorkspaceSessionKeys( } return ambiguous } + +export function findCrossHostWorkspaceSessionKeyCollisions( + sources: readonly WorkspaceSessionState[] +): Set { + const collisions = new Set() + const keysBySource = sources.map(collectWorkspaceKeys) + for (let left = 0; left < sources.length; left += 1) { + for (let right = left + 1; right < sources.length; right += 1) { + for (const key of keysBySource[left]) { + if (keysBySource[right].has(key)) { + collisions.add(key) + } + } + } + } + return collisions +} From fc6a3ecec7aea4d5182274418a058cecfb888f08 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 08:28:49 -0400 Subject: [PATCH 07/20] fix(ssh): retain durable pty authority during snapshot restore --- config/reliability-gates.jsonc | 8 ++- ...te-workspace-explicit-session-authority.ts | 61 ++++++++++++++++++ src/main/ipc/remote-workspace.test.ts | 64 +++++++++++++++++-- src/main/ipc/remote-workspace.ts | 40 +++++------- .../hooks/remote-workspace-session-merge.ts | 49 ++++++++++++-- .../hooks/remote-workspace-snapshot-apply.ts | 3 +- .../remote-workspace-target-sync.test.ts | 41 +++++++++++- .../ssh-session-partition-adoption.spec.ts | 12 ++-- 8 files changed, 235 insertions(+), 43 deletions(-) create mode 100644 src/main/ipc/remote-workspace-explicit-session-authority.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c947977b883b..1e7300ce801d 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5990,7 +5990,13 @@ "file": "src/main/ipc/remote-workspace.test.ts", "assertions": [ "duplicate repository ids resolve through the exact SSH target host and export only that target's adopted state", - "conflicting local and target partitions skip replacement exports, and explicit sessions require matching target provenance" + "conflicting local and target partitions skip replacement exports, and explicit sessions require matching target provenance and durable PTY identity" + ] + }, + { + "file": "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "assertions": [ + "identity-free target snapshots preserve the exact local tab, active leaf binding, provider session, and shutdown reconnect owner before hydration" ] }, { diff --git a/src/main/ipc/remote-workspace-explicit-session-authority.ts b/src/main/ipc/remote-workspace-explicit-session-authority.ts new file mode 100644 index 000000000000..d1ebfafc488f --- /dev/null +++ b/src/main/ipc/remote-workspace-explicit-session-authority.ts @@ -0,0 +1,61 @@ +import { isDeepStrictEqual } from 'node:util' +import type { Store } from '../persistence' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' +import type { RemoteWorkspaceSession } from '../../shared/remote-workspace-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' + +type SessionAuthorityStore = Pick + +function targetForWorktree( + store: SessionAuthorityStore, + worktreeId: string, + targetId: string +): string | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return store.getRepo(repoId, toSshExecutionHostId(targetId))?.connectionId ?? null +} + +export function exportSessionForTarget( + store: SessionAuthorityStore, + targetId: string, + session: WorkspaceSessionState +): RemoteWorkspaceSession { + return exportRemoteWorkspaceSession(session, { + isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId, targetId) === targetId + }) +} + +function hasWorkspaceTabs(session: WorkspaceSessionState): boolean { + return Object.values(session.tabsByWorktree).some((tabs) => tabs.length > 0) +} + +function hasRemoteWorkspaceTabs(session: RemoteWorkspaceSession): boolean { + return Object.values(session.tabsByWorktreePath).some((tabs) => tabs.length > 0) +} + +export function exportExplicitSessionForTarget( + store: SessionAuthorityStore, + targetId: string, + session: WorkspaceSessionState +): RemoteWorkspaceSession | null { + const projected = exportSessionForTarget(store, targetId, session) + if (hasWorkspaceTabs(session) && !hasRemoteWorkspaceTabs(projected)) { + return null + } + const durableSessions = [ + store.getWorkspaceSession(), + store.getWorkspaceSession(toSshExecutionHostId(targetId)) + ] + for (const durableSession of durableSessions) { + const durableProjection = exportSessionForTarget(store, targetId, durableSession) + if ( + hasRemoteWorkspaceTabs(durableProjection) && + !isDeepStrictEqual(durableProjection, projected) + ) { + return null + } + } + return projected +} diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 5dc4fca06990..67e434724a07 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -437,20 +437,72 @@ describe('remoteWorkspace:setForConnectedTargets', () => { ) }) - it('does not consult ssh partitions when an explicit session argument is provided', async () => { + it('fences a transient explicit PTY loss while allowing matching durable target state', async () => { const worktreeId = 'repo-target-1::/repo' - await callSetForConnectedTargets({ - session: { + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => { + if (hostId !== undefined) { + return baseSession + } + return { ...baseSession, tabsByWorktree: { - [worktreeId]: [{ id: 'tab-explicit', title: 'Shell', ptyId: null, worktreeId } as never] + [worktreeId]: [ + { id: 'tab-explicit', title: 'Shell', ptyId: 'pty-durable', worktreeId } as never + ] + }, + terminalLayoutsByTabId: { + 'tab-explicit': { + root: { type: 'leaf', id: 'leaf-1' }, + ptyIdsByLeafId: { 'leaf-1': 'pty-durable' } + } as never } - }, + } + }) + + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-explicit', title: 'Shell', ptyId: null, worktreeId } as never] + }, + terminalLayoutsByTabId: { + 'tab-explicit': { + root: { type: 'leaf', id: 'leaf-1' }, + ptyIdsByLeafId: {} + } as never + } + }, + sessionTargetId: 'target-1', + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + expect(getWorkspaceSessionMock).toHaveBeenCalledWith() + expect(requestByTargetId.get('target-1')).not.toHaveBeenCalledWith( + 'workspace.patch', + expect.anything() + ) + + const session = { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [ + { id: 'tab-explicit', title: 'Shell', ptyId: 'pty-durable', worktreeId } as never + ] + } + } + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => + hostId === undefined ? session : baseSession + ) + + await callSetForConnectedTargets({ + session, sessionTargetId: 'target-1', hydratedTargetIds: ['target-1'] }) - expect(getWorkspaceSessionMock).not.toHaveBeenCalled() + expect(getWorkspaceSessionMock).toHaveBeenCalledWith() expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( 'workspace.patch', expect.objectContaining({ diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index ed445236b902..b9e83669da6b 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -5,7 +5,6 @@ import { hostname } from 'node:os' import { isDeepStrictEqual } from 'node:util' import type { Store } from '../persistence' import { getActiveMultiplexer, getSshConnectionStore } from './ssh' -import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, @@ -15,7 +14,6 @@ import type { } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' import { toSshExecutionHostId } from '../../shared/execution-host' import { adoptOrphanedWorkspaceSessionPartition } from '../../shared/workspace-session-partition-adoption' import { @@ -24,6 +22,10 @@ import { } from '../../shared/workspace-session-partition-authority' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' +import { + exportExplicitSessionForTarget, + exportSessionForTarget +} from './remote-workspace-explicit-session-authority' const CLIENT_ID = randomUUID() const CLIENT_NAME = hostname() || 'This device' @@ -226,21 +228,6 @@ function getExplicitHydratedTargetIds(value: unknown): Set | null { return new Set(value) } -function targetForWorktree(store: Store, worktreeId: string, targetId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return store.getRepo(repoId, toSshExecutionHostId(targetId))?.connectionId ?? null -} - -function exportSessionForTarget( - store: Store, - targetId: string, - session: WorkspaceSessionState -): RemoteWorkspaceSession { - return exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId, targetId) === targetId - }) -} - async function getRemoteSnapshot(target: SshTarget): Promise { const mux = getActiveMultiplexer(target.id) if (!mux) { @@ -446,8 +433,10 @@ export function registerRemoteWorkspaceHandlers( const results = await Promise.all( targets.map(async (target) => { // Boot owns persistence; export only overlays stranded SSH state. - let workspaceSession = args.session - if (!workspaceSession) { + let session: RemoteWorkspaceSession | null + if (args.session) { + session = exportExplicitSessionForTarget(store, target.id, args.session) + } else { const targetPartition = store.getWorkspaceSession(toSshExecutionHostId(target.id)) const ambiguousKeys = findAmbiguousWorkspaceSessionKeys([ fallbackSession, @@ -461,14 +450,17 @@ export function registerRemoteWorkspaceHandlers( if (hasPopulatedLocalConflict) { return null } - workspaceSession = adoptOrphanedWorkspaceSessionPartition( - fallbackSession, - targetPartition - ).session + session = exportSessionForTarget( + store, + target.id, + adoptOrphanedWorkspaceSessionPartition(fallbackSession, targetPartition).session + ) + } + if (!session) { + return null } // Why: each target has its own revision stream. Keep same-target // writes queued, but do not let one slow relay block others. - const session = exportSessionForTarget(store, target.id, workspaceSession) const result = await queueRemoteWorkspacePatch(target.id, () => patchRemoteWorkspaceSession(target, session) ) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index cc9d47ab6e7c..2d3016999606 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -2,24 +2,45 @@ import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { splitWorktreeId } from '../../../shared/worktree-id' import type { AppState } from '../store/types' +import { collectLeafIdsInOrder } from '../components/terminal-pane/terminal-layout-leaf-ids' -function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab { +function preserveNewerLocalTerminalFields( + remote: TerminalTab, + local: TerminalTab, + retainedPtyId?: string +): TerminalTab { const preserved = { ...remote, generation: local.generation, - ptyId: local.ptyId + ptyId: local.ptyId ?? retainedPtyId ?? null } return local.pendingActivationSpawn ? { ...preserved, pendingActivationSpawn: local.pendingActivationSpawn } : preserved } +function retainedLocalPtyId( + current: WorkspaceSessionState, + reconnectPtyIdByTabId: Readonly>, + tabId: string +): string | undefined { + const layout = current.terminalLayoutsByTabId[tabId] + const mountedLeafIds = collectLeafIdsInOrder(layout?.root) + const primaryLeafId = + layout?.activeLeafId && mountedLeafIds.includes(layout.activeLeafId) + ? layout.activeLeafId + : mountedLeafIds[0] + const layoutPtyId = primaryLeafId ? layout?.ptyIdsByLeafId?.[primaryLeafId] : undefined + return reconnectPtyIdByTabId[tabId] ?? layoutPtyId +} + export function mergeDirectSshRemoteWorkspaceSession( current: WorkspaceSessionState, remote: WorkspaceSessionState, replaceWorktreeIds: ReadonlySet, liveTabsByWorktree: AppState['tabsByWorktree'], - preserveLocalTerminalTabIds: ReadonlySet + preserveLocalTerminalTabIds: ReadonlySet, + reconnectPtyIdByTabId: Readonly> ): WorkspaceSessionState { const currentTabsById = new Map( [...replaceWorktreeIds] @@ -27,21 +48,25 @@ export function mergeDirectSshRemoteWorkspaceSession( .map((tab) => [tab.id, tab]) ) const locallyPreservedTabIds = new Set() + const locallyPreservedWorktreeIds = new Set() const tabsByWorktree = Object.fromEntries( Object.entries(remote.tabsByWorktree).map(([worktreeId, tabs]) => [ worktreeId, tabs.map((tab) => { const local = currentTabsById.get(tab.id) + const retainedPtyId = retainedLocalPtyId(current, reconnectPtyIdByTabId, tab.id) if ( !local || ((local.generation ?? 0) <= (tab.generation ?? 0) && + !((local.ptyId || retainedPtyId) && !tab.ptyId) && !local.pendingActivationSpawn && !preserveLocalTerminalTabIds.has(tab.id)) ) { return tab } locallyPreservedTabIds.add(tab.id) - return preserveNewerLocalTerminalFields(tab, local) + locallyPreservedWorktreeIds.add(worktreeId) + return preserveNewerLocalTerminalFields(tab, local, retainedPtyId) }) ]) ) @@ -88,8 +113,13 @@ export function mergeDirectSshRemoteWorkspaceSession( }, terminalLayoutsByTabId, activeWorktreeIdsOnShutdown: [ - ...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)), - ...(remote.activeWorktreeIdsOnShutdown ?? []) + ...new Set([ + ...(current.activeWorktreeIdsOnShutdown ?? []).filter( + (id) => !replaceWorktreeIds.has(id) || locallyPreservedWorktreeIds.has(id) + ), + ...locallyPreservedWorktreeIds, + ...(remote.activeWorktreeIdsOnShutdown ?? []) + ]) ], activeTabIdByWorktree: { ...omitTargetWorktrees(current.activeTabIdByWorktree), @@ -105,6 +135,13 @@ export function mergeDirectSshRemoteWorkspaceSession( Object.entries(remote.remoteSessionIdsByTabId ?? {}).filter( ([tabId]) => !locallyPreservedTabIds.has(tabId) ) + ), + ...Object.fromEntries( + [...locallyPreservedTabIds] + .map( + (tabId) => [tabId, retainedLocalPtyId(current, reconnectPtyIdByTabId, tabId)] as const + ) + .filter((entry): entry is readonly [string, string] => Boolean(entry[1])) ) }, lastVisitedAtByWorktreeId: { diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 1ffad891d89f..4e423dbd258d 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -116,7 +116,8 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ remoteSession, worktreeIds, state.tabsByWorktree, - currentRecoveryTabIds(state, authority, worktreeIds) + currentRecoveryTabIds(state, authority, worktreeIds), + state.pendingReconnectPtyIdByTabId ?? {} ) if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { return diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 6720ac0989d2..79ad3f7c1ecd 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -329,7 +329,7 @@ describe('createRemoteWorkspaceTargetSync', () => { expect(state.hydrateBrowserSession).not.toHaveBeenCalled() }) - it('preserves a higher local generation from an older remote snapshot', async () => { + it('preserves a durable local PTY bundle from older or identity-free snapshots', async () => { const hydrateTabsSession = vi.fn() const state = appState({ tabsByWorktree: { @@ -342,6 +342,14 @@ describe('createRemoteWorkspaceTargetSync', () => { } ] }, + terminalLayoutsByTabId: { + 'stable-tab': { + root: { type: 'leaf', leafId: 'leaf-local' }, + activeLeafId: 'leaf-local', + ptyIdsByLeafId: { 'leaf-local': 'local-pty' } + } + }, + ptyIdsByTabId: { 'stable-tab': ['local-pty'] }, hydrateTabsSession }) const harness = createHarness(state, async () => null) @@ -361,6 +369,37 @@ describe('createRemoteWorkspaceTargetSync', () => { expect( hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] ).toMatchObject({ generation: 7, ptyId: 'local-pty' }) + + state.tabsByWorktree['repo-a::/remote/work'][0].ptyId = null + state.pendingReconnectPtyIdByTabId = {} + await harness.sync.applyUnsolicitedSnapshot( + 'target-a', + snapshot(6, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: null, + generation: 7 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + ) + + const merged = hydrateTabsSession.mock.calls[1][0] + expect(merged.tabsByWorktree['repo-a::/remote/work'][0]).toMatchObject({ + generation: 7, + ptyId: 'local-pty' + }) + expect(merged.terminalLayoutsByTabId['stable-tab']).toEqual({ + root: { type: 'leaf', leafId: 'leaf-local' }, + activeLeafId: 'leaf-local', + ptyIdsByLeafId: { 'leaf-local': 'local-pty' } + }) + expect(merged.remoteSessionIdsByTabId).toEqual({ + 'stable-tab': 'local-pty' + }) + expect(merged.activeWorktreeIdsOnShutdown).toEqual(['repo-a::/remote/work']) }) it('admits a genuinely newer remote generation without local recovery state', async () => { diff --git a/tests/e2e/ssh-session-partition-adoption.spec.ts b/tests/e2e/ssh-session-partition-adoption.spec.ts index 4e560cca5473..849061c48b42 100644 --- a/tests/e2e/ssh-session-partition-adoption.spec.ts +++ b/tests/e2e/ssh-session-partition-adoption.spec.ts @@ -1,4 +1,4 @@ -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' import { readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' @@ -103,8 +103,7 @@ test.describe('SSH session partition adoption', () => { test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') test.skip(process.platform === 'win32', 'Docker SSH adoption uses POSIX SSH tooling.') - test('round-trips stranded tabs without remote deletion @headful', async (// oxlint-disable-next-line no-empty-pattern -- The test owns each Electron launch. - {}, testInfo) => { + async function runSshSessionPartitionAdoption(testInfo: TestInfo): Promise { test.setTimeout(360_000) const restart = createRestartSession(testInfo) let target: DockerSshRelayTarget | null = null @@ -185,5 +184,10 @@ test.describe('SSH session partition adoption', () => { await restart.dispose() cleanupDockerSshRelayTarget(target) } - }) + } + + test('round-trips stranded tabs without remote deletion', async (// oxlint-disable-next-line no-empty-pattern -- The test owns each Electron launch. + {}, testInfo) => runSshSessionPartitionAdoption(testInfo)) + test('round-trips stranded tabs without remote deletion @headful', async (// oxlint-disable-next-line no-empty-pattern -- The test owns each Electron launch. + {}, testInfo) => runSshSessionPartitionAdoption(testInfo)) }) From d5286ff00855d381a66ce1e278d0aa992f2ea1a8 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 09:43:32 -0400 Subject: [PATCH 08/20] fix(ssh): fence snapshot merge by provider authority --- config/reliability-gates.jsonc | 16 +- ...te-workspace-explicit-session-authority.ts | 60 +++++- .../ipc/remote-workspace-patch-queue.test.ts | 20 +- src/main/ipc/remote-workspace.test.ts | 121 +++++++++++- src/main/ipc/remote-workspace.ts | 28 ++- src/preload/api-types.ts | 2 + .../remote-workspace-session-merge.test.ts | 176 ++++++++++++++++++ .../hooks/remote-workspace-session-merge.ts | 142 +++++++++++--- .../hooks/remote-workspace-snapshot-apply.ts | 47 +++-- ...pace-snapshot-duplicate-tab-repair.test.ts | 80 ++------ .../remote-workspace-target-sync.test.ts | 92 +++++++-- .../src/hooks/remote-workspace-target-sync.ts | 2 + 12 files changed, 642 insertions(+), 144 deletions(-) create mode 100644 src/renderer/src/hooks/remote-workspace-session-merge.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1e7300ce801d..d5987a0d5cf4 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5874,7 +5874,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, eager six-terminal remount after renderer reload, and two full restarts after a live session is stranded in its SSH partition. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, and WSL remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot merge and explicit export now carry exact target/provider incarnation authority, select local tabs by worktree plus tab, and reject cross-target layouts, mounted leaves, reconnect hints, remote sessions, and ambiguous duplicate ownership before hydration or publication. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, eager six-terminal remount after renderer reload, and two full restarts after a live session is stranded in its SSH partition. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, and WSL remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -5887,7 +5887,7 @@ "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts src/renderer/src/hooks/remote-workspace-session-merge.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", @@ -5908,6 +5908,10 @@ "src/main/ipc/session.test.ts", "src/renderer/src/lib/workspace-session-host-persistence.test.ts", "src/main/ipc/remote-workspace.test.ts", + "src/main/ipc/remote-workspace-patch-queue.test.ts", + "src/renderer/src/hooks/remote-workspace-session-merge.test.ts", + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts", "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "src/renderer/src/lib/resolved-worktree-execution-host.test.ts", "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts", @@ -5999,6 +6003,14 @@ "identity-free target snapshots preserve the exact local tab, active leaf binding, provider session, and shutdown reconnect owner before hydration" ] }, + { + "file": "src/renderer/src/hooks/remote-workspace-session-merge.test.ts", + "assertions": [ + "local retention is keyed by worktree plus tab plus exact DirectSshAuthority instead of a bare tab id", + "foreign target tab, layout, mounted-leaf, pending-reconnect, and remote-session PTYs are rejected before merge", + "duplicate ownership across worktrees fails closed while exact-target equal and unequal generations preserve coherent PTY bundles" + ] + }, { "file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "assertions": [ diff --git a/src/main/ipc/remote-workspace-explicit-session-authority.ts b/src/main/ipc/remote-workspace-explicit-session-authority.ts index d1ebfafc488f..d644c9a500a8 100644 --- a/src/main/ipc/remote-workspace-explicit-session-authority.ts +++ b/src/main/ipc/remote-workspace-explicit-session-authority.ts @@ -3,6 +3,8 @@ import type { Store } from '../persistence' import { toSshExecutionHostId } from '../../shared/execution-host' import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' import type { RemoteWorkspaceSession } from '../../shared/remote-workspace-types' +import { parseAppSshPtyId } from '../../shared/ssh-pty-id' +import type { DirectSshAuthority } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' @@ -19,12 +21,46 @@ function targetForWorktree( export function exportSessionForTarget( store: SessionAuthorityStore, - targetId: string, + authority: DirectSshAuthority, session: WorkspaceSessionState -): RemoteWorkspaceSession { - return exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId, targetId) === targetId - }) +): RemoteWorkspaceSession | null { + const isTargetWorktree = (worktreeId: string): boolean => + targetForWorktree(store, worktreeId, authority.targetId) === authority.targetId + const ownerByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { + for (const tab of tabs) { + const owner = ownerByTabId.get(tab.id) + if ( + owner && + owner !== worktreeId && + (isTargetWorktree(owner) || isTargetWorktree(worktreeId)) + ) { + return null + } + ownerByTabId.set(tab.id, worktreeId) + } + } + const ownsPty = (ptyId: string): boolean => + parseAppSshPtyId(ptyId)?.connectionId === authority.targetId + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { + if (!isTargetWorktree(worktreeId)) { + continue + } + for (const tab of tabs) { + if (tab.worktreeId !== worktreeId || (tab.ptyId && !ownsPty(tab.ptyId))) { + return null + } + const layout = session.terminalLayoutsByTabId[tab.id] + if (Object.values(layout?.ptyIdsByLeafId ?? {}).some((ptyId) => !ownsPty(ptyId))) { + return null + } + const remoteSessionId = session.remoteSessionIdsByTabId?.[tab.id] + if (remoteSessionId && !ownsPty(remoteSessionId)) { + return null + } + } + } + return exportRemoteWorkspaceSession(session, { isTargetWorktree }) } function hasWorkspaceTabs(session: WorkspaceSessionState): boolean { @@ -37,19 +73,25 @@ function hasRemoteWorkspaceTabs(session: RemoteWorkspaceSession): boolean { export function exportExplicitSessionForTarget( store: SessionAuthorityStore, - targetId: string, + authority: DirectSshAuthority, session: WorkspaceSessionState ): RemoteWorkspaceSession | null { - const projected = exportSessionForTarget(store, targetId, session) + const projected = exportSessionForTarget(store, authority, session) + if (!projected) { + return null + } if (hasWorkspaceTabs(session) && !hasRemoteWorkspaceTabs(projected)) { return null } const durableSessions = [ store.getWorkspaceSession(), - store.getWorkspaceSession(toSshExecutionHostId(targetId)) + store.getWorkspaceSession(toSshExecutionHostId(authority.targetId)) ] for (const durableSession of durableSessions) { - const durableProjection = exportSessionForTarget(store, targetId, durableSession) + const durableProjection = exportSessionForTarget(store, authority, durableSession) + if (!durableProjection) { + return null + } if ( hasRemoteWorkspaceTabs(durableProjection) && !isDeepStrictEqual(durableProjection, projected) diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index 1c97ad6b724c..ad378e210b16 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -69,8 +69,11 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { const handlers = new Map unknown>() const muxByTargetId = new Map }>() const getRepoMock = vi.fn() + const getWorkspaceSessionMock = vi.fn() + let currentSession = sessionWithTab('unused::/unused', 'unused') const store = { - getRepo: getRepoMock + getRepo: getRepoMock, + getWorkspaceSession: getWorkspaceSessionMock } as unknown as Store const target: SshTarget = { @@ -95,6 +98,18 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { listTargets: () => [target] }) getRepoMock.mockReset() + getWorkspaceSessionMock.mockReset() + getWorkspaceSessionMock.mockImplementation((hostId?: string | null) => + hostId + ? { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + } + : currentSession + ) getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -122,7 +137,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { if (!handler) { throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') } - return handler(null, args) + currentSession = args.session + return handler(null, { hydratedTargetIds: args.hydratedTargetIds }) } it('serializes overlapping writes for the same target so they use fresh base revisions', async () => { diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 67e434724a07..146ff3777070 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -5,16 +5,24 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' +import type { DirectSshAuthority, SshProviderEpoch, SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' const { getActiveMultiplexerMock, getSshConnectionStoreMock, + getSshProviderAuthorityMock, + isCurrentSshProviderAuthorityMock, registerRemoteWorkspaceNotificationHandlerMock } = vi.hoisted(() => ({ getActiveMultiplexerMock: vi.fn(), getSshConnectionStoreMock: vi.fn(), + getSshProviderAuthorityMock: vi.fn((targetId: string) => ({ + targetId, + providerEpoch: `epoch-${targetId}`, + connectionGeneration: 1 + })), + isCurrentSshProviderAuthorityMock: vi.fn(() => true), registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) })) @@ -34,6 +42,11 @@ vi.mock('./remote-workspace-events', () => ({ registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock })) +vi.mock('../ssh/ssh-provider-authority', () => ({ + getSshProviderAuthority: getSshProviderAuthorityMock, + isCurrentSshProviderAuthority: isCurrentSshProviderAuthorityMock +})) + import { _resetRemoteWorkspaceCachesForTests, registerRemoteWorkspaceHandlers, @@ -75,6 +88,14 @@ const targets: SshTarget[] = [ } ] +function authority(targetId: string): DirectSshAuthority { + return { + targetId, + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: 1 + } +} + describe('remoteWorkspaceSessionMatchesSnapshot', () => { it('matches normalized equivalent sessions', () => { expect( @@ -174,6 +195,9 @@ describe('remoteWorkspace:setForConnectedTargets', () => { listTargets: () => targets }) getRepoMock.mockReset() + getSshProviderAuthorityMock.mockClear() + isCurrentSshProviderAuthorityMock.mockReset() + isCurrentSshProviderAuthorityMock.mockReturnValue(true) getWorkspaceSessionMock.mockReset() getWorkspaceSessionMock.mockReturnValue(baseSession) patchWorkspaceSessionMock.mockReset() @@ -228,6 +252,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { async function callSetForConnectedTargets(args: { session?: WorkspaceSessionState sessionTargetId?: string + sessionAuthority?: DirectSshAuthority hydratedTargetIds?: unknown }): Promise { const handler = handlers.get('remoteWorkspace:setForConnectedTargets') @@ -259,10 +284,40 @@ describe('remoteWorkspace:setForConnectedTargets', () => { expect(getActiveMultiplexerMock).not.toHaveBeenCalled() }) + it('rejects missing, conflicting, and stale explicit provider authority', async () => { + await expect( + callSetForConnectedTargets({ + session: baseSession, + sessionTargetId: 'target-1', + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + await expect( + callSetForConnectedTargets({ + session: baseSession, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-2'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + isCurrentSshProviderAuthorityMock.mockReturnValue(false) + await expect( + callSetForConnectedTargets({ + session: baseSession, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + }) + it('writes only to explicitly hydrated connected targets', async () => { const result = await callSetForConnectedTargets({ session: baseSession, sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), hydratedTargetIds: ['target-1', 'missing-target'] }) @@ -288,7 +343,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { { id: 'tab-store', title: 'Store shell', - ptyId: 'pty-store', + ptyId: 'ssh:target-1@@pty-store', worktreeId: 'repo-target-1::/repo' } as never ] @@ -447,13 +502,18 @@ describe('remoteWorkspace:setForConnectedTargets', () => { ...baseSession, tabsByWorktree: { [worktreeId]: [ - { id: 'tab-explicit', title: 'Shell', ptyId: 'pty-durable', worktreeId } as never + { + id: 'tab-explicit', + title: 'Shell', + ptyId: 'ssh:target-1@@pty-durable', + worktreeId + } as never ] }, terminalLayoutsByTabId: { 'tab-explicit': { root: { type: 'leaf', id: 'leaf-1' }, - ptyIdsByLeafId: { 'leaf-1': 'pty-durable' } + ptyIdsByLeafId: { 'leaf-1': 'ssh:target-1@@pty-durable' } } as never } } @@ -474,6 +534,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { } }, sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), hydratedTargetIds: ['target-1'] }) ).resolves.toEqual([]) @@ -488,7 +549,12 @@ describe('remoteWorkspace:setForConnectedTargets', () => { ...baseSession, tabsByWorktree: { [worktreeId]: [ - { id: 'tab-explicit', title: 'Shell', ptyId: 'pty-durable', worktreeId } as never + { + id: 'tab-explicit', + title: 'Shell', + ptyId: 'ssh:target-1@@pty-durable', + worktreeId + } as never ] } } @@ -499,6 +565,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { await callSetForConnectedTargets({ session, sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), hydratedTargetIds: ['target-1'] }) @@ -530,6 +597,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { } }, sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), hydratedTargetIds: ['target-2'] }) ).resolves.toEqual([]) @@ -537,4 +605,47 @@ describe('remoteWorkspace:setForConnectedTargets', () => { expect(getRepoMock).not.toHaveBeenCalled() expect(requestByTargetId.get('target-2')).toBeUndefined() }) + + it('rejects cross-target PTYs and duplicate tab ownership before explicit export', async () => { + const worktreeId = 'repo-target-1::/repo' + const localWorktreeId = 'local::/local' + const targetTab = { + id: 'shared-tab', + title: 'Target A', + ptyId: 'ssh:target-2@@foreign', + worktreeId + } + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { [worktreeId]: [targetTab as never] }, + remoteSessionIdsByTabId: { 'shared-tab': 'ssh:target-2@@foreign' } + }, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + await expect( + callSetForConnectedTargets({ + session: { + ...baseSession, + tabsByWorktree: { + [worktreeId]: [{ ...targetTab, ptyId: 'ssh:target-1@@owned', worktreeId } as never], + [localWorktreeId]: [{ ...targetTab, ptyId: null, worktreeId: localWorktreeId } as never] + } + }, + sessionTargetId: 'target-1', + sessionAuthority: authority('target-1'), + hydratedTargetIds: ['target-1'] + }) + ).resolves.toEqual([]) + + expect(requestByTargetId.get('target-1')).not.toHaveBeenCalledWith( + 'workspace.patch', + expect.anything() + ) + }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index b9e83669da6b..5926d2ddec1b 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -12,9 +12,10 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' +import type { DirectSshAuthority, SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' import { toSshExecutionHostId } from '../../shared/execution-host' +import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import { adoptOrphanedWorkspaceSessionPartition } from '../../shared/workspace-session-partition-adoption' import { findAmbiguousWorkspaceSessionKeys, @@ -26,6 +27,10 @@ import { exportExplicitSessionForTarget, exportSessionForTarget } from './remote-workspace-explicit-session-authority' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority +} from '../ssh/ssh-provider-authority' const CLIENT_ID = randomUUID() const CLIENT_NAME = hostname() || 'This device' @@ -403,6 +408,7 @@ export function registerRemoteWorkspaceHandlers( args: { session?: WorkspaceSessionState sessionTargetId?: unknown + sessionAuthority?: unknown hydratedTargetIds?: unknown } ) => { @@ -416,7 +422,17 @@ export function registerRemoteWorkspaceHandlers( typeof args.sessionTargetId === 'string' && args.sessionTargetId.length > 0 ? args.sessionTargetId : null - if (args.session && (!sessionTargetId || !hydratedTargetIds.has(sessionTargetId))) { + const sessionAuthority = isAdmissibleDirectSshAuthority(args.sessionAuthority) + ? ({ ...args.sessionAuthority } as DirectSshAuthority) + : null + if ( + args.session && + (!sessionTargetId || + !sessionAuthority || + sessionAuthority.targetId !== sessionTargetId || + !hydratedTargetIds.has(sessionTargetId) || + !isCurrentSshProviderAuthority(sessionAuthority)) + ) { return [] } const targets = @@ -434,8 +450,12 @@ export function registerRemoteWorkspaceHandlers( targets.map(async (target) => { // Boot owns persistence; export only overlays stranded SSH state. let session: RemoteWorkspaceSession | null + const authority = args.session ? sessionAuthority : getSshProviderAuthority(target.id) + if (!authority || !isCurrentSshProviderAuthority(authority)) { + return null + } if (args.session) { - session = exportExplicitSessionForTarget(store, target.id, args.session) + session = exportExplicitSessionForTarget(store, authority, args.session) } else { const targetPartition = store.getWorkspaceSession(toSshExecutionHostId(target.id)) const ambiguousKeys = findAmbiguousWorkspaceSessionKeys([ @@ -452,7 +472,7 @@ export function registerRemoteWorkspaceHandlers( } session = exportSessionForTarget( store, - target.id, + authority, adoptOrphanedWorkspaceSessionPartition(fallbackSession, targetPartition).session ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ab3c0c0d0b06..3f3954563190 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -63,6 +63,7 @@ import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { + DirectSshAuthority, SshMutationExpectation, SshConnectionState, SshConfigHostListArgs, @@ -2752,6 +2753,7 @@ export type PreloadApi = { setForConnectedTargets: (args: { session?: WorkspaceSessionState sessionTargetId?: string + sessionAuthority?: DirectSshAuthority hydratedTargetIds?: string[] }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> listEnabledConnectedTargets: () => Promise diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts new file mode 100644 index 000000000000..45f6161e2515 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' +import { + directSshTerminalTabKey, + mergeDirectSshRemoteWorkspaceSession +} from './remote-workspace-session-merge' + +const authority: DirectSshAuthority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 3 +} + +function tab(worktreeId: string, ptyId: string | null, generation: number): TerminalTab { + return { id: 'shared-tab', worktreeId, ptyId, generation } as TerminalTab +} + +function session(tabsByWorktree: WorkspaceSessionState['tabsByWorktree']): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), tabsByWorktree } +} + +describe('mergeDirectSshRemoteWorkspaceSession', () => { + it('selects a local tab by worktree and tab identity instead of a colliding bare id', () => { + const worktreeA = 'repo-a::/work-a' + const worktreeB = 'repo-b::/work-b' + const localA = tab(worktreeA, 'ssh:target-a@@local-a', 9) + const localB = tab(worktreeB, 'ssh:target-b@@local-b', 99) + const current = session({ [worktreeA]: [localA], [worktreeB]: [localB] }) + const remote = session({ + [worktreeA]: [tab(worktreeA, 'ssh:target-a@@remote-a', 1)] + }) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeA, worktreeB]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeA]?.[0]).toMatchObject({ + generation: 9, + ptyId: 'ssh:target-a@@local-a' + }) + expect(merged?.tabsByWorktree[worktreeB]).toBeUndefined() + }) + + it('fails closed when the resulting snapshot collides with another worktree', () => { + const worktreeA = 'repo-a::/work-a' + const worktreeB = 'repo-b::/work-b' + const current = session({ + [worktreeA]: [tab(worktreeA, 'ssh:target-a@@local-a', 2)], + [worktreeB]: [tab(worktreeB, 'ssh:target-b@@local-b', 3)] + }) + const remote = session({ + [worktreeA]: [tab(worktreeA, 'ssh:target-a@@remote-a', 4)] + }) + + expect( + mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeA]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + ).toBeNull() + }) + + it('rejects foreign tab, layout, mounted-leaf, and session PTYs before merge', () => { + const worktreeId = 'repo-a::/work-a' + const local = tab(worktreeId, 'ssh:target-a@@local-a', 9) + const current = session({ [worktreeId]: [local] }) + current.terminalLayoutsByTabId = { + 'shared-tab': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'ssh:target-b@@foreign-layout' } + } + } + const remote = session({ + [worktreeId]: [tab(worktreeId, 'ssh:target-b@@foreign-tab', 1)] + }) + remote.terminalLayoutsByTabId = { + 'shared-tab': { + root: { type: 'leaf', leafId: 'leaf-b' }, + activeLeafId: 'leaf-b', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-b': 'ssh:target-b@@foreign-mounted' } + } + } + remote.remoteSessionIdsByTabId = { 'shared-tab': 'ssh:target-b@@foreign-session' } + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.[0]?.ptyId).toBe('ssh:target-a@@local-a') + expect(merged?.terminalLayoutsByTabId['shared-tab']).toBeUndefined() + expect(merged?.remoteSessionIdsByTabId).toEqual({ + 'shared-tab': 'ssh:target-a@@local-a' + }) + }) + + it('does not retain a foreign pending reconnect under current provider authority', () => { + const worktreeId = 'repo-a::/work-a' + const local = tab(worktreeId, null, 9) + const current = session({ [worktreeId]: [local] }) + const remote = session({ [worktreeId]: [tab(worktreeId, null, 1)] }) + const tabKey = directSshTerminalTabKey(worktreeId, 'shared-tab') + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set([tabKey]), + { 'shared-tab': 'ssh:target-b@@foreign-pending' }, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.[0]).toMatchObject({ generation: 9, ptyId: null }) + expect(merged?.remoteSessionIdsByTabId).toEqual({}) + }) + + it('admits a newer exact-target layout and remote-session bundle', () => { + const worktreeId = 'repo-a::/work-a' + const current = session({ + [worktreeId]: [tab(worktreeId, 'ssh:target-a@@old', 1)] + }) + const remote = session({ + [worktreeId]: [tab(worktreeId, 'ssh:target-a@@new', 8)] + }) + remote.terminalLayoutsByTabId = { + 'shared-tab': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'ssh:target-a@@new' } + } + } + remote.remoteSessionIdsByTabId = { 'shared-tab': 'ssh:target-a@@new' } + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.[0]).toMatchObject({ + generation: 8, + ptyId: 'ssh:target-a@@new' + }) + expect(merged?.terminalLayoutsByTabId['shared-tab']?.ptyIdsByLeafId).toEqual({ + 'leaf-a': 'ssh:target-a@@new' + }) + expect(merged?.remoteSessionIdsByTabId).toEqual({ 'shared-tab': 'ssh:target-a@@new' }) + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 2d3016999606..368e10a76e66 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -1,4 +1,6 @@ import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { splitWorktreeId } from '../../../shared/worktree-id' import type { AppState } from '../store/types' @@ -19,19 +21,85 @@ function preserveNewerLocalTerminalFields( : preserved } +export function directSshTerminalTabKey(worktreeId: string, tabId: string): string { + return JSON.stringify([worktreeId, tabId]) +} + +function isTargetPtyId(ptyId: string | null | undefined, targetId: string): ptyId is string { + return Boolean(ptyId && parseAppSshPtyId(ptyId)?.connectionId === targetId) +} + +function hasAmbiguousResultTabIds( + current: WorkspaceSessionState, + remote: WorkspaceSessionState, + replaceWorktreeIds: ReadonlySet, + targetId: string +): boolean { + const ownerByTabId = new Map() + const addOwner = (worktreeId: string, tab: TerminalTab): boolean => { + const targetOwned = isTargetPtyId(tab.ptyId, targetId) + const tabId = tab.id + const owner = ownerByTabId.get(tabId) + if (owner && owner.worktreeId !== worktreeId && (!owner.targetOwned || !targetOwned)) { + return false + } + ownerByTabId.set(tabId, { worktreeId, targetOwned }) + return true + } + for (const [worktreeId, tabs] of Object.entries(current.tabsByWorktree)) { + if (!replaceWorktreeIds.has(worktreeId)) { + for (const tab of tabs) { + if (!addOwner(worktreeId, tab)) { + return true + } + } + } + } + for (const [worktreeId, tabs] of Object.entries(remote.tabsByWorktree)) { + for (const tab of tabs) { + if (!addOwner(worktreeId, tab)) { + return true + } + } + } + return false +} + +function targetLayout( + session: WorkspaceSessionState, + tabId: string, + targetId: string +): WorkspaceSessionState['terminalLayoutsByTabId'][string] | undefined { + const layout = session.terminalLayoutsByTabId[tabId] + if ( + !layout || + Object.values(layout.ptyIdsByLeafId ?? {}).some((ptyId) => !isTargetPtyId(ptyId, targetId)) + ) { + return undefined + } + return layout +} + function retainedLocalPtyId( current: WorkspaceSessionState, reconnectPtyIdByTabId: Readonly>, - tabId: string + tabId: string, + targetId: string, + allowTabKeyedRecovery: boolean ): string | undefined { - const layout = current.terminalLayoutsByTabId[tabId] + const layout = targetLayout(current, tabId, targetId) const mountedLeafIds = collectLeafIdsInOrder(layout?.root) const primaryLeafId = layout?.activeLeafId && mountedLeafIds.includes(layout.activeLeafId) ? layout.activeLeafId : mountedLeafIds[0] const layoutPtyId = primaryLeafId ? layout?.ptyIdsByLeafId?.[primaryLeafId] : undefined - return reconnectPtyIdByTabId[tabId] ?? layoutPtyId + const reconnectPtyId = allowTabKeyedRecovery ? reconnectPtyIdByTabId[tabId] : undefined + return isTargetPtyId(reconnectPtyId, targetId) + ? reconnectPtyId + : isTargetPtyId(layoutPtyId, targetId) + ? layoutPtyId + : undefined } export function mergeDirectSshRemoteWorkspaceSession( @@ -39,34 +107,56 @@ export function mergeDirectSshRemoteWorkspaceSession( remote: WorkspaceSessionState, replaceWorktreeIds: ReadonlySet, liveTabsByWorktree: AppState['tabsByWorktree'], - preserveLocalTerminalTabIds: ReadonlySet, - reconnectPtyIdByTabId: Readonly> -): WorkspaceSessionState { - const currentTabsById = new Map( - [...replaceWorktreeIds] - .flatMap((worktreeId) => liveTabsByWorktree[worktreeId] ?? []) - .map((tab) => [tab.id, tab]) - ) + preserveLocalTerminalTabKeys: ReadonlySet, + reconnectPtyIdByTabId: Readonly>, + authority: DirectSshAuthority +): WorkspaceSessionState | null { + if (hasAmbiguousResultTabIds(current, remote, replaceWorktreeIds, authority.targetId)) { + return null + } + const { targetId } = authority const locallyPreservedTabIds = new Set() const locallyPreservedWorktreeIds = new Set() + const locallyPreservedPtyByTabId = new Map() const tabsByWorktree = Object.fromEntries( Object.entries(remote.tabsByWorktree).map(([worktreeId, tabs]) => [ worktreeId, tabs.map((tab) => { - const local = currentTabsById.get(tab.id) - const retainedPtyId = retainedLocalPtyId(current, reconnectPtyIdByTabId, tab.id) + const local = (liveTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === tab.id + ) + const tabKey = directSshTerminalTabKey(worktreeId, tab.id) + const hasCurrentAuthority = preserveLocalTerminalTabKeys.has(tabKey) + const retainedPtyId = retainedLocalPtyId( + current, + reconnectPtyIdByTabId, + tab.id, + targetId, + hasCurrentAuthority + ) + const localPtyId = isTargetPtyId(local?.ptyId, targetId) ? local.ptyId : retainedPtyId + const remoteTab = + isTargetPtyId(tab.ptyId, targetId) || !tab.ptyId ? tab : { ...tab, ptyId: null } if ( !local || - ((local.generation ?? 0) <= (tab.generation ?? 0) && - !((local.ptyId || retainedPtyId) && !tab.ptyId) && + (!localPtyId && !hasCurrentAuthority) || + ((local.generation ?? 0) <= (remoteTab.generation ?? 0) && + !(localPtyId && !remoteTab.ptyId) && !local.pendingActivationSpawn && - !preserveLocalTerminalTabIds.has(tab.id)) + !hasCurrentAuthority) ) { - return tab + return remoteTab } locallyPreservedTabIds.add(tab.id) locallyPreservedWorktreeIds.add(worktreeId) - return preserveNewerLocalTerminalFields(tab, local, retainedPtyId) + if (localPtyId) { + locallyPreservedPtyByTabId.set(tab.id, localPtyId) + } + return preserveNewerLocalTerminalFields( + remoteTab, + { ...local, ptyId: localPtyId ?? null }, + retainedPtyId + ) }) ]) ) @@ -86,12 +176,14 @@ export function mergeDirectSshRemoteWorkspaceSession( const terminalLayoutsByTabId = { ...Object.fromEntries( Object.entries(current.terminalLayoutsByTabId).filter( - ([tabId]) => !replacedTabIds.has(tabId) || locallyPreservedTabIds.has(tabId) + ([tabId]) => + !replacedTabIds.has(tabId) || + (locallyPreservedTabIds.has(tabId) && targetLayout(current, tabId, targetId)) ) ), ...Object.fromEntries( Object.entries(remote.terminalLayoutsByTabId).filter( - ([tabId]) => !locallyPreservedTabIds.has(tabId) + ([tabId]) => !locallyPreservedTabIds.has(tabId) && targetLayout(remote, tabId, targetId) ) ) } @@ -128,19 +220,19 @@ export function mergeDirectSshRemoteWorkspaceSession( remoteSessionIdsByTabId: { ...Object.fromEntries( Object.entries(current.remoteSessionIdsByTabId ?? {}).filter( - ([tabId]) => !replacedTabIds.has(tabId) || locallyPreservedTabIds.has(tabId) + ([tabId, ptyId]) => + !replacedTabIds.has(tabId) || + (locallyPreservedTabIds.has(tabId) && isTargetPtyId(ptyId, targetId)) ) ), ...Object.fromEntries( Object.entries(remote.remoteSessionIdsByTabId ?? {}).filter( - ([tabId]) => !locallyPreservedTabIds.has(tabId) + ([tabId, ptyId]) => !locallyPreservedTabIds.has(tabId) && isTargetPtyId(ptyId, targetId) ) ), ...Object.fromEntries( [...locallyPreservedTabIds] - .map( - (tabId) => [tabId, retainedLocalPtyId(current, reconnectPtyIdByTabId, tabId)] as const - ) + .map((tabId) => [tabId, locallyPreservedPtyByTabId.get(tabId)] as const) .filter((entry): entry is readonly [string, string] => Boolean(entry[1])) ) }, diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 4e423dbd258d..165ddb1f860e 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -13,6 +13,7 @@ import { } from './direct-ssh-reconnect-coordinator' import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' import { + directSshTerminalTabKey, mergeDirectSshRemoteWorkspaceSession, uniqueWorktreeIdByPath } from './remote-workspace-session-merge' @@ -50,27 +51,33 @@ function exactTargetWorktreeIds(state: AppState, authority: DirectSshAuthority): }).gitWorktreeIds } -function currentRecoveryTabIds( +function currentRecoveryTabKeys( state: AppState, authority: DirectSshAuthority, worktreeIds: ReadonlySet ): Set { - const targetTabIds = new Set( - [...worktreeIds].flatMap((worktreeId) => - (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - ) - ) - return new Set( + const ownerByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + for (const tab of tabs) { + ownerByTabId.set(tab.id, ownerByTabId.has(tab.id) ? null : worktreeId) + } + } + const recoveryTabIds = new Set( [ ...Object.entries(state.directSshPaneRetryByTabId), ...Object.entries(state.directSshLivePtyBindingByTabId) ] - .filter( - ([tabId, entry]) => - targetTabIds.has(tabId) && directSshAuthoritiesEqual(entry.authority, authority) - ) + .filter(([, entry]) => directSshAuthoritiesEqual(entry.authority, authority)) .map(([tabId]) => tabId) ) + return new Set( + [...recoveryTabIds].flatMap((tabId) => { + const worktreeId = ownerByTabId.get(tabId) + return worktreeId && worktreeIds.has(worktreeId) + ? [directSshTerminalTabKey(worktreeId, tabId)] + : [] + }) + ) } export async function applyDirectSshRemoteWorkspaceSnapshot({ @@ -116,9 +123,23 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ remoteSession, worktreeIds, state.tabsByWorktree, - currentRecoveryTabIds(state, authority, worktreeIds), - state.pendingReconnectPtyIdByTabId ?? {} + currentRecoveryTabKeys(state, authority, worktreeIds), + state.pendingReconnectPtyIdByTabId ?? {}, + authority ) + if (!merged) { + state.setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'conflict', + direction: 'pull', + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + message: translate( + 'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice', + 'Workspace changed on another device' + ) + }) + return + } if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { return } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts index 2664475db9e3..8ad42a6fc437 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts @@ -1,8 +1,7 @@ /** * A direct-SSH snapshot can retain the same tab under old and new worktree IDs - * after a path or repo-ID change. This exercises that hydration path and proves - * active-tab repair converges; it deliberately does not remove the duplicate or - * reproduce React's scheduler-level #185 throw. See PR #11950 for that evidence. + * after a path or repo-ID change. The target-scoped merge must reject the + * ambiguous owner before tab-keyed terminal state can move between worktrees. */ import { describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' @@ -11,7 +10,6 @@ import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-t import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' -import { repairActiveTerminalTab } from '../components/terminal/use-active-terminal-repair' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { @@ -24,8 +22,6 @@ const OLD_PATH = '/srv/proj/wt' const NEW_PATH = '/srv/proj/wt-renamed' const OLD_ID = `repoA::${OLD_PATH}` const NEW_ID = `repoA::${NEW_PATH}` -// Why a cap and not a while(true): on unfixed code this cycle never terminates. -const MAX_REPAIR_PASSES = 200 const authority: DirectSshAuthority = { targetId: TARGET_ID, @@ -62,7 +58,7 @@ function snapshot( [worktreePath]: tabIds.map((tabId, index) => ({ id: tabId, worktreePath, - ptyId: `pty-${tabId}`, + ptyId: `ssh:${TARGET_ID}@@pty-${tabId}`, title: `Terminal ${index + 1}`, customTitle: null, color: null, @@ -73,7 +69,9 @@ function snapshot( terminalLayoutsByTabId: {}, activeWorktreePathsOnShutdown: [], activeTabIdByWorktreePath: { [worktreePath]: activeTabId }, - remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), + remoteSessionIdsByTabId: Object.fromEntries( + tabIds.map((id) => [id, `ssh:${TARGET_ID}@@pty-${id}`]) + ), lastVisitedAtByWorktreePath: { [worktreePath]: revision }, defaultTerminalTabsAppliedByWorktreePath: { [worktreePath]: true } } @@ -116,42 +114,8 @@ function seedCatalog(store: TestStore, worktreePath: string): void { }) } -/** - * One turn of the loop in Terminal.tsx's active-terminal repair effect: - * recompute the repaired id from live state, then activate it. Reports how many - * turns it took to stop and how often `activeTabIdByWorktree` — a declared dep - * of that effect, so a fresh identity re-runs it — was reallocated. - */ -function runRepairCycle(store: TestStore): { - converged: boolean - passes: number - depIdentityChurn: number -} { - let passes = 0 - let depIdentityChurn = 0 - for (; passes < MAX_REPAIR_PASSES; passes += 1) { - const live = store.getState() - const depsBefore = live.activeTabIdByWorktree - const repaired = repairActiveTerminalTab({ - activeTabType: 'terminal', - activeTabId: live.activeTabId, - activeTabIdByWorktree: live.activeTabIdByWorktree, - renderedActiveWorktreeId: live.activeWorktreeId, - setActiveTab: live.setActiveTab, - tabs: live.activeWorktreeId ? (live.tabsByWorktree[live.activeWorktreeId] ?? []) : [] - }) - if (!repaired) { - return { converged: true, passes, depIdentityChurn } - } - if (store.getState().activeTabIdByWorktree !== depsBefore) { - depIdentityChurn += 1 - } - } - return { converged: false, passes, depIdentityChurn } -} - describe('direct-SSH snapshot apply, tab id owned by two worktrees', () => { - it('converges the active-terminal repair instead of re-running it forever', async () => { + it('rejects the second owner before tab-keyed PTY state is contaminated', async () => { const store = createTestStore() store.setState({ @@ -180,8 +144,7 @@ describe('direct-SSH snapshot apply, tab id owned by two worktrees', () => { } store.setState({ ptyIdsByTabId: registered }) }) as never, - markRemoteWorkspaceHydrated: (() => {}) as never, - setRemoteWorkspaceSyncStatus: (() => {}) as never + markRemoteWorkspaceHydrated: (() => {}) as never }) seedCatalog(store, OLD_PATH) @@ -198,27 +161,12 @@ describe('direct-SSH snapshot apply, tab id owned by two worktrees', () => { // cannot find among the imported tabs, which is what arms the repair effect. await applySnapshot(store, snapshot(3, NEW_PATH, ['tab-1', 'tab-2'], null)) - // Why assert the precondition and not its removal: the fix stops the owner - // resolver being fooled by the duplicate, it does not remove the duplicate. - // Pinned so the test cannot pass vacuously if hydration stops producing one. - expect(worktreeIdsOwningTab(store, 'tab-1')).toEqual([OLD_ID, NEW_ID]) + expect(worktreeIdsOwningTab(store, 'tab-1')).toEqual([OLD_ID]) + expect(worktreeIdsOwningTab(store, 'tab-2')).toEqual([OLD_ID]) expect(store.getState().activeTabId).toBeNull() - - const repair = runRepairCycle(store) - - expect(repair.converged).toBe(true) - expect(repair.passes).toBeLessThanOrEqual(store.getState().tabsByWorktree[NEW_ID].length) - expect(store.getState().activeTabId).toBe('tab-1') - const activeGroupId = store.getState().activeGroupIdByWorktree[NEW_ID] - expect( - store.getState().groupsByWorktree[NEW_ID].find((group) => group.id === activeGroupId) - ?.activeTabId - ).toBe('tab-1') - - // The dep identity settles: re-running the effect body after convergence - // reallocates nothing, so the effect does not schedule itself again. - const settled = runRepairCycle(store) - expect(settled.depIdentityChurn).toBe(0) - expect(settled.passes).toBe(0) + expect(store.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ + phase: 'conflict', + revision: 3 + }) }) }) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 79ad3f7c1ecd..003fef488fe9 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -275,7 +275,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreeId: 'repo-a::/remote/work', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 7, pendingActivationSpawn: { requestedAt: 10 } } @@ -285,7 +285,7 @@ describe('createRemoteWorkspaceTargetSync', () => { calls.push('hydrate') expect(session.tabsByWorktree['repo-a::/remote/work'][0]).toMatchObject({ id: 'stable-tab', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 7, pendingActivationSpawn: { requestedAt: 10 } }) @@ -304,7 +304,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreePath: '/remote/work', - ptyId: 'remote-pty', + ptyId: 'ssh:target-a@@remote-pty', generation: 99 } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] ] @@ -337,7 +337,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreeId: 'repo-a::/remote/work', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 7 } ] @@ -346,10 +346,10 @@ describe('createRemoteWorkspaceTargetSync', () => { 'stable-tab': { root: { type: 'leaf', leafId: 'leaf-local' }, activeLeafId: 'leaf-local', - ptyIdsByLeafId: { 'leaf-local': 'local-pty' } + ptyIdsByLeafId: { 'leaf-local': 'ssh:target-a@@local-pty' } } }, - ptyIdsByTabId: { 'stable-tab': ['local-pty'] }, + ptyIdsByTabId: { 'stable-tab': ['ssh:target-a@@local-pty'] }, hydrateTabsSession }) const harness = createHarness(state, async () => null) @@ -358,7 +358,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreePath: '/remote/work', - ptyId: 'old-remote-pty', + ptyId: 'ssh:target-a@@old-remote-pty', generation: 1 } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] ] @@ -368,7 +368,7 @@ describe('createRemoteWorkspaceTargetSync', () => { expect( hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] - ).toMatchObject({ generation: 7, ptyId: 'local-pty' }) + ).toMatchObject({ generation: 7, ptyId: 'ssh:target-a@@local-pty' }) state.tabsByWorktree['repo-a::/remote/work'][0].ptyId = null state.pendingReconnectPtyIdByTabId = {} @@ -389,15 +389,15 @@ describe('createRemoteWorkspaceTargetSync', () => { const merged = hydrateTabsSession.mock.calls[1][0] expect(merged.tabsByWorktree['repo-a::/remote/work'][0]).toMatchObject({ generation: 7, - ptyId: 'local-pty' + ptyId: 'ssh:target-a@@local-pty' }) expect(merged.terminalLayoutsByTabId['stable-tab']).toEqual({ root: { type: 'leaf', leafId: 'leaf-local' }, activeLeafId: 'leaf-local', - ptyIdsByLeafId: { 'leaf-local': 'local-pty' } + ptyIdsByLeafId: { 'leaf-local': 'ssh:target-a@@local-pty' } }) expect(merged.remoteSessionIdsByTabId).toEqual({ - 'stable-tab': 'local-pty' + 'stable-tab': 'ssh:target-a@@local-pty' }) expect(merged.activeWorktreeIdsOnShutdown).toEqual(['repo-a::/remote/work']) }) @@ -410,7 +410,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreeId: 'repo-a::/remote/work', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 1 } ] @@ -423,7 +423,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreePath: '/remote/work', - ptyId: 'new-remote-pty', + ptyId: 'ssh:target-a@@new-remote-pty', generation: 8 } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] ] @@ -433,7 +433,7 @@ describe('createRemoteWorkspaceTargetSync', () => { expect( hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] - ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + ).toMatchObject({ generation: 8, ptyId: 'ssh:target-a@@new-remote-pty' }) }) it('does not preserve recovery evidence from another authority', async () => { @@ -444,7 +444,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreeId: 'repo-a::/remote/work', - ptyId: 'local-pty', + ptyId: 'ssh:target-a@@local-pty', generation: 1 } ] @@ -457,7 +457,7 @@ describe('createRemoteWorkspaceTargetSync', () => { connectionGeneration: 2 }, tabGeneration: 1, - ptyId: 'local-pty' + ptyId: 'ssh:target-a@@local-pty' } }, hydrateTabsSession @@ -468,7 +468,7 @@ describe('createRemoteWorkspaceTargetSync', () => { { id: 'stable-tab', worktreePath: '/remote/work', - ptyId: 'new-remote-pty', + ptyId: 'ssh:target-a@@new-remote-pty', generation: 8 } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] ] @@ -478,7 +478,63 @@ describe('createRemoteWorkspaceTargetSync', () => { expect( hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] - ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + ).toMatchObject({ generation: 8, ptyId: 'ssh:target-a@@new-remote-pty' }) + }) + + it('rejects a target snapshot whose tab id is already owned by another worktree', async () => { + const targetWorktreeId = 'repo-a::/remote/work' + const otherWorktreeId = 'repo-b::/local/work' + const state = appState({ + repos: [ + repo(), + { + id: 'repo-b', + path: '/local', + projectGroupId: null, + connectionId: null, + executionHostId: 'local' + } + ], + worktreesByRepo: { + 'repo-a': [worktree()], + 'repo-b': [{ id: otherWorktreeId, repoId: 'repo-b', hostId: 'local' }] + }, + tabsByWorktree: { + [targetWorktreeId]: [ + { + id: 'shared-tab', + worktreeId: targetWorktreeId, + ptyId: 'ssh:target-a@@local', + generation: 1 + } + ], + [otherWorktreeId]: [ + { id: 'shared-tab', worktreeId: otherWorktreeId, ptyId: 'local-pty', generation: 9 } + ] + } + }) + const harness = createHarness(state, async () => null) + + await harness.sync.applyUnsolicitedSnapshot( + 'target-a', + snapshot(8, { + '/remote/work': [ + { + id: 'shared-tab', + worktreePath: '/remote/work', + ptyId: 'ssh:target-a@@remote', + generation: 2 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + ) + + expect(state.hydrateWorkspaceSession).not.toHaveBeenCalled() + expect(state.hydrateTabsSession).not.toHaveBeenCalled() + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( + 'target-a', + expect.objectContaining({ phase: 'conflict', revision: 8 }) + ) }) it('does not finalize an older snapshot superseded during terminal reattach', async () => { diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 3602a3f7e673..6b76bc884379 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -25,6 +25,7 @@ type RemoteWorkspaceApi = { setForConnectedTargets: (args: { session?: WorkspaceSessionState sessionTargetId?: string + sessionAuthority?: DirectSshAuthority hydratedTargetIds?: string[] }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> } @@ -202,6 +203,7 @@ export function createRemoteWorkspaceTargetSync( const results = await deps.remoteWorkspace.setForConnectedTargets({ session: buildWorkspaceSessionPayload(deps.store.getState()), sessionTargetId: authority.targetId, + sessionAuthority: authority, hydratedTargetIds: [authority.targetId] }) if (!deps.isPreparationTokenCurrent(token)) { From 98a418ac6583659be688fbf50ef309751eb29ac1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 18:04:50 -0400 Subject: [PATCH 09/20] fix(ssh): fence partition tab and queued patch authority --- config/reliability-gates.jsonc | 12 +- .../remote-workspace-patch-authority.test.ts | 204 ++++++++++++++++++ src/main/ipc/remote-workspace.ts | 8 +- src/main/persistence.test.ts | 26 ++- src/main/persistence.ts | 13 +- ...workspace-session-host-persistence.test.ts | 29 +++ ...rkspace-session-ssh-partition-hydration.ts | 18 +- ...rkspace-session-partition-adoption.test.ts | 47 ++++ .../workspace-session-partition-adoption.ts | 12 +- .../workspace-session-partition-authority.ts | 24 +++ 10 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 src/main/ipc/remote-workspace-patch-authority.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index d5987a0d5cf4..8f41f7debe07 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5874,7 +5874,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot merge and explicit export now carry exact target/provider incarnation authority, select local tabs by worktree plus tab, and reject cross-target layouts, mounted leaves, reconnect hints, remote sessions, and ambiguous duplicate ownership before hydration or publication. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, eager six-terminal remount after renderer reload, and two full restarts after a live session is stranded in its SSH partition. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, and WSL remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot merge and explicit export now carry exact target/provider incarnation authority, select local tabs by worktree plus tab, and reject cross-target layouts, mounted leaves, reconnect hints, remote sessions, and ambiguous duplicate ownership before hydration or publication. Durable adoption also rejects git or folder workspace keys that share one bare tab identity, and queued explicit export revalidates target, provider epoch, and connection generation before using a replacement multiplexer. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, eager six-terminal remount after renderer reload, and two full restarts after a live session is stranded in its SSH partition. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, and WSL remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -5887,6 +5887,7 @@ "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-patch-authority.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts src/renderer/src/hooks/remote-workspace-session-merge.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", @@ -5909,6 +5910,7 @@ "src/renderer/src/lib/workspace-session-host-persistence.test.ts", "src/main/ipc/remote-workspace.test.ts", "src/main/ipc/remote-workspace-patch-queue.test.ts", + "src/main/ipc/remote-workspace-patch-authority.test.ts", "src/renderer/src/hooks/remote-workspace-session-merge.test.ts", "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts", @@ -5969,6 +5971,7 @@ "topology revisions select terminal membership authority while each authoritative pane moves its PTY, incarnation, layout fields, and provider session as one bundle", "equal-revision pane conflicts remain unmerged instead of pairing stale PTY identity with newer provider metadata", "folder-only workspaces retain tab groups, layouts, files, and provider-session identity", + "git and folder workspace keys sharing one tab identity preserve both partitions without merging layout, incarnation, or remote-session ownership", "old tombstones cannot retire a newer SSH incarnation and reconciliation is idempotent" ] }, @@ -5997,6 +6000,13 @@ "conflicting local and target partitions skip replacement exports, and explicit sessions require matching target provenance and durable PTY identity" ] }, + { + "file": "src/main/ipc/remote-workspace-patch-authority.test.ts", + "assertions": [ + "an explicit export queued behind a same-target patch revalidates target, provider epoch, and connection generation before replacement multiplexer use", + "stale rejection leaves the per-target queue retryable for a current-authority export" + ] + }, { "file": "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "assertions": [ diff --git a/src/main/ipc/remote-workspace-patch-authority.test.ts b/src/main/ipc/remote-workspace-patch-authority.test.ts new file mode 100644 index 000000000000..948b0239e971 --- /dev/null +++ b/src/main/ipc/remote-workspace-patch-authority.test.ts @@ -0,0 +1,204 @@ +import { ipcMain } from 'electron' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch, SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/types' + +const { + getActiveMultiplexerMock, + getSshConnectionStoreMock, + getSshProviderAuthorityMock, + isCurrentSshProviderAuthorityMock, + registerRemoteWorkspaceNotificationHandlerMock +} = vi.hoisted(() => ({ + getActiveMultiplexerMock: vi.fn(), + getSshConnectionStoreMock: vi.fn(), + getSshProviderAuthorityMock: vi.fn(), + isCurrentSshProviderAuthorityMock: vi.fn(), + registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() } +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock, + getSshConnectionStore: getSshConnectionStoreMock +})) + +vi.mock('./remote-workspace-events', () => ({ + registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock +})) + +vi.mock('../ssh/ssh-provider-authority', () => ({ + getSshProviderAuthority: getSshProviderAuthorityMock, + isCurrentSshProviderAuthority: isCurrentSshProviderAuthorityMock +})) + +import { + _resetRemoteWorkspaceCachesForTests, + registerRemoteWorkspaceHandlers +} from './remote-workspace' + +const TARGET: SshTarget = { + id: 'target-1', + label: 'Target 1', + host: 'one.example.com', + port: 22, + username: 'alice' +} + +function authority(providerEpoch: string, connectionGeneration: number): DirectSshAuthority { + return { + targetId: TARGET.id, + providerEpoch: providerEpoch as SshProviderEpoch, + connectionGeneration + } +} + +function sessionWithTab(worktreePath: string, tabId: string): WorkspaceSessionState { + const worktreeId = `repo-target-1::${worktreePath}` + return { + activeRepoId: 'repo-target-1', + activeWorktreeId: worktreeId, + activeTabId: tabId, + tabsByWorktree: { + [worktreeId]: [{ id: tabId, type: 'terminal', title: tabId, worktreeId } as never] + }, + terminalLayoutsByTabId: {} + } +} + +function snapshot(session: RemoteWorkspaceSession, revision: number): RemoteWorkspaceSnapshot { + return { + namespace: 'target', + revision, + updatedAt: 123, + schemaVersion: 1, + session + } +} + +function emptyRemoteSession(activeWorktreePath: string): RemoteWorkspaceSession { + return { + activeWorktreePath, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } +} + +function patchedSession(params: Record): RemoteWorkspaceSession { + return (params.patch as { session: RemoteWorkspaceSession }).session +} + +describe('remote workspace queued patch authority', () => { + const handlers = new Map unknown>() + let currentAuthority = authority('epoch-1', 1) + let currentSession = sessionWithTab('/first', 'first-tab') + let currentMux: { request: ReturnType } | undefined + + beforeEach(() => { + _resetRemoteWorkspaceCachesForTests() + handlers.clear() + currentAuthority = authority('epoch-1', 1) + currentSession = sessionWithTab('/first', 'first-tab') + currentMux = undefined + vi.mocked(ipcMain.handle).mockReset() + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + vi.mocked(ipcMain.removeHandler).mockReset() + getActiveMultiplexerMock.mockReset() + getActiveMultiplexerMock.mockImplementation(() => currentMux) + getSshConnectionStoreMock.mockReset() + getSshConnectionStoreMock.mockReturnValue({ listTargets: () => [TARGET] }) + getSshProviderAuthorityMock.mockReset() + getSshProviderAuthorityMock.mockImplementation(() => currentAuthority) + isCurrentSshProviderAuthorityMock.mockReset() + isCurrentSshProviderAuthorityMock.mockImplementation( + (candidate: DirectSshAuthority) => + candidate.targetId === currentAuthority.targetId && + candidate.providerEpoch === currentAuthority.providerEpoch && + candidate.connectionGeneration === currentAuthority.connectionGeneration + ) + const store = { + getRepo: vi.fn(() => ({ + id: 'repo-target-1', + path: '/repo', + connectionId: TARGET.id, + executionHostId: `ssh:${TARGET.id}` + })), + getWorkspaceSession: vi.fn(() => currentSession) + } as unknown as Store + registerRemoteWorkspaceHandlers(store, () => null) + }) + + function exportExplicit( + session: WorkspaceSessionState, + sessionAuthority: DirectSshAuthority + ): Promise { + const handler = handlers.get('remoteWorkspace:setForConnectedTargets') + if (!handler) { + throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') + } + currentSession = session + return handler(null, { + session, + sessionTargetId: TARGET.id, + sessionAuthority, + hydratedTargetIds: [TARGET.id] + }) as Promise + } + + it('rejects stale queued authority before replacement mux use and permits retry', async () => { + const originalAuthority = currentAuthority + const replacementAuthority = authority('epoch-2', 2) + let releaseFirstPatch!: () => void + const firstPatchCanFinish = new Promise((resolve) => { + releaseFirstPatch = resolve + }) + const originalRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot(emptyRemoteSession('/previous'), 7) + } + await firstPatchCanFinish + return { ok: true, snapshot: snapshot(patchedSession(params), 8) } + }) + const replacementRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot(emptyRemoteSession('/replacement'), 9) + } + return { ok: true, snapshot: snapshot(patchedSession(params), 10) } + }) + currentMux = { request: originalRequest } + + const first = exportExplicit(sessionWithTab('/first', 'first-tab'), originalAuthority) + await vi.waitFor(() => + expect(originalRequest.mock.calls.some(([method]) => method === 'workspace.patch')).toBe(true) + ) + const stale = exportExplicit(sessionWithTab('/stale', 'stale-tab'), originalAuthority) + await new Promise((resolve) => setTimeout(resolve, 0)) + + currentAuthority = replacementAuthority + currentMux = { request: replacementRequest } + releaseFirstPatch() + + await expect(first).resolves.toMatchObject([{ targetId: TARGET.id, result: { ok: true } }]) + await expect(stale).resolves.toEqual([]) + expect(replacementRequest).not.toHaveBeenCalled() + + await expect( + exportExplicit(sessionWithTab('/current', 'current-tab'), replacementAuthority) + ).resolves.toMatchObject([{ targetId: TARGET.id, result: { ok: true } }]) + expect(replacementRequest).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ patch: expect.objectContaining({ kind: 'replace-session' }) }) + ) + }) +}) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 5926d2ddec1b..9b9ed93a6c74 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -277,8 +277,12 @@ async function queueRemoteWorkspacePatch( async function patchRemoteWorkspaceSession( target: SshTarget, - session: RemoteWorkspaceSession + session: RemoteWorkspaceSession, + authority: DirectSshAuthority ): Promise { + if (authority.targetId !== target.id || !isCurrentSshProviderAuthority(authority)) { + return null + } const mux = getActiveMultiplexer(target.id) if (!mux) { return null @@ -482,7 +486,7 @@ export function registerRemoteWorkspaceHandlers( // Why: each target has its own revision stream. Keep same-target // writes queued, but do not let one slow relay block others. const result = await queueRemoteWorkspacePatch(target.id, () => - patchRemoteWorkspaceSession(target, session) + patchRemoteWorkspaceSession(target, session, authority) ) return result ? { targetId: target.id, result } : null }) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 618438d87428..af983602bedf 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -11913,11 +11913,14 @@ describe('Store host-partitioned workspace sessions', () => { [folderKey]: [ { ...boundFolderSession.tabsByWorktree['repo-1::/worktree'][0], + id: 'folder-tab', worktreeId: folderKey } ] } - folderSession.terminalLayoutsByTabId = boundFolderSession.terminalLayoutsByTabId + folderSession.terminalLayoutsByTabId = { + 'folder-tab': boundFolderSession.terminalLayoutsByTabId['tab-1'] + } folderSession.openFilesByWorktree = { [folderKey]: [ { @@ -11959,6 +11962,27 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) }) + it('preserves SSH sources whose distinct workspaces reuse one tab identity', async () => { + const store = await createStore() + const left = getDefaultWorkspaceSession() + const right = getDefaultWorkspaceSession() + left.tabsByWorktree = { + 'repo-1::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-1::/worktree' })] + } + right.tabsByWorktree = { + 'repo-2::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-2::/worktree' })] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + it('keeps non-empty local state and unequal-revision duplicate SSH sources partitioned', async () => { const store = await createStore() const left = makeBoundHostSession('ssh:left@@left-pty') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 58d388e8f64d..0d632fb3d738 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -136,7 +136,10 @@ import { } from '../shared/constants' import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' import { adoptOrphanedWorkspaceSessionPartition } from '../shared/workspace-session-partition-adoption' -import { findCrossHostWorkspaceSessionKeyCollisions } from '../shared/workspace-session-partition-authority' +import { + findCrossHostWorkspaceSessionKeyCollisions, + findWorkspaceTabIdOwnerCollisions +} from '../shared/workspace-session-partition-authority' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -6305,9 +6308,11 @@ export class Store { return originalOwner } - const preservedWorkspaceKeys = findCrossHostWorkspaceSessionKeyCollisions( - sshPartitions.map(([, source]) => source) - ) + const sources = sshPartitions.map(([, source]) => source) + const preservedWorkspaceKeys = new Set([ + ...findCrossHostWorkspaceSessionKeyCollisions(sources), + ...findWorkspaceTabIdOwnerCollisions([originalOwner, ...sources]) + ]) const prunableHostIds = new Set() let owner = originalOwner for (const [partitionHostId, source] of sshPartitions) { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index 32e2aae4fc9c..434eb20d3d29 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -284,6 +284,35 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(read.session.tabsByWorktree[worktreeId]).toEqual([liveTab]) }) + it('keeps legacy SSH partitions isolated when distinct workspaces reuse a tab identity', async () => { + const leftKey = 'left::/home/user/left' + const rightKey = folderWorkspaceKey('right-folder') + const sharedTabId = 'shared-tab' + const repos = [ + { id: 'left', connectionId: 'conn-1', executionHostId: null }, + { id: 'right', connectionId: 'conn-2', executionHostId: null } + ] + const get = vi.fn(async (hostId?: string): Promise => { + if (hostId === 'ssh:conn-1') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [leftKey]: [sessionTab(sharedTabId, leftKey)] } + } + } + if (hostId === 'ssh:conn-2') { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [rightKey]: [sessionTab(sharedTabId, rightKey)] } + } + } + return getDefaultWorkspaceSession() + }) + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, repos) + + expect(read.session.tabsByWorktree).toEqual({}) + }) + it('delegates persisted SSH partition enumeration to one main transaction', async () => { const worktreeA = 'repo-a::/home/user/wt-a' const worktreeB = 'repo-b::/home/user/wt-b' diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts index 5699730a9e7e..57d57e8a5958 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-hydration.ts @@ -5,6 +5,10 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { adoptOrphanedWorkspaceSessionPartition } from '../../../shared/workspace-session-partition-adoption' +import { + findCrossHostWorkspaceSessionKeyCollisions, + findWorkspaceTabIdOwnerCollisions +} from '../../../shared/workspace-session-partition-authority' type SshPartitionHydrationApi = { get: (hostId?: ExecutionHostId) => Promise @@ -30,13 +34,23 @@ export async function adoptStrandedSshHostPartitions( repos: readonly Pick[], merged: WorkspaceSessionState ): Promise { - let session = merged + const sources: WorkspaceSessionState[] = [] for (const hostId of listKnownSshHostIds(repos)) { try { - session = adoptOrphanedWorkspaceSessionPartition(session, await api.get(hostId)).session + sources.push(await api.get(hostId)) } catch (err) { console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) } } + const preserveWorkspaceKeys = new Set([ + ...findCrossHostWorkspaceSessionKeyCollisions(sources), + ...findWorkspaceTabIdOwnerCollisions([merged, ...sources]) + ]) + let session = merged + for (const source of sources) { + session = adoptOrphanedWorkspaceSessionPartition(session, source, { + preserveWorkspaceKeys + }).session + } return session } diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index 7844934f92aa..28f07a99d638 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -36,6 +36,53 @@ function layout(ptyId: string | null): TerminalLayoutSnapshot { } describe('adoptOrphanedWorkspaceSessionPartition', () => { + it.each([ + ['git worktrees', 'repo-a::/srv/a', 'repo-b::/srv/b'], + ['folder workspaces', folderWorkspaceKey('folder-a'), folderWorkspaceKey('folder-b')] + ])('fails closed when %s reuse one tab identity', (_kind, baseKey, sourceKey) => { + const tabId = 'duplicate-tab' + const paneKey = `${tabId}:${LEAF_ID}` + const base = session({ + tabsByWorktree: { [baseKey]: [tab(tabId, baseKey, 'local-pty')] }, + terminalLayoutsByTabId: { [tabId]: layout('local-pty') }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'local-incarnation' }, + remoteSessionIdsByTabId: { [tabId]: 'local-session' } + }) + const source = session({ + tabsByWorktree: { [sourceKey]: [tab(tabId, sourceKey, 'ssh:one@@remote-pty')] }, + terminalLayoutsByTabId: { [tabId]: layout('ssh:one@@remote-pty') }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'remote-incarnation' }, + remoteSessionIdsByTabId: { [tabId]: 'ssh:one@@remote-session' }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId, + worktreeId: sourceKey, + agent: 'codex', + providerSession: { key: 'session_id', id: 'remote-provider-session' }, + prompt: 'remote', + state: 'working', + capturedAt: 2, + updatedAt: 2 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(new Set(adoption.ambiguousWorktreeIds)).toEqual(new Set([baseKey, sourceKey])) + expect(adoption.reconciledWorktreeIds).toEqual([]) + expect(adoption.sourceAuthoritativePaneKeys).toEqual([]) + expect(adoption.session.tabsByWorktree[baseKey]?.[0]?.ptyId).toBe('local-pty') + expect(adoption.session.tabsByWorktree[sourceKey]).toBeUndefined() + expect(adoption.session.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[LEAF_ID]).toBe( + 'local-pty' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe('local-incarnation') + expect(adoption.session.remoteSessionIdsByTabId?.[tabId]).toBe('local-session') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[paneKey]).toBeUndefined() + }) + it('adopts complete folder-only state without a repository entry', () => { const source = session({ activeWorkspaceKey: FOLDER_KEY, diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index a928fe0a1b24..24aa4c4708e3 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -1,5 +1,8 @@ import type { WorkspaceSessionState } from './types' -import { workspaceTerminalAuthority } from './workspace-session-partition-authority' +import { + findWorkspaceTabIdOwnerCollisions, + workspaceTerminalAuthority +} from './workspace-session-partition-authority' import { mergeAdoptedWorkspaceSessionState } from './workspace-session-partition-state-merge' import { collectTerminalProvenance, @@ -48,12 +51,13 @@ export function adoptOrphanedWorkspaceSessionPartition( }) ]) ) - const ambiguousWorktreeIds = new Set( - [...sourceKeys].filter( + const ambiguousWorktreeIds = new Set([ + ...findWorkspaceTabIdOwnerCollisions([base, source]), + ...[...sourceKeys].filter( (key) => options.preserveWorkspaceKeys?.has(key) || authorityByWorkspaceKey.get(key) === 'ambiguous' ) - ) + ]) const sourceAuthority = new Set( [...sourceKeys].filter( (key) => !ambiguousWorktreeIds.has(key) && authorityByWorkspaceKey.get(key) === 'source' diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts index 55476152b0d0..9f4d99024a5d 100644 --- a/src/shared/workspace-session-partition-authority.ts +++ b/src/shared/workspace-session-partition-authority.ts @@ -147,3 +147,27 @@ export function findCrossHostWorkspaceSessionKeyCollisions( } return collisions } + +export function findWorkspaceTabIdOwnerCollisions( + sources: readonly WorkspaceSessionState[] +): Set { + const ownersByTabId = new Map>() + for (const source of sources) { + for (const [workspaceKey, tabs] of Object.entries(source.tabsByWorktree)) { + for (const tab of tabs) { + const owners = ownersByTabId.get(tab.id) ?? new Set() + owners.add(workspaceKey) + ownersByTabId.set(tab.id, owners) + } + } + } + const collisions = new Set() + for (const owners of ownersByTabId.values()) { + if (owners.size > 1) { + for (const owner of owners) { + collisions.add(owner) + } + } + } + return collisions +} From b0785b0cd5b02c60123a62e63321b9bd5059260b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 10 Aug 2026 19:27:17 -0400 Subject: [PATCH 10/20] fix(ssh): fence snapshot authority and index adoption --- config/reliability-gates.jsonc | 19 +- src/main/ipc/remote-workspace-cache.test.ts | 44 +++- src/main/ipc/remote-workspace-events.ts | 10 +- .../remote-workspace-patch-authority.test.ts | 40 ++++ src/main/ipc/remote-workspace.test.ts | 60 +++++- src/main/ipc/remote-workspace.ts | 129 +++++++++--- src/main/ssh/ssh-relay-session.ts | 4 +- .../remote-workspace-session-merge.test.ts | 26 +++ .../hooks/remote-workspace-session-merge.ts | 12 +- ...rkspace-session-partition-adoption.test.ts | 35 ++++ .../workspace-session-partition-adoption.ts | 20 +- .../workspace-session-partition-authority.ts | 196 ++++++++++++++---- 12 files changed, 491 insertions(+), 104 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 8f41f7debe07..2862ced659b7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5887,7 +5887,7 @@ "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-patch-authority.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-patch-authority.test.ts src/main/ipc/remote-workspace-cache.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts src/renderer/src/hooks/remote-workspace-session-merge.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", @@ -5911,6 +5911,7 @@ "src/main/ipc/remote-workspace.test.ts", "src/main/ipc/remote-workspace-patch-queue.test.ts", "src/main/ipc/remote-workspace-patch-authority.test.ts", + "src/main/ipc/remote-workspace-cache.test.ts", "src/renderer/src/hooks/remote-workspace-session-merge.test.ts", "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts", @@ -5972,6 +5973,7 @@ "equal-revision pane conflicts remain unmerged instead of pairing stale PTY identity with newer provider metadata", "folder-only workspaces retain tab groups, layouts, files, and provider-session identity", "git and folder workspace keys sharing one tab identity preserve both partitions without merging layout, incarnation, or remote-session ownership", + "equivalent 1000- and 2000-workspace partitions each enumerate global layouts exactly three times", "old tombstones cannot retire a newer SSH incarnation and reconciliation is idempotent" ] }, @@ -5997,14 +5999,22 @@ "file": "src/main/ipc/remote-workspace.test.ts", "assertions": [ "duplicate repository ids resolve through the exact SSH target host and export only that target's adopted state", - "conflicting local and target partitions skip replacement exports, and explicit sessions require matching target provenance and durable PTY identity" + "conflicting local and target partitions skip replacement exports, and explicit sessions require matching target provenance and durable PTY identity", + "late workspace notifications from a replaced provider cannot enter the current provider's snapshot cache" ] }, { "file": "src/main/ipc/remote-workspace-patch-authority.test.ts", "assertions": [ "an explicit export queued behind a same-target patch revalidates target, provider epoch, and connection generation before replacement multiplexer use", - "stale rejection leaves the per-target queue retryable for a current-authority export" + "stale rejection leaves the per-target queue retryable for a current-authority export", + "a late old-provider snapshot response cannot poison the replacement provider's retry revision" + ] + }, + { + "file": "src/main/ipc/remote-workspace-cache.test.ts", + "assertions": [ + "snapshot cache entries are visible only to the exact target, provider epoch, and connection generation that produced them" ] }, { @@ -6018,7 +6028,8 @@ "assertions": [ "local retention is keyed by worktree plus tab plus exact DirectSshAuthority instead of a bare tab id", "foreign target tab, layout, mounted-leaf, pending-reconnect, and remote-session PTYs are rejected before merge", - "duplicate ownership across worktrees fails closed while exact-target equal and unequal generations preserve coherent PTY bundles" + "duplicate ownership across git or folder workspaces fails closed even when both PTYs belong to the same target", + "exact-target equal and unequal generations preserve coherent PTY bundles" ] }, { diff --git a/src/main/ipc/remote-workspace-cache.test.ts b/src/main/ipc/remote-workspace-cache.test.ts index 3965ba9ebeca..0dead3cf0ad0 100644 --- a/src/main/ipc/remote-workspace-cache.test.ts +++ b/src/main/ipc/remote-workspace-cache.test.ts @@ -3,6 +3,7 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../shared/ssh-types' import { REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES, _getRemoteWorkspaceCacheSizesForTests, @@ -30,6 +31,14 @@ function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspac } } +function authority(targetId: string): DirectSshAuthority { + return { + targetId, + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: 1 + } +} + describe('remote workspace snapshot cache', () => { beforeEach(() => { _resetRemoteWorkspaceCachesForTests() @@ -38,7 +47,7 @@ describe('remote workspace snapshot cache', () => { it('LRU-evicts old target snapshots', () => { for (let i = 0; i <= REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { _rememberRemoteWorkspaceSnapshotForTests( - `target-${i}`, + authority(`target-${i}`), snapshot({ activeWorktreePath: `/repo-${i}`, activeTabId: null, @@ -51,24 +60,39 @@ describe('remote workspace snapshot cache', () => { expect(_getRemoteWorkspaceCacheSizesForTests().snapshots).toBe( REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES ) - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeUndefined() - expect(_getRemoteWorkspaceSnapshotForTests('target-1')?.session.activeWorktreePath).toBe( - '/repo-1' - ) + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeUndefined() + expect( + _getRemoteWorkspaceSnapshotForTests(authority('target-1'))?.session.activeWorktreePath + ).toBe('/repo-1') }) it('refreshes snapshot recency on cache reads', () => { for (let i = 0; i < REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { _rememberRemoteWorkspaceSnapshotForTests( - `target-${i}`, + authority(`target-${i}`), snapshot(emptyRemoteWorkspaceSession()) ) } - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() - _rememberRemoteWorkspaceSnapshotForTests('target-new', snapshot(emptyRemoteWorkspaceSession())) + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeDefined() + _rememberRemoteWorkspaceSnapshotForTests( + authority('target-new'), + snapshot(emptyRemoteWorkspaceSession()) + ) + + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeDefined() + expect(_getRemoteWorkspaceSnapshotForTests(authority('target-1'))).toBeUndefined() + }) + + it('does not return a snapshot across provider authority rotation', () => { + const original = authority('target-1') + const replacement = { + ...original, + providerEpoch: 'epoch-replacement' as SshProviderEpoch, + connectionGeneration: 2 + } + _rememberRemoteWorkspaceSnapshotForTests(original, snapshot(emptyRemoteWorkspaceSession(), 99)) - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() - expect(_getRemoteWorkspaceSnapshotForTests('target-1')).toBeUndefined() + expect(_getRemoteWorkspaceSnapshotForTests(replacement)).toBeUndefined() }) }) diff --git a/src/main/ipc/remote-workspace-events.ts b/src/main/ipc/remote-workspace-events.ts index cd7ae2b7c459..659808c0e615 100644 --- a/src/main/ipc/remote-workspace-events.ts +++ b/src/main/ipc/remote-workspace-events.ts @@ -1,7 +1,10 @@ +import type { DirectSshAuthority } from '../../shared/ssh-types' + type RemoteWorkspaceNotificationHandler = ( targetId: string, method: string, - params: Record + params: Record, + authority: DirectSshAuthority ) => void const handlers = new Set() @@ -16,9 +19,10 @@ export function registerRemoteWorkspaceNotificationHandler( export function notifyRemoteWorkspaceHandlers( targetId: string, method: string, - params: Record + params: Record, + authority: DirectSshAuthority ): void { for (const handler of handlers) { - handler(targetId, method, params) + handler(targetId, method, params, authority) } } diff --git a/src/main/ipc/remote-workspace-patch-authority.test.ts b/src/main/ipc/remote-workspace-patch-authority.test.ts index 948b0239e971..2ded774aa3eb 100644 --- a/src/main/ipc/remote-workspace-patch-authority.test.ts +++ b/src/main/ipc/remote-workspace-patch-authority.test.ts @@ -201,4 +201,44 @@ describe('remote workspace queued patch authority', () => { expect.objectContaining({ patch: expect.objectContaining({ kind: 'replace-session' }) }) ) }) + + it('rejects a late snapshot from a replaced provider without poisoning its retry cache', async () => { + const originalAuthority = currentAuthority + const replacementAuthority = authority('epoch-2', 2) + let releaseOriginalSnapshot!: () => void + const originalSnapshotCanFinish = new Promise((resolve) => { + releaseOriginalSnapshot = resolve + }) + const originalRequest = vi.fn(async (method: string) => { + if (method === 'workspace.get') { + await originalSnapshotCanFinish + return snapshot(emptyRemoteSession('/stale-provider'), 99) + } + return { ok: false, reason: 'unavailable', message: 'provider replaced' } + }) + const replacementPatchRevisions: number[] = [] + const replacementRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot(emptyRemoteSession('/replacement'), 4) + } + replacementPatchRevisions.push(params.baseRevision as number) + return { ok: true, snapshot: snapshot(patchedSession(params), 5) } + }) + currentMux = { request: originalRequest } + + const stale = exportExplicit(sessionWithTab('/stale', 'stale-tab'), originalAuthority) + await vi.waitFor(() => + expect(originalRequest).toHaveBeenCalledWith('workspace.get', expect.anything()) + ) + currentAuthority = replacementAuthority + currentMux = { request: replacementRequest } + releaseOriginalSnapshot() + + await expect(stale).resolves.toEqual([]) + await expect( + exportExplicit(sessionWithTab('/current', 'current-tab'), replacementAuthority) + ).resolves.toMatchObject([{ targetId: TARGET.id, result: { ok: true } }]) + expect(replacementRequest).toHaveBeenCalledWith('workspace.get', expect.anything()) + expect(replacementPatchRevisions).toEqual([4]) + }) }) diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 146ff3777070..a06246e38c3b 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -22,8 +22,17 @@ const { providerEpoch: `epoch-${targetId}`, connectionGeneration: 1 })), - isCurrentSshProviderAuthorityMock: vi.fn(() => true), - registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) + isCurrentSshProviderAuthorityMock: vi.fn((_candidate: DirectSshAuthority) => true), + registerRemoteWorkspaceNotificationHandlerMock: vi.fn( + ( + _handler: ( + targetId: string, + method: string, + params: Record, + authority: DirectSshAuthority + ) => void + ) => vi.fn() + ) })) vi.mock('electron', () => ({ @@ -48,6 +57,7 @@ vi.mock('../ssh/ssh-provider-authority', () => ({ })) import { + _getRemoteWorkspaceSnapshotForTests, _resetRemoteWorkspaceCachesForTests, registerRemoteWorkspaceHandlers, remoteWorkspaceSessionMatchesSnapshot @@ -63,6 +73,15 @@ function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspac } } +function emptyRemoteSession(): RemoteWorkspaceSession { + return { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } +} + const baseSession = { activeRepoId: null, activeWorktreeId: null, @@ -275,6 +294,43 @@ describe('remoteWorkspace:setForConnectedTargets', () => { expect(getActiveMultiplexerMock).not.toHaveBeenCalled() }) + it('rejects a late workspace notification from replaced provider authority', () => { + const currentAuthority = authority('target-1') + const staleAuthority = { + ...currentAuthority, + providerEpoch: 'stale-epoch' as SshProviderEpoch, + connectionGeneration: 0 + } + getSshConnectionStoreMock.mockReturnValue({ + listTargets: () => targets, + getTarget: (targetId: string) => targets.find((target) => target.id === targetId) + }) + isCurrentSshProviderAuthorityMock.mockImplementation( + (candidate: DirectSshAuthority) => + candidate.providerEpoch === currentAuthority.providerEpoch && + candidate.connectionGeneration === currentAuthority.connectionGeneration + ) + const notificationHandler = + registerRemoteWorkspaceNotificationHandlerMock.mock.calls.at(-1)?.[0] + expect(notificationHandler).toBeTypeOf('function') + + notificationHandler?.( + 'target-1', + 'workspace.changed', + { snapshot: snapshot({ ...emptyRemoteSession(), activeWorktreePath: '/stale' }, 99) }, + staleAuthority + ) + expect(_getRemoteWorkspaceSnapshotForTests(currentAuthority)).toBeUndefined() + + notificationHandler?.( + 'target-1', + 'workspace.changed', + { snapshot: snapshot({ ...emptyRemoteSession(), activeWorktreePath: '/current' }, 4) }, + currentAuthority + ) + expect(_getRemoteWorkspaceSnapshotForTests(currentAuthority)?.revision).toBe(4) + }) + it('does not export an explicit session without target provenance', async () => { await expect( callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 9b9ed93a6c74..acb0749695a9 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -17,10 +17,7 @@ import type { WorkspaceSessionState } from '../../shared/types' import { toSshExecutionHostId } from '../../shared/execution-host' import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import { adoptOrphanedWorkspaceSessionPartition } from '../../shared/workspace-session-partition-adoption' -import { - findAmbiguousWorkspaceSessionKeys, - workspaceSessionBundlesEquivalent -} from '../../shared/workspace-session-partition-authority' +import { findAmbiguousWorkspaceSessionKeys } from '../../shared/workspace-session-partition-authority' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' import { @@ -38,18 +35,27 @@ const SNAPSHOT_SCHEMA_VERSION = 1 export const REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES = 64 let mainWindowGetter: (() => BrowserWindow | null) | null = null -const latestSnapshotByTargetId = new Map() +type RemoteWorkspaceSnapshotCacheEntry = { + authority: DirectSshAuthority + snapshot: RemoteWorkspaceSnapshot +} + +const latestSnapshotByTargetId = new Map() const remoteWorkspacePatchTailByTargetId = new Map>() let unregisterRemoteWorkspaceNotifications: (() => void) | null = null function rememberRemoteWorkspaceSnapshot( - targetId: string, + authority: DirectSshAuthority, snapshot: RemoteWorkspaceSnapshot ): void { + if (!isCurrentSshProviderAuthority(authority)) { + return + } + const targetId = authority.targetId if (latestSnapshotByTargetId.has(targetId)) { latestSnapshotByTargetId.delete(targetId) } - latestSnapshotByTargetId.set(targetId, snapshot) + latestSnapshotByTargetId.set(targetId, { authority: { ...authority }, snapshot }) while (latestSnapshotByTargetId.size > REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES) { const oldest = latestSnapshotByTargetId.keys().next() if (oldest.done) { @@ -59,15 +65,30 @@ function rememberRemoteWorkspaceSnapshot( } } -function getCachedRemoteWorkspaceSnapshot(targetId: string): RemoteWorkspaceSnapshot | undefined { - const snapshot = latestSnapshotByTargetId.get(targetId) - if (!snapshot) { +function authoritiesEqual(left: DirectSshAuthority, right: DirectSshAuthority): boolean { + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +function getCachedRemoteWorkspaceSnapshot( + authority: DirectSshAuthority +): RemoteWorkspaceSnapshot | undefined { + const entry = latestSnapshotByTargetId.get(authority.targetId) + if (!entry) { + return undefined + } + if (!authoritiesEqual(entry.authority, authority) || !isCurrentSshProviderAuthority(authority)) { + latestSnapshotByTargetId.delete(authority.targetId) return undefined } // Why: remote workspace snapshots can contain the whole tab/layout session // for a target. Touch cache hits so deleted or rarely used targets age out. - rememberRemoteWorkspaceSnapshot(targetId, snapshot) - return snapshot + latestSnapshotByTargetId.delete(authority.targetId) + latestSnapshotByTargetId.set(authority.targetId, entry) + return entry.snapshot } export function _resetRemoteWorkspaceCachesForTests(): void { @@ -87,17 +108,32 @@ export function _getRemoteWorkspaceCacheSizesForTests(): { /** @internal - exposed for cache-bound tests only. */ export function _rememberRemoteWorkspaceSnapshotForTests( - targetId: string, + authority: DirectSshAuthority, snapshot: RemoteWorkspaceSnapshot ): void { - rememberRemoteWorkspaceSnapshot(targetId, snapshot) + const targetId = authority.targetId + latestSnapshotByTargetId.delete(targetId) + latestSnapshotByTargetId.set(targetId, { authority: { ...authority }, snapshot }) + while (latestSnapshotByTargetId.size > REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldest = latestSnapshotByTargetId.keys().next() + if (oldest.done) { + break + } + latestSnapshotByTargetId.delete(oldest.value) + } } /** @internal - exposed for cache-bound tests only. */ export function _getRemoteWorkspaceSnapshotForTests( - targetId: string + authority: DirectSshAuthority ): RemoteWorkspaceSnapshot | undefined { - return getCachedRemoteWorkspaceSnapshot(targetId) + const entry = latestSnapshotByTargetId.get(authority.targetId) + if (!entry || !authoritiesEqual(entry.authority, authority)) { + return undefined + } + latestSnapshotByTargetId.delete(authority.targetId) + latestSnapshotByTargetId.set(authority.targetId, entry) + return entry.snapshot } function emptyRemoteSession(): RemoteWorkspaceSession { @@ -233,18 +269,27 @@ function getExplicitHydratedTargetIds(value: unknown): Set | null { return new Set(value) } -async function getRemoteSnapshot(target: SshTarget): Promise { +async function getRemoteSnapshot( + target: SshTarget, + authority: DirectSshAuthority +): Promise { const mux = getActiveMultiplexer(target.id) - if (!mux) { + if (!mux || authority.targetId !== target.id || !isCurrentSshProviderAuthority(authority)) { return null } const namespace = getRemoteWorkspaceNamespace(target) try { const raw = await mux.request('workspace.get', { namespace }) + if (!isCurrentSshProviderAuthority(authority)) { + return null + } const snapshot = normalizeSnapshot(raw, namespace) - rememberRemoteWorkspaceSnapshot(target.id, snapshot) + rememberRemoteWorkspaceSnapshot(authority, snapshot) return snapshot } catch (err) { + if (!isCurrentSshProviderAuthority(authority)) { + return null + } if ((err as { code?: unknown })?.code === -32601) { return null } @@ -289,7 +334,12 @@ async function patchRemoteWorkspaceSession( } const namespace = getRemoteWorkspaceNamespace(target) const current = - getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) ?? undefined + getCachedRemoteWorkspaceSnapshot(authority) ?? + (await getRemoteSnapshot(target, authority)) ?? + undefined + if (!isCurrentSshProviderAuthority(authority)) { + return null + } if (current && remoteWorkspaceSessionMatchesSnapshot(current, session)) { // Why: a pulled workspace snapshot rehydrates local state and can trigger // session persistence. Identical target sessions must stay a local no-op or @@ -299,7 +349,10 @@ async function patchRemoteWorkspaceSession( const requestPatch = async ( baseRevision: number | undefined - ): Promise => { + ): Promise => { + if (!isCurrentSshProviderAuthority(authority)) { + return null + } try { return (await mux.request('workspace.patch', { namespace, @@ -308,6 +361,9 @@ async function patchRemoteWorkspaceSession( patch: { kind: 'replace-session', session } })) as RemoteWorkspacePatchResult } catch (err) { + if (!isCurrentSshProviderAuthority(authority)) { + return null + } return (err as { code?: unknown })?.code === -32601 ? { ok: false, @@ -323,12 +379,15 @@ async function patchRemoteWorkspaceSession( } const result = await requestPatch(current?.revision) + if (!result) { + return null + } if (result.ok) { - rememberRemoteWorkspaceSnapshot(target.id, result.snapshot) + rememberRemoteWorkspaceSnapshot(authority, result.snapshot) return result } if (result.snapshot) { - rememberRemoteWorkspaceSnapshot(target.id, result.snapshot) + rememberRemoteWorkspaceSnapshot(authority, result.snapshot) } if ( @@ -345,10 +404,13 @@ async function patchRemoteWorkspaceSession( // only for backwards revisions restores the blank-slate target without // overwriting a newer snapshot from another device. const retry = await requestPatch(result.snapshot.revision) + if (!retry) { + return null + } if (retry.ok) { - rememberRemoteWorkspaceSnapshot(target.id, retry.snapshot) + rememberRemoteWorkspaceSnapshot(authority, retry.snapshot) } else if (retry.snapshot) { - rememberRemoteWorkspaceSnapshot(target.id, retry.snapshot) + rememberRemoteWorkspaceSnapshot(authority, retry.snapshot) } return retry } @@ -359,9 +421,14 @@ async function patchRemoteWorkspaceSession( export function handleRemoteWorkspaceNotification( targetId: string, method: string, - params: Record + params: Record, + authority: DirectSshAuthority ): void { - if (method !== 'workspace.changed') { + if ( + method !== 'workspace.changed' || + authority.targetId !== targetId || + !isCurrentSshProviderAuthority(authority) + ) { return } const target = getSshConnectionStore()?.getTarget(targetId) @@ -370,7 +437,7 @@ export function handleRemoteWorkspaceNotification( } const namespace = getRemoteWorkspaceNamespace(target) const snapshot = normalizeSnapshot(params.snapshot, namespace) - rememberRemoteWorkspaceSnapshot(targetId, snapshot) + rememberRemoteWorkspaceSnapshot(authority, snapshot) const event: RemoteWorkspaceChangedEvent = { targetId, snapshot, @@ -402,7 +469,7 @@ export function registerRemoteWorkspaceHandlers( if (!target) { return null } - return getRemoteSnapshot(target) + return getRemoteSnapshot(target, getSshProviderAuthority(target.id)) }) ipcMain.handle( @@ -467,9 +534,7 @@ export function registerRemoteWorkspaceHandlers( targetPartition ]) const hasPopulatedLocalConflict = [...ambiguousKeys].some( - (key) => - (fallbackSession.tabsByWorktree[key]?.length ?? 0) > 0 && - !workspaceSessionBundlesEquivalent(fallbackSession, targetPartition, key) + (key) => (fallbackSession.tabsByWorktree[key]?.length ?? 0) > 0 ) if (hasPopulatedLocalConflict) { return null diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 8cb6e6428122..50988db4fd7d 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -62,6 +62,7 @@ import { } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' +import { getSshProviderAuthority } from './ssh-provider-authority' import { PortScanner } from './ssh-port-scanner' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { SshPortForwardManager } from './ssh-port-forward' @@ -1455,8 +1456,9 @@ export class SshRelaySession { } private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void { + const authority = getSshProviderAuthority(this.targetId) mux.onNotification((method, params) => { - notifyRemoteWorkspaceHandlers(this.targetId, method, params) + notifyRemoteWorkspaceHandlers(this.targetId, method, params, authority) }) } diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts index 45f6161e2515..66f074065d85 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts @@ -73,6 +73,32 @@ describe('mergeDirectSshRemoteWorkspaceSession', () => { ).toBeNull() }) + it.each([ + ['git worktrees', 'repo-a::/work-a', 'repo-b::/work-b'], + ['folder workspaces', 'folder:folder-a', 'folder:folder-b'] + ])( + 'fails closed when one target owns a duplicate tab across %s', + (_kind, worktreeA, worktreeB) => { + const current = session({}) + const remote = session({ + [worktreeA]: [tab(worktreeA, 'ssh:target-a@@remote-a', 4)], + [worktreeB]: [tab(worktreeB, 'ssh:target-a@@remote-b', 5)] + }) + + expect( + mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeA, worktreeB]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + ).toBeNull() + } + ) + it('rejects foreign tab, layout, mounted-leaf, and session PTYs before merge', () => { const worktreeId = 'repo-a::/work-a' const local = tab(worktreeId, 'ssh:target-a@@local-a', 9) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 368e10a76e66..9e6c76b08802 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -32,18 +32,16 @@ function isTargetPtyId(ptyId: string | null | undefined, targetId: string): ptyI function hasAmbiguousResultTabIds( current: WorkspaceSessionState, remote: WorkspaceSessionState, - replaceWorktreeIds: ReadonlySet, - targetId: string + replaceWorktreeIds: ReadonlySet ): boolean { - const ownerByTabId = new Map() + const ownerByTabId = new Map() const addOwner = (worktreeId: string, tab: TerminalTab): boolean => { - const targetOwned = isTargetPtyId(tab.ptyId, targetId) const tabId = tab.id const owner = ownerByTabId.get(tabId) - if (owner && owner.worktreeId !== worktreeId && (!owner.targetOwned || !targetOwned)) { + if (owner && owner !== worktreeId) { return false } - ownerByTabId.set(tabId, { worktreeId, targetOwned }) + ownerByTabId.set(tabId, worktreeId) return true } for (const [worktreeId, tabs] of Object.entries(current.tabsByWorktree)) { @@ -111,7 +109,7 @@ export function mergeDirectSshRemoteWorkspaceSession( reconnectPtyIdByTabId: Readonly>, authority: DirectSshAuthority ): WorkspaceSessionState | null { - if (hasAmbiguousResultTabIds(current, remote, replaceWorktreeIds, authority.targetId)) { + if (hasAmbiguousResultTabIds(current, remote, replaceWorktreeIds)) { return null } const { targetId } = authority diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index 28f07a99d638..c7b1fa95ab30 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -36,6 +36,41 @@ function layout(ptyId: string | null): TerminalLayoutSnapshot { } describe('adoptOrphanedWorkspaceSessionPartition', () => { + it.each([1000, 2000])( + 'indexes %i equivalent workspaces without per-workspace global layout scans', + (workspaceCount) => { + let globalLayoutScans = 0 + const makeEquivalentSession = (): WorkspaceSessionState => { + const tabsByWorktree: WorkspaceSessionState['tabsByWorktree'] = {} + const terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] = {} + for (let index = 0; index < workspaceCount; index += 1) { + const workspaceKey = `repo-${index}::/srv/worktree-${index}` + const tabId = `tab-${index}` + tabsByWorktree[workspaceKey] = [tab(tabId, workspaceKey, null)] + terminalLayoutsByTabId[tabId] = layout(null) + } + return session({ + tabsByWorktree, + terminalLayoutsByTabId: new Proxy(terminalLayoutsByTabId, { + ownKeys(target) { + globalLayoutScans += 1 + return Reflect.ownKeys(target) + } + }) + }) + } + + const base = makeEquivalentSession() + const source = makeEquivalentSession() + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.ambiguousWorktreeIds).toEqual([]) + expect(adoption.reconciledWorktreeIds).toHaveLength(workspaceCount) + expect(globalLayoutScans).toBe(3) + }, + 20_000 + ) + it.each([ ['git worktrees', 'repo-a::/srv/a', 'repo-b::/srv/b'], ['folder workspaces', folderWorkspaceKey('folder-a'), folderWorkspaceKey('folder-b')] diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index 24aa4c4708e3..3ea90584f795 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -1,5 +1,6 @@ import type { WorkspaceSessionState } from './types' import { + createWorkspaceSessionAuthorityIndex, findWorkspaceTabIdOwnerCollisions, workspaceTerminalAuthority } from './workspace-session-partition-authority' @@ -42,13 +43,24 @@ export function adoptOrphanedWorkspaceSessionPartition( } const sourceKeys = collectWorkspaceKeys(source) const baseKeys = collectWorkspaceKeys(base) + const baseAuthorityIndex = createWorkspaceSessionAuthorityIndex(base) + const sourceAuthorityIndex = createWorkspaceSessionAuthorityIndex(source) const authorityByWorkspaceKey = new Map( [...sourceKeys].map((key) => [ key, - workspaceTerminalAuthority(base, source, key, { - base: baseKeys.has(key), - source: true - }) + workspaceTerminalAuthority( + base, + source, + key, + { + base: baseKeys.has(key), + source: true + }, + { + base: baseAuthorityIndex, + source: sourceAuthorityIndex + } + ) ]) ) const ambiguousWorktreeIds = new Set([ diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts index 9f4d99024a5d..42aae5afe1a9 100644 --- a/src/shared/workspace-session-partition-authority.ts +++ b/src/shared/workspace-session-partition-authority.ts @@ -28,50 +28,146 @@ function canonicalJson(value: unknown): string { }) } -function workspaceBundle(session: WorkspaceSessionState, workspaceKey: string): unknown { - const tabs = session.tabsByWorktree[workspaceKey] ?? [] - const tabIds = new Set(tabs.map((tab) => tab.id)) +const EMPTY_WORKSPACE_BUNDLE_SIGNATURE = canonicalJson({ + workspaceRecords: {}, + layouts: {}, + remoteSessions: {}, + incarnations: {}, + sleepingAgents: {}, + tombstones: {}, + browserPages: {} +}) + +type WorkspaceSessionBundle = { + workspaceRecords: Record + layouts: Record + remoteSessions: Record + incarnations: Record + sleepingAgents: Record + tombstones: Record + browserPages: Record +} + +export type WorkspaceSessionAuthorityIndex = { + workspaceKeys: ReadonlySet + bundleSignaturesByWorkspaceKey: ReadonlyMap +} + +function emptyWorkspaceBundle( + session: WorkspaceSessionState, + workspaceKey: string +): WorkspaceSessionBundle { return { workspaceRecords: Object.fromEntries( - WORKTREE_RECORD_FIELDS.map((field) => [field, session[field]?.[workspaceKey]]) - ), - layouts: Object.fromEntries( - Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabIds.has(tabId)) - ), - remoteSessions: Object.fromEntries( - Object.entries(session.remoteSessionIdsByTabId ?? {}).filter(([tabId]) => tabIds.has(tabId)) - ), - incarnations: Object.fromEntries( - Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => - tabIds.has(paneTabId(paneKey)) - ) - ), - sleepingAgents: Object.fromEntries( - Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( - ([, record]) => record.worktreeId === workspaceKey - ) + WORKTREE_RECORD_FIELDS.map((field) => { + const value = session[field]?.[workspaceKey] + return [field, value === undefined ? undefined : canonicalJson(value)] + }) ), - tombstones: Object.fromEntries( - Object.entries(session.terminalSurfaceTombstonesByPaneKey ?? {}).filter( - ([, tombstone]) => tombstone.worktreeId === workspaceKey - ) - ), - browserPages: Object.fromEntries( - Object.entries(session.browserPagesByWorkspace ?? {}).filter(([, pages]) => - pages.some((page) => page.worktreeId === workspaceKey) - ) + layouts: {}, + remoteSessions: {}, + incarnations: {}, + sleepingAgents: {}, + tombstones: {}, + browserPages: {} + } +} + +export function createWorkspaceSessionAuthorityIndex( + session: WorkspaceSessionState +): WorkspaceSessionAuthorityIndex { + const workspaceKeys = collectWorkspaceKeys(session) + const bundles = new Map( + [...workspaceKeys].map((workspaceKey) => [ + workspaceKey, + emptyWorkspaceBundle(session, workspaceKey) + ]) + ) + const ownersByTabId = new Map>() + for (const [workspaceKey, tabs] of Object.entries(session.tabsByWorktree)) { + for (const tab of tabs) { + const owners = ownersByTabId.get(tab.id) ?? new Set() + owners.add(workspaceKey) + ownersByTabId.set(tab.id, owners) + } + } + const addForTabOwners = ( + field: 'layouts' | 'remoteSessions' | 'incarnations', + recordKey: string, + tabId: string, + value: unknown + ): void => { + for (const workspaceKey of ownersByTabId.get(tabId) ?? []) { + const bundle = bundles.get(workspaceKey) + if (bundle) { + bundle[field][recordKey] = value + } + } + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { + addForTabOwners('layouts', tabId, tabId, canonicalJson(layout)) + } + for (const [tabId, remoteSessionId] of Object.entries(session.remoteSessionIdsByTabId ?? {})) { + addForTabOwners('remoteSessions', tabId, tabId, canonicalJson(remoteSessionId)) + } + for (const [paneKey, incarnationId] of Object.entries( + session.terminalPtyIncarnationsByPaneKey ?? {} + )) { + addForTabOwners('incarnations', paneKey, paneTabId(paneKey), canonicalJson(incarnationId)) + } + for (const [paneKey, record] of Object.entries(session.sleepingAgentSessionsByPaneKey ?? {})) { + const bundle = bundles.get(record.worktreeId) + if (bundle) { + bundle.sleepingAgents[paneKey] = canonicalJson(record) + } + } + for (const [paneKey, tombstone] of Object.entries( + session.terminalSurfaceTombstonesByPaneKey ?? {} + )) { + const bundle = bundles.get(tombstone.worktreeId) + if (bundle) { + bundle.tombstones[paneKey] = canonicalJson(tombstone) + } + } + for (const [browserWorkspaceKey, pages] of Object.entries( + session.browserPagesByWorkspace ?? {} + )) { + const pagesSignature = canonicalJson(pages) + for (const workspaceKey of new Set(pages.map((page) => page.worktreeId))) { + const bundle = bundles.get(workspaceKey) + if (bundle) { + bundle.browserPages[browserWorkspaceKey] = pagesSignature + } + } + } + return { + workspaceKeys, + bundleSignaturesByWorkspaceKey: new Map( + [...bundles].map(([workspaceKey, bundle]) => [workspaceKey, canonicalJson(bundle)]) ) } } +function indexedWorkspaceSessionBundlesEquivalent( + base: WorkspaceSessionAuthorityIndex, + source: WorkspaceSessionAuthorityIndex, + workspaceKey: string +): boolean { + return ( + (base.bundleSignaturesByWorkspaceKey.get(workspaceKey) ?? EMPTY_WORKSPACE_BUNDLE_SIGNATURE) === + (source.bundleSignaturesByWorkspaceKey.get(workspaceKey) ?? EMPTY_WORKSPACE_BUNDLE_SIGNATURE) + ) +} + export function workspaceSessionBundlesEquivalent( base: WorkspaceSessionState, source: WorkspaceSessionState, workspaceKey: string ): boolean { - return ( - canonicalJson(workspaceBundle(base, workspaceKey)) === - canonicalJson(workspaceBundle(source, workspaceKey)) + return indexedWorkspaceSessionBundlesEquivalent( + createWorkspaceSessionAuthorityIndex(base), + createWorkspaceSessionAuthorityIndex(source), + workspaceKey ) } @@ -81,7 +177,11 @@ export function workspaceTerminalAuthority( base: WorkspaceSessionState, source: WorkspaceSessionState, workspaceKey: string, - presence?: { base: boolean; source: boolean } + presence?: { base: boolean; source: boolean }, + indexes?: { + base: WorkspaceSessionAuthorityIndex + source: WorkspaceSessionAuthorityIndex + } ): WorkspaceTerminalAuthority { const repoId = repoIdForWorkspaceKey(workspaceKey) const baseRevision = repoId ? (base.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) : 0 @@ -89,8 +189,10 @@ export function workspaceTerminalAuthority( if (sourceRevision !== baseRevision) { return sourceRevision > baseRevision ? 'source' : 'base' } - const baseHasKey = presence?.base ?? collectWorkspaceKeys(base).has(workspaceKey) - const sourceHasKey = presence?.source ?? collectWorkspaceKeys(source).has(workspaceKey) + const baseIndex = indexes?.base ?? createWorkspaceSessionAuthorityIndex(base) + const sourceIndex = indexes?.source ?? createWorkspaceSessionAuthorityIndex(source) + const baseHasKey = presence?.base ?? baseIndex.workspaceKeys.has(workspaceKey) + const sourceHasKey = presence?.source ?? sourceIndex.workspaceKeys.has(workspaceKey) if (sourceHasKey && !baseHasKey) { return 'source' } @@ -105,23 +207,35 @@ export function workspaceTerminalAuthority( if (baseTabs.length > 0 && sourceTabs.length === 0) { return 'base' } - return workspaceSessionBundlesEquivalent(base, source, workspaceKey) ? 'equivalent' : 'ambiguous' + return indexedWorkspaceSessionBundlesEquivalent(baseIndex, sourceIndex, workspaceKey) + ? 'equivalent' + : 'ambiguous' } export function findAmbiguousWorkspaceSessionKeys( sources: readonly WorkspaceSessionState[] ): Set { const ambiguous = new Set() - const keysBySource = sources.map(collectWorkspaceKeys) + const indexes = sources.map(createWorkspaceSessionAuthorityIndex) + const keysBySource = indexes.map((index) => index.workspaceKeys) for (let left = 0; left < sources.length; left += 1) { for (let right = left + 1; right < sources.length; right += 1) { const keys = new Set([...keysBySource[left], ...keysBySource[right]]) for (const key of keys) { if ( - workspaceTerminalAuthority(sources[left], sources[right], key, { - base: keysBySource[left].has(key), - source: keysBySource[right].has(key) - }) === 'ambiguous' + workspaceTerminalAuthority( + sources[left], + sources[right], + key, + { + base: keysBySource[left].has(key), + source: keysBySource[right].has(key) + }, + { + base: indexes[left], + source: indexes[right] + } + ) === 'ambiguous' ) { ambiguous.add(key) } From 3e464aee530c2bc0726e7de91924aff1cd9e05d9 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 15 Aug 2026 01:13:15 +0300 Subject: [PATCH 11/20] fix(ssh): stop a one-sided topology revision from vetoing pty-bound panes A topology revision is only recorded in a partition by the process that runs the topology-authority code path, so a partition can hold live terminal state without ever receiving a bump. workspaceTerminalAuthority treated any recorded revision as newer than an unrecorded one, which let a partition of dormant tab rows win authority over the partition holding the running terminals: the adopted view then dropped the live tabs, their agent sessions failed every ownership check at the next cold start, and cold restore relaunched them as duplicates while the original processes kept running unreachable. The one-sided comparison now yields to the partition holding pty-bound panes when the recorded side has none, and a pty-bound tiebreak resolves the no-revision case before bundle equivalence. Two-sided comparisons, presence, and fail-closed ambiguity are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqNqAZQTwmAkwGcHj5TYXk --- ...rkspace-session-partition-adoption.test.ts | 76 ++++++++++ ...kspace-session-partition-authority.test.ts | 131 ++++++++++++++++++ .../workspace-session-partition-authority.ts | 50 ++++++- 3 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 src/shared/workspace-session-partition-authority.test.ts diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index c7b1fa95ab30..9b865ea5c532 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -420,4 +420,80 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { expect(second).toEqual(first) }) + + it('adopts pty-bound ssh panes over a revision-only base full of dormant tab rows', () => { + const ghostLeaf = '22222222-2222-4222-8222-222222222222' + const base = session({ + tabsByWorktree: { + [WORKTREE_ID]: [tab('ghost-1'), tab('ghost-2')] + }, + terminalLayoutsByTabId: { + 'ghost-1': { ...layout(null), root: { type: 'leaf', leafId: ghostLeaf } }, + 'ghost-2': { ...layout(null), root: { type: 'leaf', leafId: ghostLeaf } } + }, + terminalPtyIncarnationsByPaneKey: { + [`ghost-1:${ghostLeaf}`]: 'inc-ghost-1', + [`ghost-2:${ghostLeaf}`]: 'inc-ghost-2' + }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-34')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-34') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-live' }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-live' }, + prompt: 'live', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([WORKTREE_ID]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'tab-1' + ]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@pty-34') + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.providerSession?.id).toBe( + 'session-live' + ) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-live') + }) + + it('does not resurrect a tombstoned pane through the pty-bound tiebreak', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 30 }, + terminalSurfaceTombstonesByPaneKey: { + [PANE_KEY]: { + worktreeId: WORKTREE_ID, + parentTabId: 'tab-1', + leafId: LEAF_ID, + ptyId: 'ssh:one@@pty-5', + incarnationId: 'inc-closed', + retiredAt: 20 + } + } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-5')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-5') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-closed' } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect( + adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id) ?? [] + ).not.toContain('tab-1') + }) }) diff --git a/src/shared/workspace-session-partition-authority.test.ts b/src/shared/workspace-session-partition-authority.test.ts new file mode 100644 index 000000000000..124b0ddb4ba8 --- /dev/null +++ b/src/shared/workspace-session-partition-authority.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from './constants' +import type { TerminalLayoutSnapshot, TerminalTab, WorkspaceSessionState } from './types' +import { workspaceTerminalAuthority } from './workspace-session-partition-authority' + +const WORKTREE_ID = 'repo-1::/srv/wt' +const REPO_ID = 'repo-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function tab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(overrides: Partial): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), ...overrides } +} + +function layout(ptyId: string | null): TerminalLayoutSnapshot { + return { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [LEAF_ID]: ptyId } : undefined + } +} + +describe('workspaceTerminalAuthority', () => { + it('does not let a one-sided revision veto the partition holding pty-bound panes', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1'), tab('ghost-2')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null), 'ghost-2': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 162 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-34')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-34') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + }) + + it('mirrors the pty-bound tiebreak toward a live base against a revisionless source', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-3')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-3') } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 7 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + }) + + it('keeps revision precedence when both partitions record a revision', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-1')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-1') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 3 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-1')] }, + terminalLayoutsByTabId: { 'stale-1': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 9 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + }) + + it('counts a pane bound through its layout when the tab record carries no ptyId', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 12 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', null)] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-9') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + }) + + it('stays ambiguous when neither side holds a pty-bound pane and bundles differ', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a')] }, + terminalLayoutsByTabId: { a: layout(null) } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('b')] }, + terminalLayoutsByTabId: { b: layout(null) } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('ambiguous') + }) + + it('stays ambiguous when both sides hold pty-bound panes and bundles differ', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a', 'ssh:target@@pty-1')] }, + terminalLayoutsByTabId: { a: layout('ssh:target@@pty-1') } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('b', 'ssh:target@@pty-2')] }, + terminalLayoutsByTabId: { b: layout('ssh:target@@pty-2') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('ambiguous') + }) + + it('still prefers the only populated side when the other has the key with no tabs', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a')] }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 5 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [] } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + }) +}) diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts index 42aae5afe1a9..388cdc3d0e35 100644 --- a/src/shared/workspace-session-partition-authority.ts +++ b/src/shared/workspace-session-partition-authority.ts @@ -1,4 +1,4 @@ -import type { WorkspaceSessionState } from './types' +import type { TerminalTab, WorkspaceSessionState } from './types' import { parseWorkspaceKey } from './workspace-scope' import { getRepoIdFromWorktreeId } from './worktree-id' import { @@ -184,11 +184,34 @@ export function workspaceTerminalAuthority( } ): WorkspaceTerminalAuthority { const repoId = repoIdForWorkspaceKey(workspaceKey) - const baseRevision = repoId ? (base.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) : 0 - const sourceRevision = repoId ? (source.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) : 0 - if (sourceRevision !== baseRevision) { + const baseRevision = repoId ? base.terminalTopologyRevisionByRepoId?.[repoId] : undefined + const sourceRevision = repoId ? source.terminalTopologyRevisionByRepoId?.[repoId] : undefined + if ( + baseRevision !== undefined && + sourceRevision !== undefined && + sourceRevision !== baseRevision + ) { return sourceRevision > baseRevision ? 'source' : 'base' } + if ((baseRevision !== undefined) !== (sourceRevision !== undefined)) { + // Why the pty-bound veto: a revision is only recorded by the process that runs the + // topology-authority code path, and a partition can accumulate terminal state + // without any bump ever landing in it. When exactly one side records a revision, + // that side is presumed newer — except when it holds no pane bound to a pty while + // the unrecorded side does. In that shape the recorded side describes dormant tab + // rows and the unrecorded side the running terminals; preferring the counter drops + // the live panes from the adopted view, strands their agent sessions, and + // duplicates them through cold restore. + const recordedIsBase = baseRevision !== undefined + const recorded = recordedIsBase ? base : source + const unrecorded = recordedIsBase ? source : base + const recordedTabs = recorded.tabsByWorktree[workspaceKey] ?? [] + const unrecordedTabs = unrecorded.tabsByWorktree[workspaceKey] ?? [] + if (!hasPtyBoundPane(recorded, recordedTabs) && hasPtyBoundPane(unrecorded, unrecordedTabs)) { + return recordedIsBase ? 'source' : 'base' + } + return recordedIsBase ? 'base' : 'source' + } const baseIndex = indexes?.base ?? createWorkspaceSessionAuthorityIndex(base) const sourceIndex = indexes?.source ?? createWorkspaceSessionAuthorityIndex(source) const baseHasKey = presence?.base ?? baseIndex.workspaceKeys.has(workspaceKey) @@ -207,11 +230,30 @@ export function workspaceTerminalAuthority( if (baseTabs.length > 0 && sourceTabs.length === 0) { return 'base' } + // Why pty-bound wins: a pane holding a persisted pty binding is the partition's + // proof that it tracked a running terminal; a copy of the same worktree with no + // pty-bound pane describes only dormant surfaces (imported or left-over tab rows). + // When exactly one side holds pty-bound panes, treating the sides as ambiguous + // keeps the dormant copy and drops the running terminals from the adopted view — + // which strands their agent sessions and duplicates them through cold restore. + const basePtyBound = hasPtyBoundPane(base, baseTabs) + const sourcePtyBound = hasPtyBoundPane(source, sourceTabs) + if (sourcePtyBound !== basePtyBound) { + return sourcePtyBound ? 'source' : 'base' + } return indexedWorkspaceSessionBundlesEquivalent(baseIndex, sourceIndex, workspaceKey) ? 'equivalent' : 'ambiguous' } +function hasPtyBoundPane(state: WorkspaceSessionState, tabs: readonly TerminalTab[]): boolean { + return tabs.some( + (tab) => + Boolean(tab.ptyId) || + Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).some(Boolean) + ) +} + export function findAmbiguousWorkspaceSessionKeys( sources: readonly WorkspaceSessionState[] ): Set { From c4fc7065a6ba2199e2fceb07209c78e3343fd223 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 15 Aug 2026 01:27:14 +0300 Subject: [PATCH 12/20] review: round 1 fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqNqAZQTwmAkwGcHj5TYXk --- ...kspace-session-partition-authority.test.ts | 38 ++++++++-- .../workspace-session-partition-authority.ts | 72 +++++++++---------- .../workspace-session-partition-provenance.ts | 18 ++++- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/shared/workspace-session-partition-authority.test.ts b/src/shared/workspace-session-partition-authority.test.ts index 124b0ddb4ba8..c43197b88cb6 100644 --- a/src/shared/workspace-session-partition-authority.test.ts +++ b/src/shared/workspace-session-partition-authority.test.ts @@ -62,15 +62,45 @@ describe('workspaceTerminalAuthority', () => { expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') }) - it('keeps revision precedence when both partitions record a revision', () => { + it('yields a higher two-sided revision to the side holding pty-bound panes', () => { const base = session({ - tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-1')] }, - terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-1') }, - terminalTopologyRevisionByRepoId: { [REPO_ID]: 3 } + tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1'), tab('ghost-2')] }, + terminalLayoutsByTabId: { 'ghost-1': layout(null), 'ghost-2': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 162 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-34')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-34') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 5 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + }) + + it('keeps two-sided revision precedence when both sides hold pty-bound panes', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('a', 'ssh:target@@pty-1')] }, + terminalLayoutsByTabId: { a: layout('ssh:target@@pty-1') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 9 } }) const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('b', 'ssh:target@@pty-2')] }, + terminalLayoutsByTabId: { b: layout('ssh:target@@pty-2') }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 4 } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + }) + + it('keeps revision precedence when both partitions record a revision', () => { + const base = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('stale-1')] }, terminalLayoutsByTabId: { 'stale-1': layout(null) }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 3 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-1')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-1') }, terminalTopologyRevisionByRepoId: { [REPO_ID]: 9 } }) diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts index 388cdc3d0e35..eb215396e2c6 100644 --- a/src/shared/workspace-session-partition-authority.ts +++ b/src/shared/workspace-session-partition-authority.ts @@ -1,8 +1,9 @@ -import type { TerminalTab, WorkspaceSessionState } from './types' +import type { WorkspaceSessionState } from './types' import { parseWorkspaceKey } from './workspace-scope' import { getRepoIdFromWorktreeId } from './worktree-id' import { collectWorkspaceKeys, + hasPtyBoundPane, paneTabId, WORKTREE_RECORD_FIELDS } from './workspace-session-partition-provenance' @@ -186,31 +187,34 @@ export function workspaceTerminalAuthority( const repoId = repoIdForWorkspaceKey(workspaceKey) const baseRevision = repoId ? base.terminalTopologyRevisionByRepoId?.[repoId] : undefined const sourceRevision = repoId ? source.terminalTopologyRevisionByRepoId?.[repoId] : undefined - if ( - baseRevision !== undefined && - sourceRevision !== undefined && - sourceRevision !== baseRevision - ) { - return sourceRevision > baseRevision ? 'source' : 'base' - } - if ((baseRevision !== undefined) !== (sourceRevision !== undefined)) { - // Why the pty-bound veto: a revision is only recorded by the process that runs the - // topology-authority code path, and a partition can accumulate terminal state - // without any bump ever landing in it. When exactly one side records a revision, - // that side is presumed newer — except when it holds no pane bound to a pty while - // the unrecorded side does. In that shape the recorded side describes dormant tab - // rows and the unrecorded side the running terminals; preferring the counter drops - // the live panes from the adopted view, strands their agent sessions, and - // duplicates them through cold restore. - const recordedIsBase = baseRevision !== undefined - const recorded = recordedIsBase ? base : source - const unrecorded = recordedIsBase ? source : base - const recordedTabs = recorded.tabsByWorktree[workspaceKey] ?? [] - const unrecordedTabs = unrecorded.tabsByWorktree[workspaceKey] ?? [] - if (!hasPtyBoundPane(recorded, recordedTabs) && hasPtyBoundPane(unrecorded, unrecordedTabs)) { - return recordedIsBase ? 'source' : 'base' + const revisionAuthority = + baseRevision !== undefined && sourceRevision !== undefined + ? sourceRevision !== baseRevision + ? sourceRevision > baseRevision + ? ('source' as const) + : ('base' as const) + : null + : baseRevision !== undefined + ? ('base' as const) + : sourceRevision !== undefined + ? ('source' as const) + : null + if (revisionAuthority) { + // Why the pty-bound veto: the counter is keyed per repo and bumped only by the + // process that runs the topology-authority code path, so it can outrank the other + // side — or exist where the other has none — without describing this workspace's + // terminals at all. A winner with no pty-bound pane yields to a loser holding one: + // preferring the counter there drops the running terminals from the adopted view + // and duplicates their agent sessions through cold restore. Deliberately closed + // panes stay closed either way — tombstones fence resurrection, not the counter. + const winner = revisionAuthority === 'base' ? base : source + const loser = revisionAuthority === 'base' ? source : base + const winnerTabs = winner.tabsByWorktree[workspaceKey] ?? [] + const loserTabs = loser.tabsByWorktree[workspaceKey] ?? [] + if (!hasPtyBoundPane(winner, winnerTabs) && hasPtyBoundPane(loser, loserTabs)) { + return revisionAuthority === 'base' ? 'source' : 'base' } - return recordedIsBase ? 'base' : 'source' + return revisionAuthority } const baseIndex = indexes?.base ?? createWorkspaceSessionAuthorityIndex(base) const sourceIndex = indexes?.source ?? createWorkspaceSessionAuthorityIndex(source) @@ -230,12 +234,10 @@ export function workspaceTerminalAuthority( if (baseTabs.length > 0 && sourceTabs.length === 0) { return 'base' } - // Why pty-bound wins: a pane holding a persisted pty binding is the partition's - // proof that it tracked a running terminal; a copy of the same worktree with no - // pty-bound pane describes only dormant surfaces (imported or left-over tab rows). - // When exactly one side holds pty-bound panes, treating the sides as ambiguous - // keeps the dormant copy and drops the running terminals from the adopted view — - // which strands their agent sessions and duplicates them through cold restore. + // Why pty-bound wins: a persisted pty binding is the partition's proof that it + // tracked a running terminal; a copy with none describes only dormant surfaces. + // Treating the sides as ambiguous here would keep the dormant copy and duplicate + // the running terminals' agent sessions through cold restore. const basePtyBound = hasPtyBoundPane(base, baseTabs) const sourcePtyBound = hasPtyBoundPane(source, sourceTabs) if (sourcePtyBound !== basePtyBound) { @@ -246,14 +248,6 @@ export function workspaceTerminalAuthority( : 'ambiguous' } -function hasPtyBoundPane(state: WorkspaceSessionState, tabs: readonly TerminalTab[]): boolean { - return tabs.some( - (tab) => - Boolean(tab.ptyId) || - Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).some(Boolean) - ) -} - export function findAmbiguousWorkspaceSessionKeys( sources: readonly WorkspaceSessionState[] ): Set { diff --git a/src/shared/workspace-session-partition-provenance.ts b/src/shared/workspace-session-partition-provenance.ts index 4f2405d3bb27..9cc8e3a41b06 100644 --- a/src/shared/workspace-session-partition-provenance.ts +++ b/src/shared/workspace-session-partition-provenance.ts @@ -1,4 +1,9 @@ -import type { BrowserHistoryEntry, TerminalLayoutSnapshot, WorkspaceSessionState } from './types' +import type { + BrowserHistoryEntry, + TerminalLayoutSnapshot, + TerminalTab, + WorkspaceSessionState +} from './types' export const WORKTREE_RECORD_FIELDS = [ 'tabsByWorktree', @@ -53,6 +58,17 @@ export function collectWorkspaceKeys(session: WorkspaceSessionState): Set + Boolean(tab.ptyId) || + Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).some(Boolean) + ) +} + export function collectTerminalProvenance( base: WorkspaceSessionState, source: WorkspaceSessionState, From ae23afb4239e7483ba5055d0a735e213b9da2184 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 15 Aug 2026 01:40:32 +0300 Subject: [PATCH 13/20] review: round 2 fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqNqAZQTwmAkwGcHj5TYXk --- ...rkspace-session-partition-adoption.test.ts | 31 ++++++++++++++++ ...kspace-session-partition-authority.test.ts | 25 +++++++++++++ .../workspace-session-partition-authority.ts | 37 ++++++++----------- .../workspace-session-partition-provenance.ts | 20 +++++++++- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index 2d0b1c607e4f..e59a013dcb65 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -497,4 +497,35 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id) ?? [] ).not.toContain('tab-1') }) + + it('keeps a close-emptied worktree closed against a stale pty-bound partition copy', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 163 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-dead')] }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-dead') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-stale' }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-stale' }, + prompt: 'stale', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([]) + expect(adoption.session.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeUndefined() + }) }) diff --git a/src/shared/workspace-session-partition-authority.test.ts b/src/shared/workspace-session-partition-authority.test.ts index 91b463c0a248..eb24f41d096c 100644 --- a/src/shared/workspace-session-partition-authority.test.ts +++ b/src/shared/workspace-session-partition-authority.test.ts @@ -148,6 +148,31 @@ describe('workspaceTerminalAuthority', () => { expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('ambiguous') }) + it('keeps authority with a revision winner whose tab list is explicitly empty', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 163 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('stale-1', 'ssh:target@@pty-dead')] }, + terminalLayoutsByTabId: { 'stale-1': layout('ssh:target@@pty-dead') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + }) + + it('vetoes a revision winner that has no record at all for the workspace key', () => { + const base = session({ + terminalTopologyRevisionByRepoId: { [REPO_ID]: 12 } + }) + const source = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('live-1', 'ssh:target@@pty-3')] }, + terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-3') } + }) + + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + }) + it('still prefers the only populated side when the other has the key with no tabs', () => { const base = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('a')] }, diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts index 7997d81a2d00..90901ecb77d8 100644 --- a/src/shared/workspace-session-partition-authority.ts +++ b/src/shared/workspace-session-partition-authority.ts @@ -5,6 +5,7 @@ import { collectWorkspaceKeys, hasPtyBoundPane, paneTabId, + topologyRevisionAuthority, WORKTREE_RECORD_FIELDS } from './workspace-session-partition-provenance' @@ -187,31 +188,25 @@ export function workspaceTerminalAuthority( const repoId = repoIdForWorkspaceKey(workspaceKey) const baseRevision = repoId ? base.terminalTopologyRevisionByRepoId?.[repoId] : undefined const sourceRevision = repoId ? source.terminalTopologyRevisionByRepoId?.[repoId] : undefined - const revisionAuthority = - baseRevision !== undefined && sourceRevision !== undefined - ? sourceRevision !== baseRevision - ? sourceRevision > baseRevision - ? ('source' as const) - : ('base' as const) - : null - : baseRevision !== undefined - ? ('base' as const) - : sourceRevision !== undefined - ? ('source' as const) - : null + const revisionAuthority = topologyRevisionAuthority(baseRevision, sourceRevision) if (revisionAuthority) { - // Why the pty-bound veto: the counter is keyed per repo and bumped only by the - // process that runs the topology-authority code path, so it can outrank the other - // side — or exist where the other has none — without describing this workspace's - // terminals at all. A winner with no pty-bound pane yields to a loser holding one: - // preferring the counter there drops the running terminals from the adopted view - // and duplicates their agent sessions through cold restore. Deliberately closed - // panes stay closed either way — tombstones fence resurrection, not the counter. + // Why the pty-bound veto: the counter is per repo and bumped only by the process + // running the topology-authority code path, so it can outrank the other side + // without describing this workspace's terminals at all. A winner holding only + // dormant tab rows (or no record for the key) yields to a loser holding a + // pty-bound pane — preferring the counter there duplicates the running terminals' + // agent sessions through cold restore. An explicitly empty winner list is a close + // fenced by the counter alone (tombstones are legacy): it keeps authority, so a + // stale pty-bound copy elsewhere cannot resurrect what the user closed. const winner = revisionAuthority === 'base' ? base : source const loser = revisionAuthority === 'base' ? source : base - const winnerTabs = winner.tabsByWorktree[workspaceKey] ?? [] + const winnerTabs = winner.tabsByWorktree[workspaceKey] const loserTabs = loser.tabsByWorktree[workspaceKey] ?? [] - if (!hasPtyBoundPane(winner, winnerTabs) && hasPtyBoundPane(loser, loserTabs)) { + if ( + (winnerTabs === undefined || winnerTabs.length > 0) && + !hasPtyBoundPane(winner, winnerTabs ?? []) && + hasPtyBoundPane(loser, loserTabs) + ) { return revisionAuthority === 'base' ? 'source' : 'base' } return revisionAuthority diff --git a/src/shared/workspace-session-partition-provenance.ts b/src/shared/workspace-session-partition-provenance.ts index 2087fc86cfe1..466b6b96b6ef 100644 --- a/src/shared/workspace-session-partition-provenance.ts +++ b/src/shared/workspace-session-partition-provenance.ts @@ -20,12 +20,12 @@ export const WORKTREE_RECORD_FIELDS = [ export function paneTabId(paneKey: string): string { const separator = paneKey.indexOf(':') - return separator < 0 ? paneKey : paneKey.slice(0, separator) + return separator === -1 ? paneKey : paneKey.slice(0, separator) } export function paneLeafId(paneKey: string): string { const separator = paneKey.indexOf(':') - return separator < 0 ? '' : paneKey.slice(separator + 1) + return separator === -1 ? '' : paneKey.slice(separator + 1) } export function collectWorkspaceKeys(session: WorkspaceSessionState): Set { @@ -55,6 +55,22 @@ export function collectWorkspaceKeys(session: WorkspaceSessionState): Set baseRevision ? 'source' : 'base' +} + export function hasPtyBoundPane( state: WorkspaceSessionState, tabs: readonly TerminalTab[] From 6f89d85411509ab002e0ed03ba6c139cd70f7be4 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 15 Aug 2026 01:55:49 +0300 Subject: [PATCH 14/20] review: round 3 fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqNqAZQTwmAkwGcHj5TYXk --- ...rkspace-session-partition-adoption.test.ts | 40 ++++++++++++++-- .../workspace-session-partition-adoption.ts | 38 ++++++++++++--- ...kspace-session-partition-authority.test.ts | 22 ++++----- .../workspace-session-partition-authority.ts | 48 ++++++++++++------- .../workspace-session-partition-provenance.ts | 19 ++++++-- 5 files changed, 122 insertions(+), 45 deletions(-) diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index e59a013dcb65..cf031e9480a5 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -460,16 +460,48 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) expect(adoption.sourceAuthoritativeWorktreeIds).toEqual([WORKTREE_ID]) - expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ - 'tab-1' - ]) - expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('ssh:one@@pty-34') + const adoptedIds = adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id) + expect(adoptedIds).toContain('tab-1') + expect(adoptedIds).toContain('ghost-1') + expect(adoptedIds).toContain('ghost-2') + const adoptedLive = adoption.session.tabsByWorktree[WORKTREE_ID]?.find( + (entry) => entry.id === 'tab-1' + ) + expect(adoptedLive?.ptyId).toBe('ssh:one@@pty-34') expect(adoption.session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.providerSession?.id).toBe( 'session-live' ) expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-live') }) + it('keeps the winner tab list intact when the veto grafts a stray live pane', () => { + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('kept-dormant')] }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { + [WORKTREE_ID]: [ + tab('kept-dormant'), + tab('stray-dormant'), + tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-40') + ] + }, + terminalLayoutsByTabId: { 'tab-1': layout('ssh:one@@pty-40') }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: 'inc-live' } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + // The graft is additive: the winner's own row survives, only the source's + // pty-bound stray joins it, and dormant strays stay out of the adopted view. + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'kept-dormant', + 'tab-1' + ]) + expect(adoption.session.terminalPtyIncarnationsByPaneKey?.[PANE_KEY]).toBe('inc-live') + }) + it('does not resurrect a tombstoned pane through the pty-bound tiebreak', () => { const base = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('ghost-1')] }, diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index 2c851148dd18..a619164763f1 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -12,7 +12,8 @@ import { mergeTerminalLayout, mergeWorkspaceRecord, paneLeafId, - paneTabId + paneTabId, + tabIsPtyBound } from './workspace-session-partition-provenance' export type WorkspaceSessionPartitionAdoption = { @@ -45,7 +46,7 @@ export function adoptOrphanedWorkspaceSessionPartition( const baseKeys = collectWorkspaceKeys(base) const baseAuthorityIndex = createWorkspaceSessionAuthorityIndex(base) const sourceAuthorityIndex = createWorkspaceSessionAuthorityIndex(source) - const authorityByWorkspaceKey = new Map( + const decisionByWorkspaceKey = new Map( [...sourceKeys].map((key) => [ key, workspaceTerminalAuthority( @@ -67,28 +68,51 @@ export function adoptOrphanedWorkspaceSessionPartition( ...findWorkspaceTabIdOwnerCollisions([base, source]), ...[...sourceKeys].filter( (key) => - options.preserveWorkspaceKeys?.has(key) || authorityByWorkspaceKey.get(key) === 'ambiguous' + options.preserveWorkspaceKeys?.has(key) || + decisionByWorkspaceKey.get(key)?.authority === 'ambiguous' ) ]) const sourceAuthority = new Set( [...sourceKeys].filter( - (key) => !ambiguousWorktreeIds.has(key) && authorityByWorkspaceKey.get(key) === 'source' + (key) => + !ambiguousWorktreeIds.has(key) && decisionByWorkspaceKey.get(key)?.authority === 'source' + ) + ) + // Keys where the pty-bound veto overturned base's revision win. Base's own tab rows + // must survive for these: base is the side that runs the topology-authority code + // path, so its list reflects deliberate closes the source never observed. The + // source's pty-bound strays are grafted in additively below instead of replacing + // the list, and every other per-key field keeps base priority. + const grafted = new Set( + [...sourceAuthority].filter( + (key) => decisionByWorkspaceKey.get(key)?.ptyBoundVetoFlip && base.tabsByWorktree[key] ) ) + const sourceFieldAuthority = new Set([...sourceAuthority].filter((key) => !grafted.has(key))) const reconciledKeys = new Set([...sourceKeys].filter((key) => !ambiguousWorktreeIds.has(key))) const tabsByWorktree = mergeWorkspaceRecord( base.tabsByWorktree, source.tabsByWorktree, reconciledKeys, - sourceAuthority + sourceFieldAuthority ) ?? { ...base.tabsByWorktree } + for (const key of grafted) { + const baseTabs = base.tabsByWorktree[key] ?? [] + const baseTabIds = new Set(baseTabs.map((tab) => tab.id)) + const strays = (source.tabsByWorktree[key] ?? []).filter( + (tab) => !baseTabIds.has(tab.id) && tabIsPtyBound(source, tab) + ) + if (strays.length > 0) { + tabsByWorktree[key] = [...baseTabs, ...strays] + } + } const { liveTabIds, touchedTabIds, sourceWorkspaceKeyByTabId, sourcePaneAuthority } = collectTerminalProvenance(base, source, tabsByWorktree, reconciledKeys, sourceAuthority) const sourcePaneAuthoritativeTabIds = new Set( [...sourcePaneAuthority].map((paneKey) => paneTabId(paneKey)) ) for (const key of reconciledKeys) { - if (sourceAuthority.has(key)) { + if (sourceFieldAuthority.has(key)) { continue } const sourceTabsById = new Map( @@ -227,7 +251,7 @@ export function adoptOrphanedWorkspaceSessionPartition( base, source, reconciledKeys, - sourceAuthority, + sourceAuthority: sourceFieldAuthority, ambiguousWorktreeIds, tabsByWorktree, terminalLayoutsByTabId, diff --git a/src/shared/workspace-session-partition-authority.test.ts b/src/shared/workspace-session-partition-authority.test.ts index eb24f41d096c..fc4f7c328133 100644 --- a/src/shared/workspace-session-partition-authority.test.ts +++ b/src/shared/workspace-session-partition-authority.test.ts @@ -46,7 +46,7 @@ describe('workspaceTerminalAuthority', () => { terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-34') } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') }) it('mirrors the pty-bound tiebreak toward a live base against a revisionless source', () => { @@ -60,7 +60,7 @@ describe('workspaceTerminalAuthority', () => { terminalTopologyRevisionByRepoId: { [REPO_ID]: 7 } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') }) it('yields a higher two-sided revision to the side holding pty-bound panes', () => { @@ -75,7 +75,7 @@ describe('workspaceTerminalAuthority', () => { terminalTopologyRevisionByRepoId: { [REPO_ID]: 5 } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') }) it('keeps two-sided revision precedence when both sides hold pty-bound panes', () => { @@ -90,7 +90,7 @@ describe('workspaceTerminalAuthority', () => { terminalTopologyRevisionByRepoId: { [REPO_ID]: 4 } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') }) it('keeps revision precedence when both partitions record a revision', () => { @@ -105,7 +105,7 @@ describe('workspaceTerminalAuthority', () => { terminalTopologyRevisionByRepoId: { [REPO_ID]: 9 } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') }) it('counts a pane bound through its layout when the tab record carries no ptyId', () => { @@ -119,7 +119,7 @@ describe('workspaceTerminalAuthority', () => { terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-9') } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') }) it('stays ambiguous when neither side holds a pty-bound pane and bundles differ', () => { @@ -132,7 +132,7 @@ describe('workspaceTerminalAuthority', () => { terminalLayoutsByTabId: { b: layout(null) } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('ambiguous') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('ambiguous') }) it('stays ambiguous when both sides hold pty-bound panes and bundles differ', () => { @@ -145,7 +145,7 @@ describe('workspaceTerminalAuthority', () => { terminalLayoutsByTabId: { b: layout('ssh:target@@pty-2') } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('ambiguous') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('ambiguous') }) it('keeps authority with a revision winner whose tab list is explicitly empty', () => { @@ -158,7 +158,7 @@ describe('workspaceTerminalAuthority', () => { terminalLayoutsByTabId: { 'stale-1': layout('ssh:target@@pty-dead') } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') }) it('vetoes a revision winner that has no record at all for the workspace key', () => { @@ -170,7 +170,7 @@ describe('workspaceTerminalAuthority', () => { terminalLayoutsByTabId: { 'live-1': layout('ssh:target@@pty-3') } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('source') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('source') }) it('still prefers the only populated side when the other has the key with no tabs', () => { @@ -182,6 +182,6 @@ describe('workspaceTerminalAuthority', () => { tabsByWorktree: { [WORKTREE_ID]: [] } }) - expect(workspaceTerminalAuthority(base, source, WORKTREE_ID)).toBe('base') + expect(workspaceTerminalAuthority(base, source, WORKTREE_ID).authority).toBe('base') }) }) diff --git a/src/shared/workspace-session-partition-authority.ts b/src/shared/workspace-session-partition-authority.ts index 90901ecb77d8..c033dac9b4be 100644 --- a/src/shared/workspace-session-partition-authority.ts +++ b/src/shared/workspace-session-partition-authority.ts @@ -175,6 +175,19 @@ export function workspaceSessionBundlesEquivalent( export type WorkspaceTerminalAuthority = 'base' | 'source' | 'equivalent' | 'ambiguous' +function undisputed(authority: WorkspaceTerminalAuthority): WorkspaceTerminalAuthorityDecision { + return { authority, ptyBoundVetoFlip: false } +} + +export type WorkspaceTerminalAuthorityDecision = { + authority: WorkspaceTerminalAuthority + // Set when the pty-bound veto overturned a revision-based winner. Adoption uses it + // to graft the loser's pty-bound tabs into the winner's list instead of replacing + // the list wholesale, so the winner's own rows (including its closes of dormant + // tabs) survive the flip. + ptyBoundVetoFlip: boolean +} + export function workspaceTerminalAuthority( base: WorkspaceSessionState, source: WorkspaceSessionState, @@ -184,7 +197,7 @@ export function workspaceTerminalAuthority( base: WorkspaceSessionAuthorityIndex source: WorkspaceSessionAuthorityIndex } -): WorkspaceTerminalAuthority { +): WorkspaceTerminalAuthorityDecision { const repoId = repoIdForWorkspaceKey(workspaceKey) const baseRevision = repoId ? base.terminalTopologyRevisionByRepoId?.[repoId] : undefined const sourceRevision = repoId ? source.terminalTopologyRevisionByRepoId?.[repoId] : undefined @@ -207,27 +220,24 @@ export function workspaceTerminalAuthority( !hasPtyBoundPane(winner, winnerTabs ?? []) && hasPtyBoundPane(loser, loserTabs) ) { - return revisionAuthority === 'base' ? 'source' : 'base' + return { + authority: revisionAuthority === 'base' ? 'source' : 'base', + ptyBoundVetoFlip: true + } } - return revisionAuthority + return undisputed(revisionAuthority) } const baseIndex = indexes?.base ?? createWorkspaceSessionAuthorityIndex(base) const sourceIndex = indexes?.source ?? createWorkspaceSessionAuthorityIndex(source) const baseHasKey = presence?.base ?? baseIndex.workspaceKeys.has(workspaceKey) const sourceHasKey = presence?.source ?? sourceIndex.workspaceKeys.has(workspaceKey) - if (sourceHasKey && !baseHasKey) { - return 'source' - } - if (baseHasKey && !sourceHasKey) { - return 'base' + if (sourceHasKey !== baseHasKey) { + return undisputed(sourceHasKey ? 'source' : 'base') } const baseTabs = base.tabsByWorktree[workspaceKey] ?? [] const sourceTabs = source.tabsByWorktree[workspaceKey] ?? [] - if (sourceTabs.length > 0 && baseTabs.length === 0) { - return 'source' - } - if (baseTabs.length > 0 && sourceTabs.length === 0) { - return 'base' + if (sourceTabs.length > 0 !== baseTabs.length > 0) { + return undisputed(sourceTabs.length > 0 ? 'source' : 'base') } // Why pty-bound wins: a persisted pty binding is the partition's proof that it // tracked a running terminal; a copy with none describes only dormant surfaces. @@ -236,11 +246,13 @@ export function workspaceTerminalAuthority( const basePtyBound = hasPtyBoundPane(base, baseTabs) const sourcePtyBound = hasPtyBoundPane(source, sourceTabs) if (sourcePtyBound !== basePtyBound) { - return sourcePtyBound ? 'source' : 'base' + return undisputed(sourcePtyBound ? 'source' : 'base') } - return indexedWorkspaceSessionBundlesEquivalent(baseIndex, sourceIndex, workspaceKey) - ? 'equivalent' - : 'ambiguous' + return undisputed( + indexedWorkspaceSessionBundlesEquivalent(baseIndex, sourceIndex, workspaceKey) + ? 'equivalent' + : 'ambiguous' + ) } export function findAmbiguousWorkspaceSessionKeys( @@ -266,7 +278,7 @@ export function findAmbiguousWorkspaceSessionKeys( base: indexes[left], source: indexes[right] } - ) === 'ambiguous' + ).authority === 'ambiguous' ) { ambiguous.add(key) } diff --git a/src/shared/workspace-session-partition-provenance.ts b/src/shared/workspace-session-partition-provenance.ts index 466b6b96b6ef..003bb8065420 100644 --- a/src/shared/workspace-session-partition-provenance.ts +++ b/src/shared/workspace-session-partition-provenance.ts @@ -75,10 +75,13 @@ export function hasPtyBoundPane( state: WorkspaceSessionState, tabs: readonly TerminalTab[] ): boolean { - return tabs.some( - (tab) => - Boolean(tab.ptyId) || - Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).some(Boolean) + return tabs.some((tab) => tabIsPtyBound(state, tab)) +} + +export function tabIsPtyBound(state: WorkspaceSessionState, tab: TerminalTab): boolean { + return ( + Boolean(tab.ptyId) || + Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).some(Boolean) ) } @@ -122,8 +125,14 @@ export function collectTerminalProvenance( sourcePaneAuthority.add(paneKey) } } + // Why the liveTabIds gates: a grafted workspace key carries only part of the + // source's tab list, so panes of source tabs that did not land in the adopted view + // must not adopt layouts, incarnations, or sleeping-agent records. for (const key of sourceAuthority) { for (const tab of source.tabsByWorktree[key] ?? []) { + if (!liveTabIds.has(tab.id)) { + continue + } for (const leafId of Object.keys( source.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} )) { @@ -132,7 +141,7 @@ export function collectTerminalProvenance( } } for (const [paneKey, record] of Object.entries(source.sleepingAgentSessionsByPaneKey ?? {})) { - if (sourceAuthority.has(record.worktreeId)) { + if (sourceAuthority.has(record.worktreeId) && (!record.tabId || liveTabIds.has(record.tabId))) { sourcePaneAuthority.add(paneKey) } } From 187f2e7fc5b5180a79853b7a836e2189311132bc Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 15 Aug 2026 02:06:48 +0300 Subject: [PATCH 15/20] review: round 4 fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqNqAZQTwmAkwGcHj5TYXk --- ...rkspace-session-partition-adoption.test.ts | 87 +++++++++++++++++++ .../workspace-session-partition-adoption.ts | 8 +- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index cf031e9480a5..145c008a363b 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -530,6 +530,93 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { ).not.toContain('tab-1') }) + it('merges a grafted key per pane: base row and layout survive, source bindings adopt', () => { + const keptLeaf = '33333333-3333-4333-8333-333333333333' + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('kept-dormant')] }, + terminalLayoutsByTabId: { + 'kept-dormant': { + root: { type: 'leaf', leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: null, + titlesByLeafId: { [keptLeaf]: 'base-title' } + } + }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { + [WORKTREE_ID]: [ + // Why the shared-id shape: the renderer wipes its persisted bindings on every + // disconnect while the runtime partition keeps the live ones, so a base row + // without a binding whose source copy is bound is the routine live topology. + // The pane-level merge adopts the binding onto the kept row; the row itself + // and leaf state the source does not bind stay base's. + tab('kept-dormant', WORKTREE_ID, 'ssh:one@@pty-50'), + tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-51') + ] + }, + terminalLayoutsByTabId: { + 'kept-dormant': { + root: { type: 'leaf', leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [keptLeaf]: 'ssh:one@@pty-50' } + }, + 'tab-1': layout('ssh:one@@pty-51') + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect(adoption.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'kept-dormant', + 'tab-1' + ]) + const keptLayout = adoption.session.terminalLayoutsByTabId['kept-dormant'] + expect(keptLayout?.ptyIdsByLeafId?.[keptLeaf]).toBe('ssh:one@@pty-50') + expect(keptLayout?.root).toEqual({ type: 'leaf', leafId: keptLeaf }) + expect( + adoption.session.tabsByWorktree[WORKTREE_ID]?.find((entry) => entry.id === 'kept-dormant') + ?.ptyId + ).toBe('ssh:one@@pty-50') + }) + + it('keeps a base-kept tab leaf untouched when the source copy does not bind it', () => { + const keptLeaf = '44444444-4444-4444-8444-444444444444' + const baseLayout = { + root: { type: 'leaf' as const, leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: null, + titlesByLeafId: { [keptLeaf]: 'base-title' } + } + const base = session({ + tabsByWorktree: { [WORKTREE_ID]: [tab('kept-dormant')] }, + terminalLayoutsByTabId: { 'kept-dormant': baseLayout }, + terminalTopologyRevisionByRepoId: { 'repo-1': 162 } + }) + const source = session({ + tabsByWorktree: { + [WORKTREE_ID]: [tab('kept-dormant'), tab('tab-1', WORKTREE_ID, 'ssh:one@@pty-52')] + }, + terminalLayoutsByTabId: { + 'kept-dormant': { + root: { type: 'leaf', leafId: keptLeaf }, + activeLeafId: keptLeaf, + expandedLeafId: null, + titlesByLeafId: { [keptLeaf]: 'source-title-should-not-win' } + }, + 'tab-1': layout('ssh:one@@pty-52') + } + }) + + const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) + + expect( + adoption.session.terminalLayoutsByTabId['kept-dormant']?.titlesByLeafId?.[keptLeaf] + ).toBe('base-title') + }) + it('keeps a close-emptied worktree closed against a stale pty-bound partition copy', () => { const base = session({ tabsByWorktree: { [WORKTREE_ID]: [] }, diff --git a/src/shared/workspace-session-partition-adoption.ts b/src/shared/workspace-session-partition-adoption.ts index a619164763f1..cc3b1254400a 100644 --- a/src/shared/workspace-session-partition-adoption.ts +++ b/src/shared/workspace-session-partition-adoption.ts @@ -136,8 +136,12 @@ export function adoptOrphanedWorkspaceSessionPartition( } const sourceLayout = source.terminalLayoutsByTabId[tabId] const sourceWorkspaceKey = sourceWorkspaceKeyByTabId.get(tabId) + // Why sourceFieldAuthority: a grafted key keeps base's own rows, so their layout + // and remote-session entries merge per pane instead of being replaced wholesale; + // grafted strays have no base-side entry and still take source's copy through + // the merge fallback. terminalLayoutsByTabId[tabId] = - sourceWorkspaceKey && sourceAuthority.has(sourceWorkspaceKey) + sourceWorkspaceKey && sourceFieldAuthority.has(sourceWorkspaceKey) ? (sourceLayout ?? terminalLayoutsByTabId[tabId]) : (mergeTerminalLayout( terminalLayoutsByTabId[tabId], @@ -147,7 +151,7 @@ export function adoptOrphanedWorkspaceSessionPartition( ) ?? terminalLayoutsByTabId[tabId]) if ( source.remoteSessionIdsByTabId?.[tabId] !== undefined && - ((sourceWorkspaceKey && sourceAuthority.has(sourceWorkspaceKey)) || + ((sourceWorkspaceKey && sourceFieldAuthority.has(sourceWorkspaceKey)) || remoteSessionIdsByTabId[tabId] === undefined || sourcePaneAuthoritativeTabIds.has(tabId)) ) { From 56e9571292910c7b2f101e58daa14c652b8935a5 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 15 Aug 2026 02:14:28 +0300 Subject: [PATCH 16/20] review: round 5 fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqNqAZQTwmAkwGcHj5TYXk --- .../workspace-session-partition-adoption.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/shared/workspace-session-partition-adoption.test.ts b/src/shared/workspace-session-partition-adoption.test.ts index 145c008a363b..2f79540decae 100644 --- a/src/shared/workspace-session-partition-adoption.test.ts +++ b/src/shared/workspace-session-partition-adoption.test.ts @@ -560,7 +560,7 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { 'kept-dormant': { root: { type: 'leaf', leafId: keptLeaf }, activeLeafId: keptLeaf, - expandedLeafId: null, + expandedLeafId: keptLeaf, ptyIdsByLeafId: { [keptLeaf]: 'ssh:one@@pty-50' } }, 'tab-1': layout('ssh:one@@pty-51') @@ -575,7 +575,9 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { ]) const keptLayout = adoption.session.terminalLayoutsByTabId['kept-dormant'] expect(keptLayout?.ptyIdsByLeafId?.[keptLeaf]).toBe('ssh:one@@pty-50') - expect(keptLayout?.root).toEqual({ type: 'leaf', leafId: keptLeaf }) + // Base's top-level layout fields win the per-pane merge; a wholesale replace + // would take the source copy's expandedLeafId instead. + expect(keptLayout?.expandedLeafId).toBeNull() expect( adoption.session.tabsByWorktree[WORKTREE_ID]?.find((entry) => entry.id === 'kept-dormant') ?.ptyId @@ -593,6 +595,7 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { const base = session({ tabsByWorktree: { [WORKTREE_ID]: [tab('kept-dormant')] }, terminalLayoutsByTabId: { 'kept-dormant': baseLayout }, + remoteSessionIdsByTabId: { 'kept-dormant': 'base-session' }, terminalTopologyRevisionByRepoId: { 'repo-1': 162 } }) const source = session({ @@ -607,7 +610,8 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { titlesByLeafId: { [keptLeaf]: 'source-title-should-not-win' } }, 'tab-1': layout('ssh:one@@pty-52') - } + }, + remoteSessionIdsByTabId: { 'kept-dormant': 'source-session-should-not-win' } }) const adoption = adoptOrphanedWorkspaceSessionPartition(base, source) @@ -615,6 +619,7 @@ describe('adoptOrphanedWorkspaceSessionPartition', () => { expect( adoption.session.terminalLayoutsByTabId['kept-dormant']?.titlesByLeafId?.[keptLeaf] ).toBe('base-title') + expect(adoption.session.remoteSessionIdsByTabId?.['kept-dormant']).toBe('base-session') }) it('keeps a close-emptied worktree closed against a stale pty-bound partition copy', () => { From 7cb82cc7928a8b646a8199c3bb48024dfeaf656a Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Mon, 17 Aug 2026 10:00:53 +0300 Subject: [PATCH 17/20] fix(ssh): graft live local tabs over a stale remote snapshot list The remote workspace snapshot's tab list is the last state some client exported, not a close ledger. A local tab bound to a live pane on the current connection whose id the snapshot does not contain is appended to the merged list instead of dropped, keeping its exact-target layout and remote session mapping. Unbound local rows still defer to the snapshot, ids the snapshot owns elsewhere follow its placement, and rows owned by a workspace outside the replace scope never graft. --- .../remote-workspace-session-merge.test.ts | 153 ++++++++++++++++++ .../hooks/remote-workspace-session-merge.ts | 46 +++++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts index 3528dd50898c..86aa9a4f156f 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts @@ -201,3 +201,156 @@ describe('mergeDirectSshRemoteWorkspaceSession', () => { expect(merged?.remoteSessionIdsByTabId).toEqual({ 'shared-tab': 'ssh:target-a@@new' }) }) }) + +function namedTab( + id: string, + worktreeId: string, + ptyId: string | null, + generation?: number +): TerminalTab { + return { id, worktreeId, ptyId, generation } as TerminalTab +} + +describe('mergeDirectSshRemoteWorkspaceSession live-tab graft', () => { + const worktreeId = 'repo-a::/work-a' + + it('grafts pty-bound local tabs missing from a snapshot of unbound rows', () => { + const liveBound = namedTab('live-1', worktreeId, 'ssh:target-a@@pty-34') + const liveLayoutBound = namedTab('live-2', worktreeId, null) + const current = session({ [worktreeId]: [liveBound, liveLayoutBound] }) + current.terminalLayoutsByTabId = { + 'live-2': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'ssh:target-a@@pty-35' } + } + } + current.remoteSessionIdsByTabId = { 'live-1': 'ssh:target-a@@pty-34' } + const remote = session({ + [worktreeId]: [ + namedTab('ghost-1', worktreeId, null, 110), + namedTab('ghost-2', worktreeId, null, 84) + ] + }) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.map((entry) => entry.id)).toEqual([ + 'ghost-1', + 'ghost-2', + 'live-1', + 'live-2' + ]) + expect(merged?.terminalLayoutsByTabId['live-2']?.ptyIdsByLeafId).toEqual({ + 'leaf-a': 'ssh:target-a@@pty-35' + }) + expect(merged?.remoteSessionIdsByTabId).toEqual({ 'live-1': 'ssh:target-a@@pty-34' }) + expect(merged?.activeWorktreeIdsOnShutdown).toContain(worktreeId) + }) + + it('grafts a live tab whose worktree is absent from the snapshot', () => { + const live = namedTab('live-1', worktreeId, 'ssh:target-a@@pty-34') + const current = session({ [worktreeId]: [live] }) + const remote = session({}) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.map((entry) => entry.id)).toEqual(['live-1']) + }) + + it('drops unbound local rows so dormant duplicates cannot resurrect', () => { + const dormant = namedTab('dormant-1', worktreeId, null) + const current = session({ [worktreeId]: [dormant] }) + const remote = session({ [worktreeId]: [namedTab('remote-1', worktreeId, null, 5)] }) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.map((entry) => entry.id)).toEqual(['remote-1']) + }) + + it('does not graft a tab bound only to a foreign connection', () => { + const foreign = namedTab('foreign-1', worktreeId, 'ssh:target-b@@pty-9') + const current = session({ [worktreeId]: [foreign] }) + const remote = session({ [worktreeId]: [] }) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]).toEqual([]) + }) + + it('follows the snapshot placement for an id the snapshot owns elsewhere', () => { + const worktreeB = 'repo-b::/work-b' + const live = namedTab('moved-1', worktreeId, 'ssh:target-a@@pty-3') + const current = session({ [worktreeId]: [live] }) + const remote = session({ [worktreeB]: [namedTab('moved-1', worktreeB, null, 7)] }) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId, worktreeB]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]).toBeUndefined() + expect(merged?.tabsByWorktree[worktreeB]?.map((entry) => entry.id)).toEqual(['moved-1']) + }) + + it('grafts a recovery-authority tab and a pending activation spawn', () => { + const recovering = namedTab('recover-1', worktreeId, null) + const spawning = { ...namedTab('spawn-1', worktreeId, null), pendingActivationSpawn: true } + const current = session({ [worktreeId]: [recovering, spawning] }) + const remote = session({ [worktreeId]: [] }) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set([directSshTerminalTabKey(worktreeId, 'recover-1')]), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.map((entry) => entry.id)).toEqual([ + 'recover-1', + 'spawn-1' + ]) + expect(merged?.tabsByWorktree[worktreeId]?.[1]?.pendingActivationSpawn).toBeTruthy() + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 4d6b149b074b..1f9f75e7fe18 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -162,6 +162,46 @@ export function mergeDirectSshRemoteWorkspaceSession( const remoteTabIds = new Set( Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) ) + // Graft pass: a local tab bound to a live pane on this connection survives an + // apply whose remote list does not contain its id. The remote snapshot is the + // last state some client exported, not a close ledger — a tab created or + // adopted locally between exports is absent from that list without anyone + // having closed it, and dropping the row would detach a running terminal that + // cold restore then relaunches as a duplicate. Ids the snapshot does know + // (under any worktree) follow the snapshot's placement, and unbound local + // rows stay droppable so dormant duplicates cannot resurrect through the + // graft. + const graftedTabIds = new Set() + const nonReplaceOwnedTabIds = new Set( + Object.entries(liveTabsByWorktree) + .filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId)) + .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) + ) + for (const worktreeId of replaceWorktreeIds) { + const grafts = (liveTabsByWorktree[worktreeId] ?? []).filter( + (tab) => + !remoteTabIds.has(tab.id) && + !nonReplaceOwnedTabIds.has(tab.id) && + !graftedTabIds.has(tab.id) && + (isTargetPtyId(tab.ptyId, targetId) || + Object.values(targetLayout(current, tab.id, targetId)?.ptyIdsByLeafId ?? {}).some( + Boolean + ) || + preserveLocalTerminalTabKeys.has(directSshTerminalTabKey(worktreeId, tab.id)) || + Boolean(tab.pendingActivationSpawn)) + ) + if (grafts.length === 0) { + continue + } + for (const tab of grafts) { + graftedTabIds.add(tab.id) + } + locallyPreservedWorktreeIds.add(worktreeId) + tabsByWorktree[worktreeId] = [ + ...(tabsByWorktree[worktreeId] ?? []), + ...grafts.map((tab) => (isTargetPtyId(tab.ptyId, targetId) ? tab : { ...tab, ptyId: null })) + ] + } const replacedTabIds = new Set([ ...remoteTabIds, ...Object.entries(current.tabsByWorktree) @@ -177,7 +217,8 @@ export function mergeDirectSshRemoteWorkspaceSession( Object.entries(current.terminalLayoutsByTabId).filter( ([tabId]) => !replacedTabIds.has(tabId) || - (locallyPreservedTabIds.has(tabId) && targetLayout(current, tabId, targetId)) + ((locallyPreservedTabIds.has(tabId) || graftedTabIds.has(tabId)) && + targetLayout(current, tabId, targetId)) ) ), ...Object.fromEntries( @@ -221,7 +262,8 @@ export function mergeDirectSshRemoteWorkspaceSession( Object.entries(current.remoteSessionIdsByTabId ?? {}).filter( ([tabId, ptyId]) => !replacedTabIds.has(tabId) || - (locallyPreservedTabIds.has(tabId) && isTargetPtyId(ptyId, targetId)) + ((locallyPreservedTabIds.has(tabId) || graftedTabIds.has(tabId)) && + isTargetPtyId(ptyId, targetId)) ) ), ...Object.fromEntries( From cfb7568f8f543e90bc91bb1ccc597199a7bb0839 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Mon, 17 Aug 2026 10:19:25 +0300 Subject: [PATCH 18/20] review: round 1 fixes --- .../remote-workspace-session-merge-guards.ts | 79 +++++++++ .../remote-workspace-session-merge.test.ts | 107 ++++++++++++ .../hooks/remote-workspace-session-merge.ts | 154 +++++++----------- 3 files changed, 246 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/hooks/remote-workspace-session-merge-guards.ts diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-guards.ts b/src/renderer/src/hooks/remote-workspace-session-merge-guards.ts new file mode 100644 index 000000000000..27f233de5d49 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-session-merge-guards.ts @@ -0,0 +1,79 @@ +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { collectLeafIdsInOrder } from '../components/terminal-pane/terminal-layout-leaf-ids' + +export function isTargetPtyId(ptyId: string | null | undefined, targetId: string): ptyId is string { + return Boolean(ptyId && parseAppSshPtyId(ptyId)?.connectionId === targetId) +} + +export function hasAmbiguousResultTabIds( + current: WorkspaceSessionState, + remote: WorkspaceSessionState, + replaceWorktreeIds: ReadonlySet +): boolean { + const ownerByTabId = new Map() + const addOwner = (worktreeId: string, tab: TerminalTab): boolean => { + const tabId = tab.id + const owner = ownerByTabId.get(tabId) + if (owner && owner !== worktreeId) { + return false + } + ownerByTabId.set(tabId, worktreeId) + return true + } + for (const [worktreeId, tabs] of Object.entries(current.tabsByWorktree)) { + if (!replaceWorktreeIds.has(worktreeId)) { + for (const tab of tabs) { + if (!addOwner(worktreeId, tab)) { + return true + } + } + } + } + for (const [worktreeId, tabs] of Object.entries(remote.tabsByWorktree)) { + for (const tab of tabs) { + if (!addOwner(worktreeId, tab)) { + return true + } + } + } + return false +} + +export function targetLayout( + session: WorkspaceSessionState, + tabId: string, + targetId: string +): WorkspaceSessionState['terminalLayoutsByTabId'][string] | undefined { + const layout = session.terminalLayoutsByTabId[tabId] + if ( + !layout || + Object.values(layout.ptyIdsByLeafId ?? {}).some((ptyId) => !isTargetPtyId(ptyId, targetId)) + ) { + return undefined + } + return layout +} + +export function retainedLocalPtyId( + current: WorkspaceSessionState, + reconnectPtyIdByTabId: Readonly>, + tabId: string, + targetId: string, + allowTabKeyedRecovery: boolean +): string | undefined { + const layout = targetLayout(current, tabId, targetId) + const mountedLeafIds = collectLeafIdsInOrder(layout?.root) + const primaryLeafId = + layout?.activeLeafId && mountedLeafIds.includes(layout.activeLeafId) + ? layout.activeLeafId + : mountedLeafIds[0] + const layoutPtyId = primaryLeafId ? layout?.ptyIdsByLeafId?.[primaryLeafId] : undefined + const reconnectPtyId = allowTabKeyedRecovery ? reconnectPtyIdByTabId[tabId] : undefined + return isTargetPtyId(reconnectPtyId, targetId) + ? reconnectPtyId + : isTargetPtyId(layoutPtyId, targetId) + ? layoutPtyId + : undefined +} diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts index 86aa9a4f156f..8261d2d6f6d5 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts @@ -353,4 +353,111 @@ describe('mergeDirectSshRemoteWorkspaceSession live-tab graft', () => { ]) expect(merged?.tabsByWorktree[worktreeId]?.[1]?.pendingActivationSpawn).toBeTruthy() }) + + it('keeps local layout and session-id bindings over snapshot entries for a grafted id', () => { + const live = namedTab('live-1', worktreeId, 'ssh:target-a@@pty-34') + const current = session({ [worktreeId]: [live] }) + current.terminalLayoutsByTabId = { + 'live-1': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'ssh:target-a@@pty-34' } + } + } + current.remoteSessionIdsByTabId = { 'live-1': 'ssh:target-a@@pty-34' } + const remote = session({ [worktreeId]: [] }) + remote.terminalLayoutsByTabId = { + 'live-1': { + root: { type: 'leaf', leafId: 'leaf-x' }, + activeLeafId: 'leaf-x', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-x': 'ssh:target-a@@forged' } + } + } + remote.remoteSessionIdsByTabId = { 'live-1': 'ssh:target-a@@forged' } + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.terminalLayoutsByTabId['live-1']?.ptyIdsByLeafId).toEqual({ + 'leaf-a': 'ssh:target-a@@pty-34' + }) + expect(merged?.remoteSessionIdsByTabId).toEqual({ 'live-1': 'ssh:target-a@@pty-34' }) + }) + + it('fails closed when the same id is graft-eligible under two replace-scope worktrees', () => { + const worktreeB = 'repo-b::/work-b' + const current = session({ + [worktreeId]: [namedTab('dup-1', worktreeId, 'ssh:target-a@@pty-1')], + [worktreeB]: [namedTab('dup-1', worktreeB, 'ssh:target-a@@pty-2')] + }) + const remote = session({}) + + expect( + mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId, worktreeB]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + ).toBeNull() + }) + + it('does not graft an id owned by a worktree outside the replace scope', () => { + const outsideWorktree = 'repo-c::/work-c' + const current = session({ + [worktreeId]: [namedTab('outside-1', worktreeId, 'ssh:target-a@@pty-1')] + }) + const liveTabsByWorktree = { + [worktreeId]: [namedTab('outside-1', worktreeId, 'ssh:target-a@@pty-1')], + [outsideWorktree]: [namedTab('outside-1', outsideWorktree, null)] + } + const remote = session({ [worktreeId]: [] }) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + liveTabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]).toEqual([]) + }) + + it('keeps active-tab bookkeeping for a worktree reconstituted only by grafting', () => { + const live = namedTab('live-1', worktreeId, 'ssh:target-a@@pty-34') + const current = session({ [worktreeId]: [live] }) + current.activeWorktreeId = worktreeId + current.activeTabId = 'live-1' + current.activeTabIdByWorktree = { [worktreeId]: 'live-1' } + const remote = session({}) + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.activeWorktreeId).toBe(worktreeId) + expect(merged?.activeTabId).toBe('live-1') + expect(merged?.activeTabIdByWorktree?.[worktreeId]).toBe('live-1') + }) }) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 1f9f75e7fe18..690f89b95dbd 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -1,11 +1,15 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { splitWorktreeId } from '../../../shared/worktree/id' import type { AppState } from '../store/types' -import { collectLeafIdsInOrder } from '../components/terminal-pane/terminal-layout-leaf-ids' +import { + hasAmbiguousResultTabIds, + isTargetPtyId, + retainedLocalPtyId, + targetLayout +} from './remote-workspace-session-merge-guards' function preserveNewerLocalTerminalFields( remote: TerminalTab, @@ -26,81 +30,6 @@ export function directSshTerminalTabKey(worktreeId: string, tabId: string): stri return JSON.stringify([worktreeId, tabId]) } -function isTargetPtyId(ptyId: string | null | undefined, targetId: string): ptyId is string { - return Boolean(ptyId && parseAppSshPtyId(ptyId)?.connectionId === targetId) -} - -function hasAmbiguousResultTabIds( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - replaceWorktreeIds: ReadonlySet -): boolean { - const ownerByTabId = new Map() - const addOwner = (worktreeId: string, tab: TerminalTab): boolean => { - const tabId = tab.id - const owner = ownerByTabId.get(tabId) - if (owner && owner !== worktreeId) { - return false - } - ownerByTabId.set(tabId, worktreeId) - return true - } - for (const [worktreeId, tabs] of Object.entries(current.tabsByWorktree)) { - if (!replaceWorktreeIds.has(worktreeId)) { - for (const tab of tabs) { - if (!addOwner(worktreeId, tab)) { - return true - } - } - } - } - for (const [worktreeId, tabs] of Object.entries(remote.tabsByWorktree)) { - for (const tab of tabs) { - if (!addOwner(worktreeId, tab)) { - return true - } - } - } - return false -} - -function targetLayout( - session: WorkspaceSessionState, - tabId: string, - targetId: string -): WorkspaceSessionState['terminalLayoutsByTabId'][string] | undefined { - const layout = session.terminalLayoutsByTabId[tabId] - if ( - !layout || - Object.values(layout.ptyIdsByLeafId ?? {}).some((ptyId) => !isTargetPtyId(ptyId, targetId)) - ) { - return undefined - } - return layout -} - -function retainedLocalPtyId( - current: WorkspaceSessionState, - reconnectPtyIdByTabId: Readonly>, - tabId: string, - targetId: string, - allowTabKeyedRecovery: boolean -): string | undefined { - const layout = targetLayout(current, tabId, targetId) - const mountedLeafIds = collectLeafIdsInOrder(layout?.root) - const primaryLeafId = - layout?.activeLeafId && mountedLeafIds.includes(layout.activeLeafId) - ? layout.activeLeafId - : mountedLeafIds[0] - const layoutPtyId = primaryLeafId ? layout?.ptyIdsByLeafId?.[primaryLeafId] : undefined - const reconnectPtyId = allowTabKeyedRecovery ? reconnectPtyIdByTabId[tabId] : undefined - return isTargetPtyId(reconnectPtyId, targetId) - ? reconnectPtyId - : isTargetPtyId(layoutPtyId, targetId) - ? layoutPtyId - : undefined -} - export function mergeDirectSshRemoteWorkspaceSession( current: WorkspaceSessionState, remote: WorkspaceSessionState, @@ -162,16 +91,13 @@ export function mergeDirectSshRemoteWorkspaceSession( const remoteTabIds = new Set( Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) ) - // Graft pass: a local tab bound to a live pane on this connection survives an - // apply whose remote list does not contain its id. The remote snapshot is the - // last state some client exported, not a close ledger — a tab created or - // adopted locally between exports is absent from that list without anyone - // having closed it, and dropping the row would detach a running terminal that - // cold restore then relaunches as a duplicate. Ids the snapshot does know - // (under any worktree) follow the snapshot's placement, and unbound local - // rows stay droppable so dormant duplicates cannot resurrect through the - // graft. + // Graft pass: the snapshot's tab list is the last exported state, not a close + // ledger, so a local row the snapshot does not know is appended rather than + // dropped when it is pty-bound to this connection, holds recovery authority, + // or carries a pending activation spawn. Snapshot-known ids follow the + // snapshot's placement. const graftedTabIds = new Set() + const graftOnlyWorktreeIds = new Set() const nonReplaceOwnedTabIds = new Set( Object.entries(liveTabsByWorktree) .filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId)) @@ -182,7 +108,6 @@ export function mergeDirectSshRemoteWorkspaceSession( (tab) => !remoteTabIds.has(tab.id) && !nonReplaceOwnedTabIds.has(tab.id) && - !graftedTabIds.has(tab.id) && (isTargetPtyId(tab.ptyId, targetId) || Object.values(targetLayout(current, tab.id, targetId)?.ptyIdsByLeafId ?? {}).some( Boolean @@ -194,8 +119,16 @@ export function mergeDirectSshRemoteWorkspaceSession( continue } for (const tab of grafts) { + // The same graft-eligible id under two replace-scope worktrees is the + // ambiguity hasAmbiguousResultTabIds fails closed on for snapshot rows. + if (graftedTabIds.has(tab.id)) { + return null + } graftedTabIds.add(tab.id) } + if (remote.tabsByWorktree[worktreeId] === undefined) { + graftOnlyWorktreeIds.add(worktreeId) + } locallyPreservedWorktreeIds.add(worktreeId) tabsByWorktree[worktreeId] = [ ...(tabsByWorktree[worktreeId] ?? []), @@ -223,22 +156,51 @@ export function mergeDirectSshRemoteWorkspaceSession( ), ...Object.fromEntries( Object.entries(remote.terminalLayoutsByTabId).filter( - ([tabId]) => !locallyPreservedTabIds.has(tabId) && targetLayout(remote, tabId, targetId) + ([tabId]) => + !locallyPreservedTabIds.has(tabId) && + !graftedTabIds.has(tabId) && + targetLayout(remote, tabId, targetId) ) ) } + // A worktree reconstituted only by grafting has no snapshot entry to supply + // its active-tab bookkeeping, so the local entries survive for it. + const graftOnlyActiveTabByWorktree: Record = {} + for (const worktreeId of graftOnlyWorktreeIds) { + const tabs = tabsByWorktree[worktreeId] ?? [] + const currentActive = current.activeTabIdByWorktree?.[worktreeId] + graftOnlyActiveTabByWorktree[worktreeId] = + currentActive && tabs.some((tab) => tab.id === currentActive) + ? currentActive + : (tabs[0]?.id ?? null) + } const activeOutsideTarget = current.activeWorktreeId != null && !replaceWorktreeIds.has(current.activeWorktreeId) + const activeWorktreeGraftOnly = + current.activeWorktreeId != null && graftOnlyWorktreeIds.has(current.activeWorktreeId) + const keepCurrentActive = activeOutsideTarget || activeWorktreeGraftOnly + let graftOnlyActiveTabId: string | null = null + if (activeWorktreeGraftOnly && current.activeWorktreeId) { + const tabs = tabsByWorktree[current.activeWorktreeId] ?? [] + graftOnlyActiveTabId = + current.activeTabId && tabs.some((tab) => tab.id === current.activeTabId) + ? current.activeTabId + : (graftOnlyActiveTabByWorktree[current.activeWorktreeId] ?? null) + } return { ...current, - activeRepoId: activeOutsideTarget ? current.activeRepoId : remote.activeRepoId, - activeWorktreeId: activeOutsideTarget ? current.activeWorktreeId : remote.activeWorktreeId, - activeWorkspaceKey: activeOutsideTarget + activeRepoId: keepCurrentActive ? current.activeRepoId : remote.activeRepoId, + activeWorktreeId: keepCurrentActive ? current.activeWorktreeId : remote.activeWorktreeId, + activeWorkspaceKey: keepCurrentActive ? current.activeWorkspaceKey : remote.activeWorktreeId ? worktreeWorkspaceKey(remote.activeWorktreeId) : null, - activeTabId: activeOutsideTarget ? current.activeTabId : remote.activeTabId, + activeTabId: activeOutsideTarget + ? current.activeTabId + : activeWorktreeGraftOnly + ? graftOnlyActiveTabId + : remote.activeTabId, tabsByWorktree: { ...omitTargetWorktrees(current.tabsByWorktree), ...tabsByWorktree @@ -255,7 +217,8 @@ export function mergeDirectSshRemoteWorkspaceSession( ], activeTabIdByWorktree: { ...omitTargetWorktrees(current.activeTabIdByWorktree), - ...remote.activeTabIdByWorktree + ...remote.activeTabIdByWorktree, + ...graftOnlyActiveTabByWorktree }, remoteSessionIdsByTabId: { ...Object.fromEntries( @@ -268,7 +231,10 @@ export function mergeDirectSshRemoteWorkspaceSession( ), ...Object.fromEntries( Object.entries(remote.remoteSessionIdsByTabId ?? {}).filter( - ([tabId, ptyId]) => !locallyPreservedTabIds.has(tabId) && isTargetPtyId(ptyId, targetId) + ([tabId, ptyId]) => + !locallyPreservedTabIds.has(tabId) && + !graftedTabIds.has(tabId) && + isTargetPtyId(ptyId, targetId) ) ), ...Object.fromEntries( From 99e006754dbd88bd13b17c540ab680d83cc21ca9 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Mon, 17 Aug 2026 10:29:31 +0300 Subject: [PATCH 19/20] review: round 2 fixes --- .../remote-workspace-session-merge.test.ts | 25 +++++++++++++++++++ .../hooks/remote-workspace-session-merge.ts | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts index 8261d2d6f6d5..76415c8f72cf 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.test.ts @@ -460,4 +460,29 @@ describe('mergeDirectSshRemoteWorkspaceSession live-tab graft', () => { expect(merged?.activeTabId).toBe('live-1') expect(merged?.activeTabIdByWorktree?.[worktreeId]).toBe('live-1') }) + + it('keeps active-tab bookkeeping when the snapshot lists the worktree with zero rows', () => { + const live = namedTab('live-1', worktreeId, 'ssh:target-a@@pty-34') + const current = session({ [worktreeId]: [live] }) + current.activeWorktreeId = worktreeId + current.activeTabId = 'live-1' + current.activeTabIdByWorktree = { [worktreeId]: 'live-1' } + const remote = session({ [worktreeId]: [] }) + remote.activeTabIdByWorktree = { [worktreeId]: null } + + const merged = mergeDirectSshRemoteWorkspaceSession( + current, + remote, + new Set([worktreeId]), + current.tabsByWorktree, + new Set(), + {}, + authority + ) + + expect(merged?.tabsByWorktree[worktreeId]?.map((entry) => entry.id)).toEqual(['live-1']) + expect(merged?.activeWorktreeId).toBe(worktreeId) + expect(merged?.activeTabId).toBe('live-1') + expect(merged?.activeTabIdByWorktree?.[worktreeId]).toBe('live-1') + }) }) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 690f89b95dbd..55f2ccb4571c 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -126,7 +126,9 @@ export function mergeDirectSshRemoteWorkspaceSession( } graftedTabIds.add(tab.id) } - if (remote.tabsByWorktree[worktreeId] === undefined) { + // An empty snapshot list counts too: closing a worktree's last tab keeps + // the key with a zero-row array, and the export round-trips that shape. + if ((remote.tabsByWorktree[worktreeId] ?? []).length === 0) { graftOnlyWorktreeIds.add(worktreeId) } locallyPreservedWorktreeIds.add(worktreeId) From 3d078a52ef7b3feaaf8450e74f01979e12cc3c2d Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Wed, 19 Aug 2026 10:41:32 +0300 Subject: [PATCH 20/20] review: split the adoption tests to their own file and repoint the gate manifest persistence-ssh-partition-adoption.test.ts holds the 19 adoption tests (the host-partitioned file stays under the test max-lines budget), the reattach gate's testFiles/assertionRefs track the post-split file names, and upstream's generation-preservation target-sync test is restored verbatim. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqNqAZQTwmAkwGcHj5TYXk --- config/reliability-gates.jsonc | 21 +- ...sistence-host-partitioned-sessions.test.ts | 443 +------------- ...persistence-ssh-partition-adoption.test.ts | 540 ++++++++++++++++++ .../remote-workspace-target-sync.test.ts | 34 ++ 4 files changed, 581 insertions(+), 457 deletions(-) create mode 100644 src/main/persistence-ssh-partition-adoption.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3ee891f5b64a..133a52e97575 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -7745,12 +7745,13 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-patch-authority.test.ts src/main/ipc/remote-workspace-cache.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence-host-partitioned-sessions.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-session-partition-adoption.test.ts src/main/persistence-ssh-partition-adoption.test.ts src/main/ipc/session.test.ts src/renderer/src/lib/workspace-session-host-persistence.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", @@ -7761,14 +7762,13 @@ "testFiles": [ "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", "src/shared/workspace-session-partition-adoption.test.ts", - "src/main/persistence.test.ts", + "src/main/persistence-ssh-partition-adoption.test.ts", "src/main/ipc/session.test.ts", "src/renderer/src/lib/workspace-session-host-persistence.test.ts", "src/main/ipc/remote-workspace.test.ts", "src/main/ipc/remote-workspace-patch-queue.test.ts", "src/main/ipc/remote-workspace-patch-authority.test.ts", "src/main/ipc/remote-workspace-cache.test.ts", - "src/renderer/src/hooks/remote-workspace-session-merge.test.ts", "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts", "src/renderer/src/startup/ssh-startup-reconnect.test.ts", @@ -7842,7 +7842,7 @@ ] }, { - "file": "src/main/persistence.test.ts", + "file": "src/main/persistence-ssh-partition-adoption.test.ts", "assertions": [ "one transaction enumerates every persisted SSH partition and preserves non-SSH partitions", "equal- or unequal-revision duplicate repository keys preserve non-empty local state and every ambiguous SSH source while reconciled sources prune independently", @@ -7884,16 +7884,7 @@ { "file": "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "assertions": [ - "identity-free target snapshots preserve the exact local tab, active leaf binding, provider session, and shutdown reconnect owner before hydration" - ] - }, - { - "file": "src/renderer/src/hooks/remote-workspace-session-merge.test.ts", - "assertions": [ - "local retention is keyed by worktree plus tab plus exact DirectSshAuthority instead of a bare tab id", - "foreign target tab, layout, mounted-leaf, pending-reconnect, and remote-session PTYs are rejected before merge", - "duplicate ownership across git or folder workspaces fails closed even when both PTYs belong to the same target", - "exact-target equal and unequal generations preserve coherent PTY bundles" + "the connected-target push carries the session target id and authority alongside the hydrated target set" ] }, { diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index 5eb9c14915c9..023737ed3868 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { readFileSync, rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { PersistedState } from '../shared/persisted-state-types' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { isTerminalLeafId } from '../shared/stable-pane-id' @@ -17,7 +16,7 @@ import { makeWorktreeLineage, makeWorkspaceLineage } from './persistence-test-harness' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { worktreeWorkspaceKey } from '../shared/workspace-scope' import { TEST_LEAF_1 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. @@ -897,444 +896,4 @@ describe('Store host-partitioned workspace sessions', () => { const persisted = readDataFile() as PersistedSessionsFile expect(persisted.workspaceSession?.sleepingAgentSessionsByPaneKey).toBeUndefined() }) - - it('atomically moves stranded ssh tabs into the local partition', async () => { - const store = await createStore() - const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') - store.setWorkspaceSession({ - ...getDefaultWorkspaceSession(), - tabsByWorktree: { 'repo-1::/worktree': [] } - }) - store.setWorkspaceSession(source, 'ssh:ssh-1') - - const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( - source.tabsByWorktree['repo-1::/worktree'] - ) - expect(adopted.terminalLayoutsByTabId['tab-1']).toEqual(source.terminalLayoutsByTabId['tab-1']) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) - }) - - it('finishes cleanup when an earlier move left the same tab in both partitions', async () => { - const store = await createStore() - const session = makeBoundHostSession('ssh:ssh-1@@remote-pty') - store.setWorkspaceSession(session) - store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') - - const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( - session.tabsByWorktree['repo-1::/worktree'] - ) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) - }) - - it('keeps populated local state and preserves an equal-revision SSH fork', async () => { - const store = await createStore() - const local = makeBoundHostSession('ssh:ssh-1@@local-pty') - const source = makeBoundHostSession('ssh:ssh-1@@stale-pty') - source.tabsByWorktree['repo-1::/worktree'][0].id = 'stale-tab' - store.setWorkspaceSession(local) - store.setWorkspaceSession(source, 'ssh:ssh-1') - - const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( - local.tabsByWorktree['repo-1::/worktree'] - ) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) - }) - - it('ignores non-ssh adoption requests without mutating their partition', async () => { - const store = await createStore() - const local = makeBoundHostSession('local-pty') - const runtime = makeBoundHostSession('runtime:env-1@@runtime-pty') - store.setWorkspaceSession(local) - store.setWorkspaceSession(runtime, 'runtime:env-1') - - const adopted = store.adoptSshWorkspaceSessionPartition('runtime:env-1') - - expect(adopted).toEqual(local) - expect(store.getWorkspaceSession('runtime:env-1')).toEqual(runtime) - }) - - it('prunes tombstoned ssh tabs without resurrecting them locally', async () => { - const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') - writeDataFile({ - schemaVersion: 1, - workspaceSession: { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { 'repo-1::/worktree': [] }, - terminalSurfaceTombstonesByPaneKey: { - [`tab-1:${TEST_LEAF_1}`]: { - worktreeId: 'repo-1::/worktree', - parentTabId: 'tab-1', - leafId: TEST_LEAF_1, - ptyId: 'ssh:ssh-1@@remote-pty', - incarnationId: 'inc-1', - retiredAt: 1 - } - } - }, - workspaceSessionsByHostId: { 'ssh:ssh-1': source } - }) - const store = await createStore() - - const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual([]) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) - }) - - it('enumerates every persisted SSH partition including folder-only projects', async () => { - const store = await createStore() - const folderKey = folderWorkspaceKey('ssh-folder') - const boundFolderSession = makeBoundHostSession('ssh:ssh-folder@@folder-pty') - const folderSession = getDefaultWorkspaceSession() - folderSession.tabsByWorktree = { - [folderKey]: [ - { - ...boundFolderSession.tabsByWorktree['repo-1::/worktree'][0], - id: 'folder-tab', - worktreeId: folderKey - } - ] - } - folderSession.terminalLayoutsByTabId = { - 'folder-tab': boundFolderSession.terminalLayoutsByTabId['tab-1'] - } - folderSession.openFilesByWorktree = { - [folderKey]: [ - { - filePath: '/srv/folder/a.ts', - relativePath: 'a.ts', - worktreeId: folderKey, - language: 'typescript' - } - ] - } - store.setWorkspaceSession(folderSession, 'ssh:ssh-folder') - store.setWorkspaceSession(makeBoundHostSession('ssh:ssh-repo@@repo-pty'), 'ssh:ssh-repo') - store.setWorkspaceSession(makeHostSession('runtime-repo'), 'runtime:env-a') - const flush = vi.spyOn(store, 'flushOrThrow') - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(flush).toHaveBeenCalledTimes(2) - expect(adopted.tabsByWorktree[folderKey]).toHaveLength(1) - expect(adopted.openFilesByWorktree?.[folderKey]?.[0]?.relativePath).toBe('a.ts') - expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'runtime:env-a']) - expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') - }) - - it('preserves equal-revision repo conflicts in both SSH source partitions', async () => { - const store = await createStore() - const left = makeBoundHostSession('ssh:left@@left-pty') - const right = makeBoundHostSession('ssh:right@@right-pty') - left.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } - right.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } - right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' - store.setWorkspaceSession(left, 'ssh:left') - store.setWorkspaceSession(right, 'ssh:right') - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toBeUndefined() - expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) - expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) - }) - - it('preserves SSH sources whose distinct workspaces reuse one tab identity', async () => { - const store = await createStore() - const left = getDefaultWorkspaceSession() - const right = getDefaultWorkspaceSession() - left.tabsByWorktree = { - 'repo-1::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-1::/worktree' })] - } - right.tabsByWorktree = { - 'repo-2::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-2::/worktree' })] - } - store.setWorkspaceSession(left, 'ssh:left') - store.setWorkspaceSession(right, 'ssh:right') - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(adopted.tabsByWorktree).toEqual({}) - expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) - expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) - expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) - }) - - it('keeps non-empty local state and unequal-revision duplicate SSH sources partitioned', async () => { - const store = await createStore() - const left = makeBoundHostSession('ssh:left@@left-pty') - const right = makeBoundHostSession('ssh:right@@right-pty') - left.terminalTopologyRevisionByRepoId = { 'repo-1': 2 } - right.terminalTopologyRevisionByRepoId = { 'repo-1': 7 } - right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' - store.setWorkspaceSession(structuredClone(left)) - store.setWorkspaceSession(left, 'ssh:left') - store.setWorkspaceSession(right, 'ssh:right') - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( - left.tabsByWorktree['repo-1::/worktree'] - ) - expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) - expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) - expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) - }) - - it('does not choose a folder tab between conflicting SSH sources', async () => { - const store = await createStore() - const folderKey = folderWorkspaceKey('shared-folder') - const left = makeBoundHostSession('ssh:left@@left-pty') - const right = makeBoundHostSession('ssh:right@@right-pty') - left.tabsByWorktree = { - [folderKey]: [{ ...left.tabsByWorktree['repo-1::/worktree'][0], worktreeId: folderKey }] - } - right.tabsByWorktree = { - [folderKey]: [ - { - ...right.tabsByWorktree['repo-1::/worktree'][0], - id: 'right-tab', - worktreeId: folderKey - } - ] - } - store.setWorkspaceSession(left, 'ssh:left') - store.setWorkspaceSession(right, 'ssh:right') - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(adopted.tabsByWorktree[folderKey]).toBeUndefined() - expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) - }) - - it('prunes only the source whose state reconciled without ambiguity', async () => { - const store = await createStore() - const folderKey = folderWorkspaceKey('ambiguous-folder') - const local = getDefaultWorkspaceSession() - local.tabsByWorktree[folderKey] = [ - makeTerminalTab({ id: 'local-folder-tab', worktreeId: folderKey }) - ] - const accepted = makeBoundHostSession('ssh:accepted@@accepted-pty') - const retained = getDefaultWorkspaceSession() - retained.tabsByWorktree = { - [folderKey]: [makeTerminalTab({ id: 'remote-folder-tab', worktreeId: folderKey })] - } - store.setWorkspaceSession(local) - store.setWorkspaceSession(accepted, 'ssh:accepted') - store.setWorkspaceSession(retained, 'ssh:retained') - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) - expect(adopted.tabsByWorktree[folderKey]?.[0]?.id).toBe('local-folder-tab') - expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:retained']) - }) - - it('scopes a qualified adoption request to exactly one SSH partition', async () => { - const store = await createStore() - const left = makeBoundHostSession('ssh:left@@left-pty') - const right = makeBoundHostSession('ssh:right@@right-pty') - right.tabsByWorktree = { - 'repo-2::/worktree': [makeTerminalTab({ id: 'right-tab', worktreeId: 'repo-2::/worktree' })] - } - store.setWorkspaceSession(left, 'ssh:left') - store.setWorkspaceSession(right, 'ssh:right') - - const adopted = store.adoptSshWorkspaceSessionPartition('ssh:left') - - expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) - expect(adopted.tabsByWorktree['repo-2::/worktree']).toBeUndefined() - expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:right']) - }) - - it('rejects adoption before mutation when persistence writes are frozen', async () => { - const store = await createStore() - const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') - store.setWorkspaceSession(source, 'ssh:ssh-1') - store.flushOrThrow() - store.freezeWrites() - - expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('writes are frozen') - expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) - }) - - it('rolls back owner and sources when the durable owner flush fails', async () => { - const store = await createStore() - const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') - store.setWorkspaceSession(getDefaultWorkspaceSession()) - store.setWorkspaceSession(source, 'ssh:ssh-1') - const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { - throw new Error('owner flush failed') - }) - - expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('owner flush failed') - flush.mockRestore() - - expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) - expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:ssh-1']) - }) - - it('keeps the durable owner and retryable sources when source pruning fails', async () => { - const store = await createStore() - const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') - store.setWorkspaceSession(getDefaultWorkspaceSession()) - store.setWorkspaceSession(source, 'ssh:ssh-1') - const durableFlush = store.flushOrThrow.bind(store) - const flush = vi - .spyOn(store, 'flushOrThrow') - .mockImplementationOnce(durableFlush) - .mockImplementationOnce(() => { - throw new Error('source prune failed') - }) - - expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') - flush.mockRestore() - - expect(store.getWorkspaceSession().tabsByWorktree['repo-1::/worktree']).toHaveLength(1) - expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) - const persisted = readDataFile() as PersistedState - expect(persisted.workspaceSession.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) - expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']?.tabsByWorktree).toEqual( - source.tabsByWorktree - ) - }) - - it('converges idempotently when retrying after a source-prune failure', async () => { - const store = await createStore() - const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') - store.setWorkspaceSession(getDefaultWorkspaceSession()) - store.setWorkspaceSession(source, 'ssh:ssh-1') - const durableFlush = store.flushOrThrow.bind(store) - const flush = vi - .spyOn(store, 'flushOrThrow') - .mockImplementationOnce(durableFlush) - .mockImplementationOnce(() => { - throw new Error('source prune failed') - }) - expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') - flush.mockRestore() - const beforeRetry = structuredClone(store.getWorkspaceSession()) - - const afterRetry = store.adoptSshWorkspaceSessionPartition() - - expect(afterRetry).toEqual(beforeRetry) - expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) - const reloaded = await createStore() - expect(reloaded.getWorkspaceSession().tabsByWorktree).toEqual(beforeRetry.tabsByWorktree) - expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId).toEqual( - beforeRetry.terminalLayoutsByTabId - ) - expect(reloaded.getWorkspaceSessionHostIds()).toEqual(['local']) - }) - - it('reconciles a newer SSH binding, incarnation, and sleeping provider identity', async () => { - const store = await createStore() - const local = makeBoundHostSession('ssh:ssh-1@@old-pty') - local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } - local.sleepingAgentSessionsByPaneKey = { - [`tab-1:${TEST_LEAF_1}`]: { - paneKey: `tab-1:${TEST_LEAF_1}`, - tabId: 'tab-1', - worktreeId: 'repo-1::/worktree', - agent: 'codex', - providerSession: { key: 'session_id', id: 'provider-old' }, - prompt: 'old', - state: 'working', - capturedAt: 9, - updatedAt: 9 - } - } - const source = makeBoundHostSession('ssh:ssh-1@@new-pty') - source.terminalTopologyRevisionByRepoId = { 'repo-1': 1 } - source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-new' } - source.sleepingAgentSessionsByPaneKey = { - [`tab-1:${TEST_LEAF_1}`]: { - ...local.sleepingAgentSessionsByPaneKey[`tab-1:${TEST_LEAF_1}`], - providerSession: { key: 'session_id', id: 'provider-new' }, - prompt: 'new', - capturedAt: 1, - updatedAt: 1 - } - } - store.setWorkspaceSession(local) - store.setWorkspaceSession(source, 'ssh:ssh-1') - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(adopted.tabsByWorktree['repo-1::/worktree']?.[0]?.ptyId).toBe('ssh:ssh-1@@new-pty') - expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( - 'ssh:ssh-1@@new-pty' - ) - expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') - expect( - adopted.sleepingAgentSessionsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]?.providerSession.id - ).toBe('provider-new') - }) - - it('preserves a sync-flushed SSH incarnation across the adoption crash window', async () => { - const store = await createStore() - const local = makeBoundHostSession('ssh:ssh-1@@old-pty') - local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } - const source = structuredClone(local) - store.setWorkspaceSession(local) - store.setWorkspaceSession(source, 'ssh:ssh-1') - expect( - store.persistPtyBinding( - { - worktreeId: 'repo-1::/worktree', - tabId: 'tab-1', - leafId: TEST_LEAF_1, - ptyId: 'ssh:ssh-1@@new-pty', - incarnationId: 'inc-new', - expectedBinding: { ptyId: 'ssh:ssh-1@@old-pty', incarnationId: 'inc-old' } - }, - 'ssh:ssh-1' - ) - ).toBe(true) - - const adopted = store.adoptSshWorkspaceSessionPartition() - - expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( - 'ssh:ssh-1@@new-pty' - ) - expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') - expect(adopted.terminalTopologyRevisionByRepoId?.['repo-1']).toBe(1) - }) - - it('removes all SSH source metadata only after the owner is durable', async () => { - const store = await createStore() - const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') - source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-1' } - source.terminalTopologyRevisionByRepoId = { 'repo-1': 3 } - source.tabGroups = { - 'repo-1::/worktree': [ - { - id: 'group-1', - worktreeId: 'repo-1::/worktree', - activeTabId: 'tab-1', - tabOrder: ['tab-1'] - } - ] - } - store.setWorkspaceSession(source, 'ssh:ssh-1') - - store.adoptSshWorkspaceSessionPartition() - - expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) - const persisted = readDataFile() as PersistedState - expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']).toBeUndefined() - expect(persisted.workspaceSession.terminalPtyIncarnationsByPaneKey).toEqual({ - [`tab-1:${TEST_LEAF_1}`]: 'inc-1' - }) - expect(persisted.workspaceSession.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 3 }) - expect(persisted.workspaceSession.tabGroups?.['repo-1::/worktree']?.[0]?.id).toBe('group-1') - }) }) diff --git a/src/main/persistence-ssh-partition-adoption.test.ts b/src/main/persistence-ssh-partition-adoption.test.ts new file mode 100644 index 000000000000..046b04b5fe5c --- /dev/null +++ b/src/main/persistence-ssh-partition-adoption.test.ts @@ -0,0 +1,540 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { + testState, + createStore, + writeDataFile, + readDataFile, + makeTerminalTab +} from './persistence-test-harness' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { TEST_LEAF_1 } from './persistence-session-fixtures' + +// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. +const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ + loadUserSshConfigMock: vi.fn(), + sshConfigHostsToTargetsMock: vi.fn() +})) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: loadUserSshConfigMock, + sshConfigHostsToTargets: sshConfigHostsToTargetsMock +})) +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('encrypted:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('encrypted:'.length) + } + } +})) + +vi.mock('./telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + +describe('Store SSH workspace-session partition adoption', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + const makeHostSession = (activeRepoId: string): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId + }) + + const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-1', + activeWorktreeId: 'repo-1::/worktree', + activeTabId: 'tab-1', + tabsByWorktree: { + 'repo-1::/worktree': [ + { + id: 'tab-1', + worktreeId: 'repo-1::/worktree', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId + } + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [TEST_LEAF_1]: ptyId } : {} + } + } + }) + + it('atomically moves stranded ssh tabs into the local partition', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'repo-1::/worktree': [] } + }) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + source.tabsByWorktree['repo-1::/worktree'] + ) + expect(adopted.terminalLayoutsByTabId['tab-1']).toEqual(source.terminalLayoutsByTabId['tab-1']) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('finishes cleanup when an earlier move left the same tab in both partitions', async () => { + const store = await createStore() + const session = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(session) + store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + session.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('keeps populated local state and preserves an equal-revision SSH fork', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@local-pty') + const source = makeBoundHostSession('ssh:ssh-1@@stale-pty') + source.tabsByWorktree['repo-1::/worktree'][0].id = 'stale-tab' + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + local.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + }) + + it('ignores non-ssh adoption requests without mutating their partition', async () => { + const store = await createStore() + const local = makeBoundHostSession('local-pty') + const runtime = makeBoundHostSession('runtime:env-1@@runtime-pty') + store.setWorkspaceSession(local) + store.setWorkspaceSession(runtime, 'runtime:env-1') + + const adopted = store.adoptSshWorkspaceSessionPartition('runtime:env-1') + + expect(adopted).toEqual(local) + expect(store.getWorkspaceSession('runtime:env-1')).toEqual(runtime) + }) + + it('prunes tombstoned ssh tabs without resurrecting them locally', async () => { + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + writeDataFile({ + schemaVersion: 1, + workspaceSession: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'repo-1::/worktree': [] }, + terminalSurfaceTombstonesByPaneKey: { + [`tab-1:${TEST_LEAF_1}`]: { + worktreeId: 'repo-1::/worktree', + parentTabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@remote-pty', + incarnationId: 'inc-1', + retiredAt: 1 + } + } + }, + workspaceSessionsByHostId: { 'ssh:ssh-1': source } + }) + const store = await createStore() + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:ssh-1') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual([]) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual({}) + }) + + it('enumerates every persisted SSH partition including folder-only projects', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('ssh-folder') + const boundFolderSession = makeBoundHostSession('ssh:ssh-folder@@folder-pty') + const folderSession = getDefaultWorkspaceSession() + folderSession.tabsByWorktree = { + [folderKey]: [ + { + ...boundFolderSession.tabsByWorktree['repo-1::/worktree'][0], + id: 'folder-tab', + worktreeId: folderKey + } + ] + } + folderSession.terminalLayoutsByTabId = { + 'folder-tab': boundFolderSession.terminalLayoutsByTabId['tab-1'] + } + folderSession.openFilesByWorktree = { + [folderKey]: [ + { + filePath: '/srv/folder/a.ts', + relativePath: 'a.ts', + worktreeId: folderKey, + language: 'typescript' + } + ] + } + store.setWorkspaceSession(folderSession, 'ssh:ssh-folder') + store.setWorkspaceSession(makeBoundHostSession('ssh:ssh-repo@@repo-pty'), 'ssh:ssh-repo') + store.setWorkspaceSession(makeHostSession('runtime-repo'), 'runtime:env-a') + const flush = vi.spyOn(store, 'flushOrThrow') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(flush).toHaveBeenCalledTimes(2) + expect(adopted.tabsByWorktree[folderKey]).toHaveLength(1) + expect(adopted.openFilesByWorktree?.[folderKey]?.[0]?.relativePath).toBe('a.ts') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'runtime:env-a']) + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') + }) + + it('preserves equal-revision repo conflicts in both SSH source partitions', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } + right.terminalTopologyRevisionByRepoId = { 'repo-1': 4 } + right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toBeUndefined() + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + + it('preserves SSH sources whose distinct workspaces reuse one tab identity', async () => { + const store = await createStore() + const left = getDefaultWorkspaceSession() + const right = getDefaultWorkspaceSession() + left.tabsByWorktree = { + 'repo-1::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-1::/worktree' })] + } + right.tabsByWorktree = { + 'repo-2::/worktree': [makeTerminalTab({ id: 'shared-tab', worktreeId: 'repo-2::/worktree' })] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + + it('keeps non-empty local state and unequal-revision duplicate SSH sources partitioned', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.terminalTopologyRevisionByRepoId = { 'repo-1': 2 } + right.terminalTopologyRevisionByRepoId = { 'repo-1': 7 } + right.tabsByWorktree['repo-1::/worktree'][0].id = 'right-tab' + store.setWorkspaceSession(structuredClone(left)) + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toEqual( + left.tabsByWorktree['repo-1::/worktree'] + ) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + expect(store.getWorkspaceSession('ssh:left').tabsByWorktree).toEqual(left.tabsByWorktree) + expect(store.getWorkspaceSession('ssh:right').tabsByWorktree).toEqual(right.tabsByWorktree) + }) + + it('does not choose a folder tab between conflicting SSH sources', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('shared-folder') + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + left.tabsByWorktree = { + [folderKey]: [{ ...left.tabsByWorktree['repo-1::/worktree'][0], worktreeId: folderKey }] + } + right.tabsByWorktree = { + [folderKey]: [ + { + ...right.tabsByWorktree['repo-1::/worktree'][0], + id: 'right-tab', + worktreeId: folderKey + } + ] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree[folderKey]).toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:left', 'ssh:right']) + }) + + it('prunes only the source whose state reconciled without ambiguity', async () => { + const store = await createStore() + const folderKey = folderWorkspaceKey('ambiguous-folder') + const local = getDefaultWorkspaceSession() + local.tabsByWorktree[folderKey] = [ + makeTerminalTab({ id: 'local-folder-tab', worktreeId: folderKey }) + ] + const accepted = makeBoundHostSession('ssh:accepted@@accepted-pty') + const retained = getDefaultWorkspaceSession() + retained.tabsByWorktree = { + [folderKey]: [makeTerminalTab({ id: 'remote-folder-tab', worktreeId: folderKey })] + } + store.setWorkspaceSession(local) + store.setWorkspaceSession(accepted, 'ssh:accepted') + store.setWorkspaceSession(retained, 'ssh:retained') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(adopted.tabsByWorktree[folderKey]?.[0]?.id).toBe('local-folder-tab') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:retained']) + }) + + it('scopes a qualified adoption request to exactly one SSH partition', async () => { + const store = await createStore() + const left = makeBoundHostSession('ssh:left@@left-pty') + const right = makeBoundHostSession('ssh:right@@right-pty') + right.tabsByWorktree = { + 'repo-2::/worktree': [makeTerminalTab({ id: 'right-tab', worktreeId: 'repo-2::/worktree' })] + } + store.setWorkspaceSession(left, 'ssh:left') + store.setWorkspaceSession(right, 'ssh:right') + + const adopted = store.adoptSshWorkspaceSessionPartition('ssh:left') + + expect(adopted.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(adopted.tabsByWorktree['repo-2::/worktree']).toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:right']) + }) + + it('rejects adoption before mutation when persistence writes are frozen', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(source, 'ssh:ssh-1') + store.flushOrThrow() + store.freezeWrites() + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('writes are frozen') + expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + }) + + it('rolls back owner and sources when the durable owner flush fails', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('owner flush failed') + }) + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('owner flush failed') + flush.mockRestore() + + expect(store.getWorkspaceSession().tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local', 'ssh:ssh-1']) + }) + + it('keeps the durable owner and retryable sources when source pruning fails', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const durableFlush = store.flushOrThrow.bind(store) + const flush = vi + .spyOn(store, 'flushOrThrow') + .mockImplementationOnce(durableFlush) + .mockImplementationOnce(() => { + throw new Error('source prune failed') + }) + + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') + flush.mockRestore() + + expect(store.getWorkspaceSession().tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree).toEqual(source.tabsByWorktree) + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSession.tabsByWorktree['repo-1::/worktree']).toHaveLength(1) + expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']?.tabsByWorktree).toEqual( + source.tabsByWorktree + ) + }) + + it('converges idempotently when retrying after a source-prune failure', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + store.setWorkspaceSession(getDefaultWorkspaceSession()) + store.setWorkspaceSession(source, 'ssh:ssh-1') + const durableFlush = store.flushOrThrow.bind(store) + const flush = vi + .spyOn(store, 'flushOrThrow') + .mockImplementationOnce(durableFlush) + .mockImplementationOnce(() => { + throw new Error('source prune failed') + }) + expect(() => store.adoptSshWorkspaceSessionPartition()).toThrow('source prune failed') + flush.mockRestore() + const beforeRetry = structuredClone(store.getWorkspaceSession()) + + const afterRetry = store.adoptSshWorkspaceSessionPartition() + + expect(afterRetry).toEqual(beforeRetry) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().tabsByWorktree).toEqual(beforeRetry.tabsByWorktree) + expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId).toEqual( + beforeRetry.terminalLayoutsByTabId + ) + expect(reloaded.getWorkspaceSessionHostIds()).toEqual(['local']) + }) + + it('reconciles a newer SSH binding, incarnation, and sleeping provider identity', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@old-pty') + local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } + local.sleepingAgentSessionsByPaneKey = { + [`tab-1:${TEST_LEAF_1}`]: { + paneKey: `tab-1:${TEST_LEAF_1}`, + tabId: 'tab-1', + worktreeId: 'repo-1::/worktree', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-old' }, + prompt: 'old', + state: 'working', + capturedAt: 9, + updatedAt: 9 + } + } + const source = makeBoundHostSession('ssh:ssh-1@@new-pty') + source.terminalTopologyRevisionByRepoId = { 'repo-1': 1 } + source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-new' } + source.sleepingAgentSessionsByPaneKey = { + [`tab-1:${TEST_LEAF_1}`]: { + ...local.sleepingAgentSessionsByPaneKey[`tab-1:${TEST_LEAF_1}`], + providerSession: { key: 'session_id', id: 'provider-new' }, + prompt: 'new', + capturedAt: 1, + updatedAt: 1 + } + } + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.tabsByWorktree['repo-1::/worktree']?.[0]?.ptyId).toBe('ssh:ssh-1@@new-pty') + expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( + 'ssh:ssh-1@@new-pty' + ) + expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') + expect( + adopted.sleepingAgentSessionsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]?.providerSession.id + ).toBe('provider-new') + }) + + it('preserves a sync-flushed SSH incarnation across the adoption crash window', async () => { + const store = await createStore() + const local = makeBoundHostSession('ssh:ssh-1@@old-pty') + local.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-old' } + const source = structuredClone(local) + store.setWorkspaceSession(local) + store.setWorkspaceSession(source, 'ssh:ssh-1') + expect( + store.persistPtyBinding( + { + worktreeId: 'repo-1::/worktree', + tabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@new-pty', + incarnationId: 'inc-new', + expectedBinding: { ptyId: 'ssh:ssh-1@@old-pty', incarnationId: 'inc-old' } + }, + 'ssh:ssh-1' + ) + ).toBe(true) + + const adopted = store.adoptSshWorkspaceSessionPartition() + + expect(adopted.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[TEST_LEAF_1]).toBe( + 'ssh:ssh-1@@new-pty' + ) + expect(adopted.terminalPtyIncarnationsByPaneKey?.[`tab-1:${TEST_LEAF_1}`]).toBe('inc-new') + expect(adopted.terminalTopologyRevisionByRepoId?.['repo-1']).toBe(1) + }) + + it('removes all SSH source metadata only after the owner is durable', async () => { + const store = await createStore() + const source = makeBoundHostSession('ssh:ssh-1@@remote-pty') + source.terminalPtyIncarnationsByPaneKey = { [`tab-1:${TEST_LEAF_1}`]: 'inc-1' } + source.terminalTopologyRevisionByRepoId = { 'repo-1': 3 } + source.tabGroups = { + 'repo-1::/worktree': [ + { + id: 'group-1', + worktreeId: 'repo-1::/worktree', + activeTabId: 'tab-1', + tabOrder: ['tab-1'] + } + ] + } + store.setWorkspaceSession(source, 'ssh:ssh-1') + + store.adoptSshWorkspaceSessionPartition() + + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSessionsByHostId?.['ssh:ssh-1']).toBeUndefined() + expect(persisted.workspaceSession.terminalPtyIncarnationsByPaneKey).toEqual({ + [`tab-1:${TEST_LEAF_1}`]: 'inc-1' + }) + expect(persisted.workspaceSession.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 3 }) + expect(persisted.workspaceSession.tabGroups?.['repo-1::/worktree']?.[0]?.id).toBe('group-1') + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 8346922892b5..8a1e907bc078 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -329,6 +329,40 @@ describe('createRemoteWorkspaceTargetSync', () => { expect(state.hydrateBrowserSession).not.toHaveBeenCalled() }) + it('preserves a higher local generation from an older remote snapshot', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 7 + } + ] + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(5, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'old-remote-pty', + generation: 1 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect( + hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] + ).toMatchObject({ generation: 7, ptyId: 'local-pty' }) + }) + it('admits a genuinely newer remote generation without local recovery state', async () => { const hydrateTabsSession = vi.fn() const state = appState({