Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/main/ipc/pty/runtime/spawn-execute-sleep-disposition.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
6 changes: 5 additions & 1 deletion src/main/ipc/pty/runtime/spawn-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/pty/runtime/spawn-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeMobileSessionCreateTerminalResult> {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/main/runtime/orca-runtime-create-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
...(launchOpts.leaveWorktreeSleeping ? { leaveWorktreeSleeping: true } : {}),
Comment thread
innocarpe marked this conversation as resolved.
preAllocatedHandle,
tabId,
leafId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 18 additions & 13 deletions src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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<string, WorktreeTerminalSleepSnapshot> {
return (
runtime as unknown as {
terminalSleepStateByWorktreeId: Map<string, WorktreeTerminalSleepSnapshot>
}
).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<typeof vi.fn>
} {
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')
Comment on lines +385 to +390

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sleep race remains untested The test inserts an already-sleeping record before the spawn tries to acquire the lock. It therefore does not cover the regression's timing: a sleep committing while the spawn waits. A future change to that ordering could pass these tests while recreating the terminal. Please add a test that holds the sleep lock, queues the spawn, then commits and releases the sleep.

Knowledge Base Used: PTY streaming and recovery

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

).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 () => {
Expand Down
5 changes: 5 additions & 0 deletions src/main/runtime/runtime-pty-controller-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/main/runtime/runtime-terminal-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading