From 8570b0a7d3c1eb95cc05bcc0befd59cd0e8f7906 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:22:50 -0700 Subject: [PATCH] fix(remote): the first renderer publication after a restart drops a durably closed paired tab (#29433) --- ...obile-close-preserved-resurrection.test.ts | 30 ++++++++- .../mobile-session-preservation.ts | 63 ++++++++++++------- ...mobile-session-publication-preservation.ts | 13 ++++ 3 files changed, 83 insertions(+), 23 deletions(-) diff --git a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts index 4260c8213ef..f4c7cf88494 100644 --- a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts +++ b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts @@ -95,7 +95,7 @@ function makeDeferred() { return { promise, resolve } } -function createHarness() { +function createHarness(options: { restarted?: boolean } = {}) { let session = makeSession([TAB_A, TAB_B]) const repo = { id: REPO_ID, @@ -196,7 +196,10 @@ function createHarness() { runtime as unknown as { pairedRendererSessionOwnedPtyIds: Set } ).pairedRendererSessionOwnedPtyIds.add(spec.ptyId) } - publishRendererSnapshot([TAB_A, TAB_B], 1) + // Why: a restarted host serves its disk-hydrated snapshot until the renderer's first publication. + if (!options.restarted) { + publishRendererSnapshot([TAB_A, TAB_B], 1) + } return { runtime, @@ -454,6 +457,29 @@ describe('a committed paired close stays closed while its PTY lingers', () => { ]) }) + it('red: the first renderer publication after a restart must not resurrect a retired tab', async () => { + const harness = createHarness({ restarted: true }) + expect(await listParentTabIds(harness.runtime)).toEqual([TAB_A, TAB_B].sort()) + + harness.retirePersistedTab(TAB_A) + harness.publishRendererSnapshot([TAB_B], 1) + + expect( + await listParentTabIds(harness.runtime), + 'the first post-restart merge resurrected a tab the host renderer closed' + ).toEqual([TAB_B]) + expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(false) + expect(harness.isRuntimeSessionOwned(PTY_B)).toBe(true) + }) + + it('control: the first post-restart publication keeps a still-persisted tab it omits', async () => { + const harness = createHarness({ restarted: true }) + harness.publishRendererSnapshot([TAB_B], 1) + + expect(await listParentTabIds(harness.runtime)).toEqual([TAB_A, TAB_B].sort()) + expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(true) + }) + it('control: a create still in flight is not retirement — its tab and ownership survive the omission', async () => { const harness = createHarness() diff --git a/src/main/runtime/session-tabs/mobile-session-preservation.ts b/src/main/runtime/session-tabs/mobile-session-preservation.ts index bb349791dec..e3a7e4ddd09 100644 --- a/src/main/runtime/session-tabs/mobile-session-preservation.ts +++ b/src/main/runtime/session-tabs/mobile-session-preservation.ts @@ -108,22 +108,21 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext return next } - protected releaseRuntimeSessionOwnershipForRendererRetiredTabs( + // Why: omitted from the renderer's publication AND from persistence = durably closed; a create in flight is not. + protected listRendererRetiredTerminalTabs( incoming: RuntimeMobileSessionTabsSnapshot, - existing: RuntimeMobileSessionTabsSnapshot | undefined - ): void { - if (!existing || this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)) { - return - } + existing: RuntimeMobileSessionTabsSnapshot + ): { tab: RuntimeMobileSessionTerminalTab; scope: 'tab' | 'leaf' }[] { const session = this.getWorkspaceSessionForWorktree(existing.worktree) const persistedTabs = session?.tabsByWorktree?.[existing.worktree] if (!session || !persistedTabs) { - return + return [] } const persistedTabsById = new Map(persistedTabs.map((tab) => [tab.id, tab])) const incomingIds = new Set( incoming.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab)) ) + const retired: { tab: RuntimeMobileSessionTerminalTab; scope: 'tab' | 'leaf' }[] = [] for (const tab of existing.tabs) { if ( tab.type !== 'terminal' || @@ -132,18 +131,12 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext ) { continue } - if (!this.hasLiveRuntimeSessionOwnedPtyBinding(existing.worktree, tab)) { - continue - } const persistedParent = persistedTabsById.get(tab.parentTabId) if (!persistedParent) { - this.clearRuntimeSessionOwnershipForMobileTab(existing.worktree, existing, tab.parentTabId) + retired.push({ tab, scope: 'tab' }) continue } const layout = session.terminalLayoutsByTabId?.[tab.parentTabId] - const boundPtyIds = [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter( - (id): id is string => typeof id === 'string' - ) const persistedIds = new Set( [persistedParent.ptyId, ...Object.values(layout?.ptyIdsByLeafId ?? {})].filter( (id): id is string => typeof id === 'string' @@ -153,14 +146,36 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext layout && !layout.ptyIdsByLeafId?.[tab.leafId] && !terminalLayoutContainsLeaf(layout.root, tab.leafId) && - !boundPtyIds.some((id) => persistedIds.has(id)) + !getMobileTerminalLeafPtyIds(tab).some((id) => persistedIds.has(id)) ) { - for (const ptyId of boundPtyIds) { - const pty = this.ptysById.get(ptyId) - if (pty?.worktreeId === existing.worktree && pty.tabId === tab.parentTabId) { - pty.runtimeSessionOwned = false - this.setPairedRendererSessionOwnership(ptyId, false) - } + retired.push({ tab, scope: 'leaf' }) + } + } + return retired + } + + protected releaseRuntimeSessionOwnershipForRendererRetiredTabs( + incoming: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined + ): void { + // Why no headless-built exemption: the first renderer publication after a restart lands on a + // disk-hydrated base, and skipping it left a retired paired tab owned until the next sync (STA-4897). + if (!existing) { + return + } + for (const { tab, scope } of this.listRendererRetiredTerminalTabs(incoming, existing)) { + if (!this.hasLiveRuntimeSessionOwnedPtyBinding(existing.worktree, tab)) { + continue + } + if (scope === 'tab') { + this.clearRuntimeSessionOwnershipForMobileTab(existing.worktree, existing, tab.parentTabId) + continue + } + for (const ptyId of getMobileTerminalLeafPtyIds(tab)) { + const pty = this.ptysById.get(ptyId) + if (pty?.worktreeId === existing.worktree && pty.tabId === tab.parentTabId) { + pty.runtimeSessionOwned = false + this.setPairedRendererSessionOwnership(ptyId, false) } } } @@ -199,3 +214,9 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext } } } + +function getMobileTerminalLeafPtyIds(tab: RuntimeMobileSessionTerminalTab): string[] { + return [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter( + (id): id is string => typeof id === 'string' + ) +} diff --git a/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts b/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts index bd96b4f5f22..39d36adeb23 100644 --- a/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts +++ b/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts @@ -44,10 +44,23 @@ export abstract class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab ex const incomingIds = new Set( incoming?.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] ) + // Why: a headless-built base preserves every terminal, so a renderer publication over it + // (the first after a restart) must still drop the tabs the renderer durably closed (STA-4897). + const retiredTabIds = + incoming && this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) + ? new Set(this.listRendererRetiredTerminalTabs(incoming, existing).map(({ tab }) => tab.id)) + : null return existing.tabs.filter((tab) => { if (getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) { return false } + if ( + tab.type === 'terminal' && + retiredTabIds?.has(tab.id) && + !this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(existing.worktree, tab) + ) { + return false + } return this.shouldPreserveHeadlessMobileSessionTab(existing, tab) }) }