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-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/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..f1734df79dec --- /dev/null +++ b/src/main/runtime/worktree-terminal-spawn-sleep-disposition.ts @@ -0,0 +1,71 @@ +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) + // `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 + } + 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 +}