diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts index 92e51407287..b4f9b8190ef 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts @@ -117,8 +117,14 @@ describe('web session terminal orphan inventory retries', () => { { call: call as never } ) + // An unlisted pending row stays pending; the live binding survives the apply either way. + const unlistedPending = incoming === 'pending' && firstInventory.length === 0 expect(first?.tabs).toEqual([ - expect.objectContaining({ leafId: LEAF_ID, status: 'ready', terminal: HANDLE }) + expect.objectContaining( + unlistedPending + ? { leafId: LEAF_ID, status: 'pending-handle', terminal: null } + : { leafId: LEAF_ID, status: 'ready', terminal: HANDLE } + ) ]) expect(appliedState.ptyIdsByTabId[localTabId]).toEqual(state.ptyIdsByTabId[localTabId]) expect(appliedState.terminalLayoutsByTabId[localTabId]?.ptyIdsByLeafId).toEqual( @@ -248,7 +254,7 @@ describe('web session terminal orphan inventory retries', () => { ) expect(recovered?.tabs).toEqual([ - expect.objectContaining({ leafId: LEAF_ID, status: 'ready', terminal: HANDLE }) + expect.objectContaining({ leafId: LEAF_ID, status: 'pending-handle', terminal: null }) ]) expect(call).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts index 36a945cf84b..57c59b46699 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts @@ -607,10 +607,7 @@ describe('web session terminal orphan adoption regressions', () => { verdict === 'pending-without-proof' ? { ...projected, - tabs: [ - projected.tabs[0], - { ...snapshot.tabs[1], status: 'ready', terminal: 'term-hold' } - ] + tabs: [projected.tabs[0], snapshot.tabs[1]] } : projected ) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts index 8dac25b6451..fafd5134c8c 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts @@ -14,6 +14,7 @@ import { import { hasExactTerminalRetirementProof, hasStrongOrphanIdentity, + withoutHostAttestation, type RecoveryDisposition, type RecoverySurface } from './web-session-terminal-orphan-recovery-surface' @@ -41,7 +42,7 @@ function fallback( removed: ReadonlySet ): TerminalOrphanInventoryResolution { return { - retained: [...retainedBeforeListing, ...inventorySurfaces], + retained: [...retainedBeforeListing, ...inventorySurfaces.map(withoutHostAttestation)], removed: new Set(removed), claims: [], topologyRevision: 0 @@ -104,7 +105,7 @@ export async function resolveTerminalOrphanInventory(args: { const retainedBeforeListing = [...duplicateSurfaces, ...stableRetained] if (inventorySurfaces.length > 64) { return { - retained: [...retainedBeforeListing, ...inventorySurfaces], + retained: [...retainedBeforeListing, ...inventorySurfaces.map(withoutHostAttestation)], removed: provenRemoved, claims: [], topologyRevision: 0 @@ -238,9 +239,11 @@ export async function resolveTerminalOrphanInventory(args: { } dispositions.set(surface.surfaceKey, disposition) } - const retainedAfterListing = inventorySurfaces.filter( - (surface) => dispositions.get(surface.surfaceKey) === 'retain' - ) + const retainedAfterListing = inventorySurfaces + .filter((surface) => dispositions.get(surface.surfaceKey) === 'retain') + .map((surface) => + listedByHandle.has(surface.handle) ? surface : withoutHostAttestation(surface) + ) const removed = new Set( [ ...retiredSurfaces, diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts index e9dbff4d4f6..67d5cd65b4f 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts @@ -88,8 +88,8 @@ describe('web session terminal orphan recovery regressions', () => { expect.objectContaining({ parentTabId: tabId, leafId: 'leaf-hold', - status: 'ready', - terminal: 'term-hold' + status: 'pending-handle', + terminal: null }) ]) expect(call).toHaveBeenCalledOnce() @@ -329,8 +329,8 @@ describe('web session terminal orphan recovery regressions', () => { expect.objectContaining({ parentTabId: 'host-tab', leafId: 'leaf-1', - status: 'ready', - terminal: 'term-live' + status: 'pending-handle', + terminal: null }), editor ]) @@ -362,8 +362,8 @@ describe('web session terminal orphan recovery regressions', () => { expect(recovered?.tabs.slice(0, 2)).toEqual(snapshot.tabs.slice(0, 2)) expect(recovered?.tabs[2]).toMatchObject({ leafId: 'leaf-1', - status: 'ready', - terminal: 'term-live' + status: 'pending-handle', + terminal: null }) }) @@ -569,7 +569,7 @@ describe('web session terminal orphan recovery regressions', () => { expect(recovered?.tabs).toEqual( expect.arrayContaining([ expect.objectContaining({ leafId: 'leaf-claim', terminal: 'term-claim', status: 'ready' }), - expect.objectContaining({ leafId: 'leaf-hold', terminal: 'term-hold', status: 'ready' }) + expect.objectContaining({ leafId: 'leaf-hold', terminal: null, status: 'pending-handle' }) ]) ) expect(call).toHaveBeenNthCalledWith( @@ -669,7 +669,8 @@ describe('web session terminal orphan recovery regressions', () => { expect.objectContaining({ parentTabId: 'hold-tab', leafId: 'leaf-hold', - terminal: 'term-hold' + status: 'pending-handle', + terminal: null }) ]) ) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-sleep.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-sleep.test.ts new file mode 100644 index 00000000000..91cb7aaeedd --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-sleep.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { isInactiveWorkspace } from '../lib/worktree-activity-state' +import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '../lib/worktree-sleep-intent' +import { listResult } from './__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures' +import { + clearWebSessionTerminalOrphanRecoveryForTests, + recoverWebSessionTerminalOrphansBeforeApply +} from './web-session-terminal-orphan-recovery' +import type { TerminalOrphanRecoveryCall } from './web-session-terminal-orphan-recovery-adoption' +import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) + +const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1') +const HANDLE = 'terminal-1' +const PTY_ID = `remote:${ENV}@@${HANDLE}` +const FOLDER = 'folder:folder-1' + +// Sleep keeps the layout binding as the wake binding and clears only the live map. +function sleptState(worktreeId: string, livePtyIds: string[] = []) { + return makeState({ + activeWorktreeId: 'another-workspace', + tabsByWorktree: { + [worktreeId]: [ + { + id: TAB_ID, + ptyId: PTY_ID, + worktreeId, + title: 'claude', + defaultTitle: 'claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + activeTabIdByWorktree: { [worktreeId]: TAB_ID }, + ptyIdsByTabId: { [TAB_ID]: livePtyIds }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + } + }) +} + +function pendingFrame(worktreeId: string): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'claude', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'pending-handle', + terminal: null + } + ], + { worktree: worktreeId } + ) +} + +function inactive(state: ReturnType, worktreeId: string): boolean { + return isInactiveWorkspace(worktreeId, state.tabsByWorktree, state.ptyIdsByTabId, {}, new Set()) +} + +function listed(result: unknown): RuntimeRpcResponse { + return { id: 'list', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function listCall(listing: () => RuntimeRpcResponse) { + return vi.fn(async ({ method }) => { + if (method !== 'terminal.list') { + throw new Error(`unexpected ${method}`) + } + return listing() + }) +} + +const INVENTORIES = [ + { + name: 'a complete inventory', + listing: () => listed(listResult(WT, [])) + }, + { + name: 'an older host inventory without hostScope', + listing: () => listed({ ...listResult(WT, []), hostScope: undefined }) + }, + { + name: 'a truncated inventory', + listing: () => listed(listResult(WT, [], { truncated: true })) + }, + { + name: 'a failed inventory', + listing: (): RuntimeRpcResponse => { + throw new Error('connection lost') + } + } +] + +describe('terminal orphan recovery of a slept workspace (#30108)', () => { + beforeEach(() => { + clearWebSessionTerminalOrphanRecoveryForTests() + resetWebSessionTabsSyncTestState() + }) + afterEach(() => { + clearWorktreeSleepIntent(WT) + clearWorktreeSleepIntent(FOLDER) + }) + + it.each( + [WT, FOLDER].flatMap((worktreeId) => + INVENTORIES.map((inventory) => ({ worktreeId, ...inventory })) + ) + )('keeps $worktreeId asleep when $name omits its handle', async ({ worktreeId, listing }) => { + const state = sleptState(worktreeId) + markWorktreeSleepIntent(worktreeId) + const frame = pendingFrame(worktreeId) + const call = listCall(listing) + + const recovered = await recoverWebSessionTerminalOrphansBeforeApply(state, frame, ENV, { + call + }) + + expect(call).toHaveBeenCalledOnce() + expect(recovered?.tabs).toEqual([ + expect.objectContaining({ + leafId: LEAF_ID, + status: 'pending-handle', + terminal: null + }) + ]) + const applied = { + ...state, + ...applyWebSessionTabsSnapshot(state, recovered!, ENV, NOW + 1) + } + expect(applied.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ + [LEAF_ID]: PTY_ID + }) + expect(applied.ptyIdsByTabId[TAB_ID] ?? []).toEqual([]) + expect(inactive(applied, worktreeId)).toBe(true) + }) + + it('keeps an already-live binding live through the same pending gap', async () => { + const state = sleptState(WT, [PTY_ID]) + const call = listCall(() => listed(listResult(WT, []))) + + const recovered = await recoverWebSessionTerminalOrphansBeforeApply( + state, + pendingFrame(WT), + ENV, + { call } + ) + const applied = { + ...state, + ...applyWebSessionTabsSnapshot(state, recovered!, ENV, NOW + 1) + } + + expect(applied.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ + [LEAF_ID]: PTY_ID + }) + expect(applied.ptyIdsByTabId[TAB_ID]).toEqual([PTY_ID]) + expect(inactive(applied, WT)).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts index c8387979542..cd9187c6e45 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts @@ -44,6 +44,8 @@ type RecoverySurfaceCoordinates = { locallyActive: boolean /** A persisted binding whose leaf no longer appears in the layout tree. */ offTree?: boolean + /** A pending row whose handle no host inventory vouched for; it must stay pending, not ready. */ + unattested?: boolean } export type RecoverySurface = RecoverySurfaceCoordinates & { handle: string } @@ -169,7 +171,8 @@ export function prepareTerminalOrphanRecovery( export function buildRetainedTerminalSurface(surface: AnyRecoverySurface): TerminalSurface { const incoming = surface.incoming const localTitle = typeof surface.localTab.title === 'string' ? surface.localTab.title.trim() : '' - if (!surface.handle) { + // Why: a ready row counts as a live terminal, so a slept pane (#30108) would wake in the mirror. + if (!surface.handle || surface.unattested) { return { ...(incoming ?? { type: 'terminal', @@ -212,6 +215,11 @@ export function buildRetainedTerminalSurface(surface: AnyRecoverySurface): Termi } } +/** Marks a pending surface the host's terminal inventory did not list as live. */ +export function withoutHostAttestation(surface: RecoverySurface): RecoverySurface { + return surface.pending ? { ...surface, unattested: true } : surface +} + export function mergeRetainedTerminalSurfaces( snapshot: RuntimeMobileSessionTabsResult, surfaces: readonly AnyRecoverySurface[], diff --git a/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts b/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts index d5fea390809..a7b35b9bd97 100644 --- a/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts @@ -482,7 +482,7 @@ describe('web session pending terminal handle recovery', () => { { call: call as never } ) ).resolves.toMatchObject({ - tabs: [expect.objectContaining({ status: 'ready', terminal: TERMINAL_HANDLE })] + tabs: [expect.objectContaining({ status: 'pending-handle', terminal: null })] }) })