From 41fc895cd12f216b21d3309e91a8e59c843c4c32 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Fri, 2 Oct 2026 01:21:18 +0900 Subject: [PATCH 1/2] fix(runtime): keep a slept remote shell terminal stopped Automatic recovery recreated the terminal because the park guard only consults an agent resume record, and the spawn lock treated a sleep that committed while it waited as a user wake. A background materialize now leaves a stopping, sleeping, or partial host sleep in place, and the single spawn acquire uses that leave disposition. Fixes #24399 --- .../spawn-execute-sleep-disposition.test.ts | 36 ++++++++ src/main/ipc/pty/runtime/spawn-execute.ts | 6 +- src/main/ipc/pty/runtime/spawn-state.ts | 2 + ...e-runtime-owned-mobile-session-terminal.ts | 5 +- .../runtime/orca-runtime-create-terminal.ts | 1 + ...form-mobile-session-pty-records-refresh.ts | 50 +++++++---- ...rca-runtime-stop-terminals-for-worktree.ts | 31 ++++--- .../mobile-session-tabs-part-09.spec.ts | 90 +++++++++++++++++++ .../runtime/runtime-terminal-contracts.ts | 5 ++ ...e-terminal-spawn-sleep-disposition.test.ts | 62 +++++++++++++ ...rktree-terminal-spawn-sleep-disposition.ts | 69 ++++++++++++++ 11 files changed, 327 insertions(+), 30 deletions(-) create mode 100644 src/main/ipc/pty/runtime/spawn-execute-sleep-disposition.test.ts create mode 100644 src/main/runtime/worktree-terminal-spawn-sleep-disposition.test.ts create mode 100644 src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts diff --git a/src/main/ipc/pty/runtime/spawn-execute-sleep-disposition.test.ts b/src/main/ipc/pty/runtime/spawn-execute-sleep-disposition.test.ts new file mode 100644 index 000000000000..01d092d0150a --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-execute-sleep-disposition.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { executeRuntimePtySpawn } from './spawn-execute' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' + +function spawnCtx(leaveWorktreeSleeping: boolean) { + const acquireWorktreeTerminalSpawn = vi.fn(async () => { + throw new Error('stop-after-acquire') + }) + const deps = { + runtime: { acquireWorktreeTerminalSpawn }, + store: undefined, + options: {} + } as unknown as PtyRuntimeControllerDeps + const args = { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + ...(leaveWorktreeSleeping ? { leaveWorktreeSleeping: true } : {}) + } satisfies RuntimePtySpawnArgs + return { ctx: createRuntimePtySpawnState(deps, args), acquireWorktreeTerminalSpawn } +} + +describe('runtime spawn sleep disposition', () => { + it('asks an automatic recovery to leave a committed host sleep', async () => { + const { ctx, acquireWorktreeTerminalSpawn } = spawnCtx(true) + await expect(executeRuntimePtySpawn(ctx)).rejects.toThrow('stop-after-acquire') + expect(acquireWorktreeTerminalSpawn).toHaveBeenCalledWith('wt-1', 'leave') + }) + + it('wakes a committed host sleep for an explicit spawn', async () => { + const { ctx, acquireWorktreeTerminalSpawn } = spawnCtx(false) + await expect(executeRuntimePtySpawn(ctx)).rejects.toThrow('stop-after-acquire') + expect(acquireWorktreeTerminalSpawn).toHaveBeenCalledWith('wt-1', 'wake') + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 8228175a2cf1..0144061598f0 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -24,7 +24,11 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise const runtime = ctx.deps.runtime const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn ctx.releaseWorktreeSpawn = acquireWorktreeSpawn - ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + ? await acquireWorktreeSpawn.call( + runtime, + args.worktreeId, + args.leaveWorktreeSleeping ? 'leave' : 'wake' + ) : undefined try { if (args.preAllocatedHandle) { diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 581d335ecdbc..4ca567b54b35 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -99,6 +99,8 @@ export type RuntimePtySpawnArgs = { resumeProviderSession?: AgentProviderSessionMetadata connectionId?: string | null worktreeId?: string + /** Automatic recovery must not clear a host sleep that commits while this spawn waits. */ + leaveWorktreeSleeping?: boolean preAllocatedHandle?: string tabId?: string leafId?: string diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index f1b8fa16af5c..26f262344195 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -35,6 +35,8 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig signal?: AbortSignal + /** Automatic recovery must not clear a host sleep that commits while this spawn waits. */ + leaveWorktreeSleeping?: boolean } = {} ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) @@ -66,7 +68,8 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca persistHostSessionBinding: true, // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. deferMobileSessionPublish: true, - signal: opts.signal + signal: opts.signal, + ...(opts.leaveWorktreeSleeping ? { leaveWorktreeSleeping: true } : {}) }) const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 242e0f7f4544..ec847060b806 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -148,6 +148,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, + ...(launchOpts.leaveWorktreeSleeping ? { leaveWorktreeSleeping: true } : {}), preAllocatedHandle, tabId, leafId, diff --git a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts index 8101145b2c2c..e11c14618382 100644 --- a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts @@ -9,7 +9,14 @@ import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import type { ResolvedWorktree } from './runtime-worktree-path-identity' -import { includeTargetResolvedWorktree } from './runtime-worktree-path-identity' +import { + includeTargetResolvedWorktree, + runtimeWorktreeIdentityKey +} from './runtime-worktree-path-identity' +import { + hostWorktreeSleepBlocksAutomaticRecovery, + isWorktreeTerminalLeftAsleep +} from './worktree-terminal-spawn-sleep-disposition' import { parseExecutionHostId } from '../../shared/execution-host' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { navigationTargetsHost } from '../../shared/runtime-navigation' @@ -138,16 +145,23 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu // their tab id, so focusing the renderer would silently no-op. // Phone-local activation also needs this path for inactive restored tabs: // desktop focus is intentionally suppressed, but the PTY still must exist. + const automaticActivation = isAutomaticTabActivation(opts.intent) + const hostSleepPhase = this.terminalSleepStateByWorktreeId.get( + runtimeWorktreeIdentityKey(worktreeId) + )?.phase const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && // Why: opening a tab is the documented wake gesture for a slept pane // (#11598), so only a background probe may be refused for one. - (!isAutomaticTabActivation(opts.intent) || - !this.isDeliberatelyParkedPane(worktreeId, tab)) && + // The host sleep map covers shell tabs that never got an agent resume record. + (!automaticActivation || + (!this.isDeliberatelyParkedPane(worktreeId, tab) && + !hostWorktreeSleepBlocksAutomaticRecovery(hostSleepPhase))) && (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) + let materializedPendingTerminal = false if (shouldMaterializePendingTerminal) { const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined const targetGroupId = snapshot?.tabGroups?.find((group) => @@ -185,22 +199,28 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu startupCommandDelivery: agentStartup.startupCommandDelivery, launchConfig: agentStartup.launchConfig, launchAgent: tab.launchAgent, - targetGroupId + targetGroupId, + ...(automaticActivation ? { leaveWorktreeSleeping: true } : {}) }) + materializedPendingTerminal = true } catch (err) { - if (sessionId && parseAppSshPtyId(sessionId)) { - // Why: an expired SSH reattach clears durable bindings in the store, - // but this in-memory headless snapshot can still carry the old id. - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { force: true }) + if (!isWorktreeTerminalLeftAsleep(err)) { + if (sessionId && parseAppSshPtyId(sessionId)) { + // Why: an expired SSH reattach clears durable bindings in the store, + // but this in-memory headless snapshot can still carry the old id. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { force: true }) + } + throw err } - throw err } - return this.applyMobileSessionTabNavigation( - this.getMobileSessionTabsForWorktree(worktreeId), - tab.id, - navigation, - opts.clientNavigationId - ) + if (materializedPendingTerminal) { + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) + } } const callerSnapshot = this.getMobileSessionTabsForWorktree( worktreeId, diff --git a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts index 747384d21032..ac1391d26c2d 100644 --- a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts @@ -21,6 +21,11 @@ import { import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from './worktree-pty-host-fence' import { summarizeWorktreePtyStopVerdict } from './worktree-pty-stop-verdict' import { describeMobileSessionTabCloseRefusal } from './mobile-session-tab-close-refusal-message' +import { + settleWorktreeSpawnSleep, + WorktreeTerminalLeftAsleepError, + type WorktreeSpawnSleepDisposition +} from './worktree-terminal-spawn-sleep-disposition' export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithResolveTerminalSplitSourceAuthority { private collectWorktreePtyIds( @@ -262,23 +267,23 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso } } - async acquireWorktreeTerminalSpawn(worktreeId?: string): Promise<() => void> { + async acquireWorktreeTerminalSpawn( + worktreeId?: string, + disposition: WorktreeSpawnSleepDisposition = 'wake' + ): Promise<() => void> { if (!worktreeId) { return () => {} } const release = await this.acquireWorktreeTerminalMutation(worktreeId, 'shared') - const key = runtimeWorktreeIdentityKey(worktreeId) - const sleepState = this.terminalSleepStateByWorktreeId.get(key) - if (sleepState?.phase === 'sleeping' || sleepState?.phase === 'partial') { - this.terminalSleepStateByWorktreeId.delete(key) - this.emitClientEvent({ - type: 'worktreeTerminalSleepState', - worktreeId: sleepState.worktreeId, - generation: sleepState.generation, - phase: 'woken', - ptyIds: sleepState.ptyIds, - terminalHandles: sleepState.terminalHandles - }) + const leftAsleep = settleWorktreeSpawnSleep( + this.terminalSleepStateByWorktreeId, + worktreeId, + disposition, + (event) => this.emitClientEvent(event) + ) + if (leftAsleep) { + release() + throw new WorktreeTerminalLeftAsleepError() } return release } diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts index ff867e7ce034..b954ba543ac6 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts @@ -20,6 +20,11 @@ import { makeWorkspaceSessionWithHeadlessTerminal, store } from '../orca-runtime-test-fixtures.spec' +import { runtimeWorktreeIdentityKey } from '../runtime-worktree-path-identity' +import { + WorktreeTerminalLeftAsleepError, + type WorktreeTerminalSleepSnapshot +} from '../worktree-terminal-spawn-sleep-disposition' describe('OrcaRuntimeService', () => { describe('deliberately parked pane activation (STA-3465)', () => { @@ -307,6 +312,91 @@ describe('OrcaRuntimeService', () => { expect(spawn).not.toHaveBeenCalled() expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) }) + + function hostSleepStates( + runtime: OrcaRuntimeService + ): Map { + return ( + runtime as unknown as { + terminalSleepStateByWorktreeId: Map + } + ).terminalSleepStateByWorktreeId + } + + function rememberHostSleep( + runtime: OrcaRuntimeService, + phase: WorktreeTerminalSleepSnapshot['phase'] + ): void { + hostSleepStates(runtime).set(runtimeWorktreeIdentityKey(TEST_WORKTREE_ID), { + worktreeId: TEST_WORKTREE_ID, + generation: 2, + phase, + ptyIds: ['persisted-pty'], + terminalHandles: ['term-1'], + terminalHandlesByPtyId: { 'persisted-pty': ['term-1'] } + }) + } + + function shellSessionRuntime(): { + runtime: OrcaRuntimeService + spawn: ReturnType + } { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + return makeParkedRuntime(runtimeStore) + } + + it('still materializes a shell tab that has no host sleep record', async () => { + const { runtime, spawn } = shellSessionRuntime() + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it.each(['sleeping', 'partial', 'stopping'] as const)( + 'refuses automatic recovery while the host sleep phase is %s', + async (phase) => { + const { runtime, spawn } = shellSessionRuntime() + rememberHostSleep(runtime, phase) + + const activated = await automaticActivate(runtime) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + expect( + hostSleepStates(runtime).get(runtimeWorktreeIdentityKey(TEST_WORKTREE_ID))?.phase + ).toBe(phase) + } + ) + + it('still materializes a host-slept shell tab when the user opens it', async () => { + const { runtime, spawn } = shellSessionRuntime() + rememberHostSleep(runtime, 'sleeping') + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('keeps a committed host sleep when an automatic spawn reaches the lock', async () => { + const { runtime } = shellSessionRuntime() + rememberHostSleep(runtime, 'sleeping') + + await expect( + runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, 'leave') + ).rejects.toBeInstanceOf(WorktreeTerminalLeftAsleepError) + expect( + hostSleepStates(runtime).get(runtimeWorktreeIdentityKey(TEST_WORKTREE_ID))?.phase + ).toBe('sleeping') + + const release = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + release() + expect(hostSleepStates(runtime).has(runtimeWorktreeIdentityKey(TEST_WORKTREE_ID))).toBe(false) + }) }) it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => { diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index d623a3f1fd14..de812bfda528 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -86,6 +86,11 @@ export type TerminalCreateOptions = { /** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */ onPtySpawnDispatched?: () => void deferMobileSessionPublish?: boolean + /** + * Automatic recovery must not clear a host sleep that became committed while + * this spawn waited for the worktree lock. An explicit user spawn omits this. + */ + leaveWorktreeSleeping?: boolean } /** Identity a fenced spawn can be re-found by in the execution host's own inventory. */ diff --git a/src/main/runtime/worktree-terminal-spawn-sleep-disposition.test.ts b/src/main/runtime/worktree-terminal-spawn-sleep-disposition.test.ts new file mode 100644 index 000000000000..a31cbc8378f4 --- /dev/null +++ b/src/main/runtime/worktree-terminal-spawn-sleep-disposition.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { runtimeWorktreeIdentityKey } from './runtime-worktree-path-identity' +import { + hostWorktreeSleepBlocksAutomaticRecovery, + settleWorktreeSpawnSleep, + type WorktreeTerminalSleepSnapshot +} from './worktree-terminal-spawn-sleep-disposition' + +const WORKTREE_ID = 'repo::/tmp/workspace' + +function sleepingSnapshot( + phase: WorktreeTerminalSleepSnapshot['phase'] +): WorktreeTerminalSleepSnapshot { + return { + worktreeId: WORKTREE_ID, + generation: 4, + phase, + ptyIds: ['pty-1'], + terminalHandles: ['term-1'], + terminalHandlesByPtyId: { 'pty-1': ['term-1'] } + } +} + +describe('host worktree sleep versus automatic recovery', () => { + it('treats an in-progress or committed sleep as deliberate', () => { + expect(hostWorktreeSleepBlocksAutomaticRecovery('stopping')).toBe(true) + expect(hostWorktreeSleepBlocksAutomaticRecovery('sleeping')).toBe(true) + expect(hostWorktreeSleepBlocksAutomaticRecovery('partial')).toBe(true) + expect(hostWorktreeSleepBlocksAutomaticRecovery(undefined)).toBe(false) + }) + + it('leaves a committed sleep in place for an automatic spawn', () => { + const sleepStates = new Map([ + [runtimeWorktreeIdentityKey(WORKTREE_ID), sleepingSnapshot('sleeping')] + ]) + const emitWoken = vi.fn() + + expect(settleWorktreeSpawnSleep(sleepStates, WORKTREE_ID, 'leave', emitWoken)).toBe(true) + + expect(emitWoken).not.toHaveBeenCalled() + expect(sleepStates.get(runtimeWorktreeIdentityKey(WORKTREE_ID))?.phase).toBe('sleeping') + }) + + it('clears a committed sleep for an explicit spawn', () => { + const snapshot = sleepingSnapshot('partial') + const sleepStates = new Map([[runtimeWorktreeIdentityKey(WORKTREE_ID), snapshot]]) + const emitWoken = vi.fn() + + expect(settleWorktreeSpawnSleep(sleepStates, WORKTREE_ID, 'wake', emitWoken)).toBe(false) + + expect(sleepStates.size).toBe(0) + expect(emitWoken).toHaveBeenCalledOnce() + expect(emitWoken).toHaveBeenCalledWith({ + type: 'worktreeTerminalSleepState', + worktreeId: snapshot.worktreeId, + generation: snapshot.generation, + phase: 'woken', + ptyIds: snapshot.ptyIds, + terminalHandles: snapshot.terminalHandles + }) + }) +}) diff --git a/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts b/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts new file mode 100644 index 000000000000..4844f44e97f0 --- /dev/null +++ b/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts @@ -0,0 +1,69 @@ +import { runtimeWorktreeIdentityKey } from './runtime-worktree-path-identity' + +export type WorktreeSpawnSleepDisposition = 'wake' | 'leave' + +export type WorktreeTerminalSleepPhase = 'stopping' | 'partial' | 'sleeping' + +export type WorktreeTerminalSleepSnapshot = { + worktreeId: string + generation: number + phase: WorktreeTerminalSleepPhase + ptyIds: string[] + terminalHandles: string[] + terminalHandlesByPtyId: Record +} + +export class WorktreeTerminalLeftAsleepError extends Error { + constructor() { + super('worktree_terminal_left_asleep') + this.name = 'WorktreeTerminalLeftAsleepError' + } +} + +export function isWorktreeTerminalLeftAsleep(error: unknown): boolean { + return error instanceof WorktreeTerminalLeftAsleepError +} + +/** Host sleep, including an in-progress stop, is not a dropped connection. */ +export function hostWorktreeSleepBlocksAutomaticRecovery( + phase: WorktreeTerminalSleepPhase | undefined +): boolean { + return phase === 'stopping' || phase === 'sleeping' || phase === 'partial' +} + +/** + * Returns true when the spawn lock must be released without creating a PTY. + * A user spawn still clears a committed sleep. An automatic recovery must not. + */ +export function settleWorktreeSpawnSleep( + sleepStates: Map, + worktreeId: string, + disposition: WorktreeSpawnSleepDisposition, + emitClientEvent: (event: { + type: 'worktreeTerminalSleepState' + worktreeId: string + generation: number + phase: 'woken' + ptyIds: string[] + terminalHandles: string[] + }) => void +): boolean { + const key = runtimeWorktreeIdentityKey(worktreeId) + const sleepState = sleepStates.get(key) + if (sleepState?.phase !== 'sleeping' && sleepState?.phase !== 'partial') { + return false + } + if (disposition === 'leave') { + return true + } + sleepStates.delete(key) + emitClientEvent({ + type: 'worktreeTerminalSleepState', + worktreeId: sleepState.worktreeId, + generation: sleepState.generation, + phase: 'woken', + ptyIds: sleepState.ptyIds, + terminalHandles: sleepState.terminalHandles + }) + return false +} From 9447a72ee103332d997edca33777c31649a57847 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Fri, 2 Oct 2026 07:40:39 +0900 Subject: [PATCH 2/2] fix(runtime): declare leaveWorktreeSleeping on the spawn contract Recovery already passes the flag and the spawn path reads it, but the controller options type omitted it. stopping stays out of the leave check because that phase never outlives the exclusive mutation lock. Co-authored-by: Cursor --- src/main/runtime/runtime-pty-controller-contract.ts | 5 +++++ .../runtime/worktree-terminal-spawn-sleep-disposition.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 60c1e18cf10a..0d4d5775c194 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -61,6 +61,11 @@ export type RuntimePtyController = { sessionId?: string /** Windows shell to spawn AS this PTY, instead of the host default. */ shellOverride?: string + /** + * Automatic recovery must not clear a host sleep that commits while this + * spawn waits for the worktree lock. An explicit user spawn omits this. + */ + leaveWorktreeSleeping?: boolean isNewSession?: boolean /** No renderer view exists at spawn; main owns delivery and query replies until one mounts. */ initiallyHidden?: boolean diff --git a/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts b/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts index 4844f44e97f0..f1734df79dec 100644 --- a/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts +++ b/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts @@ -50,6 +50,8 @@ export function settleWorktreeSpawnSleep( ): boolean { const key = runtimeWorktreeIdentityKey(worktreeId) const sleepState = sleepStates.get(key) + // `stopping` stays inside the exclusive mutation lock and is replaced by + // sleeping, partial, or deletion before that lock is released. if (sleepState?.phase !== 'sleeping' && sleepState?.phase !== 'partial') { return false }