diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec4d930ad793..4c56a99ea4ae 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -31367,6 +31367,88 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) + it('closes headless terminal parents whose persisted record is missing instead of resurrecting them', async () => { + const layout = makeHeadlessTerminalLayout({ + [HEADLESS_LEAF_ID]: 'pty-a', + [HEADLESS_SECOND_LEAF_ID]: 'pty-b' + }) + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'pty-a', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { 'host-tab': layout } + }) + ) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-a', + paneTitle: 'A' + }, + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 2, + ptyId: 'pty-b', + paneTitle: 'B' + } + ] + }) + + // Why: warm the live snapshot, then drop the persisted row out-of-band to + // reproduce the persisted/live-state drift (#10747, #12920) — a + // never-persisted background tab or a close that de-persisted then failed. + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + setSession({ + ...getSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalLayoutsByTabId: {} + }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(kill).toHaveBeenCalledWith('pty-a') + expect(kill).toHaveBeenCalledWith('pty-b') + const relisted = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect( + relisted.tabs.filter((tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab') + ).toEqual([]) + }) + it('closes persisted headless terminal parents before any prior list call', async () => { const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e81da239cef..173bb9b4dc23 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7285,11 +7285,7 @@ export class OrcaRuntimeService { } } - private removePersistedHeadlessTerminalTab( - worktreeId: string, - parentTabId: string, - options: { allowMissing?: boolean } = {} - ): string[] { + private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { throw new Error('workspace_session_unavailable') @@ -7299,10 +7295,12 @@ export class OrcaRuntimeService { throw new Error('terminal_tab_pinned') } if (!result.closed) { - if (options.allowMissing) { - return [] - } - throw new Error('tab_not_found') + // Why: the caller resolved the tab from the live snapshot, so a missing + // persisted row is always drift (never-persisted background tab, or an + // earlier close that de-persisted then failed). Aborting here would skip + // the PTY kill and snapshot prune below and the republished snapshot + // would resurrect the tab on every client forever (#10747, #12920). + return [] } this.setWorkspaceSessionForWorktree( worktreeId, @@ -7927,10 +7925,7 @@ export class OrcaRuntimeService { this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab) ) { // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. - this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { - // Why: the renderer may already have durably removed the tab before acknowledging. - allowMissingPersistedTab: true - }) + this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() } @@ -8104,13 +8099,11 @@ export class OrcaRuntimeService { worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, - options: { allowMissingPersistedTab?: boolean; killPtys?: boolean } = {} + options: { killPtys?: boolean } = {} ): void { const closedParentTabId = tab.parentTabId this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) - const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId, { - allowMissing: options.allowMissingPersistedTab - }) + const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) // Why: local provider ids can be reused after restart, so a dormant // persisted id is not kill authority. SSH relay ids remain durable exact // identities even before pane metadata reconnects.