From 09f12fa6dcbc910fea9113c3adf0617b0efe3e35 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:27:55 -0700 Subject: [PATCH] fix(runtime): a slept remote workspace stays asleep through terminal orphan recovery (#30118) Orphan recovery republished a pending host row as ready on the strength of the client's retained wake binding alone, even when the host's terminal inventory omitted the handle. The mirror then counted the dead handle as live, so a slept workspace came back awake in the sidebar. A pending row now stays pending unless the host's inventory lists its handle; the wake binding is still kept. Fixes #30108 --- ...on-terminal-orphan-inventory-retry.test.ts | 10 +- ...phan-recovery-adoption-regressions.test.ts | 5 +- ...sion-terminal-orphan-recovery-inventory.ts | 13 +- ...rminal-orphan-recovery-regressions.test.ts | 17 +- ...ion-terminal-orphan-recovery-sleep.test.ts | 184 ++++++++++++++++++ ...ession-terminal-orphan-recovery-surface.ts | 10 +- ...n-terminal-pending-handle-recovery.test.ts | 2 +- 7 files changed, 220 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-terminal-orphan-recovery-sleep.test.ts 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 })] }) })