From dc12495a3500fd0cbc99cd14438ec2653ca625a6 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 19:38:54 -0700 Subject: [PATCH] fix(runtime): automatic tab activation no longer wakes a worktree the host put to sleep (#27081) * fix(runtime): automatic tab activation no longer wakes a worktree the host put to sleep * fix(pty): a joined user wake no longer inherits an automatic sleep refusal * refactor(runtime): pass caller spawn guards through one helper --------- Co-authored-by: m4air --- src/main/ipc/pty/pane/adopt-stable.ts | 10 ++- src/main/ipc/pty/pane/join-pane-spawn.test.ts | 47 ++++++++++ src/main/ipc/pty/pane/spawn-reservation.ts | 18 ++++ src/main/ipc/pty/runtime/spawn-execute.ts | 4 +- src/main/ipc/pty/runtime/spawn-options.ts | 5 +- src/main/ipc/pty/runtime/spawn-state.ts | 1 + .../automatic-activation-host-sleep.test.ts | 90 +++++++++++++++++++ ...e-runtime-owned-mobile-session-terminal.ts | 4 +- ...ca-runtime-create-terminal-dependencies.ts | 14 ++- .../runtime/orca-runtime-create-terminal.ts | 2 +- ...form-mobile-session-pty-records-refresh.ts | 19 +++- ...rca-runtime-stop-terminals-for-worktree.ts | 16 +++- .../runtime-pty-controller-contract.ts | 1 + .../runtime/runtime-terminal-contracts.ts | 2 + .../worktree-terminals-sleeping-error.ts | 6 ++ 15 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 src/main/ipc/pty/pane/join-pane-spawn.test.ts create mode 100644 src/main/runtime/automatic-activation-host-sleep.test.ts create mode 100644 src/main/runtime/worktree-terminals-sleeping-error.ts diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts index e6c1f3d0793..fd42b1c0d72 100644 --- a/src/main/ipc/pty/pane/adopt-stable.ts +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -3,7 +3,11 @@ import type { Store } from '../../../persistence' import { makePaneKey } from '../../../../shared/stable-pane-id' import { getProvider } from '../provider/registry' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' -import { makePaneSpawnReservationKey, paneSpawnReservationsByOwnerKey } from './spawn-reservation' +import { + joinPaneSpawn, + makePaneSpawnReservationKey, + paneSpawnReservationsByOwnerKey +} from './spawn-reservation' import { attachStablePaneOwner, resolveStablePaneOwner, @@ -26,8 +30,8 @@ export async function adoptStablePane( ownerKey && !args.ownsPaneSpawnReservation ? paneSpawnReservationsByOwnerKey.get(ownerKey) : undefined - if (activePaneSpawn) { - const result = await activePaneSpawn.promise + const result = activePaneSpawn ? await joinPaneSpawn(activePaneSpawn) : null + if (result) { const owner = resolveStablePaneOwner( runtime, store, diff --git a/src/main/ipc/pty/pane/join-pane-spawn.test.ts b/src/main/ipc/pty/pane/join-pane-spawn.test.ts new file mode 100644 index 00000000000..2c0ac6c8bc4 --- /dev/null +++ b/src/main/ipc/pty/pane/join-pane-spawn.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, it } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { WORKTREE_TERMINALS_SLEEPING_ERROR } from '../../../runtime/worktree-terminals-sleeping-error' +import { adoptStablePane } from './adopt-stable' +import { + joinPaneSpawn, + makePaneSpawnReservationKey, + paneSpawnReservationsByOwnerKey, + rejectPaneSpawnReservation, + reservePaneSpawn +} from './spawn-reservation' + +const pane = { + worktreeId: 'repo::/w', + connectionId: null, + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' +} +const ownerKey = makePaneSpawnReservationKey( + pane.worktreeId, + null, + makePaneKey(pane.tabId, pane.leafId) +)! + +afterEach(() => paneSpawnReservationsByOwnerKey.clear()) + +it('lets a joiner spawn itself when the owner refused to wake a slept worktree', async () => { + const reservation = reservePaneSpawn(ownerKey) + const joined = joinPaneSpawn(reservation) + rejectPaneSpawnReservation(ownerKey, reservation, new Error(WORKTREE_TERMINALS_SLEEPING_ERROR)) + await expect(joined).resolves.toBeNull() +}) + +it('still propagates a real spawn failure to joiners', async () => { + const reservation = reservePaneSpawn(ownerKey) + const joined = joinPaneSpawn(reservation) + rejectPaneSpawnReservation(ownerKey, reservation, new Error('spawn_failed')) + await expect(joined).rejects.toThrow('spawn_failed') +}) + +it('does not hand an automatic refusal to a user adoption of the same pane', async () => { + const reservation = reservePaneSpawn(ownerKey) + const adopted = adoptStablePane(undefined, undefined, { ...pane, cols: 80, rows: 24 }) + rejectPaneSpawnReservation(ownerKey, reservation, new Error(WORKTREE_TERMINALS_SLEEPING_ERROR)) + // No owner exists, so the user's own create proceeds to spawn (and wake) instead of failing. + await expect(adopted).resolves.toBeNull() +}) diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts index 936854347aa..095c365c182 100644 --- a/src/main/ipc/pty/pane/spawn-reservation.ts +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -1,5 +1,6 @@ import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' import type { PtySpawnResult } from '../../../providers/types' +import { isWorktreeTerminalsSleepingError } from '../../../runtime/worktree-terminals-sleeping-error' export type PaneSpawnReservation = { promise: Promise @@ -38,6 +39,23 @@ export function reservePaneSpawn(paneKey: string): PaneSpawnReservation { return reservation } +/** + * Awaits a concurrent spawn of the same pane. Null when that spawn was an automatic one refused for a + * slept worktree: its refusal is not the pane's outcome, so a joining user wake must spawn itself. + */ +export async function joinPaneSpawn( + reservation: PaneSpawnReservation +): Promise { + try { + return await reservation.promise + } catch (error) { + if (isWorktreeTerminalsSleepingError(error)) { + return null + } + throw error + } +} + /** Reserves the pane once no runtime create or other spawn holds it. */ export async function reserveIdlePaneSpawn(ownerKey: string): Promise { for (;;) { diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index bf28e095ca3..76d15d8650f 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -24,7 +24,9 @@ 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, { + refuseSleptWorktree: args.refuseSleptWorktree + }) : undefined try { if (args.preAllocatedHandle) { diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 6f041b0686b..ac05460961e 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -27,6 +27,7 @@ import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-com import { resolveStablePaneOwner } from '../pane/stable-owner' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' import { + joinPaneSpawn, makePaneSpawnReservationKey, reservePaneSpawn, paneSpawnReservationsByOwnerKey @@ -253,8 +254,8 @@ export async function buildRuntimePtySpawnOptions( const existingPaneSpawn = ctx.spawnIdentityPaneKey ? paneSpawnReservationsByOwnerKey.get(resolvedPaneSpawnReservationKey!) : undefined - if (existingPaneSpawn) { - const concurrentResult = await existingPaneSpawn.promise + const concurrentResult = existingPaneSpawn ? await joinPaneSpawn(existingPaneSpawn) : null + if (concurrentResult) { const concurrentOwner = resolveStablePaneOwner( ctx.deps.runtime, ctx.deps.store, diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index d0e31889ad3..aeaa36a4557 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -120,6 +120,7 @@ export type RuntimePtySpawnArgs = { } agentSessionCreateOperationId?: string signal?: AbortSignal + refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void adoptedStablePane?: { result: PtySpawnResult diff --git a/src/main/runtime/automatic-activation-host-sleep.test.ts b/src/main/runtime/automatic-activation-host-sleep.test.ts new file mode 100644 index 00000000000..667ba373609 --- /dev/null +++ b/src/main/runtime/automatic-activation-host-sleep.test.ts @@ -0,0 +1,90 @@ +// #24399: a plain shell pane has no agent sleep record, so only the host's sleep state proves it was slept. +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeClientEvent } from '../../shared/runtime-client-events' + +// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures. +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + deferred, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} = await import('./orca-runtime-test-fixtures.spec') + +function makeRuntime(listProcesses: () => Promise = async () => []) { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const runtime = new OrcaRuntimeService(runtimeStore) + const spawn = vi.fn().mockResolvedValue({ id: 'persisted-pty' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + stopAndWait: async () => true, + getForegroundProcess: async () => null, + listProcesses + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const events: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => events.push(event)) + const sleepPhases = (): string[] => + events.flatMap((event) => (event.type === 'worktreeTerminalSleepState' ? [event.phase] : [])) + return { runtime, spawn, sleepPhases } +} + +function activate(runtime: InstanceType, intent: 'user' | 'automatic') { + return runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', HEADLESS_LEAF_ID, { + notifyClients: false, + navigation: 'caller', + intent + }) +} + +describe('automatic tab activation honors the host sleep state', () => { + it('refuses an automatic probe for a slept worktree and still wakes on a user tap', async () => { + const { runtime, spawn, sleepPhases } = makeRuntime() + await runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`) + + const refused = await activate(runtime, 'automatic') + + expect(spawn).not.toHaveBeenCalled() + expect(refused.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + expect(sleepPhases()).not.toContain('woken') + + await activate(runtime, 'user') + + expect(spawn).toHaveBeenCalledOnce() + }) + + it('asks the spawn to refuse a slept worktree only for an automatic probe', async () => { + const { runtime, spawn } = makeRuntime() + + await activate(runtime, 'automatic') + + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ refuseSleptWorktree: true })) + }) + + it('refuses under the spawn lock when a sleep commits while an automatic spawn waits', async () => { + const inventory = deferred() + const listProcesses = vi.fn(() => inventory.promise) + const { runtime, sleepPhases } = makeRuntime(listProcesses) + const sleeping = runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`) + await vi.waitFor(() => expect(listProcesses).toHaveBeenCalled()) + // Queued behind the sleep's exclusive hold, so the pre-spawn check has already passed. + const automaticSpawn = runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, { + refuseSleptWorktree: true + }) + inventory.resolve([]) + + await expect(sleeping).resolves.toBeDefined() + await expect(automaticSpawn).rejects.toThrow('worktree_terminals_sleeping') + expect(sleepPhases()).not.toContain('woken') + // The refusal released the lock, so a user spawn still proceeds and wakes. + const release = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + release() + expect(sleepPhases()).toContain('woken') + }) +}) 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 0799fd1b17a..dac1c0e199d 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 @@ -59,6 +59,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig signal?: AbortSignal + refuseSleptWorktree?: boolean } = {} ): Promise { this.pruneOwnedMobileDispatchRecipes() @@ -124,7 +125,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.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}) }) const terminal = opts.createMutation ? await this.dedupeTerminalCreate( diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index b01e6f7f9a0..3be610ed18f 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -1,4 +1,6 @@ -export type { TerminalCreateOptions } from './runtime-terminal-contracts' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' + +export type { TerminalCreateOptions } export type { Worktree } from '../../shared/worktree/types' export type { RuntimeTerminalCreate } from '../../shared/runtime-types' export { @@ -36,3 +38,13 @@ export const BACKGROUND_TERMINAL_SPAWN_FLAGS = { initiallyHidden: true, persistHostSessionBinding: true } as const + +/** Caller-scoped spawn guards: abort when the caller disconnects; never wake a slept worktree. */ +export function callerSpawnGuards( + opts: Pick +): { signal?: AbortSignal; refuseSleptWorktree?: true } { + return { + ...(opts.signal ? { signal: opts.signal } : {}), + ...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}) + } +} diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 8c4ed9ac7bf..dcc80914b6f 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -164,7 +164,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ...(launchOpts.agentSessionCreateOperationId ? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId } : {}), - ...(launchOpts.signal ? { signal: launchOpts.signal } : {}), + ...dependencies.callerSpawnGuards(launchOpts), ...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), 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 5af3fb288c3..01910354224 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 @@ -16,6 +16,7 @@ import { parseWorkspaceKey } from '../../shared/workspace-scope' import { navigationTargetsHost } from '../../shared/runtime-navigation' import { isAutomaticTabActivation } from '../../shared/tab-activation-intent' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' +import { isWorktreeTerminalsSleepingError } from './worktree-terminals-sleeping-error' export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs { protected async performMobileSessionPtyRecordsRefresh( @@ -141,13 +142,15 @@ 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 automatic = isAutomaticTabActivation(opts.intent) 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)) && + (!automatic || + (!this.isDeliberatelyParkedPane(worktreeId, tab) && + !this.isWorktreeTerminalSleepHeld(worktreeId))) && // Why: a launch's early tab is listed before its agent spawns; that launch's spawn fills it. !isAgentLaunchRunningIn(worktreeId, { kind: 'pane', @@ -194,9 +197,19 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu startupCommandDelivery: agentStartup.startupCommandDelivery, launchConfig: agentStartup.launchConfig, launchAgent: tab.launchAgent, - targetGroupId + targetGroupId, + ...(automatic ? { refuseSleptWorktree: true } : {}) }) } catch (err) { + if (automatic && isWorktreeTerminalsSleepingError(err)) { + // Why: the host slept the worktree while this probe waited for the spawn lock. + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) + } 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. 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 747384d2103..08dee7a8333 100644 --- a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts @@ -5,6 +5,7 @@ import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { teardownRpcDeadline } from './worktree-teardown' +import { WORKTREE_TERMINALS_SLEEPING_ERROR } from './worktree-terminals-sleeping-error' import type { RuntimeWorktreeTerminalCloseResult, RuntimeWorktreeTerminalSleepResult @@ -262,13 +263,21 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso } } - async acquireWorktreeTerminalSpawn(worktreeId?: string): Promise<() => void> { + async acquireWorktreeTerminalSpawn( + worktreeId?: string, + opts: { refuseSleptWorktree?: boolean } = {} + ): Promise<() => void> { if (!worktreeId) { return () => {} } const release = await this.acquireWorktreeTerminalMutation(worktreeId, 'shared') const key = runtimeWorktreeIdentityKey(worktreeId) const sleepState = this.terminalSleepStateByWorktreeId.get(key) + // Why: re-checked under the lock, since a sleep can commit while an automatic spawn waits for it. + if (sleepState && opts.refuseSleptWorktree) { + release() + throw new Error(WORKTREE_TERMINALS_SLEEPING_ERROR) + } if (sleepState?.phase === 'sleeping' || sleepState?.phase === 'partial') { this.terminalSleepStateByWorktreeId.delete(key) this.emitClientEvent({ @@ -283,6 +292,11 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso return release } + /** Whether the host itself has put (or is putting) this worktree's terminals to sleep. */ + protected isWorktreeTerminalSleepHeld(worktreeId: string): boolean { + return this.terminalSleepStateByWorktreeId.has(runtimeWorktreeIdentityKey(worktreeId)) + } + protected async runWorktreeTerminalMutation( worktreeId: string, operation: () => Promise diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 6b6dafcf899..80a85821c14 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -81,6 +81,7 @@ export type RuntimePtyController = { } agentSessionCreateOperationId?: string signal?: AbortSignal + refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void adoptedStablePane?: { result: PtySpawnResult diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 43b487db3c9..c243d21fe03 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -89,6 +89,8 @@ export type TerminalCreateOptions = { agentSessionClaim?: AgentSessionExecutionClaim agentSessionCreateOperationId?: string signal?: AbortSignal + /** Refuse (`worktree_terminals_sleeping`) instead of waking a worktree the host has put to sleep. */ + refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void /** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */ onPtySpawnDispatched?: (launch?: { diff --git a/src/main/runtime/worktree-terminals-sleeping-error.ts b/src/main/runtime/worktree-terminals-sleeping-error.ts new file mode 100644 index 00000000000..03ea8221df8 --- /dev/null +++ b/src/main/runtime/worktree-terminals-sleeping-error.ts @@ -0,0 +1,6 @@ +/** Refusal for an automatic spawn into a worktree whose terminals the host has put to sleep. */ +export const WORKTREE_TERMINALS_SLEEPING_ERROR = 'worktree_terminals_sleeping' + +export function isWorktreeTerminalsSleepingError(error: unknown): boolean { + return error instanceof Error && error.message === WORKTREE_TERMINALS_SLEEPING_ERROR +}