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