Keep a sleeping conversation resumable when the terminal limit is reached (#35401)

* Keep a sleeping conversation resumable when the terminal limit is reached

Resuming a slept pane while the host terminal limit was reached could drop
the Resume screen and fall back to a plain spawn. Route capacity refusals back
into the deferred sleep state, retry through the main-owned resume, and keep
the original session and provider until space frees up. A later Sleep or
cancellation still wins.

* Retain sleeping pane retries after transient owner failures

* Verify sleeping pane retry lookup bounds at scale

* Correct sleeping pane retry test fixture types

* Use transport attach contract in terminal test fixture

* Construct complete terminal transport test fixture
This commit is contained in:
Brennan Benson
2026-10-29 12:17:37 -07:00
committed by GitHub
parent 98f5d13689
commit 6d76f5a77b
18 changed files with 890 additions and 73 deletions
@@ -28,11 +28,14 @@ export function registerWorktreeSleepHandlers(context: WorktreeIpcContext): void
context.runtime.noteWorkspaceReopened(worktreeId)
)
// Main resumes the session before the renderer attaches its spawn.
ipcMain.handle('worktrees:resumeSleptPane', (_event, pane: unknown) => {
ipcMain.handle('worktrees:resumeSleptPane', (_event, pane: unknown, automatic: unknown) => {
if (!isRestoredPane(pane)) {
throw new Error('invalid_pane')
}
return context.runtime.resumeSleptPane(pane)
if (automatic !== undefined && typeof automatic !== 'boolean') {
throw new Error('invalid_automatic')
}
return context.runtime.resumeSleptPane(pane, automatic)
})
ipcMain.handle(
'worktrees:retireStrandedSleepRecord',
@@ -0,0 +1,51 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { ipcRenderer } from 'electron'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import type { WorktreeApi } from '../../../../preload/api/worktree-api'
const { handlers } = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler)
},
ipcRenderer: {
invoke: async (name: string, ...args: unknown[]) => handlers.get(name)?.({}, ...args)
}
}))
import { registerWorktreeSleepHandlers } from './register-worktree-sleep-handlers'
import { worktreesApi } from '../../../../preload/api/worktrees-bridge'
beforeEach(() => handlers.clear())
it('validates the bundled bridge argument before the runtime receives any trigger authority', async () => {
const resumeSleptPane = vi.fn(async () => true)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only runtime; this test invokes only resumeSleptPane.
registerWorktreeSleepHandlers({ runtime: { resumeSleptPane } } as unknown as WorktreeIpcContext)
const pane = { worktreeId: 'wt', tabId: 'tab', leafId: 'leaf' }
const bridge: WorktreeApi = worktreesApi
await expect(bridge.resumeSleptPane(pane)).resolves.toBe(true)
for (const automatic of [undefined, false, true]) {
await expect(bridge.resumeSleptPane(pane, automatic)).resolves.toBe(true)
expect(resumeSleptPane).toHaveBeenLastCalledWith(pane, automatic)
}
resumeSleptPane.mockClear()
for (const invalid of [
null,
0,
1,
'true',
[],
{},
{ automatic: true, targetsHost: true, targetGroupId: 'g' }
]) {
await expect(ipcRenderer.invoke('worktrees:resumeSleptPane', pane, invalid)).rejects.toThrow(
'invalid_automatic'
)
}
await expect(
ipcRenderer.invoke('worktrees:resumeSleptPane', { tabId: 'tab' }, true)
).rejects.toThrow('invalid_pane')
expect(resumeSleptPane).not.toHaveBeenCalled()
})
@@ -1,3 +1,11 @@
import { spawnWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
import type { SpawnRequestFacts } from '../../../shared/host-spawn-admission'
import {
createSpawnAdmissionState,
DEFAULT_SPAWN_ADMISSION_LIMITS,
decideSpawnAdmission,
resolveSpawnCharge
} from '../../../shared/host-spawn-admission'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec'
@@ -284,6 +292,88 @@ describe('opening a workspace-slept running agent on a host with no window', ()
describe("the window's open of a slept pane (worktrees:resumeSleptPane)", () => {
const pane = { worktreeId: TEST_WORKTREE_ID, tabId: 'host-tab', leafId: HEADLESS_LEAF_ID }
it('keeps the original provider and current retry intent through a refused main start', async () => {
const fixture = makeWorkingSleepStore()
const { runtime, spawn, opened } = openPane(fixture.runtimeStore, 'openable', {
withWindow: true,
tap: false
})
await opened
const limits = DEFAULT_SPAWN_ADMISSION_LIMITS
const state = createSpawnAdmissionState(limits, 0)
state.liveAutomatic = limits.maxLiveAutomatic
spawn.mockImplementation(async (...args: unknown[]) => {
const request = args[0]
if (typeof request !== 'object' || request === null || !('spawnIntent' in request)) {
throw new Error('missing intent')
}
const declaredIntent = request.spawnIntent
if (declaredIntent !== 'user-resume' && declaredIntent !== 'automatic-restore') {
throw new Error('wrong intent')
}
const release = await runtime.acquireWorktreeTerminalSpawn(
spawnWorktreeTarget(TEST_WORKTREE_ID, null),
{
refuseSleptWorktree:
'refuseSleptWorktree' in request && request.refuseSleptWorktree === true
}
)
try {
const facts = {
clientId: 'resume-test',
declaredIntent,
hasAgentSessionClaim: false,
hasHistorySeed: false,
deadlineAt: 1000
} satisfies SpawnRequestFacts
const decision = decideSpawnAdmission({
state,
facts,
charge: resolveSpawnCharge(state, facts, limits, 0),
pressure: 'ok',
mode: { kind: 'normal' },
limits,
now: 0
})
if (spawn.mock.calls.length === 1 || decision.kind === 'defer') {
throw new Error('terminal_spawn_deferred:live_cap')
}
return { id: 'persisted-pty' }
} finally {
release()
}
})
await runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)
await expect(runtime.resumeSleptPane(pane)).rejects.toThrow(
'terminal_spawn_deferred:live_cap'
)
expect(spawn).toHaveBeenLastCalledWith(
expect.objectContaining({ spawnIntent: 'user-resume' })
)
await expect(runtime.resumeSleptPane(pane, true)).rejects.toThrow(
'terminal_spawn_deferred:live_cap'
)
expect(spawn).toHaveBeenLastCalledWith(
expect.objectContaining({
spawnIntent: 'automatic-restore',
refuseSleptWorktree: true
})
)
await runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)
await expect(runtime.resumeSleptPane(pane, true)).rejects.toThrow(
'worktree_terminals_sleeping'
)
expect(spawn).toHaveBeenCalledTimes(3)
await expect(runtime.resumeSleptPane(pane, false)).resolves.toBe(true)
for (let call = 0; call < 4; call += 1) {
expect(spawnedCommand(spawn, call)).toContain('provider-session-1')
expect(spawnedCommand(spawn, call)).not.toContain('--dangerously-skip-permissions')
}
expect(spawn).toHaveBeenLastCalledWith(
expect.objectContaining({ spawnIntent: 'user-resume' })
)
})
it('resumes once; a second open after it finished attaches to the same terminal', async () => {
const fixture = makeWorkingSleepStore()
const { runtime, spawn, opened } = openPane(fixture.runtimeStore, 'openable', {
@@ -262,8 +262,8 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit
}
/** The window opened a slept pane; main resumes it and the window attaches. */
resumeSleptPane(pane: RestoredPane): Promise<boolean> {
return this.workspaceSleep.resumePane(pane)
resumeSleptPane(pane: RestoredPane, automatic?: boolean): Promise<boolean> {
return this.workspaceSleep.resumePane(pane, automatic)
}
/** The window reopened a slept agent whose tab is gone in a fresh tab; this record is spent. */
@@ -80,3 +80,35 @@ describe("a phone's or the CLI's sleep", () => {
expect(failedNotices(notices)).toEqual([])
})
})
it.each([true, false])(
'an automatic=%s retry preserves Sleep/VM suspension intent',
async (automatic) => {
let finishStop: (result: RuntimeWorktreeTerminalSleepResult) => void = () => {}
const stopped = new Promise<RuntimeWorktreeTerminalSleepResult>((resolve) => {
finishStop = resolve
})
const suspendVm = vi.fn(async () => {})
const ensureRestoredPaneStarted = vi.fn(async () => null)
const stop = vi.fn(() => stopped)
const { sleep } = coordinator({
workspaceLifecycle: null,
sleepTerminalsForWorktree: stop,
ensureRestoredPaneStarted,
workspaceSleepPlatform: {
callEnvironment: async () => {
throw new Error('unused')
},
suspendVm
}
})
const sleeping = sleep.sleepOwnWorktree(WORKTREE, {})
await vi.waitFor(() => expect(stop).toHaveBeenCalled())
const pane = { worktreeId: WORKTREE, tabId: 'tab', leafId: 'leaf' }
await sleep.resumePane(pane, automatic)
finishStop(verified)
await sleeping
expect(ensureRestoredPaneStarted).toHaveBeenCalledWith(pane, { automatic })
expect(suspendVm).toHaveBeenCalledTimes(automatic ? 1 : 0)
}
)
@@ -43,7 +43,10 @@ export type WorkspaceSleepRuntime = {
notifyWorkspaceSleep: (notice: WorkspaceSleepNotice) => void
wakeRecordsForWorktree: (worktreeId: string) => SleepingAgentSessionRecord[]
resumeSleptWorkspaceAgents: (worktreeId: string) => Promise<void>
ensureRestoredPaneStarted: (pane: RestoredPane) => Promise<StartedPty | null>
ensureRestoredPaneStarted: (
pane: RestoredPane,
trigger?: { automatic?: boolean }
) => Promise<StartedPty | null>
/** The window's wake of its own completed-agent hibernation, which main does not hold. */
wakeWindowHibernatedAgents: (worktreeId: string) => void
}
@@ -133,11 +136,13 @@ export class WorkspaceSleepCoordinator {
}
/** A slept pane was opened: main resumes it, queued like any wake, and the opener attaches. */
resumePane(pane: RestoredPane): Promise<boolean> {
this.noteReopened(pane.worktreeId)
resumePane(pane: RestoredPane, automatic = false): Promise<boolean> {
if (!automatic) {
this.noteReopened(pane.worktreeId)
}
return queueTerminalWorkspaceCommand(this.runtime, `id:${pane.worktreeId}`, {
kind: 'wake',
run: async () => (await this.runtime.ensureRestoredPaneStarted(pane)) !== null
run: async () => (await this.runtime.ensureRestoredPaneStarted(pane, { automatic })) !== null
})
}
@@ -1,6 +1,4 @@
/** 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
}
export {
WORKTREE_TERMINALS_SLEEPING_ERROR,
isWorktreeTerminalsSleepingError
} from '../../shared/workspace-sleep'
+4 -1
View File
@@ -94,7 +94,10 @@ export type WorktreeApi = {
sleep: (request: WorkspaceSleepRequest) => Promise<WorkspaceSleepOutcome[]>
sleepReopened: (worktreeId: string) => Promise<void>
/** True once running, so the renderer's subsequent spawn reattaches. */
resumeSleptPane: (pane: { worktreeId: string; tabId: string; leafId: string }) => Promise<boolean>
resumeSleptPane: (
pane: { worktreeId: string; tabId: string; leafId: string },
automatic?: boolean
) => Promise<boolean>
/** Drops main's workspace-sleep record only while no tab holds its pane. */
retireStrandedSleepRecord: (args: { worktreeId: string; paneKey: string }) => Promise<void>
remove: (args: {
+2 -1
View File
@@ -46,7 +46,8 @@ export const worktreesApi = {
sleep: (request) => ipcRenderer.invoke('worktrees:sleep', request),
sleepReopened: (worktreeId) => ipcRenderer.invoke('worktrees:sleepReopened', worktreeId),
resumeSleptPane: (pane) => ipcRenderer.invoke('worktrees:resumeSleptPane', pane),
resumeSleptPane: (pane, automatic) =>
ipcRenderer.invoke('worktrees:resumeSleptPane', pane, automatic),
retireStrandedSleepRecord: (args) =>
ipcRenderer.invoke('worktrees:retireStrandedSleepRecord', args),
@@ -229,6 +229,20 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void {
expect(resumeCommands(transport)[0]).toContain("'resume' 'codex-session-1'")
})
it('keeps an initial hidden workspace-sleep wake explicit', async () => {
seedSleepingPane(null, { origin: 'worktree-sleep' })
const resumeSleptPane = vi.fn(async () => true)
window.api.worktrees = { ...window.api.worktrees, resumeSleptPane }
const { requestSleepingAgentWakeOnMount } = await import('@/lib/sleeping-agent-wake-requests')
requestSleepingAgentWakeOnMount('wt-1', ['tab-1'])
connectHiddenPane(null)
await flushAsyncTicks(20)
expect(resumeSleptPane).toHaveBeenCalledWith(
{ worktreeId: 'wt-1', tabId: 'tab-1', leafId: LEAF_2 },
undefined
)
})
it('resumes a held hidden pane immediately on a phone wake', async () => {
seedSleepingPane(DEAD_SESSION_ID)
setHostLiveness(false)
@@ -0,0 +1,299 @@
import { expect, it, vi, type Mock } from 'vitest'
import { createDeferred, flushAsyncTicks } from './pty-connection-test-async'
import type { MockTransport } from './pty-connection-test-pane-fixtures'
import type { PaneConnectionDeps } from './pty-connection-test-deps'
import type { PanePtyBinding } from './pty-connection/pane-pty-binding'
import type { StoreState } from './pty-connection-test-store-state'
type MainOwnedResumeFixture = {
mainResume: Mock<(pane: unknown, automatic?: boolean) => Promise<boolean>>
transport: MockTransport
deps: PaneConnectionDeps
binding: PanePtyBinding
paneKey: string
record: unknown
owner: { worktreeId: string; tabId: string; leafId: string }
onDeferredCwdSpawnFailed: Mock
}
export function registerMainOwnedResumeDrainTests(
mainOwnedResumeFixture: () => Promise<MainOwnedResumeFixture>,
getStoreState: () => StoreState,
enableRealDrain: () => void
): void {
it.each([
'timer',
'parked-reveal',
'inflight-reveal',
'sleep',
'false',
'cancel',
'dispose',
'teardown',
'generation',
'transport',
'superseded',
'paused'
] as const)(
'retains the production drain obligation across transient failure: %s',
async (ending) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
enableRealDrain()
window.api.runtime.call = vi.fn<typeof window.api.runtime.call>(async () => ({
id: 'automatic-work-status',
ok: true,
_meta: { runtimeId: 'local-runtime' },
result: { automaticWork: { mode: { kind: 'normal' } } }
}))
const helper = await import('./pty-connection/main-resumed-sleep-pane')
const attach = vi.spyOn(helper, 'attachToMainResumedSleepPane')
const recovery = await import('./terminal-pane-recovery')
const settled = vi.spyOn(recovery, 'settleTerminalPaneRecovery')
const {
mainResume,
transport,
deps,
binding,
paneKey,
record,
owner,
onDeferredCwdSpawnFailed
} = await mainOwnedResumeFixture()
const { getPaneSpawnDeferral } = await import('@/lib/pane-spawn-deferral-registry')
expect(mainResume).toHaveBeenCalledTimes(1)
expect(attach.mock.calls, 'main-helper call-through spy').toHaveLength(1)
const [session, , startup, options] = attach.mock.calls[0]
const original = session.deferredSpawn
const timeout = new Error(
"Error invoking remote method 'worktrees:resumeSleptPane': Error: Request createOrAttach timed out after 60000ms"
)
const tick = async (ms: number) => {
const before = mainResume.mock.calls.length
await vi.advanceTimersByTimeAsync(ms - 1)
expect(mainResume).toHaveBeenCalledTimes(before)
await vi.advanceTimersByTimeAsync(1)
await flushAsyncTicks(30)
expect(mainResume).toHaveBeenCalledTimes(before + 1)
}
const expectParked = (failed: number) => {
expect(session.deferredSpawn).toEqual(original)
expect(session.deferredSpawn?.startupOverride).toBe(startup)
expect(session.deferredSpawn?.options).toBe(options)
expect(getPaneSpawnDeferral(paneKey)).toMatchObject({ reason: 'live_cap' })
expect(getPaneSpawnDeferral(paneKey)?.resuming).not.toBe(true)
expect(settled.mock.calls.filter(([, , status]) => status === 'failed')).toHaveLength(
failed
)
expect(onDeferredCwdSpawnFailed).toHaveBeenCalledTimes(failed)
expect(transport.connect).not.toHaveBeenCalled()
expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled()
expect(getStoreState().clearSleepingAgentSession).not.toHaveBeenCalled()
expect(getStoreState().sleepingAgentSessionsByPaneKey[paneKey]).toBe(record)
}
deps.isVisibleRef.current = false
await tick(30_000) // repeated typed capacity refusal through the real host drain
mainResume.mockRejectedValue(timeout)
await tick(60_000)
expectParked(3) // old source loses source, control and timer here
expect(mainResume).toHaveBeenLastCalledWith(owner, true)
if (ending === 'timer') {
for (const delay of [120_000, 240_000, 300_000, 300_000]) {
await tick(delay)
expectParked(mainResume.mock.calls.length)
}
mainResume.mockResolvedValue(true) // legitimate daemon/capacity recovery
await tick(300_000)
} else if (ending === 'parked-reveal') {
mainResume.mockResolvedValue(true)
deps.isVisibleRef.current = true
binding.noteVisibilityResume()
await flushAsyncTicks(30)
expect(mainResume).toHaveBeenLastCalledWith(owner, false)
} else {
const pending = createDeferred<boolean>()
mainResume.mockImplementationOnce(() => pending.promise)
await tick(120_000)
if (ending === 'inflight-reveal') {
deps.isVisibleRef.current = true
binding.noteVisibilityResume()
expect(mainResume).toHaveBeenCalledTimes(4)
mainResume.mockRejectedValueOnce(new Error('terminal_spawn_deferred:pressure'))
} else if (ending === 'cancel') {
if (!startup.resumeSource) {
throw new Error('missing saved resume source')
}
startup.resumeSource.cancelled = true
} else if (ending === 'dispose') {
binding.dispose()
} else if (ending === 'teardown') {
getStoreState().deleteStateByWorktreeId = { 'wt-1': { isDeleting: true } }
} else if (ending === 'generation') {
session.tabGeneration += 1
} else if (ending === 'transport') {
session.transportStreamGeneration += 1
} else if (ending === 'superseded') {
session.enterSpawnDeferredSleep('paused', startup, options, true)
}
if (ending === 'false') {
pending.resolve(false)
} else {
pending.reject(
ending === 'sleep'
? new Error(
"Error invoking remote method 'worktrees:resumeSleptPane': Error: worktree_terminals_sleeping"
)
: ending === 'paused'
? new Error('terminal_spawn_deferred:paused')
: timeout
)
}
await flushAsyncTicks(40)
if (ending === 'inflight-reveal') {
expect(mainResume).toHaveBeenCalledTimes(5)
expect(mainResume).toHaveBeenLastCalledWith(owner, false)
expect(getPaneSpawnDeferral(paneKey)?.reason).toBe('pressure')
deps.isVisibleRef.current = false
mainResume.mockResolvedValue(true)
await tick(240_000)
expect(mainResume).toHaveBeenLastCalledWith(owner, true)
} else if (ending === 'paused' || ending === 'superseded') {
expect(getPaneSpawnDeferral(paneKey)?.reason).toBe('paused')
expect(session.deferredSpawn?.reason).toBe('paused')
await vi.advanceTimersByTimeAsync(600_000)
expect(mainResume).toHaveBeenCalledTimes(4)
mainResume.mockResolvedValue(true)
deps.isVisibleRef.current = true
binding.noteVisibilityResume()
await flushAsyncTicks(30)
} else {
expect(session.deferredSpawn).toBeNull()
expect(getPaneSpawnDeferral(paneKey)).toBeUndefined()
await vi.advanceTimersByTimeAsync(600_000)
deps.isVisibleRef.current = true
binding.noteVisibilityResume()
await flushAsyncTicks(30)
expect(mainResume).toHaveBeenCalledTimes(4)
expect(transport.connect).not.toHaveBeenCalled()
binding.dispose()
return
}
}
expect(transport.connect).toHaveBeenCalledExactlyOnceWith(
expect.not.objectContaining({ command: expect.anything() })
)
expect(settled.mock.calls.filter(([, , status]) => status === 'success')).toHaveLength(1)
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledOnce()
expect(getStoreState().clearSleepingAgentSession).toHaveBeenCalledExactlyOnceWith(paneKey)
expect(transport.getPtyId()).toBe('main-pty')
for (const [, pane, source] of attach.mock.calls) {
expect(pane).toEqual(owner)
expect(source).toBe(startup)
}
expect(getPaneSpawnDeferral(paneKey)).toBeUndefined()
expect(session.deferredSpawn).toBeNull()
const calls = mainResume.mock.calls.length
await vi.advanceTimersByTimeAsync(600_000)
expect(mainResume).toHaveBeenCalledTimes(calls)
binding.dispose()
}
)
it.each(
['workspace', 'fallback'].flatMap((shape) =>
[100, 1_000].flatMap((population) =>
['recovery', 'cancel'].map((ending) => ({ shape, population, ending }))
)
)
)(
'bounds unknown retry work at $population tabs: $shape / $ending',
async ({ shape, population, ending }) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
enableRealDrain()
window.api.runtime.call = vi.fn<typeof window.api.runtime.call>(async () => ({
id: 'automatic-work-status',
ok: true,
_meta: { runtimeId: 'local-runtime' },
result: { automaticWork: { mode: { kind: 'normal' } } }
}))
const helper = await import('./pty-connection/main-resumed-sleep-pane')
const attach = vi.spyOn(helper, 'attachToMainResumedSleepPane')
const tabs = await import('./pty-connection/terminal-tab-id')
const lookup = vi.spyOn(tabs, 'findTerminalTabForPane')
const { mainResume, transport, deps, binding, owner, paneKey } =
await mainOwnedResumeFixture()
const [session, , startup] = attach.mock.calls[0]
const { getPaneSpawnDeferral } = await import('@/lib/pane-spawn-deferral-registry')
let visits = 0
const rows = Array.from({ length: population }, (_, index) => ({
get id() {
visits += 1
return index === population - 1 ? owner.tabId : `unrelated-tab-${index}`
},
ptyId: null,
generation: 0
}))
getStoreState().tabsByWorktree =
shape === 'workspace'
? { [owner.worktreeId]: rows }
: { [owner.worktreeId]: rows.slice(0, -1), 'moved-worktree': rows.slice(-1) }
// Count actual row reads by the real resolver, including its all-workspaces fallback.
const visitsPerLookup = shape === 'workspace' ? 2 * population : 3 * population - 2
const timeout = new Error(
"Error invoking remote method 'worktrees:resumeSleptPane': Error: Request createOrAttach timed out after 60000ms"
)
deps.isVisibleRef.current = false
mainResume.mockRejectedValue(timeout)
for (const delay of [30_000, 60_000, 120_000]) {
visits = 0
lookup.mockClear()
const dispatched = mainResume.mock.calls.length
await vi.advanceTimersByTimeAsync(delay)
await flushAsyncTicks(30)
expect(mainResume).toHaveBeenCalledTimes(dispatched + 1)
expect(mainResume).toHaveBeenLastCalledWith(owner, true)
expect(lookup).toHaveBeenCalledTimes(3)
expect(visits).toBe(3 * visitsPerLookup)
expect(session.deferredSpawn?.startupOverride).toBe(startup)
expect(getPaneSpawnDeferral(paneKey)?.resuming).not.toBe(true)
expect(transport.connect).not.toHaveBeenCalled()
}
visits = 0
lookup.mockClear()
if (ending === 'recovery') {
mainResume.mockResolvedValue(true)
await vi.advanceTimersByTimeAsync(240_000)
await flushAsyncTicks(30)
expect(mainResume).toHaveBeenCalledTimes(5)
expect(lookup).toHaveBeenCalledTimes(3)
expect(visits).toBe(3 * visitsPerLookup)
expect(transport.getPtyId()).toBe('main-pty')
expect(transport.connect).toHaveBeenCalledOnce()
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledOnce()
} else {
const pending = createDeferred<boolean>()
mainResume.mockImplementationOnce(() => pending.promise)
await vi.advanceTimersByTimeAsync(240_000)
expect(mainResume).toHaveBeenCalledTimes(5)
if (!startup.resumeSource) {
throw new Error('missing saved resume source')
}
startup.resumeSource.cancelled = true
pending.reject(timeout)
await flushAsyncTicks(30)
expect(lookup).toHaveBeenCalledTimes(2)
expect(visits).toBe(2 * visitsPerLookup)
expect(transport.connect).not.toHaveBeenCalled()
}
expect(session.deferredSpawn).toBeNull()
expect(getPaneSpawnDeferral(paneKey)).toBeUndefined()
lookup.mockClear()
visits = 0
await vi.advanceTimersByTimeAsync(600_000)
expect(mainResume).toHaveBeenCalledTimes(5)
expect(lookup).not.toHaveBeenCalled()
expect(visits).toBe(0)
binding.dispose()
}
)
}
@@ -1,3 +1,6 @@
import type * as DeferralDrain from '@/lib/pane-spawn-deferral-drain'
import { registerMainOwnedResumeDrainTests } from './pty-connection-main-owned-resume-drain.spec'
import type { ConnectPanePtySession } from './pty-connection/connect-pane-pty-session'
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -97,17 +100,26 @@ vi.mock('@/runtime/resume-host-automatic-restores', () => ({
}))
const drain = vi.hoisted(() => ({
real: false,
queued: new Map<string, () => Promise<boolean>>()
}))
vi.mock('@/lib/pane-spawn-deferral-drain', () => ({
queueDeferredRestoreRetry: (
paneKey: string,
_env: string | null,
retry: () => Promise<boolean>
) => drain.queued.set(paneKey, retry),
cancelDeferredRestoreRetry: (paneKey: string) => drain.queued.delete(paneKey)
}))
vi.mock('@/lib/pane-spawn-deferral-drain', async (importOriginal) => {
const actual = await importOriginal<typeof DeferralDrain>()
return {
...actual,
queueDeferredRestoreRetry: (
paneKey: string,
env: string | null,
retry: () => Promise<boolean>
) =>
drain.real
? actual.queueDeferredRestoreRetry(paneKey, env, retry)
: drain.queued.set(paneKey, retry),
cancelDeferredRestoreRetry: (paneKey: string) =>
drain.real ? actual.cancelDeferredRestoreRetry(paneKey) : drain.queued.delete(paneKey)
}
})
vi.mock('./terminal-pane-recovery', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
@@ -210,6 +222,7 @@ describe('connectPanePty: a restore the host kept asleep', () => {
vi.resetModules()
vi.clearAllMocks()
transportFactoryQueue = []
drain.real = false
drain.queued.clear()
createdTransportOptions = []
storeSubscribers = []
@@ -224,9 +237,223 @@ describe('connectPanePty: a restore the host kept asleep', () => {
})
afterEach(async () => {
const { resetDeferredRestoreDrainForTests } = await import('@/lib/pane-spawn-deferral-drain')
resetDeferredRestoreDrainForTests()
vi.useRealTimers()
vi.restoreAllMocks()
await restoreTerminalTestGlobals()
})
async function mainOwnedResumeFixture() {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
const paneKey = makePaneKey('tab-1', pane.leafId)
const record = {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: RESUME,
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = record
mockStoreState.agentStatusByPaneKey = {}
mockStoreState.tabsByWorktree['wt-1'] = [{ id: 'tab-1', ptyId: null }]
mockStoreState.terminalLayoutsByTabId ??= {}
mockStoreState.terminalLayoutsByTabId['tab-1'] = {
root: { type: 'leaf', leafId: pane.leafId },
activeLeafId: pane.leafId,
expandedLeafId: null,
ptyIdsByLeafId: {}
}
const mainResume = vi.fn(async (_pane: unknown, _automatic?: boolean): Promise<boolean> => {
throw new Error(
"Error invoking remote method 'worktrees:resumeSleptPane': Error: terminal_spawn_deferred:live_cap"
)
})
window.api.worktrees = { ...window.api.worktrees, resumeSleptPane: mainResume }
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ callbacks }) => {
transport.attach({ existingPtyId: 'main-pty', callbacks: callbacks ?? {} })
return 'main-pty'
})
transportFactoryQueue.push(transport)
const onDeferredCwdSpawnFailed = vi.fn()
const deps = createDeps({ onDeferredCwdSpawnFailed })
const binding = connectPanePty(...mockPanePtyArgs(pane, createManager(1), deps))
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(0)
}
await flushAsyncTicks(30)
return {
mainResume,
transport,
deps,
binding,
paneKey,
record,
onDeferredCwdSpawnFailed,
owner: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: pane.leafId }
}
}
registerMainOwnedResumeDrainTests(
mainOwnedResumeFixture,
() => mockStoreState,
() => {
drain.real = true
}
)
it('fences late main outcomes and preserves only explicit legacy fallback', async () => {
const { attachToMainResumedSleepPane } =
await import('./pty-connection/main-resumed-sleep-pane')
const { mainResumedSleepPane } = await import('./pty-connection/main-resumed-sleep-pane')
for (const outcome of [
'false',
'generic',
'automatic-false',
'sleeping',
'cancel',
'dispose',
'superseded',
'cancel-refused',
'dispose-refused',
'teardown-refused',
'attach-failed'
]) {
mockStoreState.deleteStateByWorktreeId = {}
const pane = createPane(1)
const paneKey = makePaneKey('tab-1', pane.leafId)
const source = { cancelled: false }
const startup = {
command: "claude '--resume' 'provider-session-1'",
agent: 'claude' as const,
resumeProviderSession: RESUME,
resumeOrigin: 'saved' as const,
launchToken: 'token',
launchConfig: { agentArgs: '', agentEnv: {} },
useLiveEntry: false,
hasSleepingRecord: true,
resumeSource: source,
sleepingRecordEntry: {
paneKey,
record: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude' as const,
providerSession: RESUME,
prompt: '',
state: 'working' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep' as const
}
}
}
let land: (value: boolean) => void = () => {}
let refuse: (error: Error) => void = () => {}
const pending = new Promise<boolean>((resolve, reject) => {
land = resolve
refuse = reject
})
const mainResume = vi.fn(() => pending)
window.api.worktrees = { ...window.api.worktrees, resumeSleptPane: mainResume }
const fixture = {
disposed: false,
transportStreamGeneration: 1,
tabGeneration: 0,
cacheKey: paneKey,
deps: createDeps(),
transport: { getPtyId: () => null, abandonPreconnectInput: vi.fn() },
startFreshSpawn: vi.fn(async () => (outcome === 'attach-failed' ? null : 'pty')),
settlePaneAttachAttempt: vi.fn(),
enterSpawnDeferredSleep: vi.fn(),
showSessionRestoredBanner: vi.fn()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies every field the main resume helper and owner selector read.
const session = fixture as unknown as ConnectPanePtySession
const owner = { worktreeId: 'wt-1', tabId: 'tab-1', leafId: pane.leafId }
expect(mainResumedSleepPane(session, startup)).toEqual(owner)
expect(
mainResumedSleepPane({ ...session, runtimeEnvironmentId: 'paired' }, startup)
).toBeNull()
expect(mainResumedSleepPane(session, { ...startup, useLiveEntry: true })).toBeNull()
expect(
mainResumedSleepPane(session, {
...startup,
sleepingRecordEntry: {
paneKey,
record: { ...startup.sleepingRecordEntry.record, origin: 'quit' }
}
})
).toBeNull()
expect(
mainResumedSleepPane(session, {
...startup,
sleepingRecordEntry: {
paneKey,
record: { ...startup.sleepingRecordEntry.record, tabId: 'web-terminal-remote' }
}
})
).toBeNull()
if (outcome === 'false') {
const { installSpawnDeferredSleep } = await import('./pty-connection/spawn-deferred-sleep')
installSpawnDeferredSleep(session)
const { launchConfig: _launchConfig, ...withoutConfig } = startup
session.deferredSpawn = { reason: 'live_cap', startupOverride: withoutConfig, options: {} }
session.spawnColdRestoreAgentResume = vi.fn(async () => null)
await session.resumeDeferredSpawn()
expect(session.spawnColdRestoreAgentResume).toHaveBeenCalledWith(withoutConfig, {}, false)
session.disposeSpawnDeferral()
session.enterSpawnDeferredSleep = vi.fn()
}
const running = attachToMainResumedSleepPane(
session,
owner,
startup,
{},
outcome === 'sleeping' || outcome === 'automatic-false'
)
if (outcome.startsWith('cancel')) {
source.cancelled = true
}
if (outcome.startsWith('dispose')) {
fixture.disposed = true
}
if (outcome === 'teardown-refused') {
mockStoreState.deleteStateByWorktreeId = {
'wt-1': { isDeleting: true }
}
}
if (outcome === 'superseded') {
fixture.transportStreamGeneration += 1
}
// A real Electron rejection is delivered through the same pending operation.
if (outcome === 'generic' || outcome === 'sleeping' || outcome.endsWith('-refused')) {
refuse(
new Error(
`Error invoking remote method 'worktrees:resumeSleptPane': Error: ${outcome === 'sleeping' ? 'worktree_terminals_sleeping' : outcome.endsWith('-refused') ? 'terminal_spawn_deferred:live_cap' : 'terminal_not_started'}`
)
)
} else {
land(outcome !== 'false' && outcome !== 'automatic-false')
}
await running
expect(fixture.startFreshSpawn).toHaveBeenCalledTimes(
['false', 'generic', 'attach-failed'].includes(outcome) ? 1 : 0
)
expect(fixture.showSessionRestoredBanner).not.toHaveBeenCalled()
expect(fixture.enterSpawnDeferredSleep).not.toHaveBeenCalled()
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
}
})
it('keeps the pane sleeping with its resume handle and resumes it when opened', async () => {
const { connectPanePty } = await import('./pty-connection')
const { getPaneSpawnDeferral } = await import('@/lib/pane-spawn-deferral-registry')
@@ -1,6 +1,6 @@
import { vi } from 'vitest'
import type { Mock } from 'vitest'
import type { PtyReplayDataMeta } from './pty-transport-types'
import type { PtyReplayDataMeta, PtyTransport } from './pty-transport-types'
export const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const
export const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const
@@ -27,7 +27,7 @@ export type ConnectCallbacks = {
export type MockTransport = {
retryRecovery?: () => boolean
attach: ReturnType<typeof vi.fn>
attach: Mock<PtyTransport['attach']>
connect: ReturnType<typeof vi.fn> & {
mockImplementation: (
impl: (opts: { callbacks?: ConnectCallbacks } & Record<string, unknown>) => Promise<unknown>
@@ -42,7 +42,7 @@ export type MockTransport = {
setOutputPaused?: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
isConnected?: ReturnType<typeof vi.fn>
getPtyId: ReturnType<typeof vi.fn>
getPtyId: Mock<() => string | null>
getConnectionId: ReturnType<typeof vi.fn>
serializeBuffer?: ReturnType<typeof vi.fn>
serializeBufferOutcome?: ReturnType<typeof vi.fn>
@@ -50,8 +50,9 @@ export type MockTransport = {
export function createMockTransport(initialPtyId: string | null = null): MockTransport {
let ptyId = initialPtyId
const transport = {
attach: vi.fn(({ existingPtyId }: { existingPtyId: string }) => {
const sendInput = vi.fn((_data: string) => true)
const transport: MockTransport = {
attach: vi.fn<PtyTransport['attach']>(({ existingPtyId }) => {
ptyId = existingPtyId
}),
connect: vi.fn().mockImplementation(async (opts: { sessionId?: string }) => {
@@ -64,18 +65,17 @@ export function createMockTransport(initialPtyId: string | null = null): MockTra
disconnect: vi.fn(() => {
ptyId = null
}),
sendInput: vi.fn(() => true),
sendInput,
// Query replies delegate to the same spy so reply-delivery assertions observe them (#7329).
sendInputImmediate: vi.fn((data: string) => sendInput(data)),
sendInputAccepted: vi.fn(async (data: string) => sendInput(data)),
claimViewport: vi.fn(() => true),
resize: vi.fn(() => true),
getPtyId: vi.fn(() => ptyId),
getConnectionId: vi.fn(() => null),
serializeBuffer: undefined
} as MockTransport
}
transport.isConnected = vi.fn(() => ptyId !== null)
const sendInput = transport.sendInput as unknown as (data: string) => boolean
// Why: query replies route through sendInputImmediate; delegate to the same spy so reply-delivery assertions still observe them (#7329).
transport.sendInputImmediate = vi.fn((data: string) => sendInput(data))
transport.sendInputAccepted = vi.fn(async (data: string) => sendInput(data))
return transport
}
@@ -86,19 +86,15 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio
// Why ungated: a reveal, a restart replacement on show, or a phone wake already chose to run now.
session.spawnColdRestoreAgentResume = (
startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(),
options: FreshSpawnOptions = {}
options: FreshSpawnOptions = {},
automatic?: boolean
): Promise<string | null> => {
if (startup?.resumeSource?.cancelled) {
return Promise.resolve(null)
}
const mainPane = mainResumedSleepPane(session, startup)
if (mainPane && startup?.sleepingRecordEntry) {
return attachToMainResumedSleepPane(
session,
mainPane,
startup.sleepingRecordEntry.paneKey,
options
)
return attachToMainResumedSleepPane(session, mainPane, startup, options, automatic)
}
return startPaneThroughMainThenAttach(session, () => {
session.applyColdRestoreAgentResumeStartup(startup)
@@ -21,35 +21,34 @@ import type {
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { findTerminalTabForPane } from './terminal-tab-id'
export function releaseDeferredCwdFence(session: ConnectPanePtySession): void {
if (!session.transport.getPtyId()) {
session.transport.abandonPreconnectInput?.()
try {
session.deps.onDeferredCwdSpawnFailed?.()
} catch {
// Cleanup must not turn a settled spawn into an unhandled rejection.
}
}
}
export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
session.startFreshSpawn = (
startupOverride?: PendingStartupCommand | null,
options: FreshSpawnOptions = {}
): Promise<string | null> => {
const releaseDeferredCwdFence = (): void => {
if (!session.transport.getPtyId()) {
// Abandoned spawns never connect; discard buffered input and settle its write promises here.
session.transport.abandonPreconnectInput?.()
try {
session.deps.onDeferredCwdSpawnFailed?.()
} catch {
// A cleanup callback must not turn a settled spawn into an unhandled rejection.
}
}
}
if (
hasWorktreeTeardownStarted(
useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]
)
) {
// Worktree deletion already killed its PTYs; skip respawn into the directory being removed instead of racing main’s fence.
releaseDeferredCwdFence()
releaseDeferredCwdFence(session)
return Promise.resolve(null)
}
const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride
if (effectiveStartup?.resumeSource?.cancelled) {
releaseDeferredCwdFence()
releaseDeferredCwdFence(session)
return Promise.resolve(null)
}
session.authoritativeReattachGeneration += 1
@@ -144,7 +143,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
session.pendingSpawnDeferral = null
if (deferral) {
// Why before the launch-config cleanup below: a deferred launch is postponed, not dead.
releaseDeferredCwdFence()
clearPreSignaledSerializer()
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
session.enterSpawnDeferredSleep(deferral, startupOverride, options, visibleAtSpawn)
@@ -183,7 +181,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
}
if (!accepted) {
// A rejected reattach ends this spawn; nothing later clears the fence.
releaseDeferredCwdFence()
releaseDeferredCwdFence(session)
}
return accepted ? resolvedPtyId : null
}
@@ -248,7 +246,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
session.reconcilePtySizeAfterSpawn(resolvedPtyId, session.cols, session.rows)
}
if (!resolvedPtyId) {
releaseDeferredCwdFence()
releaseDeferredCwdFence(session)
clearPreSignaledSerializer()
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
return null
@@ -271,7 +269,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
if (!ownerRefused && isPaneOwnerRefusal(message)) {
outputCallbacks.callbacks.onError(message)
}
releaseDeferredCwdFence()
releaseDeferredCwdFence(session)
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
if (
session.paneStartup?.launchConfig ||
@@ -312,7 +310,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
}
if (session.deferredSpawn) {
// Why no remount: the host would defer the remount's restore again, in a loop.
session.settlePaneAttachAttempt?.('failed')
return
}
settleSpawnThatLeftPaneUnbound(session)
@@ -1,9 +1,17 @@
// A slept workspace's pane: main resumes its agent, and this window only attaches to that terminal.
import { useAppStore } from '@/store'
import { hasWorktreeTeardownStarted } from '@/store/slices/worktree-delete-state-types'
import { readTerminalSpawnDeferral } from '../../../../../shared/host-spawn-admission'
import { releaseDeferredCwdFence } from './fresh-spawn-start'
import { findTerminalTabForPane } from './terminal-tab-id'
import { isHostMirroredSleepingRecord } from '@/lib/host-mirrored-pane-liveness'
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
import { isWorkspaceSleepRecord } from '../../../../../shared/workspace-sleep'
import {
isWorkspaceSleepRecord,
WORKTREE_TERMINALS_SLEEPING_ERROR
} from '../../../../../shared/workspace-sleep'
import { readIpcErrorDetail } from '@/lib/ipc-error'
import type { ColdRestoreAgentResumeStartup, FreshSpawnOptions } from './fresh-spawn-types'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -27,22 +35,73 @@ export function mainResumedSleepPane(
return { worktreeId: session.deps.worktreeId, tabId: pane.tabId, leafId: pane.leafId }
}
export function isMainResumedSleepPaneCurrent(
session: ConnectPanePtySession,
startup: ColdRestoreAgentResumeStartup,
generation?: number
): boolean {
const tab = findTerminalTabForPane(
useAppStore.getState(),
session.deps.worktreeId,
session.deps.tabId
)
return (
!session.disposed &&
!hasWorktreeTeardownStarted(
useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]
) &&
!startup.resumeSource?.cancelled &&
(startup.isCurrent?.() ?? true) &&
(tab?.generation ?? 0) === session.tabGeneration &&
(generation === undefined || generation === session.transportStreamGeneration)
)
}
/** Asks main to resume the pane, then spawns it plainly so the spawn attaches to main's terminal. */
export async function attachToMainResumedSleepPane(
session: ConnectPanePtySession,
pane: { worktreeId: string; tabId: string; leafId: string },
paneKey: string,
options: FreshSpawnOptions
startup: ColdRestoreAgentResumeStartup,
options: FreshSpawnOptions,
automatic?: boolean
): Promise<string | null> {
const resumed = await window.api.worktrees.resumeSleptPane(pane).catch(() => false)
if (session.disposed) {
const generation = session.transportStreamGeneration
const isCurrent = (): boolean => isMainResumedSleepPaneCurrent(session, startup)
if (!isCurrent()) {
return null
}
const visibleAtSpawn = session.deps.isVisibleRef.current === true
let resumed = false
try {
resumed = await window.api.worktrees.resumeSleptPane(pane, automatic)
} catch (error: unknown) {
if (!isCurrent() || generation !== session.transportStreamGeneration) {
return null
}
const deferral = readTerminalSpawnDeferral(error)
if (deferral) {
session.enterSpawnDeferredSleep(deferral, startup, options, visibleAtSpawn)
return null
}
if (automatic && readIpcErrorDetail(error) !== WORKTREE_TERMINALS_SLEEPING_ERROR) {
throw error
}
}
if (!isCurrent() || generation !== session.transportStreamGeneration) {
return null
}
if (automatic && !resumed) {
releaseDeferredCwdFence(session)
session.settlePaneAttachAttempt?.('failed')
return null
}
const ptyId = await session.startFreshSpawn(null, options)
if (resumed && ptyId) {
if (isCurrent() && resumed && ptyId) {
session.showSessionRestoredBanner()
// Main keeps its record; the window's mirror of it is spent.
useAppStore.getState().clearSleepingAgentSession(paneKey)
if (startup.sleepingRecordEntry) {
useAppStore.getState().clearSleepingAgentSession(startup.sleepingRecordEntry.paneKey)
}
}
return ptyId
}
@@ -1,3 +1,5 @@
import { releaseDeferredCwdFence } from './fresh-spawn-start'
import { isMainResumedSleepPaneCurrent, mainResumedSleepPane } from './main-resumed-sleep-pane'
import { useAppStore } from '@/store'
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission'
@@ -43,7 +45,13 @@ type DeferredSpawn = {
function isColdRestoreStartup(
startup: PendingStartupCommand | null | undefined
): startup is ColdRestoreAgentResumeStartup {
return Boolean(startup && 'launchConfig' in startup && 'resumeProviderSession' in startup)
return Boolean(
startup &&
'agent' in startup &&
'resumeProviderSession' in startup &&
'useLiveEntry' in startup &&
'sleepingRecordEntry' in startup
)
}
/** The record a deferred restore leaves behind, so the session stays listed and resumable even if
@@ -168,6 +176,7 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void
options: FreshSpawnOptions,
visibleAtSpawn: boolean
): void => {
releaseDeferredCwdFence(session)
session.keepUnrunResumeHandle(startupOverride)
session.deferredSpawn = { reason, startupOverride, options }
const deferral = { reason, resume: () => void session.resumeDeferredSpawn() }
@@ -182,6 +191,7 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void
async () => (await session.resumeDeferredSpawn().catch(() => null)) !== null
)
}
session.settlePaneAttachAttempt?.('failed')
if (!visibleAtSpawn) {
// Why: a pane opened while its hidden restore was in flight resumes as the user's own.
queueMicrotask(() => session.resumeDeferredSpawnOnReveal())
@@ -216,16 +226,41 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void
clearPaneSpawnDeferral(session.cacheKey)
}
}
return startPaneThroughMainThenAttach(session, () =>
session.startFreshSpawn(deferred.startupOverride, deferred.options)
).then(
const startup = deferred.startupOverride
const mainStartup =
isColdRestoreStartup(startup) && mainResumedSleepPane(session, startup) ? startup : null
const generation = session.transportStreamGeneration
const visibleAtDispatch = session.deps.isVisibleRef.current === true
const spawned: Promise<string | null> = mainStartup
? session.spawnColdRestoreAgentResume(mainStartup, deferred.options, !visibleAtDispatch)
: startPaneThroughMainThenAttach(session, () =>
session.startFreshSpawn(startup, deferred.options)
)
return spawned.then(
(ptyId: string | null) => {
settle()
return ptyId
},
(error: unknown) => {
if (!mainStartup) {
settle()
throw error
}
if (
getPaneSpawnDeferral(session.cacheKey) === resuming &&
session.deferredSpawn === null &&
!session.transport.getPtyId() &&
isMainResumedSleepPaneCurrent(session, mainStartup, generation)
) {
session.enterSpawnDeferredSleep(
deferred.reason,
mainStartup,
deferred.options,
visibleAtDispatch
)
}
settle()
throw error
return null
}
)
}
+7
View File
@@ -91,3 +91,10 @@ export function phoneWakeReport(
)
return mainResumes ? 'requested' : 'not-applicable'
}
/** 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
}