From 917c71fddef689e4ccbbf21b29ead18a739300e7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 5 Aug 2026 03:39:02 -0400 Subject: [PATCH] fix(runtime): refuse to silently wake a deliberately slept pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `activateMobileSessionTab` decided whether to materialize a pane from `publicTab.status !== 'ready'` alone. A pane the user put to sleep publishes `pending-handle` indefinitely, so it was indistinguishable there from a pane whose PTY was lost and is awaiting reconnect — and activating it respawned it with a freshly re-resolved agent launch the user never asked for. Gate materialization on the durable sleeping record's `origin`. Only a worktree-owned capture ('worktree-sleep', written by workspace sleep and by completed-agent hibernation) records a deliberate takedown; 'live' and 'quit' captures describe a pane that was still running, so reconnect-driven materialization (#11542) is unchanged for them. --- src/main/runtime/orca-runtime.test.ts | 152 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 23 ++++ 2 files changed, 175 insertions(+) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c481a0a4239..9fab8a3e8c9 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -79,6 +79,7 @@ import { } from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES, @@ -29145,6 +29146,157 @@ describe('OrcaRuntimeService', () => { }) }) + describe('deliberately parked pane activation (STA-3465)', () => { + function makeParkedSessionStore( + origin: SleepingAgentSessionRecord['origin'] | undefined, + overrides: Partial = {} + ) { + return makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + sleepingAgentSessionsByPaneKey: { + [`host-tab:${HEADLESS_LEAF_ID}`]: { + paneKey: `host-tab:${HEADLESS_LEAF_ID}`, + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + prompt: 'do the thing', + state: 'done', + capturedAt: 1, + updatedAt: 1, + ...(origin ? { origin } : {}), + ...overrides + } as SleepingAgentSessionRecord + } + }) + ) + } + + function makeParkedRuntime(runtimeStore: unknown): { + runtime: OrcaRuntimeService + spawn: ReturnType + } { + const spawn = vi.fn().mockResolvedValue({ id: 'persisted-pty' }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return { runtime, spawn } + } + + it('refuses to respawn a deliberately slept pane on a reconnect-driven activate', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + 'host-tab', + undefined, + { notifyClients: false, navigation: 'caller' } + ) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'pending-handle', + terminal: null + }) + // Negative safety: refusing to wake must not retire the surface either. + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject( + { parentTabId: 'host-tab', status: 'pending-handle' } + ) + }) + + it('refuses to respawn a deliberately slept pane on a headless host activate', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + }) + + // Why: #11542's reconnect fix depends on activate materializing a genuinely + // awaiting pane. These three prove the park guard did not break it. + it('still materializes a pane awaiting reconnect with no sleeping record', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + 'host-tab', + undefined, + { notifyClients: false, navigation: 'caller' } + ) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + sessionId: 'persisted-pty' + }) + ) + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('still materializes a pane whose record was captured while it was live', async () => { + const { runtimeStore } = makeParkedSessionStore('live') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + 'host-tab', + undefined, + { notifyClients: false, navigation: 'caller' } + ) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('still materializes a pane whose record was captured at app quit', async () => { + const { runtimeStore } = makeParkedSessionStore('quit') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + 'host-tab', + undefined, + { notifyClients: false, navigation: 'caller' } + ) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('ignores a park record that belongs to a different worktree', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + worktreeId: 'other-repo::/other' + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + 'host-tab', + undefined, + { notifyClients: false, navigation: 'caller' } + ) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + }) + it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aa9ee8d557b..28dddfdf147 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7321,6 +7321,7 @@ export class OrcaRuntimeService { const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && + !this.isDeliberatelyParkedPane(worktreeId, tab) && (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) @@ -7474,6 +7475,28 @@ export class OrcaRuntimeService { return snapshot } + /** + * Whether persistence proves this pane's PTY was deliberately taken down and parked + * (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect. + * Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it + * indefinitely — and respawning a parked pane re-launches its agent behind the user. + */ + private isDeliberatelyParkedPane( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const record = + this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ + makePaneKey(tab.parentTabId, tab.leafId) + ] + // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect + // must still mint its replacement PTY (#11542). Only a worktree-owned capture records + // a deliberate takedown the user did not ask to undo. + return ( + record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId) + ) + } + private shouldMaterializeHeadlessMobileSessionTab( snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab