mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 (;;) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user