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 <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
OrcaWin
2026-10-09 19:38:54 -07:00
committed by GitHub
co-authored by m4air
parent 13d8ca87dd
commit dc12495a35
15 changed files with 226 additions and 13 deletions
+7 -3
View File
@@ -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,
@@ -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()
})
@@ -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<PaneSpawnReservationResult>
@@ -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<PaneSpawnReservationResult | null> {
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<PaneSpawnReservation> {
for (;;) {
+3 -1
View File
@@ -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) {
+3 -2
View File
@@ -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,
+1
View File
@@ -120,6 +120,7 @@ export type RuntimePtySpawnArgs = {
}
agentSessionCreateOperationId?: string
signal?: AbortSignal
refuseSleptWorktree?: boolean
onPtySpawnCommitted?: () => void
adoptedStablePane?: {
result: PtySpawnResult
@@ -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<never[]> = 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<typeof OrcaRuntimeService>, 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<never[]>()
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')
})
})
@@ -59,6 +59,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
supportsSplitGroupPlacement?: boolean
launchConfig?: SleepingAgentLaunchConfig
signal?: AbortSignal
refuseSleptWorktree?: boolean
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
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(
@@ -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<TerminalCreateOptions, 'signal' | 'refuseSleptWorktree'>
): { signal?: AbortSignal; refuseSleptWorktree?: true } {
return {
...(opts.signal ? { signal: opts.signal } : {}),
...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {})
}
}
@@ -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 }
: {}),
@@ -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.
@@ -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<T>(
worktreeId: string,
operation: () => Promise<T>
@@ -81,6 +81,7 @@ export type RuntimePtyController = {
}
agentSessionCreateOperationId?: string
signal?: AbortSignal
refuseSleptWorktree?: boolean
onPtySpawnCommitted?: () => void
adoptedStablePane?: {
result: PtySpawnResult
@@ -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?: {
@@ -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
}