diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 73c21f42f5d..e42fcdaa34f 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -885,6 +885,26 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(procs[0].cwd).toBe('/repo/owned-before-osc7') expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7') }) + + it('reports the daemon session WSL owner', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const spawned = await adapter.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + + const procs = await adapter.listProcesses() + + expect(procs.find((process) => process.id === spawned.id)?.wslDistro).toBe('Ubuntu') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) }) describe('hasChildProcesses / getForegroundProcess', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index fc20ae5e110..b31c9c6c505 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -921,6 +921,7 @@ export class DaemonPtyAdapter implements IPtyProvider { title: 'shell', ...(worktreeId ? { worktreeId } : {}), ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}), + ...(s.wslDistro !== undefined ? { wslDistro: s.wslDistro } : {}), ...this.validatedAgentSessionOwners(s.agentSessionOwners) } }) diff --git a/src/main/daemon/terminal-host-session-listing.ts b/src/main/daemon/terminal-host-session-listing.ts index 2f0e99c332d..3ae2d5e845e 100644 --- a/src/main/daemon/terminal-host-session-listing.ts +++ b/src/main/daemon/terminal-host-session-listing.ts @@ -19,6 +19,7 @@ export function listLiveTerminalHostSessions( shellState: session.shellState, isAlive: true, ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + wslDistro: session.wslDistro, pid: session.pid, cwd: session.getCwd(), cols: size?.cols ?? 0, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index bec511d8e1e..082ae1e64b9 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -355,6 +355,7 @@ export type SessionInfo = { shellState: ShellReadyState isAlive: boolean terminalHandle?: string + wslDistro?: string | null pid: number | null cwd: string | null cols: number diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index c2dcfcff523..18351f6bac0 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -1789,6 +1789,26 @@ describe('LocalPtyProvider', () => { expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd') expect(newEntries[0]).toHaveProperty('worktreeId', 'repo::/tmp/owned-cwd') }) + + it('reports native and WSL ownership explicitly on Windows', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const native = await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe' + }) + const wsl = await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + + const processes = await provider.listProcesses() + + expect(processes.find((process) => process.id === native.id)?.wslDistro).toBeNull() + expect(processes.find((process) => process.id === wsl.id)?.wslDistro).toBe('Ubuntu') + }) }) describe('getDefaultShell', () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 226b5167e8d..10afd708062 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1323,7 +1323,8 @@ export class LocalPtyProvider implements IPtyProvider { cwd: ptyInitialCwd.get(id) ?? '', title: proc.process || ptyShellName.get(id) || 'shell', ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), - ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), + ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}) })) } diff --git a/src/main/providers/pty-process-info.ts b/src/main/providers/pty-process-info.ts new file mode 100644 index 00000000000..a34746a6267 --- /dev/null +++ b/src/main/providers/pty-process-info.ts @@ -0,0 +1,16 @@ +import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' + +export type PtyProcessInfo = { + id: string + incarnationId?: PtyIncarnationId + cwd: string + title: string + /** Owning worktree when the provider can report it authoritatively. */ + worktreeId?: string + /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ + terminalHandle?: string + /** Exact WSL owner reported by the PTY provider; null means native Windows. */ + wslDistro?: string | null + agentSessionOwners?: AgentSessionOwnerBinding[] +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index ba12c07e1ef..7ebe3de9f17 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -29,9 +29,9 @@ import type { PtySpawnResult } from './pty-spawn-result' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { AgentSessionExecutionClaim, - AgentSessionOwnerBinding, AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' +import type { PtyProcessInfo } from './pty-process-info' export type { PtyBackgroundStreamEvent, @@ -114,19 +114,7 @@ export type PtySpawnOptions = { signal?: AbortSignal } -export type { PtySpawnResult } - -export type PtyProcessInfo = { - id: string - incarnationId?: PtyIncarnationId - cwd: string - title: string - /** Owning worktree when the provider can report it authoritatively. */ - worktreeId?: string - /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ - terminalHandle?: string - agentSessionOwners?: AgentSessionOwnerBinding[] -} +export type { PtyProcessInfo, PtySpawnResult } type PtyProbeOptions = { signal?: AbortSignal } @@ -393,10 +381,7 @@ export type IGitProvider = { // ─── Provider Registry ────────────────────────────────────────────── -/** - * Routes operations to the correct provider based on connectionId. - * null/undefined connectionId = local provider. - */ +/** Routes operations by connectionId; null/undefined selects the local provider. */ export type IProviderRegistry = { getPtyProvider(connectionId: string | null | undefined): IPtyProvider getFilesystemProvider(connectionId: string | null | undefined): IFilesystemProvider diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3c8066265e1..c58b05f29bc 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -910,6 +910,7 @@ const TEST_FOLDER_WORKSPACE_PATH = '/tmp/platform' const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111' const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const HEADLESS_THIRD_LEAF_ID = '33333333-3333-4333-8333-333333333333' function isOriginMainBaseRefProbe(args: string[]): boolean { return ( @@ -15910,6 +15911,976 @@ describe('OrcaRuntimeService', () => { expect(writes).toEqual(['still writable']) }) + it('adopts a v1.4.150-shaped agent, setup, and shell orphan as one topology transaction', async () => { + const session = { + ...getDefaultWorkspaceSession(), + activeRepoId: TEST_REPO_ID, + activeWorktreeId: TEST_WORKTREE_ID, + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + } + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const writes: [string, string][] = [] + const resize = vi.fn(() => true) + const processes = [ + ['pty-agent', 'inc-agent', 'term_agent', 'Agent'], + ['pty-setup', 'inc-setup', 'term_setup', 'Setup'], + ['pty-shell', 'inc-shell', 'term_shell', 'Shell'] + ] as const + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + runtime.setPtyController({ + write: (ptyId, data) => { + writes.push([ptyId, data]) + return true + }, + resize, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => + processes.map(([id, incarnationId, terminalHandle, title]) => ({ + id, + incarnationId, + terminalHandle, + title, + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + })) + }) + const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(before.terminals.map((terminal) => terminal.tabId)).toEqual( + processes.map(([id]) => `pty:${id}`) + ) + const targeted = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`, 100, { + handles: ['term_setup'], + requireFreshPtyLiveness: true + }) + expect(targeted).toMatchObject({ + terminals: [expect.objectContaining({ handle: 'term_setup', ptyId: 'pty-setup' })], + totalCount: 1, + truncated: false + }) + runtime.onPtyData('pty-agent', 'legacy output\n', 1) + + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_agent', + ptyId: 'pty-agent', + incarnationId: 'stale-incarnation', + tabId: 'tab-agent', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_stale') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + + const adopted = await runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: before.topologyRevisions?.[TEST_WORKTREE_ID] ?? 0, + activeTabId: 'tab-agent', + activeGroupId: 'legacy-group', + claims: processes.map(([ptyId, incarnationId, terminal], index) => ({ + terminal, + ptyId, + incarnationId, + tabId: ['tab-agent', 'tab-setup', 'tab-shell'][index]!, + leafId: [HEADLESS_LEAF_ID, HEADLESS_SECOND_LEAF_ID, HEADLESS_THIRD_LEAF_ID][index]! + })) + }) + + expect(adopted.adopted).toBe(true) + expect(adopted.topologyRevision).toBe(1) + expect(adopted.snapshot.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + parentTabId: 'tab-agent', + leafId: HEADLESS_LEAF_ID, + title: 'Agent', + terminal: 'term_agent' + }), + expect.objectContaining({ + parentTabId: 'tab-setup', + leafId: HEADLESS_SECOND_LEAF_ID, + title: 'Setup', + terminal: 'term_setup' + }), + expect.objectContaining({ + parentTabId: 'tab-shell', + leafId: HEADLESS_THIRD_LEAF_ID, + title: 'Shell', + terminal: 'term_shell' + }) + ]) + ) + expect(adopted.snapshot.tabGroups).toEqual([ + expect.objectContaining({ + activeTabId: 'tab-agent', + tabOrder: ['tab-agent', 'tab-setup', 'tab-shell'] + }) + ]) + expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1) + + await runtime.sendTerminal('term_agent', { text: 'input' }) + await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41) + expect(writes).toEqual([['pty-agent', 'input']]) + expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41) + await expect(runtime.readTerminal('term_agent')).resolves.toMatchObject({ + tail: ['legacy output'] + }) + + const secondClient = await runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: processes.map(([ptyId, incarnationId, terminal], index) => ({ + terminal, + ptyId, + incarnationId, + tabId: ['tab-agent', 'tab-setup', 'tab-shell'][index]!, + leafId: [HEADLESS_LEAF_ID, HEADLESS_SECOND_LEAF_ID, HEADLESS_THIRD_LEAF_ID][index]! + })) + }) + expect(secondClient).toMatchObject({ adopted: false, topologyRevision: 1 }) + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_agent', + ptyId: 'pty-agent', + incarnationId: 'inc-agent', + tabId: 'competing-tab', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_competing_owner') + }) + + it('restores orphan pane and group topology without replacing a newer host-owned tab', async () => { + const session: WorkspaceSessionState = { + ...makeWorkspaceSessionWithHeadlessTerminal({ + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'terminal-3' }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'terminal-3', + ptyId: 'pty-new', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 3', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 3 + } + ] + }, + terminalLayoutsByTabId: { + 'terminal-3': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'pty-new' }) + } + }), + tabGroups: { + [TEST_WORKTREE_ID]: [ + { + id: 'group-live', + worktreeId: TEST_WORKTREE_ID, + activeTabId: 'terminal-3', + tabOrder: ['terminal-3'] + } + ] + }, + tabGroupLayouts: { + [TEST_WORKTREE_ID]: { type: 'leaf', groupId: 'group-live' } + }, + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-live' }, + terminalPtyIncarnationsByPaneKey: { + [`terminal-3:${HEADLESS_LEAF_ID}`]: 'inc-new' + } + } + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-new', + incarnationId: 'inc-new', + terminalHandle: 'term_new', + title: 'Terminal 3', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }, + { + id: 'pty-agent', + incarnationId: 'inc-agent', + terminalHandle: 'term_agent', + title: 'Claude', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }, + { + id: 'pty-setup', + incarnationId: 'inc-setup', + terminalHandle: 'term_setup', + title: 'Setup', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }, + { + id: 'pty-shell', + incarnationId: 'inc-shell', + terminalHandle: 'term_shell', + title: 'Shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + }) + + const adopted = await runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + activeTabId: 'tab-shell', + activeGroupId: 'group-old-right', + claims: [ + { + terminal: 'term_agent', + ptyId: 'pty-agent', + incarnationId: 'inc-agent', + tabId: 'tab-agent', + leafId: HEADLESS_LEAF_ID + }, + { + terminal: 'term_setup', + ptyId: 'pty-setup', + incarnationId: 'inc-setup', + tabId: 'tab-agent', + leafId: HEADLESS_SECOND_LEAF_ID + }, + { + terminal: 'term_shell', + ptyId: 'pty-shell', + incarnationId: 'inc-shell', + tabId: 'tab-shell', + leafId: HEADLESS_THIRD_LEAF_ID + } + ], + topology: { + tabs: [ + { + tabId: 'tab-agent', + root: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: HEADLESS_SECOND_LEAF_ID } + }, + activeLeafId: HEADLESS_SECOND_LEAF_ID, + expandedLeafId: null + }, + { + tabId: 'tab-shell', + root: { type: 'leaf', leafId: HEADLESS_THIRD_LEAF_ID }, + activeLeafId: HEADLESS_THIRD_LEAF_ID, + expandedLeafId: HEADLESS_THIRD_LEAF_ID + } + ], + groups: [ + { + id: 'group-old-left', + activeTabId: 'tab-agent', + tabOrder: ['tab-agent'], + recentTabIds: ['tab-agent'] + }, + { + id: 'group-old-right', + activeTabId: 'tab-shell', + tabOrder: ['tab-shell'] + } + ], + groupLayout: { + type: 'split', + direction: 'vertical', + ratio: 0.6, + first: { type: 'leaf', groupId: 'group-old-left' }, + second: { type: 'leaf', groupId: 'group-old-right' } + } + } + }) + + expect(adopted.snapshot.activeGroupId).toBe('group-old-right') + expect(adopted.snapshot.activeTabId).toBe(`tab-shell::${HEADLESS_THIRD_LEAF_ID}`) + expect(adopted.snapshot.tabGroups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'group-live', tabOrder: ['terminal-3'] }), + expect.objectContaining({ id: 'group-old-left', tabOrder: ['tab-agent'] }), + expect.objectContaining({ id: 'group-old-right', tabOrder: ['tab-shell'] }) + ]) + ) + expect(adopted.snapshot.tabGroupLayout).toMatchObject({ + type: 'split', + direction: 'vertical', + first: { type: 'leaf', groupId: 'group-live' }, + second: { + type: 'split', + direction: 'vertical', + ratio: 0.6, + first: { type: 'leaf', groupId: 'group-old-left' }, + second: { type: 'leaf', groupId: 'group-old-right' } + } + }) + expect(getSession().terminalLayoutsByTabId['tab-agent']).toMatchObject({ + root: { type: 'split', direction: 'horizontal', ratio: 0.7 }, + activeLeafId: HEADLESS_SECOND_LEAF_ID, + ptyIdsByLeafId: { + [HEADLESS_LEAF_ID]: 'pty-agent', + [HEADLESS_SECOND_LEAF_ID]: 'pty-setup' + } + }) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID].map((tab) => tab.id)).toEqual([ + 'terminal-3', + 'tab-agent', + 'tab-shell' + ]) + }) + + it('canonicalizes an equivalent persisted worktree key without duplicating terminal topology', async () => { + const aliasWorktreeId = `${TEST_REPO_ID}::/tmp//worktree-a/` + const base = makeWorkspaceSessionWithHeadlessTerminal({ + terminalPtyIncarnationsByPaneKey: { + [`host-tab:${HEADLESS_LEAF_ID}`]: 'inc-alias' + } + }) + const session: WorkspaceSessionState = { + ...base, + activeTabIdByWorktree: { [aliasWorktreeId]: 'host-tab' }, + tabsByWorktree: { + [aliasWorktreeId]: base.tabsByWorktree[TEST_WORKTREE_ID]!.map((tab) => ({ + ...tab, + worktreeId: aliasWorktreeId + })) + } + } + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'persisted-pty', + incarnationId: 'inc-alias', + terminalHandle: 'term_alias', + title: 'Alias shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + }) + + const adopted = await runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_alias', + ptyId: 'persisted-pty', + incarnationId: 'inc-alias', + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + } + ] + }) + + expect(adopted).toMatchObject({ adopted: true, topologyRevision: 1 }) + expect(adopted.snapshot.worktree).toBe(TEST_WORKTREE_ID) + expect(Object.keys(getSession().tabsByWorktree)).toContain(TEST_WORKTREE_ID) + expect(Object.keys(getSession().tabsByWorktree)).not.toContain(aliasWorktreeId) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]?.[0]?.worktreeId).toBe(TEST_WORKTREE_ID) + expect(getSession().activeTabIdByWorktree).toEqual({ [TEST_WORKTREE_ID]: 'host-tab' }) + }) + + it('keeps current-generation tab and leaf identity across a host restart', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal({ + terminalPtyIncarnationsByPaneKey: { + [`host-tab:${HEADLESS_LEAF_ID}`]: 'inc-current' + }, + terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 4 } + }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + let connected = true + const writes: [string, string][] = [] + const resize = vi.fn(() => true) + const makeRuntime = (): OrcaRuntimeService => { + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: (ptyId, data) => { + writes.push([ptyId, data]) + return true + }, + resize, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => + connected + ? [ + { + id: 'persisted-pty', + incarnationId: 'inc-current', + terminalHandle: 'term_current', + title: 'Current shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + : [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return runtime + } + + const originalRuntime = makeRuntime() + const beforeRestart = await originalRuntime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + connected = false + const disconnected = await originalRuntime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + connected = true + const reconnected = await originalRuntime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const restarted = makeRuntime() + const afterRestart = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const listed = await restarted.listTerminals(`id:${TEST_WORKTREE_ID}`) + restarted.onPtyData('persisted-pty', 'after restart\n', 1) + await restarted.sendTerminal('term_current', { text: 'input' }) + await restarted.updateRemoteDesktopViewer('persisted-pty', 'viewer', 'client', 132, 41) + + expect(beforeRestart.tabs[0]).toMatchObject({ + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'ready', + terminal: 'term_current', + title: 'Persisted Terminal' + }) + expect(afterRestart.tabs[0]).toMatchObject({ + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'ready', + terminal: 'term_current' + }) + expect(disconnected.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + expect(reconnected.tabs[0]).toMatchObject({ + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'ready', + terminal: 'term_current' + }) + expect(listed.terminals[0]).toMatchObject({ + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + incarnationId: 'inc-current', + orphaned: false + }) + expect(listed.topologyRevisions?.[TEST_WORKTREE_ID]).toBe(4) + await expect(restarted.readTerminal('term_current')).resolves.toMatchObject({ + tail: ['after restart'] + }) + expect(writes).toEqual([['persisted-pty', 'input']]) + expect(resize).toHaveBeenCalledWith('persisted-pty', 132, 41) + }) + + it('uses topology CAS before a client can claim a still-orphaned PTY', async () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 } + } + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-cas', + incarnationId: 'inc-cas', + terminalHandle: 'term_cas', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + }) + + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 6, + claims: [ + { + terminal: 'term_cas', + ptyId: 'pty-cas', + incarnationId: 'inc-cas', + tabId: 'tab-cas', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_topology_conflict') + }) + + it('rejects connection mismatch and reused handles while allowing a WSL-owned orphan', async () => { + const makeRuntime = (): OrcaRuntimeService => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + }) + return new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + } + const ownerMismatch = makeRuntime() + ownerMismatch.registerPty('pty-wrong-owner', TEST_WORKTREE_ID, 'ssh-other-host') + ownerMismatch.onPtySpawned('pty-wrong-owner', 'inc-owner', { awaitsRegistration: false }) + ownerMismatch.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-wrong-owner', + incarnationId: 'inc-owner', + terminalHandle: 'term_wrong_owner', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + }) + await expect( + ownerMismatch.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_wrong_owner', + ptyId: 'pty-wrong-owner', + incarnationId: 'inc-owner', + tabId: 'tab-owner', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_owner_mismatch') + + const reusedHandle = makeRuntime() + for (const [ptyId, incarnationId] of [ + ['pty-first', 'inc-first'], + ['pty-second', 'inc-second'] + ] as const) { + reusedHandle.registerPty(ptyId, TEST_WORKTREE_ID) + reusedHandle.onPtySpawned(ptyId, incarnationId, { awaitsRegistration: false }) + } + reusedHandle.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-first', + incarnationId: 'inc-first', + terminalHandle: 'term_reused', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }, + { + id: 'pty-second', + incarnationId: 'inc-second', + terminalHandle: 'term_reused', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + }) + await expect( + reusedHandle.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_reused', + ptyId: 'pty-second', + incarnationId: 'inc-second', + tabId: 'tab-second', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_stale') + + await withPlatform('win32', async () => { + const makeWslRuntime = (reportedWslDistro?: string | null): OrcaRuntimeService => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + }) + const wsl = new OrcaRuntimeService({ + ...runtimeStore, + flushOrThrow: vi.fn(), + getProjects: () => [ + { + id: 'project-wsl', + displayName: 'WSL', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 1, + updatedAt: 1 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } as never) + wsl.registerPty('pty-wsl', TEST_WORKTREE_ID, null, undefined, true) + wsl.onPtySpawned('pty-wsl', 'inc-wsl', { awaitsRegistration: false }) + wsl.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-wsl', + incarnationId: 'inc-wsl', + terminalHandle: 'term_wsl', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + ...(reportedWslDistro !== undefined ? { wslDistro: reportedWslDistro } : {}) + } + ] + }) + return wsl + } + const request = { + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_wsl', + ptyId: 'pty-wsl', + incarnationId: 'inc-wsl', + tabId: 'tab-wsl', + leafId: HEADLESS_LEAF_ID + } + ] + } + + await expect(makeWslRuntime('Ubuntu').adoptTerminalOrphans(request)).resolves.toMatchObject({ + adopted: true, + topologyRevision: 1 + }) + await expect(makeWslRuntime('Debian').adoptTerminalOrphans(request)).rejects.toThrow( + 'terminal_orphan_owner_mismatch' + ) + await expect(makeWslRuntime().adoptTerminalOrphans(request)).rejects.toThrow( + 'terminal_orphan_owner_mismatch' + ) + }) + }) + + it('preserves legacy pane and group topology without changing host focus', async () => { + const session = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: 'other-worktree', + activeTabId: 'other-tab', + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + } + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const processes = [ + ['pty-left', 'inc-left', 'term_left'], + ['pty-right', 'inc-right', 'term_right'], + ['pty-shell', 'inc-shell', 'term_shell'] + ] as const + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => + processes.map(([id, incarnationId, terminalHandle]) => ({ + id, + incarnationId, + terminalHandle, + title: id, + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + })) + }) + + await runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + activeTabId: 'tab-agent', + activeGroupId: 'group-left', + claims: processes.map(([ptyId, incarnationId, terminal], index) => ({ + terminal, + ptyId, + incarnationId, + tabId: index < 2 ? 'tab-agent' : 'tab-shell', + leafId: [HEADLESS_LEAF_ID, HEADLESS_SECOND_LEAF_ID, HEADLESS_THIRD_LEAF_ID][index]! + })), + topology: { + tabs: [ + { + tabId: 'tab-agent', + root: { + type: 'split', + direction: 'horizontal', + ratio: 0.35, + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: HEADLESS_SECOND_LEAF_ID } + }, + activeLeafId: HEADLESS_SECOND_LEAF_ID, + expandedLeafId: HEADLESS_SECOND_LEAF_ID + }, + { + tabId: 'tab-shell', + root: { type: 'leaf', leafId: HEADLESS_THIRD_LEAF_ID }, + activeLeafId: HEADLESS_THIRD_LEAF_ID, + expandedLeafId: null + } + ], + groups: [ + { + id: 'group-left', + activeTabId: 'tab-agent', + tabOrder: ['tab-agent'], + recentTabIds: ['tab-agent'] + }, + { id: 'group-right', activeTabId: 'tab-shell', tabOrder: ['tab-shell'] } + ], + groupLayout: { + type: 'split', + direction: 'vertical', + ratio: 0.6, + first: { type: 'leaf', groupId: 'group-left' }, + second: { type: 'leaf', groupId: 'group-right' } + } + } + }) + + expect(getSession()).toMatchObject({ + activeWorktreeId: 'other-worktree', + activeTabId: 'other-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'tab-agent' }, + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-left' }, + tabGroups: { + [TEST_WORKTREE_ID]: [ + { id: 'group-left', activeTabId: 'tab-agent', tabOrder: ['tab-agent'] }, + { id: 'group-right', activeTabId: 'tab-shell', tabOrder: ['tab-shell'] } + ] + }, + tabGroupLayouts: { + [TEST_WORKTREE_ID]: expect.objectContaining({ + type: 'split', + direction: 'vertical', + ratio: 0.6 + }) + }, + terminalLayoutsByTabId: { + 'tab-agent': expect.objectContaining({ + root: expect.objectContaining({ + type: 'split', + direction: 'horizontal', + ratio: 0.35 + }), + activeLeafId: HEADLESS_SECOND_LEAF_ID, + expandedLeafId: HEADLESS_SECOND_LEAF_ID + }) + } + }) + }) + + it('never lets an old handle adopt a replacement PTY incarnation', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + }) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + let process = { + id: 'reused-pty-id', + incarnationId: 'inc-old', + terminalHandle: 'term_old', + title: 'old', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [process] + }) + await expect(runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({ + terminals: [expect.objectContaining({ handle: 'term_old', incarnationId: 'inc-old' })] + }) + + process = { ...process, incarnationId: 'inc-new', terminalHandle: 'term_new', title: 'new' } + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_old', + ptyId: process.id, + incarnationId: 'inc-new', + tabId: 'stale-tab', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_stale') + await expect(runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({ + terminals: [expect.objectContaining({ handle: 'term_new', incarnationId: 'inc-new' })] + }) + }) + + it('rejects a proposed visual surface occupied by a different PTY', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + }) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'occupied-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'occupied', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'occupied-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'visual-pty' + } + ] + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'orphan-pty', + incarnationId: 'inc-orphan', + terminalHandle: 'term_orphan', + title: 'orphan', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + }) + + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_orphan', + ptyId: 'orphan-pty', + incarnationId: 'inc-orphan', + tabId: 'occupied-tab', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_surface_occupied') + }) + + it('rejects ambiguous duplicate persisted bindings before idempotence', async () => { + const duplicateTab = (id: string) => ({ + id, + ptyId: 'duplicate-pty', + worktreeId: TEST_WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [TEST_WORKTREE_ID]: [duplicateTab('duplicate-a'), duplicateTab('duplicate-b')] + }, + terminalLayoutsByTabId: { + 'duplicate-a': { + root: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + activeLeafId: HEADLESS_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [HEADLESS_LEAF_ID]: 'duplicate-pty' } + }, + 'duplicate-b': { + root: { type: 'leaf', leafId: HEADLESS_SECOND_LEAF_ID }, + activeLeafId: HEADLESS_SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [HEADLESS_SECOND_LEAF_ID]: 'duplicate-pty' } + } + }, + terminalPtyIncarnationsByPaneKey: { + [`duplicate-a:${HEADLESS_LEAF_ID}`]: 'inc-duplicate', + [`duplicate-b:${HEADLESS_SECOND_LEAF_ID}`]: 'inc-duplicate' + } + }) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'duplicate-pty', + incarnationId: 'inc-duplicate', + terminalHandle: 'term_duplicate', + title: 'duplicate', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + } + ] + }) + + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 0, + claims: [ + { + terminal: 'term_duplicate', + ptyId: 'duplicate-pty', + incarnationId: 'inc-duplicate', + tabId: 'duplicate-a', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_competing_owner') + }) + it('does not adopt a discovered terminal handle already bound to another live PTY', async () => { const runtime = new OrcaRuntimeService(store) const writesByPty = new Map() @@ -17192,9 +18163,16 @@ describe('OrcaRuntimeService', () => { worktree: TEST_WORKTREE_ID, publicationEpoch: 'epoch-1', snapshotVersion: 1, - activeGroupId: null, + activeGroupId: 'group-1', activeTabId: 'tab-1::pane:1', activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'missing-tab', + tabOrder: ['missing-tab', 'tab-1'] + } + ], tabs: [ { type: 'terminal', @@ -17226,6 +18204,7 @@ describe('OrcaRuntimeService', () => { worktreeId: TEST_WORKTREE_ID, root: { type: 'group', + activeTabId: 'tab-1', tabs: [ { tabId: 'tab-1', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 625a60e9a6b..200522ba500 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -368,6 +368,8 @@ import type { RuntimeTerminalFocus, RuntimeTerminalClose, RuntimeTerminalListResult, + RuntimeTerminalOrphanAdoptionRequest, + RuntimeTerminalOrphanAdoptionResult, RuntimeWorktreeTerminalSleepResult, RuntimeTerminalResolvePane, RuntimeTerminalState, @@ -426,6 +428,11 @@ import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit } from './headless-tab-group-split-layout' +import { + hasExactTerminalOrphanGroupLayout, + mergeTerminalOrphanGroupLayout +} from './terminal-orphan-topology' +import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner' import { retireTerminalSurfacesFromSnapshot, type RetiredTerminalSurface @@ -1134,6 +1141,7 @@ type RuntimePtyWorktreeRecord = { lastOscTitleAt: number | null managementTitle: string | null managementTitleAt: number | null + controllerTitle: string | null title: string | null titleUpdatedAt: number | null lastOutputAt: number | null @@ -2462,6 +2470,10 @@ export class OrcaRuntimeService { private handles = new Map() private handleByLeafKey = new Map() private handleByPtyId = new Map() + private controllerTerminalIdentityByPtyId = new Map< + string, + { handle: string; incarnationId: string; wslDistro?: string | null } + >() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] private waitersByHandle = new Map>() @@ -7141,11 +7153,29 @@ export class OrcaRuntimeService { } } - private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void { + private adoptControllerTerminalHandle( + ptyId: string, + handle: string | undefined, + incarnationId?: string + ): void { const trimmed = handle?.trim() if (!trimmed || !trimmed.startsWith('term_')) { return } + const pty = this.ptysById.get(ptyId) + const changedIncarnation = Boolean( + incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId + ) + if (changedIncarnation) { + const priorHandle = this.handleByPtyId.get(ptyId) + this.invalidateAllHandlesForPty(ptyId) + pty!.tabId = null + pty!.paneKey = null + // Reusing an exported handle would make stale client metadata name the replacement process. + if (priorHandle === trimmed) { + return + } + } if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { return } @@ -7154,6 +7184,23 @@ export class OrcaRuntimeService { this.registerPreAllocatedHandleForPty(ptyId, trimmed) } + private invalidateAllHandlesForPty(ptyId: string): void { + this.handleByPtyId.delete(ptyId) + const invalidated = new Set() + for (const [handle, record] of this.handles) { + if (record.ptyId === ptyId) { + invalidated.add(handle) + this.handles.delete(handle) + this.rejectWaitersForHandle(handle, 'terminal_handle_stale') + } + } + for (const [leafKey, handle] of this.handleByLeafKey) { + if (invalidated.has(handle)) { + this.handleByLeafKey.delete(leafKey) + } + } + } + // Why: adoption is best-effort restart recovery and must be first-wins. // Re-keying a pty that already has a handle this session would strand // waiters registered under the old handle, and provider-reported values @@ -12417,7 +12464,7 @@ export class OrcaRuntimeService { async listTerminals( worktreeSelector?: string, limit = DEFAULT_TERMINAL_LIST_LIMIT, - opts: { requireFreshPtyLiveness?: boolean } = {} + opts: { handles?: readonly string[]; requireFreshPtyLiveness?: boolean } = {} ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') @@ -12529,7 +12576,11 @@ export class OrcaRuntimeService { terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) } - const listedTerminals = terminals.slice(0, limit) + const requestedHandles = opts.handles ? new Set(opts.handles) : null + const matchingTerminals = requestedHandles + ? terminals.filter((terminal) => requestedHandles.has(terminal.handle)) + : terminals + const listedTerminals = matchingTerminals.slice(0, limit) const visualLayouts = this.buildTerminalVisualLayouts( listedTerminals, worktreesById, @@ -12539,8 +12590,480 @@ export class OrcaRuntimeService { return { terminals: listedTerminals, ...(visualLayouts.length > 0 ? { visualLayouts } : {}), - totalCount: terminals.length, - truncated: terminals.length > limit + topologyRevisions: Object.fromEntries( + [...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [ + worktreeId, + this.getTerminalTopologyRevision(worktreeId) + ]) + ), + totalCount: matchingTerminals.length, + truncated: matchingTerminals.length > limit + } + } + + private getTerminalTopologyRevision(worktreeId: string): number { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return ( + this.store?.getWorkspaceSession?.()?.terminalTopologyRevisionByRepoId?.[repoId] ?? + this.terminalTopologyRevisionByRepoId.get(repoId) ?? + 0 + ) + } + + async adoptTerminalOrphans( + request: RuntimeTerminalOrphanAdoptionRequest + ): Promise { + if (request.claims.length === 0) { + throw new Error('terminal_orphan_claims_required') + } + const worktree = await this.resolveWorktreeSelector(request.worktree) + const livePtyIds = await this.refreshPtyWorktreeRecordsFromController([worktree], worktree.id) + if (!livePtyIds) { + throw new Error('terminal_liveness_unavailable') + } + const store = this.store + const session = store?.getWorkspaceSession?.() + if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) { + throw new Error('workspace_session_unavailable') + } + const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, worktree.id) + if (!sessionWorktreeId) { + throw new Error('terminal_orphan_competing_owner') + } + const repoId = getRepoIdFromWorktreeId(worktree.id) + const worktreeRepo = store.getRepo(repoId) + if (!worktreeRepo) { + throw new Error('terminal_orphan_owner_mismatch') + } + const worktreeConnectionId = worktreeRepo.connectionId ?? null + let worktreeWslDistro: string | null = null + if (!worktreeConnectionId) { + try { + worktreeWslDistro = + getLocalProjectWorktreeGitOptions(this.requireStore(), worktreeRepo).wslDistro ?? null + } catch { + throw new Error('terminal_orphan_owner_mismatch') + } + } + const currentRevision = this.getTerminalTopologyRevision(worktree.id) + const seenPtyIds = new Set() + const seenPaneKeys = new Set() + const validated = request.claims.map((claim) => { + const paneKey = makePaneKey(claim.tabId, claim.leafId) + if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) { + throw new Error('terminal_orphan_claim_duplicate') + } + seenPtyIds.add(claim.ptyId) + seenPaneKeys.add(paneKey) + const live = this.getLivePtyForHandle(claim.terminal) + const pty = live?.pty + const controllerIdentity = this.controllerTerminalIdentityByPtyId.get(claim.ptyId) + if ( + !pty || + pty.ptyId !== claim.ptyId || + controllerIdentity?.handle !== claim.terminal || + controllerIdentity?.incarnationId !== claim.incarnationId || + !livePtyIds.has(claim.ptyId) || + !pty.connected || + !pty.incarnationId || + pty.incarnationId !== claim.incarnationId + ) { + throw new Error('terminal_orphan_stale') + } + if ( + !runtimeWorktreeIdsEqual(pty.worktreeId, worktree.id) || + !terminalOrphanExecutionOwnersEqual( + { connectionId: worktreeConnectionId, wslDistro: worktreeWslDistro }, + { + connectionId: pty.connectionId ?? null, + ...(controllerIdentity?.wslDistro !== undefined + ? { wslDistro: controllerIdentity.wslDistro } + : process.platform === 'win32' && !worktreeConnectionId + ? {} + : { wslDistro: null }) + } + ) + ) { + throw new Error('terminal_orphan_owner_mismatch') + } + const visualOwners = this.getLeavesForPty(claim.ptyId) + if ( + visualOwners.some( + (owner) => + !runtimeWorktreeIdsEqual(owner.worktreeId, worktree.id) || + owner.tabId !== claim.tabId || + owner.leafId !== claim.leafId + ) + ) { + throw new Error('terminal_orphan_already_visual') + } + if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) { + throw new Error('terminal_orphan_competing_owner') + } + return { claim, pty, paneKey } + }) + + const persistedBindingsByPtyId = new Map() + const addPersistedBinding = ( + ptyId: string, + binding: { worktreeId: string; paneKey: string } + ): void => { + const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] + bindings.push(binding) + persistedBindingsByPtyId.set(ptyId, bindings) + } + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { + for (const tab of tabs) { + const layout = session.terminalLayoutsByTabId[tab.id] + for (const [leafId, boundPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { + if (boundPtyId) { + addPersistedBinding(boundPtyId, { + worktreeId, + paneKey: makePaneKey(tab.id, leafId) + }) + } + } + if (tab.ptyId && !layout) { + addPersistedBinding(tab.ptyId, { worktreeId, paneKey: tab.id }) + } + } + } + const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => { + const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] + if (bindings.length > 1) { + throw new Error('terminal_orphan_competing_owner') + } + return bindings[0] ?? null + } + const isExactPersisted = validated.every(({ claim, paneKey }) => { + const binding = persistedBinding(claim.ptyId) + return ( + binding !== null && + runtimeWorktreeIdsEqual(binding.worktreeId, worktree.id) && + binding.paneKey === paneKey && + session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId + ) + }) + if (isExactPersisted && sessionWorktreeId === worktree.id) { + return { + adopted: false, + topologyRevision: currentRevision, + snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`) + } + } + if (currentRevision !== request.expectedTopologyRevision) { + throw new Error('terminal_topology_conflict') + } + + const topologyTabsById = new Map(request.topology?.tabs.map((tab) => [tab.tabId, tab]) ?? []) + const topologyGroups = request.topology?.groups ?? [] + if (request.topology) { + const claimedLeafIdsByTabId = new Map>() + for (const { claim } of validated) { + const leafIds = claimedLeafIdsByTabId.get(claim.tabId) ?? new Set() + leafIds.add(claim.leafId) + claimedLeafIdsByTabId.set(claim.tabId, leafIds) + } + if ( + topologyTabsById.size !== request.topology.tabs.length || + topologyTabsById.size !== claimedLeafIdsByTabId.size + ) { + throw new Error('terminal_orphan_topology_invalid') + } + for (const [tabId, claimedLeafIds] of claimedLeafIdsByTabId) { + const topologyTab = topologyTabsById.get(tabId) + if (!topologyTab) { + throw new Error('terminal_orphan_topology_invalid') + } + const topologyLeafIds = new Set() + const nodes = [topologyTab.root] + let leafCount = 0 + while (nodes.length > 0) { + const node = nodes.pop()! + if (node.type === 'leaf') { + leafCount += 1 + topologyLeafIds.add(node.leafId) + } else { + nodes.push(node.first, node.second) + } + } + if ( + leafCount !== topologyLeafIds.size || + topologyLeafIds.size !== claimedLeafIds.size || + [...topologyLeafIds].some((leafId) => !claimedLeafIds.has(leafId)) || + !topologyLeafIds.has(topologyTab.activeLeafId) || + (topologyTab.expandedLeafId !== null && !topologyLeafIds.has(topologyTab.expandedLeafId)) + ) { + throw new Error('terminal_orphan_topology_invalid') + } + } + const seenGroupIds = new Set() + const groupedTabIds = new Set() + for (const group of topologyGroups) { + if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) { + throw new Error('terminal_orphan_topology_invalid') + } + seenGroupIds.add(group.id) + for (const tabId of group.tabOrder) { + if (!topologyTabsById.has(tabId) || groupedTabIds.has(tabId)) { + throw new Error('terminal_orphan_topology_invalid') + } + groupedTabIds.add(tabId) + } + if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) { + throw new Error('terminal_orphan_topology_invalid') + } + } + if (groupedTabIds.size !== topologyTabsById.size) { + throw new Error('terminal_orphan_topology_invalid') + } + if (request.topology.groupLayout) { + if (!hasExactTerminalOrphanGroupLayout(request.topology.groupLayout, seenGroupIds)) { + throw new Error('terminal_orphan_topology_invalid') + } + } + } + + for (const { claim, paneKey } of validated) { + const existingBinding = persistedBinding(claim.ptyId) + if ( + existingBinding && + (!runtimeWorktreeIdsEqual(existingBinding.worktreeId, worktree.id) || + existingBinding.paneKey !== paneKey) + ) { + throw new Error('terminal_orphan_competing_owner') + } + const proposedPtyId = + session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId] + if (proposedPtyId && proposedPtyId !== claim.ptyId) { + throw new Error('terminal_orphan_surface_occupied') + } + const graphOwner = this.leaves.get(this.getLeafKey(claim.tabId, claim.leafId)) + if ( + graphOwner && + (graphOwner.ptyId !== claim.ptyId || + !runtimeWorktreeIdsEqual(graphOwner.worktreeId, worktree.id)) + ) { + throw new Error('terminal_orphan_surface_occupied') + } + if ( + Object.entries(session.tabsByWorktree).some( + ([ownerWorktreeId, tabs]) => + !runtimeWorktreeIdsEqual(ownerWorktreeId, worktree.id) && + tabs.some((tab) => tab.id === claim.tabId) + ) + ) { + throw new Error('terminal_orphan_surface_occupied') + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + throw new Error('terminal_orphan_surface_retired') + } + for (const snapshot of this.mobileSessionTabsByWorktree.values()) { + const surfaceOwner = snapshot.tabs.find( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && + tab.parentTabId === claim.tabId && + tab.leafId === claim.leafId + ) + if ( + surfaceOwner && + (snapshot.worktree !== worktree.id || surfaceOwner.ptyId !== claim.ptyId) + ) { + throw new Error('terminal_orphan_surface_occupied') + } + const owner = snapshot.tabs.find( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && tab.ptyId === claim.ptyId + ) + if ( + owner && + (snapshot.worktree !== worktree.id || + owner.parentTabId !== claim.tabId || + owner.leafId !== claim.leafId) + ) { + throw new Error('terminal_orphan_competing_owner') + } + } + } + + const next = structuredClone(session) + canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, worktree.id) + const existingTabs = next.tabsByWorktree[worktree.id] ?? [] + const tabsById = new Map(existingTabs.map((tab) => [tab.id, tab])) + for (const { claim, pty, paneKey } of validated) { + let tab = tabsById.get(claim.tabId) + if (!tab) { + const title = + getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` + tab = { + id: claim.tabId, + ptyId: claim.ptyId, + worktreeId: worktree.id, + title, + defaultTitle: title, + customTitle: null, + color: null, + sortOrder: tabsById.size, + createdAt: Date.now(), + pendingActivationSpawn: true + } + tabsById.set(claim.tabId, tab) + } + const existingLayout = next.terminalLayoutsByTabId[claim.tabId] + const topologyTab = topologyTabsById.get(claim.tabId) + next.terminalLayoutsByTabId[claim.tabId] = topologyTab + ? { + ...existingLayout, + root: topologyTab.root, + activeLeafId: topologyTab.activeLeafId, + expandedLeafId: topologyTab.expandedLeafId, + ptyIdsByLeafId: { + ...existingLayout?.ptyIdsByLeafId, + [claim.leafId]: claim.ptyId + } + } + : existingLayout + ? { + ...existingLayout, + root: this.collectPersistedTerminalLeafIds(existingLayout).includes(claim.leafId) + ? existingLayout.root + : existingLayout.root === null + ? { type: 'leaf', leafId: claim.leafId } + : { + type: 'split', + direction: 'vertical', + first: existingLayout.root, + second: { type: 'leaf', leafId: claim.leafId } + }, + ptyIdsByLeafId: { + ...existingLayout.ptyIdsByLeafId, + [claim.leafId]: claim.ptyId + } + } + : { + root: { type: 'leaf', leafId: claim.leafId }, + activeLeafId: claim.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [claim.leafId]: claim.ptyId } + } + next.terminalPtyIncarnationsByPaneKey = { + ...next.terminalPtyIncarnationsByPaneKey, + [paneKey]: claim.incarnationId + } + } + const adoptedTabIds = [...new Set(validated.map(({ claim }) => claim.tabId))] + next.tabsByWorktree[worktree.id] = [...tabsById.values()] + const activeTabId = + request.activeTabId && tabsById.has(request.activeTabId) + ? request.activeTabId + : (adoptedTabIds[0] ?? null) + const existingGroups = next.tabGroups?.[worktree.id] ?? [] + const targetGroupId = + (request.activeGroupId && existingGroups.some((group) => group.id === request.activeGroupId) + ? request.activeGroupId + : existingGroups[0]?.id) ?? + request.activeGroupId ?? + randomUUID() + const proposedGroups = topologyGroups.map((group) => ({ + ...group, + worktreeId: worktree.id + })) + const groups = + existingGroups.length === 0 && proposedGroups.length > 0 + ? proposedGroups + : existingGroups.length > 0 + ? existingGroups + .map((group) => { + const proposed = proposedGroups.find((candidate) => candidate.id === group.id) + const tabOrder = proposed + ? [ + ...group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)), + ...proposed.tabOrder + ] + : group.id === targetGroupId && proposedGroups.length === 0 + ? [...new Set([...group.tabOrder, ...adoptedTabIds])] + : group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)) + return { + ...group, + tabOrder, + activeTabId: proposed + ? proposed.activeTabId + : group.id === targetGroupId && activeTabId + ? activeTabId + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null), + ...(proposed?.recentTabIds ? { recentTabIds: proposed.recentTabIds } : {}) + } + }) + .concat( + proposedGroups.filter( + (proposed) => !existingGroups.some((group) => group.id === proposed.id) + ) + ) + : [{ id: targetGroupId, worktreeId: worktree.id, activeTabId, tabOrder: adoptedTabIds }] + const retainedGroups = groups.filter((group) => group.tabOrder.length > 0) + next.tabGroups = { + ...next.tabGroups, + [worktree.id]: retainedGroups + } + const mergedGroupLayout = mergeTerminalOrphanGroupLayout({ + existingLayout: next.tabGroupLayouts?.[worktree.id], + existingGroupIds: existingGroups.map((group) => group.id), + proposedLayout: request.topology?.groupLayout, + proposedGroupIds: proposedGroups.map((group) => group.id), + mergedGroupIds: retainedGroups.map((group) => group.id) + }) + if (mergedGroupLayout) { + next.tabGroupLayouts = { + ...next.tabGroupLayouts, + [worktree.id]: mergedGroupLayout + } + } + const activeGroup = + (request.activeGroupId + ? retainedGroups.find( + (group) => + group.id === request.activeGroupId && + (!activeTabId || group.tabOrder.includes(activeTabId)) + ) + : undefined) ?? + retainedGroups.find((group) => activeTabId && group.tabOrder.includes(activeTabId)) ?? + retainedGroups[0]! + const convergedActiveTabId = + activeTabId && activeGroup.tabOrder.includes(activeTabId) + ? activeTabId + : activeGroup.activeTabId + next.activeTabIdByWorktree = { + ...next.activeTabIdByWorktree, + ...(convergedActiveTabId ? { [worktree.id]: convergedActiveTabId } : {}) + } + next.activeGroupIdByWorktree = { + ...next.activeGroupIdByWorktree, + [worktree.id]: activeGroup.id + } + const persisted = advanceTerminalTopologyRevision(next, worktree.id) + try { + store.setWorkspaceSession(persisted) + store.flushOrThrow() + } catch (error) { + store.setWorkspaceSession(session) + throw error + } + for (const { claim, pty, paneKey } of validated) { + pty.tabId = claim.tabId + pty.paneKey = paneKey + } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { + force: true, + allowAttachedWindow: true, + onlyServeOwnedTerminals: true + }) + this.notifyMobileSessionTabsChanged(worktree.id) + return { + adopted: true, + topologyRevision: persisted.terminalTopologyRevisionByRepoId?.[repoId] ?? currentRevision + 1, + snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`) } } @@ -12643,7 +13166,10 @@ export class OrcaRuntimeService { return { type: 'group', groupId: group.id, - activeTabId: group.activeTabId, + activeTabId: + group.activeTabId && tabs.some((tab) => tab.tabId === group.activeTabId) + ? group.activeTabId + : (tabs[0]?.tabId ?? null), tabs } }) @@ -24394,6 +24920,7 @@ export class OrcaRuntimeService { lastOscTitleAt: null, managementTitle: null, managementTitleAt: null, + controllerTitle: null, title: state.title ?? null, titleUpdatedAt: titleObservedAt, lastOutputAt: state.lastOutputAt ?? null, @@ -24519,13 +25046,56 @@ export class OrcaRuntimeService { return null } const sessions = sessionsResult.value + const controllerIdentityByPtyId = new Map< + string, + { handle: string; incarnationId: string; wslDistro?: string | null } + >() + const ptyIdByControllerHandle = new Map() + const ambiguousControllerPtyIds = new Set() + for (const session of sessions) { + const handle = session.terminalHandle?.trim() + const incarnationId = session.incarnationId?.trim() + if (!handle?.startsWith('term_') || !incarnationId) { + continue + } + const priorPtyId = ptyIdByControllerHandle.get(handle) + if (priorPtyId && priorPtyId !== session.id) { + ambiguousControllerPtyIds.add(priorPtyId) + ambiguousControllerPtyIds.add(session.id) + controllerIdentityByPtyId.delete(priorPtyId) + continue + } + if (controllerIdentityByPtyId.has(session.id)) { + ambiguousControllerPtyIds.add(session.id) + controllerIdentityByPtyId.delete(session.id) + continue + } + ptyIdByControllerHandle.set(handle, session.id) + controllerIdentityByPtyId.set(session.id, { + handle, + incarnationId, + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}) + }) + } + for (const ptyId of ambiguousControllerPtyIds) { + controllerIdentityByPtyId.delete(ptyId) + } + this.controllerTerminalIdentityByPtyId = controllerIdentityByPtyId const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings( this.store?.getWorkspaceSession?.() ) + const persistedSurfaceByPtyId = indexPersistedPtySurfaceBindings( + this.store?.getWorkspaceSession?.() + ) const allLivePtyIds = new Set(sessions.map((session) => session.id)) const selectedLivePtyIds = new Set() for (const session of sessions) { - this.adoptControllerTerminalHandle(session.id, session.terminalHandle) + const controllerIdentity = controllerIdentityByPtyId.get(session.id) + this.adoptControllerTerminalHandle( + session.id, + controllerIdentity?.handle ?? session.terminalHandle, + controllerIdentity?.incarnationId ?? session.incarnationId + ) const persistedWorktreeId = persistedWorktreeIdByPtyId.get(session.id) const providerWorktree = resolvedWorktrees.find( (worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId) @@ -24564,10 +25134,23 @@ export class OrcaRuntimeService { continue } if (worktreeId) { - this.recordPtyWorktree(session.id, worktreeId, { + const persistedSurface = persistedSurfaceByPtyId.get(session.id) + const restoresExactSurface = + persistedSurface && + session.incarnationId && + persistedSurface.incarnationId === session.incarnationId && + runtimeWorktreeIdsEqual(persistedSurface.worktreeId, worktreeId) + const pty = this.recordPtyWorktree(session.id, worktreeId, { connected: true, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}) + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.wslDistro !== undefined + ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } + : {}), + ...(restoresExactSurface + ? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey } + : {}) }) + pty.controllerTitle = session.title?.trim() || null } // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. this.refreshPtyForegroundAgent(session.id) @@ -24805,9 +25388,12 @@ export class OrcaRuntimeService { const worktree = worktreesById.get(leaf.worktreeId) const tab = this.tabs.get(leaf.tabId) ?? null + const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined return { handle: this.issueHandle(leaf), ptyId: leaf.ptyId, + incarnationId: pty?.incarnationId ?? null, + orphaned: false, worktreeId: leaf.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', @@ -26155,14 +26741,18 @@ export class OrcaRuntimeService { ): RuntimeTerminalSummary { const worktree = worktreesById.get(pty.worktreeId) + const pane = parsePaneKey(pty.paneKey ?? '') + const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId return { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + orphaned, worktreeId: pty.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', - tabId: `pty:${pty.ptyId}`, - leafId: `pty:${pty.ptyId}`, + tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!, + leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId, title: getLatestPtyTitle(pty), connected: pty.connected, writable: pty.connected, @@ -31425,6 +32015,59 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string { : worktreeId } +function resolveTerminalSessionWorktreeId( + session: WorkspaceSessionState, + targetWorktreeId: string +): string | null { + const keyedWorktreeIds = new Set([ + ...Object.keys(session.tabsByWorktree), + ...Object.keys(session.tabGroups ?? {}), + ...Object.keys(session.tabGroupLayouts ?? {}), + ...Object.keys(session.activeTabIdByWorktree ?? {}), + ...Object.keys(session.activeGroupIdByWorktree ?? {}) + ]) + const matches = [...keyedWorktreeIds].filter((worktreeId) => + runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId) + ) + return matches.length > 1 ? null : (matches[0] ?? targetWorktreeId) +} + +function canonicalizeTerminalSessionWorktreeId( + session: WorkspaceSessionState, + sourceWorktreeId: string, + targetWorktreeId: string +): void { + if (sourceWorktreeId === targetWorktreeId) { + return + } + const tabs = session.tabsByWorktree[sourceWorktreeId] ?? [] + delete session.tabsByWorktree[sourceWorktreeId] + session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({ + ...tab, + worktreeId: targetWorktreeId + })) + + const groups = session.tabGroups?.[sourceWorktreeId] + if (groups) { + delete session.tabGroups![sourceWorktreeId] + session.tabGroups![targetWorktreeId] = groups.map((group) => ({ + ...group, + worktreeId: targetWorktreeId + })) + } + for (const keyedState of [ + session.tabGroupLayouts, + session.activeTabIdByWorktree, + session.activeGroupIdByWorktree + ]) { + if (!keyedState || !Object.hasOwn(keyedState, sourceWorktreeId)) { + continue + } + keyedState[targetWorktreeId] = keyedState[sourceWorktreeId] as never + delete keyedState[sourceWorktreeId] + } +} + function inferWorktreeIdFromPtyId(ptyId: string): string | null { return parsePtySessionId(ptyId).worktreeId } @@ -31461,6 +32104,49 @@ function indexPersistedPtyWorktreeBindings( return worktreeIdByPtyId } +function indexPersistedPtySurfaceBindings( + session: WorkspaceSessionState | null | undefined +): ReadonlyMap< + string, + { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } +> { + const bindingByPtyId = new Map< + string, + { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } + >() + const ambiguousPtyIds = new Set() + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + for (const [leafId, ptyId] of Object.entries( + session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + )) { + if (!ptyId || ambiguousPtyIds.has(ptyId)) { + continue + } + const paneKey = makePaneKey(tab.id, leafId) + const incarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey] + if (!incarnationId) { + continue + } + const binding = { worktreeId, tabId: tab.id, paneKey, incarnationId } + const existing = bindingByPtyId.get(ptyId) + if ( + existing && + (existing.worktreeId !== worktreeId || + existing.paneKey !== paneKey || + existing.incarnationId !== incarnationId) + ) { + bindingByPtyId.delete(ptyId) + ambiguousPtyIds.add(ptyId) + continue + } + bindingByPtyId.set(ptyId, binding) + } + } + } + return bindingByPtyId +} + function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) { return false diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 7212729348b..537e7ce97f5 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -5,6 +5,7 @@ import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' import { TERMINAL_METHODS } from './terminal' +import { TERMINAL_ORPHAN_METHODS } from './terminal-orphan' import { BROWSER_CORE_METHODS } from './browser-core' import { BROWSER_EXTRA_METHODS } from './browser-extras' import { BROWSER_SCREENCAST_METHODS } from './browser-screencast' @@ -49,6 +50,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...TERMINAL_METHODS, + ...TERMINAL_ORPHAN_METHODS, ...BROWSER_CORE_METHODS, ...BROWSER_SCREENCAST_METHODS, ...BROWSER_EXTRA_METHODS, diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index ac600e661ed..c035099d083 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -81,7 +81,10 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu } if ( node.ratio !== undefined && - (typeof node.ratio !== 'number' || node.ratio < 0 || node.ratio > 1) + (typeof node.ratio !== 'number' || + !Number.isFinite(node.ratio) || + node.ratio < 0 || + node.ratio > 1) ) { return null } @@ -93,7 +96,7 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu return value as TerminalPaneLayoutNodeInput } -const TerminalPaneLayoutNodeSchema = z +export const TerminalPaneLayoutNodeSchema = z .unknown() .transform((value) => parseTerminalPaneLayoutNode(value)) .pipe( diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts new file mode 100644 index 00000000000..5ca6fe21705 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -0,0 +1,107 @@ +import { z } from 'zod' +import type { TabGroupLayoutNode } from '../../../../shared/types' +import { defineMethod, type RpcAnyMethod } from '../core' +import { OptionalString, requiredString } from '../schemas' +import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas' + +function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null { + const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }] + let count = 0 + while (stack.length > 0) { + const current = stack.pop()! + if ( + current.depth > 64 || + ++count > 1_024 || + !current.value || + typeof current.value !== 'object' + ) { + return null + } + const node = current.value as Record + if (node.type === 'leaf') { + if ( + typeof node.groupId !== 'string' || + node.groupId.length < 1 || + node.groupId.length > 256 + ) { + return null + } + continue + } + if ( + node.type !== 'split' || + (node.direction !== 'horizontal' && node.direction !== 'vertical') || + (node.ratio !== undefined && + (typeof node.ratio !== 'number' || + !Number.isFinite(node.ratio) || + node.ratio < 0 || + node.ratio > 1)) + ) { + return null + } + stack.push( + { value: node.first, depth: current.depth + 1 }, + { value: node.second, depth: current.depth + 1 } + ) + } + return value as TabGroupLayoutNode +} + +const TerminalOrphanGroupLayout = z + .unknown() + .transform(parseOrphanGroupLayout) + .pipe(z.custom((value) => value !== null, 'Invalid orphan group layout')) + +const TerminalOrphanTopology = z.object({ + tabs: z + .array( + z.object({ + tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)), + root: TerminalPaneLayoutNodeSchema, + activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)), + expandedLeafId: z.string().max(128).nullable() + }) + ) + .min(1) + .max(64), + groups: z + .array( + z.object({ + id: z.string().min(1).max(256), + activeTabId: z.string().min(1).max(256), + tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64), + recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional() + }) + ) + .min(1) + .max(64), + groupLayout: TerminalOrphanGroupLayout.optional() +}) + +const TerminalAdoptOrphans = z.object({ + worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)), + expectedTopologyRevision: z.number().int().nonnegative(), + claims: z + .array( + z.object({ + terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)), + ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)), + incarnationId: requiredString('Missing PTY incarnation').pipe(z.string().max(256)), + tabId: requiredString('Missing tab id').pipe(z.string().max(256)), + leafId: requiredString('Missing leaf id').pipe(z.string().max(128)) + }) + ) + .min(1) + .max(64), + activeTabId: OptionalString.pipe(z.string().max(256).optional()), + activeGroupId: OptionalString.pipe(z.string().max(256).optional()), + topology: TerminalOrphanTopology.optional() +}) + +export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.adoptOrphans', + params: TerminalAdoptOrphans, + handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 81c582c8f30..91f2444cdff 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -831,6 +831,10 @@ const TerminalFocus = TerminalHandle.extend({ const TerminalListParams = z.object({ worktree: OptionalString, limit: OptionalFiniteNumber, + handles: z + .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) + .max(64) + .optional(), requireFreshPtyLiveness: z.boolean().optional() }) @@ -1103,6 +1107,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalListParams, handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit, { + handles: params.handles, requireFreshPtyLiveness: params.requireFreshPtyLiveness }) }), diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 010ef2bea11..bc7dc5ae690 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -17,6 +17,7 @@ import { Wait } from './methods/browser-schemas' import { TERMINAL_METHODS } from './methods/terminal' +import { TERMINAL_ORPHAN_METHODS } from './methods/terminal-orphan' import { WORKTREE_METHODS } from './methods/worktree' function expectParses(schema: ZodType, value: unknown): void { @@ -85,6 +86,85 @@ describe('RPC optional pipe schemas', () => { expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) + it('requires complete, bounded orphan adoption claims and a topology revision', () => { + const adopt = methodParams(TERMINAL_ORPHAN_METHODS, 'terminal.adoptOrphans') + const claim = { + terminal: 'term-live', + ptyId: 'pty-live', + incarnationId: 'inc-live', + tabId: 'tab-live', + leafId: 'leaf-live' + } + + expectParses(adopt, { + worktree: 'id:repo::/worktree', + expectedTopologyRevision: 4, + claims: [claim], + topology: { + tabs: [ + { + tabId: 'tab-live', + root: { type: 'leaf', leafId: 'leaf-live' }, + activeLeafId: 'leaf-live', + expandedLeafId: null + } + ], + groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }], + groupLayout: { type: 'leaf', groupId: 'group-live' } + } + }) + expectRejects(adopt, { + worktree: 'id:repo::/worktree', + expectedTopologyRevision: -1, + claims: [claim] + }) + expectRejects(adopt, { + worktree: 'id:repo::/worktree', + expectedTopologyRevision: 4, + claims: [{ ...claim, incarnationId: '' }] + }) + expectRejects(adopt, { + worktree: 'id:repo::/worktree', + expectedTopologyRevision: 4, + claims: [] + }) + expectRejects(adopt, { + worktree: 'id:repo::/worktree', + expectedTopologyRevision: 4, + claims: [{ ...claim, terminal: 't'.repeat(257) }] + }) + expectRejects(adopt, { + worktree: 'id:repo::/worktree', + expectedTopologyRevision: 4, + claims: [claim], + topology: { + tabs: [ + { + tabId: 'tab-live', + root: { + type: 'split', + direction: 'horizontal', + ratio: Number.NaN, + first: { type: 'leaf', leafId: 'leaf-live' }, + second: { type: 'leaf', leafId: 'leaf-other' } + }, + activeLeafId: 'leaf-live', + expandedLeafId: null + } + ], + groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }] + } + }) + }) + + it('bounds targeted terminal listing used by orphan recovery', () => { + const list = methodParams(TERMINAL_METHODS, 'terminal.list') + + expectParses(list, { worktree: 'id:repo::/worktree', handles: ['term-live'] }) + expectRejects(list, { handles: [''] }) + expectRejects(list, { handles: Array.from({ length: 65 }, (_, index) => `term-${index}`) }) + }) + it('accepts worktree.create payloads sent by the previous mobile protocol', () => { const create = methodParams(WORKTREE_METHODS, 'worktree.create') diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 57029a95762..726981e98de 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -362,6 +362,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'terminal.ensureAgentSession', 'terminal.focus', 'terminal.agentStatus', + 'terminal.adoptOrphans', 'terminal.getAutoRestoreFit', 'terminal.isRunningAgent', 'terminal.list', diff --git a/src/main/runtime/terminal-orphan-owner.test.ts b/src/main/runtime/terminal-orphan-owner.test.ts new file mode 100644 index 00000000000..37e24e63b14 --- /dev/null +++ b/src/main/runtime/terminal-orphan-owner.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner' + +describe('terminal orphan execution owner', () => { + it('requires exact SSH host ownership', () => { + expect( + terminalOrphanExecutionOwnersEqual( + { connectionId: 'ssh-a', wslDistro: null }, + { connectionId: 'ssh-b', wslDistro: null } + ) + ).toBe(false) + expect( + terminalOrphanExecutionOwnersEqual( + { connectionId: 'ssh-a', wslDistro: null }, + { connectionId: 'ssh-a' } + ) + ).toBe(true) + }) + + it('matches WSL distro case-insensitively but never crosses native or another distro', () => { + const expected = { connectionId: null, wslDistro: 'Ubuntu' } + expect( + terminalOrphanExecutionOwnersEqual(expected, { + connectionId: null, + wslDistro: 'ubuntu' + }) + ).toBe(true) + expect( + terminalOrphanExecutionOwnersEqual(expected, { connectionId: null, wslDistro: null }) + ).toBe(false) + expect( + terminalOrphanExecutionOwnersEqual(expected, { + connectionId: null, + wslDistro: 'Debian' + }) + ).toBe(false) + expect(terminalOrphanExecutionOwnersEqual(expected, { connectionId: null })).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-orphan-owner.ts b/src/main/runtime/terminal-orphan-owner.ts new file mode 100644 index 00000000000..2b098b288ff --- /dev/null +++ b/src/main/runtime/terminal-orphan-owner.ts @@ -0,0 +1,25 @@ +export type TerminalOrphanExecutionOwner = { + connectionId: string | null + wslDistro?: string | null +} + +function normalizeWslDistro(distro: string | null): string | null { + const normalized = distro?.trim().toLowerCase() ?? '' + return normalized || null +} + +export function terminalOrphanExecutionOwnersEqual( + expected: TerminalOrphanExecutionOwner, + actual: TerminalOrphanExecutionOwner +): boolean { + if (expected.connectionId !== actual.connectionId) { + return false + } + if (expected.connectionId !== null) { + return true + } + if (expected.wslDistro === undefined || actual.wslDistro === undefined) { + return false + } + return normalizeWslDistro(expected.wslDistro) === normalizeWslDistro(actual.wslDistro) +} diff --git a/src/main/runtime/terminal-orphan-topology.test.ts b/src/main/runtime/terminal-orphan-topology.test.ts new file mode 100644 index 00000000000..8de05377a1e --- /dev/null +++ b/src/main/runtime/terminal-orphan-topology.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + hasExactTerminalOrphanGroupLayout, + mergeTerminalOrphanGroupLayout +} from './terminal-orphan-topology' + +describe('terminal orphan topology', () => { + it('rejects duplicate and missing group leaves', () => { + expect( + hasExactTerminalOrphanGroupLayout( + { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-a' } + }, + new Set(['group-a', 'group-b']) + ) + ).toBe(false) + }) + + it('keeps current host layout while preserving an unrelated proposed subtree', () => { + expect( + mergeTerminalOrphanGroupLayout({ + existingLayout: { type: 'leaf', groupId: 'group-live' }, + existingGroupIds: ['group-live'], + proposedLayout: { + type: 'split', + direction: 'vertical', + ratio: 0.6, + first: { type: 'leaf', groupId: 'group-old-left' }, + second: { type: 'leaf', groupId: 'group-old-right' } + }, + proposedGroupIds: ['group-old-left', 'group-old-right'], + mergedGroupIds: ['group-live', 'group-old-left', 'group-old-right'] + }) + ).toEqual({ + type: 'split', + direction: 'vertical', + ratio: 0.6, + first: { type: 'leaf', groupId: 'group-live' }, + second: { + type: 'split', + direction: 'vertical', + ratio: 0.6, + first: { type: 'leaf', groupId: 'group-old-left' }, + second: { type: 'leaf', groupId: 'group-old-right' } + } + }) + }) + + it('grafts proposed groups beside their one current anchor without duplicating it', () => { + expect( + mergeTerminalOrphanGroupLayout({ + existingLayout: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', groupId: 'group-live' }, + second: { type: 'leaf', groupId: 'group-other' } + }, + existingGroupIds: ['group-live', 'group-other'], + proposedLayout: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', groupId: 'group-live' }, + second: { type: 'leaf', groupId: 'group-recovered' } + }, + proposedGroupIds: ['group-live', 'group-recovered'], + mergedGroupIds: ['group-live', 'group-other', 'group-recovered'] + }) + ).toEqual({ + type: 'split', + direction: 'vertical', + first: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', groupId: 'group-live' }, + second: { type: 'leaf', groupId: 'group-recovered' } + }, + second: { type: 'leaf', groupId: 'group-other' } + }) + }) +}) diff --git a/src/main/runtime/terminal-orphan-topology.ts b/src/main/runtime/terminal-orphan-topology.ts new file mode 100644 index 00000000000..11fec13daac --- /dev/null +++ b/src/main/runtime/terminal-orphan-topology.ts @@ -0,0 +1,137 @@ +import type { TabGroupLayoutNode } from '../../shared/types' + +function collectLayoutGroupIds(node: TabGroupLayoutNode | null | undefined, ids: string[]): void { + if (!node) { + return + } + if (node.type === 'leaf') { + ids.push(node.groupId) + return + } + collectLayoutGroupIds(node.first, ids) + collectLayoutGroupIds(node.second, ids) +} + +function pruneLayout( + node: TabGroupLayoutNode | null | undefined, + retainedGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!node) { + return null + } + if (node.type === 'leaf') { + return retainedGroupIds.has(node.groupId) ? node : null + } + const first = pruneLayout(node.first, retainedGroupIds) + const second = pruneLayout(node.second, retainedGroupIds) + if (!first) { + return second + } + if (!second) { + return first + } + return { ...node, first, second } +} + +function appendMissingGroups( + layout: TabGroupLayoutNode | null, + orderedGroupIds: readonly string[] +): TabGroupLayoutNode | null { + const present: string[] = [] + collectLayoutGroupIds(layout, present) + const presentSet = new Set(present) + let next = layout + for (const groupId of orderedGroupIds) { + if (presentSet.has(groupId)) { + continue + } + const leaf = { type: 'leaf' as const, groupId } + next = next + ? { type: 'split', direction: 'horizontal', first: next, second: leaf, ratio: 0.5 } + : leaf + presentSet.add(groupId) + } + return next +} + +function replaceLeaf( + node: TabGroupLayoutNode, + groupId: string, + replacement: TabGroupLayoutNode +): TabGroupLayoutNode { + if (node.type === 'leaf') { + return node.groupId === groupId ? replacement : node + } + return { + ...node, + first: replaceLeaf(node.first, groupId, replacement), + second: replaceLeaf(node.second, groupId, replacement) + } +} + +export function hasExactTerminalOrphanGroupLayout( + layout: TabGroupLayoutNode, + expectedGroupIds: ReadonlySet +): boolean { + const groupIds: string[] = [] + collectLayoutGroupIds(layout, groupIds) + return ( + groupIds.length === expectedGroupIds.size && + new Set(groupIds).size === groupIds.length && + groupIds.every((groupId) => expectedGroupIds.has(groupId)) + ) +} + +export function mergeTerminalOrphanGroupLayout(args: { + existingLayout: TabGroupLayoutNode | null | undefined + existingGroupIds: readonly string[] + proposedLayout: TabGroupLayoutNode | null | undefined + proposedGroupIds: readonly string[] + mergedGroupIds: readonly string[] +}): TabGroupLayoutNode | undefined { + const mergedGroupIdSet = new Set(args.mergedGroupIds) + const existingGroupIdSet = new Set(args.existingGroupIds) + const proposedGroupIdSet = new Set(args.proposedGroupIds) + let existing = appendMissingGroups( + pruneLayout(args.existingLayout, existingGroupIdSet), + args.existingGroupIds + ) + const proposed = appendMissingGroups( + pruneLayout(args.proposedLayout, proposedGroupIdSet), + args.proposedGroupIds + ) + + if (!existing) { + return appendMissingGroups(proposed, args.mergedGroupIds) ?? undefined + } + if (!proposed) { + return appendMissingGroups(existing, args.mergedGroupIds) ?? undefined + } + + const sharedGroupIds = args.proposedGroupIds.filter((groupId) => existingGroupIdSet.has(groupId)) + const newGroupIds = new Set( + args.proposedGroupIds.filter((groupId) => !existingGroupIdSet.has(groupId)) + ) + if (newGroupIds.size > 0 && sharedGroupIds.length === 1) { + const anchorGroupId = sharedGroupIds[0]! + const proposalAtAnchor = pruneLayout(proposed, new Set([anchorGroupId, ...newGroupIds])) + if (proposalAtAnchor) { + existing = replaceLeaf(existing, anchorGroupId, proposalAtAnchor) + } + } else if (newGroupIds.size > 0) { + const newSubtree = pruneLayout(proposed, newGroupIds) + if (newSubtree) { + existing = { + type: 'split', + direction: proposed.type === 'split' ? proposed.direction : 'horizontal', + first: existing, + second: newSubtree, + ratio: proposed.type === 'split' ? proposed.ratio : 0.5 + } + } + } + + return ( + appendMissingGroups(pruneLayout(existing, mergedGroupIdSet), args.mergedGroupIds) ?? undefined + ) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index eb3d0533189..ef1b15075df 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -74,6 +74,7 @@ import { } from './web-runtime-wake-terminal-respawn' import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events' +import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' import { clearWebAgentSessionHandoff, clearWebAgentSessionHandoffsForEnvironment, @@ -2684,7 +2685,7 @@ export function useWebSessionTabsSync(): void { timeoutMs: 15_000, expectedEnvironmentPairingRevision }) - .then((response: RuntimeRpcResponse) => { + .then(async (response: RuntimeRpcResponse) => { if ( disposed || getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision @@ -2700,8 +2701,23 @@ export function useWebSessionTabsSync(): void { console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') return } + const recovered = await Promise.all( + result.snapshots.map((snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId + ) + ) + ) + if (disposed) { + return + } + const applicable = recovered.filter( + (snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null + ) applyWebSessionTabsStorePatch((state) => - applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) + applyFreshWebSessionTabsSnapshots(state, applicable, environmentId) ) }) .catch((error) => { @@ -2745,9 +2761,24 @@ export function useWebSessionTabsSync(): void { acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree) } } - applyWebSessionTabsStorePatch((state) => - applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) - ) + void Promise.all( + event.snapshots.map((snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId + ) + ) + ).then((recovered) => { + if (!disposed) { + const applicable = recovered.filter( + (snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null + ) + applyWebSessionTabsStorePatch((state) => + applyFreshWebSessionTabsSnapshots(state, applicable, environmentId) + ) + } + }) return } if (event.type !== 'snapshot' && event.type !== 'updated') { @@ -2756,9 +2787,17 @@ export function useWebSessionTabsSync(): void { if (replayed) { acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree) } - applyWebSessionTabsStorePatch((state) => - applyFreshWebSessionTabsSnapshot(state, event, environmentId) - ) + void recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + event, + environmentId + ).then((recovered) => { + if (!disposed && recovered) { + applyWebSessionTabsStorePatch((state) => + applyFreshWebSessionTabsSnapshot(state, recovered, environmentId) + ) + } + }) }, onError: (error) => { console.warn('[web-session-tabs-sync] global subscription error:', error.message) @@ -2822,6 +2861,73 @@ export function useWebSessionTabsSync(): void { let requestedInitialTerminal = false let requestedRespawnAfterWake = false let unsubscribe: (() => void) | null = null + const applyActiveSnapshot = async ( + event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, + response: RuntimeRpcResponse + ): Promise => { + const recovered = await recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + event, + environmentId + ) + if (disposed || !recovered) { + return + } + if (isRuntimeSubscriptionReplayResponse(response)) { + acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) + } + const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type } + const fresh = shouldApplyWebSessionTabsSnapshot(recovered, environmentId) + const syncState = useAppStore.getState() + const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] + const localTerminalCount = localWorktreeTabs.length + const hasLiveLocalPty = localWorktreeTabs.some( + (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 + ) + const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({ + event: recoveredEvent, + activeWorktreeId, + requestedInitialTerminal, + snapshotIsFresh: fresh, + localTerminalCount + }) + const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({ + event: recoveredEvent, + activeWorktreeId, + requestedRespawnAfterWake, + snapshotIsFresh: fresh, + localTerminalCount, + hasLiveLocalPty, + skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + }) + if (fresh) { + applyWebSessionTabsStorePatch((state) => + applyWebSessionTabsSnapshot(state, recovered, environmentId) + ) + } + if (!disposed && shouldBootstrapInitialTerminal) { + requestedInitialTerminal = true + void createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId, + activate: true + }) + } else if ( + !disposed && + shouldRespawnAfterWake && + beginWebRuntimeWakeTerminalRespawn(activeWorktreeId) + ) { + requestedRespawnAfterWake = true + void createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId, + activate: true, + selectWorktree: false + }).finally(() => { + endWebRuntimeWakeTerminalRespawn(activeWorktreeId) + }) + } + } void window.api.runtimeEnvironments .subscribe( { @@ -2847,60 +2953,7 @@ export function useWebSessionTabsSync(): void { if (event.type !== 'snapshot' && event.type !== 'updated') { return } - if (isRuntimeSubscriptionReplayResponse(response)) { - acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree) - } - const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId) - const syncState = useAppStore.getState() - const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] - const localTerminalCount = localWorktreeTabs.length - const hasLiveLocalPty = localWorktreeTabs.some( - (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 - ) - const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({ - event, - activeWorktreeId, - requestedInitialTerminal, - snapshotIsFresh: fresh, - localTerminalCount - }) - const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({ - event, - activeWorktreeId, - requestedRespawnAfterWake, - snapshotIsFresh: fresh, - localTerminalCount, - hasLiveLocalPty, - skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) - }) - if (fresh) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshot(state, event, environmentId) - ) - } - if (!disposed && shouldBootstrapInitialTerminal) { - requestedInitialTerminal = true - void createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId, - activate: true - }) - } else if ( - !disposed && - shouldRespawnAfterWake && - beginWebRuntimeWakeTerminalRespawn(activeWorktreeId) - ) { - requestedRespawnAfterWake = true - // Why: recreate the terminal without changing selected worktree to avoid re-triggering activation churn. - void createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId, - activate: true, - selectWorktree: false - }).finally(() => { - endWebRuntimeWakeTerminalRespawn(activeWorktreeId) - }) - } + void applyActiveSnapshot(event, response) }, onError: (error) => { console.warn('[web-session-tabs-sync] subscription error:', error.message) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts new file mode 100644 index 00000000000..758d7e4b280 --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { + clearWebSessionTerminalOrphanRecoveryForTests, + recoverWebSessionTerminalOrphansBeforeApply +} from './web-session-terminal-orphan-recovery' + +const worktree = 'repo::/worktree' + +function legacyRecoveryState() { + return { + tabsByWorktree: { + [worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never] + }, + terminalLayoutsByTabId: { + 'web-terminal-host-tab': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { + 'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2') + } + } + }, + activeTabIdByWorktree: {}, + activeGroupIdByWorktree: {} + } +} + +const missingSnapshot = { + worktree, + publicationEpoch: 'mixed-version', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] +} + +describe('mixed-version web terminal orphan recovery', () => { + beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests()) + + it.each([ + { + name: 'incarnation evidence is unavailable', + result: { + terminals: [{ handle: 'term_live', ptyId: 'pty-live', worktreeId: worktree }], + totalCount: 1, + truncated: false + } + }, + { + name: 'a legacy unfiltered listing truncates before the candidate', + result: { + terminals: [{ handle: 'term_other', ptyId: 'pty-other', worktreeId: worktree }], + totalCount: 101, + truncated: true + } + } + ])('keeps the live candidate visible when $name', async ({ result }) => { + const call = vi.fn(async () => ({ ok: true as const, result })) + + await expect( + recoverWebSessionTerminalOrphansBeforeApply( + legacyRecoveryState(), + missingSnapshot, + 'windows-2', + call as never + ) + ).resolves.toBeNull() + expect(call).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts new file mode 100644 index 00000000000..156d5eac558 --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts @@ -0,0 +1,449 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { + clearWebSessionTerminalOrphanRecoveryForTests, + recoverWebSessionTerminalOrphansBeforeApply +} from './web-session-terminal-orphan-recovery' + +describe('web session terminal orphan recovery', () => { + beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests()) + + it('keeps a missing mirror pending until exact live orphan adoption returns', async () => { + let resolveAdoption: ((value: never) => void) | null = null + const adoptedSnapshot = { + worktree: 'repo::C:\\worktree', + publicationEpoch: 'adopted', + snapshotVersion: 2, + activeGroupId: 'group-1', + activeTabId: 'host-tab::leaf-1', + activeTabType: 'terminal' as const, + tabs: [ + { + type: 'terminal' as const, + id: 'host-tab::leaf-1', + parentTabId: 'host-tab', + leafId: 'leaf-1', + title: 'Claude', + isActive: true, + status: 'ready' as const, + terminal: 'term_live' + } + ] + } + const call = vi.fn(async ({ method }) => { + if (method === 'terminal.list') { + return { + ok: true as const, + result: { + terminals: [ + { + handle: 'term_live', + ptyId: 'native-pty', + incarnationId: 'inc-1', + orphaned: true, + worktreeId: adoptedSnapshot.worktree + } + ], + topologyRevisions: { [adoptedSnapshot.worktree]: 0 }, + totalCount: 1, + truncated: false + } + } + } + return await new Promise((resolve) => { + resolveAdoption = resolve as (value: never) => void + }) + }) + const state = { + tabsByWorktree: { + [adoptedSnapshot.worktree]: [ + { id: 'web-terminal-host-tab', worktreeId: adoptedSnapshot.worktree } as never + ] + }, + terminalLayoutsByTabId: { + 'web-terminal-host-tab': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2') } + } + }, + activeTabIdByWorktree: { [adoptedSnapshot.worktree]: 'web-terminal-host-tab' }, + activeGroupIdByWorktree: { [adoptedSnapshot.worktree]: 'group-1' }, + groupsByWorktree: { + [adoptedSnapshot.worktree]: [ + { + id: 'group-1', + worktreeId: adoptedSnapshot.worktree, + activeTabId: 'web-terminal-host-tab', + tabOrder: ['web-terminal-host-tab'] + } + ] + }, + layoutByWorktree: { + [adoptedSnapshot.worktree]: { type: 'leaf' as const, groupId: 'group-1' } + } + } + const missingSnapshot = { ...adoptedSnapshot, publicationEpoch: 'missing', tabs: [] } + let settled = false + const recovery = recoverWebSessionTerminalOrphansBeforeApply( + state, + missingSnapshot, + 'windows-2', + call as never + ).then((result) => { + settled = true + return result + }) + await vi.waitFor(() => expect(resolveAdoption).not.toBeNull()) + expect(settled).toBe(false) + expect(call).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'terminal.list', + params: expect.objectContaining({ handles: ['term_live'] }) + }) + ) + resolveAdoption!({ + ok: true, + result: { adopted: true, topologyRevision: 1, snapshot: adoptedSnapshot } + } as never) + + await expect(recovery).resolves.toEqual(adoptedSnapshot) + expect(call).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: 'terminal.adoptOrphans', + params: expect.objectContaining({ + expectedTopologyRevision: 0, + activeTabId: 'host-tab', + claims: [ + expect.objectContaining({ + terminal: 'term_live', + ptyId: 'native-pty', + incarnationId: 'inc-1', + tabId: 'host-tab', + leafId: 'leaf-1' + }) + ], + topology: { + tabs: [ + { + tabId: 'host-tab', + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + ], + groups: [{ id: 'group-1', activeTabId: 'host-tab', tabOrder: ['host-tab'] }], + groupLayout: { type: 'leaf', groupId: 'group-1' } + } + }) + }) + ) + }) + + it('does not apply absence when an exact recoverable orphan cannot be adopted yet', async () => { + const worktree = 'repo::/worktree' + const call = vi.fn(async ({ method }) => + method === 'terminal.list' + ? { + ok: true as const, + result: { + terminals: [ + { + handle: 'term_live', + ptyId: 'pty-live', + incarnationId: 'inc-live', + orphaned: true + } + ], + topologyRevisions: { [worktree]: 4 }, + totalCount: 1, + truncated: false + } + } + : { ok: false as const, error: { code: 'conflict', message: 'retry' } } + ) + const state = { + tabsByWorktree: { + [worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never] + }, + terminalLayoutsByTabId: { + 'web-terminal-host-tab': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { + 'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2') + } + } + }, + activeTabIdByWorktree: {}, + activeGroupIdByWorktree: {} + } + const missing = { + worktree, + publicationEpoch: 'missing', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + + await expect( + recoverWebSessionTerminalOrphansBeforeApply(state, missing, 'windows-2', call as never) + ).resolves.toBeNull() + }) + + it('proposes pruned pane and group topology using host tab identities', async () => { + const worktree = 'repo::/worktree' + const adoptedSnapshot = { + worktree, + publicationEpoch: 'adopted', + snapshotVersion: 2, + activeGroupId: 'group-right', + activeTabId: 'shell-tab', + activeTabType: 'terminal' as const, + tabs: [] + } + const call = vi.fn(async ({ method }) => + method === 'terminal.list' + ? { + ok: true as const, + result: { + terminals: [ + { + handle: 'term_agent', + ptyId: 'pty-agent', + incarnationId: 'inc-agent', + orphaned: true + }, + { + handle: 'term_setup', + ptyId: 'pty-setup', + incarnationId: 'inc-setup', + orphaned: true + }, + { + handle: 'term_shell', + ptyId: 'pty-shell', + incarnationId: 'inc-shell', + orphaned: true + } + ], + topologyRevisions: { [worktree]: 8 }, + totalCount: 3, + truncated: false + } + } + : { + ok: true as const, + result: { adopted: true, topologyRevision: 9, snapshot: adoptedSnapshot } + } + ) + const state = { + tabsByWorktree: { + [worktree]: [ + { id: 'web-terminal-agent-tab', worktreeId: worktree } as never, + { id: 'web-terminal-shell-tab', worktreeId: worktree } as never + ] + }, + terminalLayoutsByTabId: { + 'web-terminal-agent-tab': { + root: { + type: 'split' as const, + direction: 'horizontal' as const, + ratio: 0.7, + first: { type: 'leaf' as const, leafId: 'leaf-agent' }, + second: { type: 'leaf' as const, leafId: 'leaf-setup' } + }, + activeLeafId: 'leaf-setup', + expandedLeafId: null, + ptyIdsByLeafId: { + 'leaf-agent': toRemoteRuntimePtyId('term_agent', 'windows-2'), + 'leaf-setup': toRemoteRuntimePtyId('term_setup', 'windows-2') + } + }, + 'web-terminal-shell-tab': { + root: { type: 'leaf' as const, leafId: 'leaf-shell' }, + activeLeafId: 'leaf-shell', + expandedLeafId: 'leaf-shell', + ptyIdsByLeafId: { + 'leaf-shell': toRemoteRuntimePtyId('term_shell', 'windows-2') + } + } + }, + activeTabIdByWorktree: { [worktree]: 'web-terminal-shell-tab' }, + activeGroupIdByWorktree: { [worktree]: 'group-right' }, + groupsByWorktree: { + [worktree]: [ + { + id: 'group-left', + worktreeId: worktree, + activeTabId: 'web-terminal-agent-tab', + tabOrder: ['web-terminal-agent-tab'] + }, + { + id: 'group-right', + worktreeId: worktree, + activeTabId: 'web-terminal-shell-tab', + tabOrder: ['web-terminal-shell-tab'] + } + ] + }, + layoutByWorktree: { + [worktree]: { + type: 'split' as const, + direction: 'vertical' as const, + ratio: 0.6, + first: { type: 'leaf' as const, groupId: 'group-left' }, + second: { type: 'leaf' as const, groupId: 'group-right' } + } + } + } + + await expect( + recoverWebSessionTerminalOrphansBeforeApply( + state, + { ...adoptedSnapshot, publicationEpoch: 'missing' }, + 'windows-2', + call as never + ) + ).resolves.toEqual(adoptedSnapshot) + expect(call).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: 'terminal.adoptOrphans', + params: expect.objectContaining({ + expectedTopologyRevision: 8, + activeTabId: 'shell-tab', + activeGroupId: 'group-right', + topology: { + tabs: [ + expect.objectContaining({ + tabId: 'agent-tab', + root: expect.objectContaining({ + type: 'split', + direction: 'horizontal', + ratio: 0.7 + }), + activeLeafId: 'leaf-setup' + }), + expect.objectContaining({ + tabId: 'shell-tab', + expandedLeafId: 'leaf-shell' + }) + ], + groups: [ + expect.objectContaining({ + id: 'group-left', + activeTabId: 'agent-tab', + tabOrder: ['agent-tab'] + }), + expect.objectContaining({ + id: 'group-right', + activeTabId: 'shell-tab', + tabOrder: ['shell-tab'] + }) + ], + groupLayout: expect.objectContaining({ + type: 'split', + direction: 'vertical', + ratio: 0.6 + }) + } + }) + }) + ) + }) + + it('serializes a newer convergence snapshot after an in-flight adoption conflict', async () => { + const worktree = 'repo::/worktree' + let rejectFirstAdoption: (() => void) | null = null + const call = vi.fn(async ({ method }) => { + if (method === 'terminal.list') { + return { + ok: true as const, + result: { + terminals: [ + { + handle: 'term_live', + ptyId: 'pty-live', + incarnationId: 'inc-live', + orphaned: true + } + ], + topologyRevisions: { [worktree]: 1 }, + totalCount: 1, + truncated: false + } + } + } + return await new Promise((resolve) => { + rejectFirstAdoption = () => + resolve({ ok: false as const, error: { code: 'conflict', message: 'closed' } }) + }) + }) + const state = { + tabsByWorktree: { + [worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never] + }, + terminalLayoutsByTabId: { + 'web-terminal-host-tab': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { + 'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2') + } + } + }, + activeTabIdByWorktree: {}, + activeGroupIdByWorktree: {} + } + const missing = { + worktree, + publicationEpoch: 'missing', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + const converged = { + ...missing, + publicationEpoch: 'closed', + snapshotVersion: 2, + tabs: [ + { + type: 'terminal' as const, + id: 'host-tab::leaf-1', + parentTabId: 'host-tab', + leafId: 'leaf-1', + title: 'closed', + isActive: false, + status: 'pending-handle' as const, + terminal: null + } + ] + } + + const first = recoverWebSessionTerminalOrphansBeforeApply( + state, + missing, + 'windows-2', + call as never + ) + await vi.waitFor(() => expect(rejectFirstAdoption).not.toBeNull()) + const second = recoverWebSessionTerminalOrphansBeforeApply( + state, + converged, + 'windows-2', + call as never + ) + rejectFirstAdoption!() + + await expect(first).resolves.toBeNull() + await expect(second).resolves.toEqual(converged) + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts new file mode 100644 index 00000000000..088a387e82d --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts @@ -0,0 +1,196 @@ +import type { + RuntimeMobileSessionTabsResult, + RuntimeTerminalListResult, + RuntimeTerminalOrphanAdoptionResult +} from '../../../shared/runtime-types' +import type { TerminalTab } from '../../../shared/types' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id' +import { + buildWebTerminalOrphanTopologyProposal, + type WebTerminalOrphanTopologyState +} from './web-session-terminal-orphan-topology' + +type TerminalOrphanRecoveryState = WebTerminalOrphanTopologyState & { + tabsByWorktree: Record +} + +type RuntimeCall = (args: { + selector: string + method: string + params: unknown + timeoutMs: number +}) => Promise> + +const inFlightRecoveryByWorktree = new Map>() + +function recoveryKey(environmentId: string, worktreeId: string): string { + return `${environmentId}\0${worktreeId}` +} + +function isTerminalListResult(value: unknown): value is RuntimeTerminalListResult { + return ( + Boolean(value) && + typeof value === 'object' && + Array.isArray((value as { terminals?: unknown }).terminals) + ) +} + +function isAdoptionResult(value: unknown): value is RuntimeTerminalOrphanAdoptionResult { + return ( + Boolean(value) && + typeof value === 'object' && + Boolean((value as { snapshot?: unknown }).snapshot) && + Array.isArray((value as { snapshot?: { tabs?: unknown } }).snapshot?.tabs) + ) +} + +async function recoverTerminalOrphans( + state: TerminalOrphanRecoveryState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + call: RuntimeCall +): Promise { + const hostTabIds = new Set( + snapshot.tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.parentTabId) + ) + const candidates = (state.tabsByWorktree[snapshot.worktree] ?? []).filter( + (tab) => isWebTerminalSurfaceTabId(tab.id) && !hostTabIds.has(toHostSessionTabId(tab.id)) + ) + if (candidates.length === 0) { + return snapshot + } + const candidateSurfaces = candidates.flatMap((tab) => { + const layout = state.terminalLayoutsByTabId[tab.id] + return Object.entries(layout?.ptyIdsByLeafId ?? {}).flatMap(([leafId, remotePtyId]) => { + const remote = parseRemoteRuntimePtyId(remotePtyId) + return remote?.environmentId === environmentId + ? [{ tabId: toHostSessionTabId(tab.id), leafId, handle: remote.handle }] + : [] + }) + }) + const candidateHandles = new Set(candidateSurfaces.map((surface) => surface.handle)) + if (candidateHandles.size === 0) { + return snapshot + } + if (candidateHandles.size > 64) { + return null + } + const listedResponse = await call({ + selector: environmentId, + method: 'terminal.list', + params: { + worktree: toRuntimeWorktreeSelector(snapshot.worktree), + handles: [...candidateHandles], + requireFreshPtyLiveness: true + }, + timeoutMs: 15_000 + }) + if (listedResponse.ok === false || !isTerminalListResult(listedResponse.result)) { + return null + } + const listed = listedResponse.result + const orphanByHandle = new Map( + listed.terminals + .filter( + (terminal) => + terminal.orphaned === true && + typeof terminal.ptyId === 'string' && + typeof terminal.incarnationId === 'string' + ) + .map((terminal) => [terminal.handle, terminal]) + ) + const claims = candidateSurfaces.flatMap(({ tabId, leafId, handle }) => { + const orphan = orphanByHandle.get(handle) + if (!orphan?.ptyId || !orphan.incarnationId) { + return [] + } + return [ + { + terminal: orphan.handle, + ptyId: orphan.ptyId, + incarnationId: orphan.incarnationId, + tabId, + leafId + } + ] + }) + const claimedHandles = new Set(claims.map((claim) => claim.terminal)) + const listedCandidateHandles = new Set( + listed.terminals + .filter((terminal) => candidateHandles.has(terminal.handle)) + .map((terminal) => terminal.handle) + ) + if ( + listed.truncated && + [...candidateHandles].some((handle) => !listedCandidateHandles.has(handle)) + ) { + return null + } + const hasUnresolvedLiveCandidate = listed.terminals.some( + (terminal) => candidateHandles.has(terminal.handle) && !claimedHandles.has(terminal.handle) + ) + if (hasUnresolvedLiveCandidate) { + return null + } + if (claims.length === 0) { + return snapshot + } + const localActiveTabId = state.activeTabIdByWorktree[snapshot.worktree] + const activeTabId = + localActiveTabId && isWebTerminalSurfaceTabId(localActiveTabId) + ? toHostSessionTabId(localActiveTabId) + : undefined + const activeGroupId = state.activeGroupIdByWorktree[snapshot.worktree] ?? undefined + const topology = buildWebTerminalOrphanTopologyProposal( + state, + snapshot.worktree, + candidates, + claims + ) + const response = await call({ + selector: environmentId, + method: 'terminal.adoptOrphans', + params: { + worktree: toRuntimeWorktreeSelector(snapshot.worktree), + expectedTopologyRevision: listed.topologyRevisions?.[snapshot.worktree] ?? 0, + claims, + ...(activeTabId ? { activeTabId } : {}), + ...(activeGroupId ? { activeGroupId } : {}), + ...(topology ? { topology } : {}) + }, + timeoutMs: 15_000 + }) + return response.ok !== false && + isAdoptionResult(response.result) && + response.result.snapshot.worktree === snapshot.worktree + ? response.result.snapshot + : null +} + +export function recoverWebSessionTerminalOrphansBeforeApply( + state: TerminalOrphanRecoveryState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args) +): Promise { + const key = recoveryKey(environmentId, snapshot.worktree) + const existing = inFlightRecoveryByWorktree.get(key) + const recovery = (existing ?? Promise.resolve(null)) + .catch(() => null) + .then(() => recoverTerminalOrphans(state, snapshot, environmentId, call)) + .catch(() => null) + .finally(() => { + if (inFlightRecoveryByWorktree.get(key) === recovery) { + inFlightRecoveryByWorktree.delete(key) + } + }) + inFlightRecoveryByWorktree.set(key, recovery) + return recovery +} + +export function clearWebSessionTerminalOrphanRecoveryForTests(): void { + inFlightRecoveryByWorktree.clear() +} diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-topology.ts b/src/renderer/src/runtime/web-session-terminal-orphan-topology.ts new file mode 100644 index 00000000000..5d7e0a9db8f --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-topology.ts @@ -0,0 +1,140 @@ +import type { RuntimeTerminalOrphanTopology } from '../../../shared/runtime-types' +import type { + TabGroup, + TabGroupLayoutNode, + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TerminalTab +} from '../../../shared/types' +import { toHostSessionTabId } from './web-terminal-surface-id' + +export type WebTerminalOrphanTopologyState = { + terminalLayoutsByTabId: Record + activeTabIdByWorktree: Record + activeGroupIdByWorktree: Record + groupsByWorktree?: Record + layoutByWorktree?: Record +} + +function prunePaneLayout( + node: TerminalPaneLayoutNode | null, + retainedLeafIds: ReadonlySet +): TerminalPaneLayoutNode | null { + if (!node) { + return null + } + if (node.type === 'leaf') { + return retainedLeafIds.has(node.leafId) ? node : null + } + const first = prunePaneLayout(node.first, retainedLeafIds) + const second = prunePaneLayout(node.second, retainedLeafIds) + if (!first) { + return second + } + if (!second) { + return first + } + return { ...node, first, second } +} + +function pruneGroupLayout( + node: TabGroupLayoutNode | undefined, + retainedGroupIds: ReadonlySet +): TabGroupLayoutNode | undefined { + if (!node) { + return undefined + } + if (node.type === 'leaf') { + return retainedGroupIds.has(node.groupId) ? node : undefined + } + const first = pruneGroupLayout(node.first, retainedGroupIds) + const second = pruneGroupLayout(node.second, retainedGroupIds) + if (!first) { + return second + } + if (!second) { + return first + } + return { ...node, first, second } +} + +export function buildWebTerminalOrphanTopologyProposal( + state: WebTerminalOrphanTopologyState, + worktreeId: string, + candidates: readonly TerminalTab[], + claims: readonly { tabId: string; leafId: string }[] +): RuntimeTerminalOrphanTopology | undefined { + const leafIdsByTabId = new Map>() + for (const claim of claims) { + const leafIds = leafIdsByTabId.get(claim.tabId) ?? new Set() + leafIds.add(claim.leafId) + leafIdsByTabId.set(claim.tabId, leafIds) + } + const hostTabIdByLocalId = new Map( + candidates.map((tab) => [tab.id, toHostSessionTabId(tab.id)] as const) + ) + const tabs = candidates.flatMap((tab) => { + const tabId = hostTabIdByLocalId.get(tab.id)! + const retainedLeafIds = leafIdsByTabId.get(tabId) + const layout = state.terminalLayoutsByTabId[tab.id] + const root = retainedLeafIds ? prunePaneLayout(layout?.root ?? null, retainedLeafIds) : null + if (!layout || !root || !retainedLeafIds || retainedLeafIds.size === 0) { + return [] + } + const fallbackLeafId = [...retainedLeafIds][0]! + return [ + { + tabId, + root, + activeLeafId: retainedLeafIds.has(layout.activeLeafId ?? '') + ? layout.activeLeafId! + : fallbackLeafId, + expandedLeafId: + layout.expandedLeafId && retainedLeafIds.has(layout.expandedLeafId) + ? layout.expandedLeafId + : null + } + ] + }) + if (tabs.length !== leafIdsByTabId.size) { + return undefined + } + + const adoptedTabIds = new Set(tabs.map((tab) => tab.tabId)) + const groups = (state.groupsByWorktree?.[worktreeId] ?? []).flatMap((group) => { + const tabOrder = group.tabOrder + .map((tabId) => hostTabIdByLocalId.get(tabId)) + .filter((tabId): tabId is string => Boolean(tabId && adoptedTabIds.has(tabId))) + if (tabOrder.length === 0) { + return [] + } + const requestedActive = group.activeTabId + ? hostTabIdByLocalId.get(group.activeTabId) + : undefined + const recentTabIds = group.recentTabIds + ?.map((tabId) => hostTabIdByLocalId.get(tabId)) + .filter((tabId): tabId is string => Boolean(tabId && tabOrder.includes(tabId))) + return [ + { + id: group.id, + activeTabId: + requestedActive && tabOrder.includes(requestedActive) ? requestedActive : tabOrder[0]!, + tabOrder, + ...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {}) + } + ] + }) + const completeGroups = + groups.length > 0 + ? groups + : [ + { + id: state.activeGroupIdByWorktree[worktreeId] ?? 'recovered-orphans', + activeTabId: tabs[0]!.tabId, + tabOrder: tabs.map((tab) => tab.tabId) + } + ] + const groupIds = new Set(completeGroups.map((group) => group.id)) + const groupLayout = pruneGroupLayout(state.layoutByWorktree?.[worktreeId], groupIds) + return { tabs, groups: completeGroups, ...(groupLayout ? { groupLayout } : {}) } +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 5d5216bdeab..42d017b29cc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -417,6 +417,8 @@ export type RuntimeFileReadChunkResult = { export type RuntimeTerminalSummary = { handle: string ptyId: string | null + incarnationId?: string | null + orphaned?: boolean worktreeId: string worktreePath: string branch: string @@ -480,10 +482,54 @@ export type RuntimeTerminalVisualLayout = { export type RuntimeTerminalListResult = { terminals: RuntimeTerminalSummary[] visualLayouts?: RuntimeTerminalVisualLayout[] + topologyRevisions?: Record totalCount: number truncated: boolean } +export type RuntimeTerminalOrphanAdoptionClaim = { + terminal: string + ptyId: string + incarnationId: string + tabId: string + leafId: string +} + +export type RuntimeTerminalOrphanTopologyTab = { + tabId: string + root: TerminalPaneLayoutNode + activeLeafId: string + expandedLeafId: string | null +} + +export type RuntimeTerminalOrphanTopologyGroup = { + id: string + activeTabId: string + tabOrder: string[] + recentTabIds?: string[] +} + +export type RuntimeTerminalOrphanTopology = { + tabs: RuntimeTerminalOrphanTopologyTab[] + groups: RuntimeTerminalOrphanTopologyGroup[] + groupLayout?: TabGroupLayoutNode +} + +export type RuntimeTerminalOrphanAdoptionRequest = { + worktree: string + expectedTopologyRevision: number + claims: RuntimeTerminalOrphanAdoptionClaim[] + activeTabId?: string + activeGroupId?: string + topology?: RuntimeTerminalOrphanTopology +} + +export type RuntimeTerminalOrphanAdoptionResult = { + adopted: boolean + topologyRevision: number + snapshot: RuntimeMobileSessionTabsResult +} + export type RuntimeWorktreeTerminalSleepFailure = | 'terminal_liveness_unavailable' | 'terminal_worktree_sleep_still_live'