diff --git a/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts b/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts index 98c4425414d..fea90d1d36c 100644 --- a/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts +++ b/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts @@ -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', diff --git a/src/main/ipc/worktrees/sleep/resume-slept-pane.test.ts b/src/main/ipc/worktrees/sleep/resume-slept-pane.test.ts new file mode 100644 index 00000000000..58369aa41d1 --- /dev/null +++ b/src/main/ipc/worktrees/sleep/resume-slept-pane.test.ts @@ -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 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() +}) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts index 5f106b187a3..3d9b3fcb39a 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts @@ -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', { diff --git a/src/main/runtime/workspaces/managed-workspace-operations.ts b/src/main/runtime/workspaces/managed-workspace-operations.ts index f3a97bda7da..15a974f6dc7 100644 --- a/src/main/runtime/workspaces/managed-workspace-operations.ts +++ b/src/main/runtime/workspaces/managed-workspace-operations.ts @@ -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 { - return this.workspaceSleep.resumePane(pane) + resumeSleptPane(pane: RestoredPane, automatic?: boolean): Promise { + return this.workspaceSleep.resumePane(pane, automatic) } /** The window reopened a slept agent whose tab is gone in a fresh tab; this record is spent. */ diff --git a/src/main/runtime/workspaces/workspace-sleep-own-worktree.test.ts b/src/main/runtime/workspaces/workspace-sleep-own-worktree.test.ts index 22bda27e4ac..98a9b79d093 100644 --- a/src/main/runtime/workspaces/workspace-sleep-own-worktree.test.ts +++ b/src/main/runtime/workspaces/workspace-sleep-own-worktree.test.ts @@ -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((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) + } +) diff --git a/src/main/runtime/workspaces/workspace-sleep-runtime.ts b/src/main/runtime/workspaces/workspace-sleep-runtime.ts index c1a3ecf6346..e3c6f5c1f84 100644 --- a/src/main/runtime/workspaces/workspace-sleep-runtime.ts +++ b/src/main/runtime/workspaces/workspace-sleep-runtime.ts @@ -43,7 +43,10 @@ export type WorkspaceSleepRuntime = { notifyWorkspaceSleep: (notice: WorkspaceSleepNotice) => void wakeRecordsForWorktree: (worktreeId: string) => SleepingAgentSessionRecord[] resumeSleptWorkspaceAgents: (worktreeId: string) => Promise - ensureRestoredPaneStarted: (pane: RestoredPane) => Promise + ensureRestoredPaneStarted: ( + pane: RestoredPane, + trigger?: { automatic?: boolean } + ) => Promise /** 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 { - this.noteReopened(pane.worktreeId) + resumePane(pane: RestoredPane, automatic = false): Promise { + 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 }) } diff --git a/src/main/runtime/worktree-terminals-sleeping-error.ts b/src/main/runtime/worktree-terminals-sleeping-error.ts index 03ea8221df8..b6287811d4f 100644 --- a/src/main/runtime/worktree-terminals-sleeping-error.ts +++ b/src/main/runtime/worktree-terminals-sleeping-error.ts @@ -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' diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 62c18ce713a..e09e2fcf2f2 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -94,7 +94,10 @@ export type WorktreeApi = { sleep: (request: WorkspaceSleepRequest) => Promise sleepReopened: (worktreeId: string) => Promise /** True once running, so the renderer's subsequent spawn reattaches. */ - resumeSleptPane: (pane: { worktreeId: string; tabId: string; leafId: string }) => Promise + resumeSleptPane: ( + pane: { worktreeId: string; tabId: string; leafId: string }, + automatic?: boolean + ) => Promise /** Drops main's workspace-sleep record only while no tab holds its pane. */ retireStrandedSleepRecord: (args: { worktreeId: string; paneKey: string }) => Promise remove: (args: { diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index eea50c704d0..4dfb7c55f8f 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -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), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts index a0346d86995..8d939d727a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-owned-resume-drain.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-owned-resume-drain.spec.ts new file mode 100644 index 00000000000..735f9e4ce5f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-owned-resume-drain.spec.ts @@ -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> + transport: MockTransport + deps: PaneConnectionDeps + binding: PanePtyBinding + paneKey: string + record: unknown + owner: { worktreeId: string; tabId: string; leafId: string } + onDeferredCwdSpawnFailed: Mock +} + +export function registerMainOwnedResumeDrainTests( + mainOwnedResumeFixture: () => Promise, + 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(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() + 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(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() + 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() + } + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts index 99d876eca53..cdbcdd7fa7f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts @@ -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 Promise>() })) -vi.mock('@/lib/pane-spawn-deferral-drain', () => ({ - queueDeferredRestoreRetry: ( - paneKey: string, - _env: string | null, - retry: () => Promise - ) => 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() + return { + ...actual, + queueDeferredRestoreRetry: ( + paneKey: string, + env: string | null, + retry: () => Promise + ) => + 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>()), @@ -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 => { + 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((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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index 50125e2559c..3fbb0305957 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -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 + attach: Mock connect: ReturnType & { mockImplementation: ( impl: (opts: { callbacks?: ConnectCallbacks } & Record) => Promise @@ -42,7 +42,7 @@ export type MockTransport = { setOutputPaused?: ReturnType resize: ReturnType isConnected?: ReturnType - getPtyId: ReturnType + getPtyId: Mock<() => string | null> getConnectionId: ReturnType serializeBuffer?: ReturnType serializeBufferOutcome?: ReturnType @@ -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(({ 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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 4fe3ccf75f9..7d398d1cdee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -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 => { 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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 3c82084939f..eb01fd74414 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -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 => { - 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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-resumed-sleep-pane.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-resumed-sleep-pane.ts index 3aea67d5b1d..13b4c39d9fb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/main-resumed-sleep-pane.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-resumed-sleep-pane.ts @@ -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 { - 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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts index 0f3232297d4..dbc2a0e0b1a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts @@ -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 = 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 } ) } diff --git a/src/shared/workspace-sleep.ts b/src/shared/workspace-sleep.ts index 824062cbb67..96f9110f747 100644 --- a/src/shared/workspace-sleep.ts +++ b/src/shared/workspace-sleep.ts @@ -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 +}