mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(terminal): declare restore intent and keep deferred panes sleeping (#27455)
* feat(terminal): declare restore intent and keep deferred panes sleeping The visible pane's agent restore is sent as user-resume and hidden ones as automatic-restore, to hosts that advertise terminal.spawn-intent.v1 (and always on the local spawn path). When the host keeps a restore asleep, the pane shows 'Sleeping — click to resume' with the reason, keeps a sleeping record so the session stays listed and resumable, and resumes as the user's own when it is opened or clicked. A deferred pane is not remounted, which would only be deferred again. * refactor(shared): one restore spawn intent type * fix(terminal): a deferred pane owns its session against wake sweeps A deferred pane has no PTY, so the activation and mobile wake sweeps read its unfinished record as unowned and could launch the session in a new tab while the pane still held a resume. The sweep now treats a deferred pane as the owner, the remembered record is a quit-style record a sweep never discards, and a retry declines to resume a session that is already live in another pane. * fix(terminal): keep a deferred pane's claim through its sweep and retry The wake sweep now leaves a deferred pane's record alone, since that pane's own queued resume matches it and is not a duplicate. A retry keeps the pane registered as the session's owner until its PTY binds, and declines to resume when another tab already has the same session queued. --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import { usePaneSpawnDeferral } from '@/lib/pane-spawn-deferral-registry'
|
||||
import { TerminalSleepingPaneOverlay } from './TerminalSleepingPaneOverlay'
|
||||
import type { TerminalPaneController } from './use-terminal-pane-controller'
|
||||
|
||||
function SleepingPanePortal({
|
||||
pane,
|
||||
tabId
|
||||
}: {
|
||||
pane: ManagedPane
|
||||
tabId: string
|
||||
}): React.JSX.Element | null {
|
||||
const deferral = usePaneSpawnDeferral(makePaneKey(tabId, pane.leafId))
|
||||
if (!deferral || deferral.resuming) {
|
||||
return null
|
||||
}
|
||||
return createPortal(
|
||||
<TerminalSleepingPaneOverlay reason={deferral.reason} onResume={deferral.resume} />,
|
||||
pane.container,
|
||||
`sleeping-pane-${pane.id}`
|
||||
)
|
||||
}
|
||||
|
||||
export function TerminalPaneSleepingPortals({
|
||||
controller
|
||||
}: {
|
||||
controller: TerminalPaneController
|
||||
}): React.JSX.Element {
|
||||
const { managedPanes, tabId } = controller
|
||||
return (
|
||||
<>
|
||||
{managedPanes.map((pane) => (
|
||||
<SleepingPanePortal key={pane.id} pane={pane} tabId={tabId} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals'
|
||||
import { handleInternalTerminalFileDrop } from './terminal-drop-handler'
|
||||
import { TerminalQuickCommandEditorDialog } from './TerminalQuickCommandEditorDialog'
|
||||
import { TerminalPaneNativeChatPortal } from './TerminalPaneNativeChatPortal'
|
||||
import { TerminalPaneSleepingPortals } from './TerminalPaneSleepingPortals'
|
||||
import {
|
||||
TerminalPaneCodexRestartPortals,
|
||||
TerminalPaneMobileDriverPortals,
|
||||
@@ -368,6 +369,7 @@ export function TerminalPaneSurface({
|
||||
onRenameBlur={handleRenameBlur}
|
||||
/>
|
||||
<TerminalPaneRecoveryPortals controller={controller} />
|
||||
<TerminalPaneSleepingPortals controller={controller} />
|
||||
<TerminalPaneMobileDriverPortals controller={controller} />
|
||||
<CloseTerminalDialog
|
||||
open={pendingCloseConfirmation !== null}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalSleepingPaneOverlay } from './TerminalSleepingPaneOverlay'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('TerminalSleepingPaneOverlay', () => {
|
||||
it('says the session is asleep, why, and that it is saved', () => {
|
||||
render(<TerminalSleepingPaneOverlay reason="paused" onResume={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Sleeping — click to resume')).toBeInTheDocument()
|
||||
expect(screen.getByText(/paused because the host stopped unexpectedly/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Your session is saved/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resumes once from the button and from anywhere on the pane', async () => {
|
||||
const onResume = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<TerminalSleepingPaneOverlay reason="live_cap" onResume={onResume} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Resume' }))
|
||||
expect(onResume).toHaveBeenCalledOnce()
|
||||
await user.click(screen.getByText('Sleeping — click to resume'))
|
||||
expect(onResume).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Moon, Play } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { SpawnDeferralReason } from '../../../../shared/host-spawn-admission'
|
||||
|
||||
function sleepingReasonDetail(reason: SpawnDeferralReason): string {
|
||||
switch (reason) {
|
||||
case 'paused':
|
||||
return translate(
|
||||
'auto.components.terminal.pane.TerminalSleepingPaneOverlay.pausedDetail',
|
||||
'Automatic restores are paused because the host stopped unexpectedly. Your session is saved.'
|
||||
)
|
||||
case 'live_cap':
|
||||
return translate(
|
||||
'auto.components.terminal.pane.TerminalSleepingPaneOverlay.liveCapDetail',
|
||||
'Enough agents are already running on this host. Your session is saved.'
|
||||
)
|
||||
case 'pressure':
|
||||
return translate(
|
||||
'auto.components.terminal.pane.TerminalSleepingPaneOverlay.pressureDetail',
|
||||
'The host is under heavy load. Your session is saved.'
|
||||
)
|
||||
case 'deadline':
|
||||
return translate(
|
||||
'auto.components.terminal.pane.TerminalSleepingPaneOverlay.deadlineDetail',
|
||||
'The host was busy restoring other terminals. Your session is saved.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A restore the host kept asleep. Clicking anywhere on the pane resumes it. */
|
||||
export function TerminalSleepingPaneOverlay({
|
||||
reason,
|
||||
onResume
|
||||
}: {
|
||||
reason: SpawnDeferralReason
|
||||
onResume: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex cursor-pointer items-center justify-center bg-background/70 p-4"
|
||||
data-terminal-sleeping-pane={reason}
|
||||
onClick={onResume}
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full max-w-md items-center gap-3 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xs"
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground">
|
||||
<Moon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalSleepingPaneOverlay.title',
|
||||
'Sleeping — click to resume'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{sleepingReasonDetail(reason)}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
// Why: the pane-wide click below would resume a second time.
|
||||
event.stopPropagation()
|
||||
onResume()
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
{translate('auto.components.terminal.pane.TerminalSleepingPaneOverlay.resume', 'Resume')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
|
||||
import { readIpcErrorDetail } from '@/lib/ipc-error'
|
||||
import { ensurePtyDispatcher } from './pty-dispatcher'
|
||||
@@ -199,6 +200,11 @@ function handleConnectError(
|
||||
if (message.includes('was explicitly killed')) {
|
||||
return undefined
|
||||
}
|
||||
const deferral = readTerminalSpawnDeferral(message)
|
||||
if (deferral) {
|
||||
context.getCallbacks().onSpawnDeferred?.(deferral)
|
||||
return undefined
|
||||
}
|
||||
if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) {
|
||||
// Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a
|
||||
// pure client-side id comparison, before any relay is contacted — it reports that the id is not
|
||||
|
||||
@@ -94,6 +94,9 @@ export async function spawnIpcPty(
|
||||
} = transportOptions
|
||||
const shouldSendLocalCwdFallback =
|
||||
cwdFallback === 'worktree' && !connectionId && !admittedSessionId
|
||||
const resumesAgent = Boolean(connectOptions.resumeProviderSession ?? resumeProviderSession)
|
||||
const spawnIntent =
|
||||
resumesAgent && !admittedSessionId ? connectOptions.resumeSpawnIntent : undefined
|
||||
// Why: a reattach under an admitted session id must never stop the PTY it is reattaching.
|
||||
const replacesPtyId = admittedSessionId ? null : (connectOptions.claimReplacedPtyId?.() ?? null)
|
||||
return window.api.pty.spawn({
|
||||
@@ -133,6 +136,7 @@ export async function spawnIpcPty(
|
||||
...(tabId ? { tabId } : {}),
|
||||
...(leafId ? { leafId } : {}),
|
||||
...(placement && !admittedSessionId ? { placement } : {}),
|
||||
...(spawnIntent ? { spawnIntent } : {}),
|
||||
...(replacesPtyId ? { replacesPtyId } : {}),
|
||||
...(shellOverride ? { shellOverride } : {}),
|
||||
...(projectRuntime ? { projectRuntime } : {}),
|
||||
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import { createMockTransport, createPane, createManager } from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import type { MockTransport } from './pty-connection-test-pane-fixtures'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({
|
||||
scheduleRuntimeGraphSync
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('./cache-timer-seeding', () => ({
|
||||
shouldSeedCacheTimerOnInitialTitle
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
info: toastInfo
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-pane-recovery', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
requestTerminalPaneRecovery: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
}))
|
||||
|
||||
// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React).
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./remote-runtime-pty-transport', () => ({
|
||||
createRemoteRuntimePtyTransport: vi.fn(
|
||||
(_environmentId: string, options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher.
|
||||
vi.mock('./pty-dispatcher', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
getEagerPtyBufferHandle: vi.fn(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
function createDeps(overrides: Record<string, unknown> = {}) {
|
||||
return buildPaneConnectionDeps(() => mockStoreState, overrides)
|
||||
}
|
||||
|
||||
const RESUME = { key: 'session_id', id: 'provider-session-1' } as const
|
||||
|
||||
function freshSpawns(transport: MockTransport): Record<string, unknown>[] {
|
||||
return transport.connect.mock.calls.flatMap((call: unknown[]) => {
|
||||
const [options] = call
|
||||
return typeof options === 'object' && options !== null && !('sessionId' in options)
|
||||
? [{ ...options }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
function deferringTransport(): MockTransport {
|
||||
const transport = createMockTransport(null)
|
||||
transport.connect.mockImplementation(
|
||||
async (opts: {
|
||||
sessionId?: string
|
||||
callbacks?: { onSpawnDeferred?: (reason: string) => void }
|
||||
}) => {
|
||||
// Why: the fixture tab names a PTY that is gone, so only the fresh restore reaches the gate.
|
||||
if (!opts.sessionId) {
|
||||
opts.callbacks?.onSpawnDeferred?.('live_cap')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
return transport
|
||||
}
|
||||
|
||||
describe('connectPanePty: a restore the host kept asleep', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
mockStoreState.rememberSleepingAgentSession = vi.fn((record: { paneKey: string }) => {
|
||||
mockStoreState.sleepingAgentSessionsByPaneKey[record.paneKey] ??= record
|
||||
})
|
||||
mockStoreState.pendingStartupByTabId = {}
|
||||
mockStoreState.automaticAgentResumeClaimsByTabId = {}
|
||||
await installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
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')
|
||||
const transport = deferringTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const paneKey = makePaneKey('tab-1', pane.leafId)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: {
|
||||
command: "claude '--resume' 'provider-session-1'",
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
})
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs.
|
||||
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(freshSpawns(transport)).toEqual([
|
||||
expect.objectContaining({ resumeSpawnIntent: 'automatic-restore' })
|
||||
])
|
||||
expect(getPaneSpawnDeferral(paneKey)?.reason).toBe('live_cap')
|
||||
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toMatchObject({
|
||||
paneKey,
|
||||
agent: 'claude',
|
||||
providerSession: RESUME,
|
||||
origin: 'quit',
|
||||
restoreOnTabOpenOnly: true
|
||||
})
|
||||
expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled()
|
||||
|
||||
transport.connect.mockImplementation(async () => 'resumed-pty')
|
||||
deps.isVisibleRef.current = true
|
||||
binding.noteVisibilityResume()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(freshSpawns(transport)).toHaveLength(2)
|
||||
expect(freshSpawns(transport)[1]).toMatchObject({ resumeSpawnIntent: 'user-resume' })
|
||||
expect(getPaneSpawnDeferral(paneKey)).toBeUndefined()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('does not remount a deferred pane, which would only be deferred again', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { getPaneSpawnDeferral } = await import('@/lib/pane-spawn-deferral-registry')
|
||||
const { requestTerminalPaneRecovery } = await import('./terminal-pane-recovery')
|
||||
transportFactoryQueue.push(deferringTransport())
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: {
|
||||
command: "claude '--resume' 'provider-session-1'",
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME
|
||||
}
|
||||
})
|
||||
|
||||
const pane = createPane(1)
|
||||
const paneKey = makePaneKey('tab-1', pane.leafId)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs.
|
||||
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(getPaneSpawnDeferral(paneKey)?.reason).toBe('live_cap')
|
||||
expect(requestTerminalPaneRecovery).not.toHaveBeenCalled()
|
||||
binding.dispose()
|
||||
expect(getPaneSpawnDeferral(paneKey)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not resume a session that meanwhile started in another tab', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { getPaneSpawnDeferral } = await import('@/lib/pane-spawn-deferral-registry')
|
||||
const transport = deferringTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const paneKey = makePaneKey('tab-1', pane.leafId)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: {
|
||||
command: "claude '--resume' 'provider-session-1'",
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME
|
||||
}
|
||||
})
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs.
|
||||
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
expect(getPaneSpawnDeferral(paneKey)).toBeDefined()
|
||||
|
||||
const otherLeaf = '22222222-2222-4222-8222-222222222222'
|
||||
mockStoreState.agentStatusByPaneKey[makePaneKey('tab-2', otherLeaf)] = {
|
||||
paneKey: makePaneKey('tab-2', otherLeaf),
|
||||
agentType: 'claude',
|
||||
providerSession: RESUME,
|
||||
state: 'working'
|
||||
}
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
ptyIdsByTabId: { ...mockStoreState.ptyIdsByTabId, 'tab-2': ['pty-elsewhere'] },
|
||||
terminalLayoutsByTabId: {
|
||||
...mockStoreState.terminalLayoutsByTabId,
|
||||
'tab-2': {
|
||||
root: { type: 'leaf', leafId: otherLeaf },
|
||||
activeLeafId: otherLeaf,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
deps.isVisibleRef.current = true
|
||||
binding.noteVisibilityResume()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(freshSpawns(transport)).toHaveLength(1)
|
||||
expect(getPaneSpawnDeferral(paneKey)).toBeUndefined()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('stays the owner while its retry is in flight, then hands over to the PTY', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { getPaneSpawnDeferral, isPaneSpawnDeferred } =
|
||||
await import('@/lib/pane-spawn-deferral-registry')
|
||||
const transport = deferringTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const paneKey = makePaneKey('tab-1', pane.leafId)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: {
|
||||
command: "claude '--resume' 'provider-session-1'",
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME
|
||||
}
|
||||
})
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs.
|
||||
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
let bind: (ptyId: string) => void = () => {}
|
||||
transport.connect.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
bind = resolve
|
||||
})
|
||||
)
|
||||
deps.isVisibleRef.current = true
|
||||
binding.noteVisibilityResume()
|
||||
await flushAsyncTicks(5)
|
||||
|
||||
expect(isPaneSpawnDeferred(paneKey)).toBe(true)
|
||||
expect(getPaneSpawnDeferral(paneKey)?.resuming).toBe(true)
|
||||
bind('resumed-pty')
|
||||
await flushAsyncTicks(20)
|
||||
expect(isPaneSpawnDeferred(paneKey)).toBe(false)
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('does not resume a session another tab has already queued', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = deferringTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
startup: {
|
||||
command: "claude '--resume' 'provider-session-1'",
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME
|
||||
}
|
||||
})
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs.
|
||||
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
mockStoreState.pendingStartupByTabId = {
|
||||
'tab-9': { command: 'claude', launchAgent: 'claude', resumeProviderSession: RESUME }
|
||||
}
|
||||
deps.isVisibleRef.current = true
|
||||
binding.noteVisibilityResume()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(freshSpawns(transport)).toHaveLength(1)
|
||||
binding.dispose()
|
||||
})
|
||||
})
|
||||
@@ -108,6 +108,9 @@ export type StoreState = {
|
||||
>
|
||||
getAgentLaunchConfigForStatusEntry: ReturnType<typeof vi.fn>
|
||||
clearSleepingAgentSession: ReturnType<typeof vi.fn>
|
||||
rememberSleepingAgentSession?: ReturnType<typeof vi.fn>
|
||||
pendingStartupByTabId?: Record<string, unknown>
|
||||
automaticAgentResumeClaimsByTabId?: Record<string, unknown>
|
||||
registerAgentLaunchConfig: ReturnType<typeof vi.fn>
|
||||
clearAgentLaunchConfig: ReturnType<typeof vi.fn>
|
||||
markWorktreeUnread: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installSpawnDeferredSleep } from './spawn-deferred-sleep'
|
||||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import { useAppStore } from '@/store'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
@@ -179,6 +180,7 @@ export function connectPanePty(
|
||||
installInterruptInputIntent(session)
|
||||
installTerminalKeydownFit(session)
|
||||
installPtyExitHibernate(session)
|
||||
installSpawnDeferredSleep(session)
|
||||
installTitleSpawnBell(session)
|
||||
installAgentTaskCompleteNotify(session)
|
||||
installDirectSshRetryStatus(session)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission'
|
||||
import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import { PROCESS_BOUNDARY_GROUND } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
@@ -86,6 +87,8 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
}
|
||||
|
||||
session.transportConnectInFlightSince = Date.now()
|
||||
session.pendingSpawnDeferral = null
|
||||
const visibleAtSpawn = session.deps.isVisibleRef.current === true
|
||||
const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(
|
||||
session.reportError,
|
||||
@@ -114,6 +117,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
|
||||
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
resumeSpawnIntent: visibleAtSpawn ? 'user-resume' : 'automatic-restore',
|
||||
...(session.pendingReplacedPtyId
|
||||
? { claimReplacedPtyId: session.claimPendingReplacedPtyId }
|
||||
: {}),
|
||||
@@ -142,6 +146,18 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
: typeof spawnedPtyId === 'string'
|
||||
? spawnedPtyId
|
||||
: session.transport.getPtyId()
|
||||
const deferral: SpawnDeferralReason | null = resolvedPtyId
|
||||
? null
|
||||
: session.pendingSpawnDeferral
|
||||
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)
|
||||
return null
|
||||
}
|
||||
if (resolvedPtyId && !session.claimCapturedDirectSshRetryPty(resolvedPtyId)) {
|
||||
releaseDeferredCwdFence()
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
@@ -302,6 +318,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (session.deferredSpawn) {
|
||||
// Why no remount: the host would defer the remount's restore again, in a loop.
|
||||
session.settlePaneAttachAttempt?.(session.directSshRetryAttempt, 'failed')
|
||||
return
|
||||
}
|
||||
settleSpawnThatLeftPaneUnbound(session)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,6 +104,7 @@ export function installSessionReconcileDispose(session: ConnectPanePtySession):
|
||||
session.claimPendingVisibleRemoteViewport()
|
||||
session.ptySizeReassertion.request({ fit: false })
|
||||
session.consumeHibernatedAgentWake()
|
||||
session.resumeDeferredSpawnOnReveal()
|
||||
session.requestKnownWindowsShiftEnterReconfirmation()
|
||||
session.sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
@@ -174,6 +175,7 @@ export function installSessionReconcileDispose(session: ConnectPanePtySession):
|
||||
session.spawnedFreshPtyId === ptyId && !Number.isFinite(session.lastTerminalInputAt),
|
||||
dispose() {
|
||||
session.disposed = true
|
||||
session.disposeSpawnDeferral()
|
||||
session.startupTiming?.finish('disposed')
|
||||
const unsentReplacedPtyId: string | null = session.claimPendingReplacedPtyId()
|
||||
if (unsentReplacedPtyId) {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission'
|
||||
import {
|
||||
agentProviderSessionsEqual,
|
||||
isResumableTuiAgent,
|
||||
type AgentProviderSessionMetadata,
|
||||
type SleepingAgentSessionRecord
|
||||
} from '../../../../../shared/agent-session-resume'
|
||||
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import { stablePaneHasLivePty } from '@/lib/sleeping-agent-pane-ownership'
|
||||
import {
|
||||
clearPaneSpawnDeferral,
|
||||
getPaneSpawnDeferral,
|
||||
setPaneSpawnDeferral
|
||||
} from '@/lib/pane-spawn-deferral-registry'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import type {
|
||||
ColdRestoreAgentResumeStartup,
|
||||
FreshSpawnOptions,
|
||||
PendingStartupCommand
|
||||
} from './fresh-spawn-types'
|
||||
|
||||
const SESSION_RUNNING_ELSEWHERE_NOTICE =
|
||||
'\r\n\x1b[2mThis agent session is already running in another tab.\x1b[0m\r\n'
|
||||
|
||||
type DeferredSpawn = {
|
||||
reason: SpawnDeferralReason
|
||||
startupOverride: PendingStartupCommand | null | undefined
|
||||
options: FreshSpawnOptions
|
||||
}
|
||||
|
||||
function isColdRestoreStartup(
|
||||
startup: PendingStartupCommand | null | undefined
|
||||
): startup is ColdRestoreAgentResumeStartup {
|
||||
return Boolean(startup && 'launchConfig' in startup && 'resumeProviderSession' in startup)
|
||||
}
|
||||
|
||||
/** The record a deferred restore leaves behind, so the session stays listed and resumable even if
|
||||
* this pane goes away before the user opens it. */
|
||||
function deferredResumeRecord(
|
||||
session: ConnectPanePtySession,
|
||||
startupOverride: PendingStartupCommand | null | undefined
|
||||
): SleepingAgentSessionRecord | null {
|
||||
const coldRestore = isColdRestoreStartup(startupOverride) ? startupOverride : null
|
||||
const agent = coldRestore?.agent ?? session.paneStartup?.launchAgent
|
||||
const providerSession =
|
||||
coldRestore?.resumeProviderSession ?? session.paneStartup?.resumeProviderSession
|
||||
if (!agent || !isResumableTuiAgent(agent) || !providerSession) {
|
||||
return null
|
||||
}
|
||||
const launchConfig = coldRestore?.launchConfig ?? session.paneStartup?.launchConfig
|
||||
const now = Date.now()
|
||||
return {
|
||||
paneKey: session.cacheKey,
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
agent,
|
||||
providerSession,
|
||||
prompt: '',
|
||||
state: 'done',
|
||||
capturedAt: now,
|
||||
updatedAt: now,
|
||||
...(typeof session.connectionId === 'string' ? { connectionId: session.connectionId } : {}),
|
||||
...(launchConfig ? { launchConfig } : {}),
|
||||
// Why `quit`: like a quit capture it names a pane that still exists and is unfinished work,
|
||||
// so a sweep never discards it as passive history (a `live` done note is).
|
||||
origin: 'quit',
|
||||
restoreOnTabOpenOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
function providerSessionLiveInAnotherPane(resume: SleepingAgentSessionRecord): boolean {
|
||||
const state = useAppStore.getState()
|
||||
return Object.values(state.agentStatusByPaneKey).some((entry) => {
|
||||
const pane = entry.paneKey === resume.paneKey ? null : parsePaneKey(entry.paneKey)
|
||||
return (
|
||||
pane !== null &&
|
||||
entry.agentType === resume.agent &&
|
||||
agentProviderSessionsEqual(resume.agent, entry.providerSession, resume.providerSession) &&
|
||||
stablePaneHasLivePty(
|
||||
pane.tabId,
|
||||
pane.leafId,
|
||||
state.ptyIdsByTabId,
|
||||
state.terminalLayoutsByTabId[pane.tabId]
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function providerSessionQueuedInAnotherTab(
|
||||
resume: SleepingAgentSessionRecord,
|
||||
ownTabId: string
|
||||
): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const sameSession = (
|
||||
agent: string | undefined,
|
||||
providerSession: AgentProviderSessionMetadata | undefined
|
||||
): boolean =>
|
||||
agent === resume.agent &&
|
||||
agentProviderSessionsEqual(resume.agent, providerSession, resume.providerSession)
|
||||
return (
|
||||
Object.entries(state.pendingStartupByTabId).some(
|
||||
([tabId, startup]) =>
|
||||
tabId !== ownTabId && sameSession(startup.launchAgent, startup.resumeProviderSession)
|
||||
) ||
|
||||
Object.entries(state.automaticAgentResumeClaimsByTabId).some(
|
||||
([tabId, claim]) =>
|
||||
tabId !== ownTabId && sameSession(claim.launchAgent, claim.providerSession)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** A restore the host kept asleep: the pane shows a sleeping overlay and retries when opened. */
|
||||
export function installSpawnDeferredSleep(session: ConnectPanePtySession): void {
|
||||
// Set by the transport for the spawn in flight; read when that spawn settles without a PTY.
|
||||
session.pendingSpawnDeferral = null
|
||||
session.deferredSpawn = null
|
||||
|
||||
session.enterSpawnDeferredSleep = (
|
||||
reason: SpawnDeferralReason,
|
||||
startupOverride: PendingStartupCommand | null | undefined,
|
||||
options: FreshSpawnOptions,
|
||||
visibleAtSpawn: boolean
|
||||
): void => {
|
||||
const record = deferredResumeRecord(session, startupOverride)
|
||||
if (record) {
|
||||
// Why left after a later resume: the next quit capture replaces it, and a pane with a live PTY owns it.
|
||||
useAppStore.getState().rememberSleepingAgentSession(record)
|
||||
}
|
||||
session.deferredSpawn = { reason, startupOverride, options }
|
||||
setPaneSpawnDeferral(session.cacheKey, {
|
||||
reason,
|
||||
resume: () => void session.resumeDeferredSpawn()
|
||||
})
|
||||
if (!visibleAtSpawn) {
|
||||
// Why: a pane opened while its hidden restore was in flight resumes as the user's own.
|
||||
queueMicrotask(() => session.resumeDeferredSpawnOnReveal())
|
||||
}
|
||||
}
|
||||
|
||||
session.resumeDeferredSpawn = (): Promise<string | null> => {
|
||||
const deferred: DeferredSpawn | null = session.deferredSpawn
|
||||
if (!deferred || session.disposed || session.transport.getPtyId()) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
session.deferredSpawn = null
|
||||
const resume = deferredResumeRecord(session, deferred.startupOverride)
|
||||
if (
|
||||
resume &&
|
||||
(providerSessionLiveInAnotherPane(resume) ||
|
||||
providerSessionQueuedInAnotherTab(resume, session.deps.tabId))
|
||||
) {
|
||||
clearPaneSpawnDeferral(session.cacheKey)
|
||||
// Why no spawn: the session runs or is starting elsewhere, and a second `--resume` forks it.
|
||||
writeTerminalOutput(session.pane.terminal, SESSION_RUNNING_ELSEWHERE_NOTICE, {
|
||||
foreground: session.deps.isVisibleRef.current === true
|
||||
})
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
// Why keep the entry while in flight: until the PTY binds, the pane is still the session's
|
||||
// owner, and a sweep in between must not start it in another tab.
|
||||
const resuming = { reason: deferred.reason, resume: () => {}, resuming: true as const }
|
||||
setPaneSpawnDeferral(session.cacheKey, resuming)
|
||||
const settle = (): void => {
|
||||
if (getPaneSpawnDeferral(session.cacheKey) === resuming) {
|
||||
clearPaneSpawnDeferral(session.cacheKey)
|
||||
}
|
||||
}
|
||||
return session.startFreshSpawn(deferred.startupOverride, deferred.options).then(
|
||||
(ptyId: string | null) => {
|
||||
settle()
|
||||
return ptyId
|
||||
},
|
||||
(error: unknown) => {
|
||||
settle()
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
session.resumeDeferredSpawnOnReveal = (): void => {
|
||||
if (session.deferredSpawn && session.deps.isVisibleRef.current) {
|
||||
void session.resumeDeferredSpawn()
|
||||
}
|
||||
}
|
||||
|
||||
session.disposeSpawnDeferral = (): void => {
|
||||
session.deferredSpawn = null
|
||||
clearPaneSpawnDeferral(session.cacheKey)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission'
|
||||
import { createTerminalStartupTiming } from '../terminal-startup-timing'
|
||||
import type { PtyReplayDataMeta } from '../pty-transport'
|
||||
import type { PtyTransportRecoveryState } from '../pty-transport-types'
|
||||
@@ -99,6 +100,11 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi
|
||||
if (isCurrent()) {
|
||||
session.handleRemoteOutputPauseChanged(paused, supported)
|
||||
}
|
||||
},
|
||||
onSpawnDeferred: (reason: SpawnDeferralReason): void => {
|
||||
if (isCurrent()) {
|
||||
session.pendingSpawnDeferral = reason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness'
|
||||
|
||||
const RESUME = { key: 'session_id', id: 'provider-session-1' } as const
|
||||
|
||||
describe('createIpcPtyTransport: restore spawn intent', () => {
|
||||
const originalWindow: typeof window | undefined = globalThis.window
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
installIpcPtyWindow(originalWindow, {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restorePtySpecWindow(originalWindow)
|
||||
})
|
||||
|
||||
it('declares the intent only when the spawn resumes an agent', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
await createIpcPtyTransport().connect({
|
||||
url: '',
|
||||
resumeProviderSession: RESUME,
|
||||
resumeSpawnIntent: 'automatic-restore',
|
||||
callbacks: {}
|
||||
})
|
||||
await createIpcPtyTransport().connect({
|
||||
url: '',
|
||||
resumeSpawnIntent: 'automatic-restore',
|
||||
callbacks: {}
|
||||
})
|
||||
const [resumed, plain] = vi.mocked(window.api.pty.spawn).mock.calls.map(([args]) => args)
|
||||
expect(resumed).toMatchObject({ spawnIntent: 'automatic-restore' })
|
||||
expect(plain).not.toHaveProperty('spawnIntent')
|
||||
})
|
||||
|
||||
it('reports a deferral to the pane instead of an error toast', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'pty:spawn': Error: terminal_spawn_deferred:live_cap")
|
||||
)
|
||||
const onError = vi.fn()
|
||||
const onSpawnDeferred = vi.fn()
|
||||
|
||||
const result = await createIpcPtyTransport().connect({
|
||||
url: '',
|
||||
resumeProviderSession: RESUME,
|
||||
resumeSpawnIntent: 'automatic-restore',
|
||||
callbacks: { onError, onSpawnDeferred }
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(onSpawnDeferred).toHaveBeenCalledWith('live_cap')
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
RestoreSpawnIntent,
|
||||
SpawnDeferralReason
|
||||
} from '../../../../shared/host-spawn-admission'
|
||||
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
|
||||
import type {
|
||||
AgentProviderSessionMetadata,
|
||||
@@ -138,6 +142,8 @@ type PtyCallbacks = {
|
||||
onWriteUnavailable?: () => void
|
||||
onRecoveryStateChange?: (state: PtyTransportRecoveryState) => void
|
||||
onOutputPauseChanged?: (paused: boolean, supported: boolean) => void
|
||||
/** The host kept this restore asleep; no process started and the resume handle is intact. */
|
||||
onSpawnDeferred?: (reason: SpawnDeferralReason) => void
|
||||
}
|
||||
|
||||
export type PtyTransportRecoveryState = {
|
||||
@@ -166,6 +172,8 @@ export type PtyTransport = {
|
||||
* first byte and the gate + model responder own spawn-time queries.
|
||||
* Ignored by remote-runtime transports (not gate-markable). */
|
||||
initiallyHidden?: boolean
|
||||
/** Sent only when this connect resumes an agent, and only to hosts that accept it. */
|
||||
resumeSpawnIntent?: RestoreSpawnIntent
|
||||
command?: string
|
||||
commandDelivery?: 'renderer' | 'provider'
|
||||
env?: Record<string, string>
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const RESUME = { key: 'session_id', id: 'provider-session-1' } as const
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
type RpcCall = { method?: string; params?: Record<string, unknown> }
|
||||
|
||||
function okResponse(result: unknown): unknown {
|
||||
return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'runtime-remote' } }
|
||||
}
|
||||
|
||||
describe('createRemoteRuntimePtyTransport: restore spawn intent', () => {
|
||||
const runtimeCall = vi.fn()
|
||||
let capabilities: string[] = []
|
||||
let ensureResponse: unknown = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
runtimeCall.mockReset()
|
||||
capabilities = ['agent-session.host-authority.v1']
|
||||
ensureResponse = {
|
||||
id: 'rpc',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'terminal_spawn_deferred',
|
||||
message: 'terminal_spawn_deferred:paused',
|
||||
data: { reason: 'paused' }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
runtimeCall.mockImplementation(async (args: RpcCall) =>
|
||||
args.method === 'status.get'
|
||||
? okResponse({
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities
|
||||
})
|
||||
: ensureResponse
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
...globalThis.window,
|
||||
api: {
|
||||
...globalThis.window?.api,
|
||||
runtimeEnvironments: {
|
||||
...globalThis.window?.api?.runtimeEnvironments,
|
||||
call: runtimeCall,
|
||||
subscribe: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function connectResume(callbacks: Record<string, unknown> = {}): Promise<unknown> {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF_ID
|
||||
})
|
||||
return transport.connect({ url: '', resumeSpawnIntent: 'automatic-restore', callbacks })
|
||||
}
|
||||
|
||||
function ensureParams(): Record<string, unknown> | undefined {
|
||||
for (const [args] of runtimeCall.mock.calls) {
|
||||
const call: RpcCall = args
|
||||
if (call.method === 'terminal.ensureAgentSession') {
|
||||
return call.params
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('sends the intent to a host that advertises it', async () => {
|
||||
capabilities.push('terminal.spawn-intent.v1')
|
||||
await connectResume()
|
||||
expect(ensureParams()).toMatchObject({ kind: 'explicit', spawnIntent: 'automatic-restore' })
|
||||
})
|
||||
|
||||
it('omits it for an older host, whose strict params would reject the request', async () => {
|
||||
ensureResponse = okResponse({
|
||||
disposition: 'created',
|
||||
terminal: { handle: 'term-remote', worktreeId: 'repo1::/remote/wt', title: null }
|
||||
})
|
||||
await connectResume()
|
||||
expect(ensureParams()).toBeDefined()
|
||||
expect(ensureParams()).not.toHaveProperty('spawnIntent')
|
||||
})
|
||||
|
||||
it('reports a deferral to the pane instead of surfacing an error', async () => {
|
||||
capabilities.push('terminal.spawn-intent.v1')
|
||||
const onError = vi.fn()
|
||||
const onSpawnDeferred = vi.fn()
|
||||
|
||||
await expect(connectResume({ onError, onSpawnDeferred })).resolves.toBeUndefined()
|
||||
|
||||
expect(onSpawnDeferred).toHaveBeenCalledWith('paused')
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission'
|
||||
import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent'
|
||||
import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability'
|
||||
import { withRemoteReattachInputBuffer } from './remote-reattach-input-buffer'
|
||||
import { createRemoteRuntimeDisconnectedInputGrace } from './remote-runtime-disconnected-input-grace'
|
||||
@@ -2678,6 +2680,16 @@ export function createRemoteRuntimePtyTransport(
|
||||
const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession
|
||||
const launchTokenToSend = options.launchToken ?? launchToken
|
||||
const launchAgentToSend = options.launchAgent ?? launchAgent
|
||||
const restoreSpawnIntent = resumeProviderSessionToSend
|
||||
? options.resumeSpawnIntent
|
||||
: undefined
|
||||
// Why only for a restore: an extra await would reorder every plain create against destroy.
|
||||
const spawnIntentParams = restoreSpawnIntent
|
||||
? await remoteRestoreSpawnIntentParams(createEnvironmentId, restoreSpawnIntent)
|
||||
: {}
|
||||
if (restoreSpawnIntent && (destroyed || lifecycleEpoch !== connectLifecycleEpoch)) {
|
||||
return
|
||||
}
|
||||
const legacyCreateParams = {
|
||||
worktree: toRuntimeTerminalWorktreeSelector(worktreeId),
|
||||
clientMutationId: terminalCreateMutationId,
|
||||
@@ -2693,6 +2705,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
: {}),
|
||||
...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}),
|
||||
...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}),
|
||||
...spawnIntentParams,
|
||||
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
|
||||
...(terminalKittyKeyboardProtocol === true
|
||||
? { terminalKittyKeyboardProtocol: true }
|
||||
@@ -2743,7 +2756,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
? { launchPreferences: agentLaunchPreferences }
|
||||
: {}),
|
||||
placement: { tabId, leafId },
|
||||
presentation: 'background'
|
||||
presentation: 'background',
|
||||
...spawnIntentParams
|
||||
},
|
||||
timeoutMs
|
||||
)
|
||||
@@ -2867,7 +2881,12 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
|
||||
connecting = false
|
||||
const message = runtimeTerminalErrorMessage(error)
|
||||
if (isRemoteTerminalGoneMessage(message)) {
|
||||
const deferral = readTerminalSpawnDeferral(error)
|
||||
if (deferral) {
|
||||
recovery.cancel()
|
||||
emitRecoveryState()
|
||||
storedCallbacks.onSpawnDeferred?.(deferral)
|
||||
} else if (isRemoteTerminalGoneMessage(message)) {
|
||||
recovery.cancel()
|
||||
handleRemoteTerminalError(error)
|
||||
} else if (isRecoverablePaneBindingError(error)) {
|
||||
|
||||
@@ -3565,6 +3565,14 @@
|
||||
"notStarted": "The agent couldn't start.",
|
||||
"unconfirmed": "Couldn't confirm the agent started.",
|
||||
"workspaceGone": "Its workspace is no longer available."
|
||||
},
|
||||
"TerminalSleepingPaneOverlay": {
|
||||
"pausedDetail": "Automatic restores are paused because the host stopped unexpectedly. Your session is saved.",
|
||||
"liveCapDetail": "Enough agents are already running on this host. Your session is saved.",
|
||||
"pressureDetail": "The host is under heavy load. Your session is saved.",
|
||||
"deadlineDetail": "The host was busy restoring other terminals. Your session is saved.",
|
||||
"title": "Sleeping — click to resume",
|
||||
"resume": "Resume"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { SpawnDeferralReason } from '../../../shared/host-spawn-admission'
|
||||
|
||||
export type PaneSpawnDeferral = {
|
||||
reason: SpawnDeferralReason
|
||||
resume: () => void
|
||||
/** The retry is in flight; the pane still owns the session but shows no overlay. */
|
||||
resuming?: true
|
||||
}
|
||||
|
||||
// Why module state: the pane session is imperative and outlives renders; overlays only read it.
|
||||
const deferralsByPaneKey = new Map<string, PaneSpawnDeferral>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function setPaneSpawnDeferral(paneKey: string, deferral: PaneSpawnDeferral): void {
|
||||
deferralsByPaneKey.set(paneKey, deferral)
|
||||
notify()
|
||||
}
|
||||
|
||||
export function clearPaneSpawnDeferral(paneKey: string): void {
|
||||
if (deferralsByPaneKey.delete(paneKey)) {
|
||||
notify()
|
||||
}
|
||||
}
|
||||
|
||||
export function getPaneSpawnDeferral(paneKey: string): PaneSpawnDeferral | undefined {
|
||||
return deferralsByPaneKey.get(paneKey)
|
||||
}
|
||||
|
||||
/** A mounted pane holding a restore its host deferred; it owns that session's resume. */
|
||||
export function isPaneSpawnDeferred(paneKey: string): boolean {
|
||||
return deferralsByPaneKey.has(paneKey)
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePaneSpawnDeferral(paneKey: string): PaneSpawnDeferral | undefined {
|
||||
return useSyncExternalStore(subscribe, () => deferralsByPaneKey.get(paneKey))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { clearPaneSpawnDeferral, setPaneSpawnDeferral } from './pane-spawn-deferral-registry'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
|
||||
const RESUME = { key: 'session_id', id: 'sess-deferred' } as const
|
||||
|
||||
afterEach(() => {
|
||||
clearPaneSpawnDeferral(PANE_KEY)
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
function deferredPaneState(): Partial<AppState> {
|
||||
const record: SleepingAgentSessionRecord = {
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'claude',
|
||||
providerSession: RESUME,
|
||||
prompt: '',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'quit',
|
||||
restoreOnTabOpenOnly: true
|
||||
}
|
||||
return {
|
||||
activeWorktreeId: 'wt-other',
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
// The wake path's own queued resume for this very tab.
|
||||
pendingStartupByTabId: {
|
||||
'tab-1': {
|
||||
command: "claude '--resume' 'sess-deferred'",
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: { [PANE_KEY]: record }
|
||||
}
|
||||
}
|
||||
|
||||
describe('wake sweep and a pane whose restore the host deferred', () => {
|
||||
it('neither launches nor clears the record the deferred pane will resume', () => {
|
||||
useAppStore.setState(deferredPaneState())
|
||||
setPaneSpawnDeferral(PANE_KEY, { reason: 'live_cap', resume: () => {} })
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
recordPaneIsOwnedByPreservedPane,
|
||||
stablePaneHasLivePty
|
||||
} from './sleeping-agent-pane-ownership'
|
||||
import { isPaneSpawnDeferred } from './pane-spawn-deferral-registry'
|
||||
import {
|
||||
launchSleepingAgentSession,
|
||||
type ResumeSleepingAgentSessionsOptions
|
||||
@@ -259,6 +260,11 @@ export function resumeSleepingAgentSessionsForWorktree(
|
||||
if (options?.skipClaimKeys?.has(claimKey)) {
|
||||
continue
|
||||
}
|
||||
// Why: a pane whose restore its host deferred resumes this record itself when opened; its own
|
||||
// queued startup matches the record, which must not read as a duplicate to clear.
|
||||
if (isPaneSpawnDeferred(record.paneKey)) {
|
||||
continue
|
||||
}
|
||||
if (isInvalidWorktreeActivationRecord(record)) {
|
||||
state.clearSleepingAgentSession(record.paneKey)
|
||||
continue
|
||||
|
||||
@@ -33,3 +33,34 @@ describe('activationTreatsNoteAsFinished', () => {
|
||||
expect(activationTreatsNoteAsFinished(record)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordPaneIsOwnedByPreservedPane: a pane its host kept asleep', () => {
|
||||
const layout = {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf' as const, leafId: '11111111-1111-4111-8111-111111111111' },
|
||||
activeLeafId: '11111111-1111-4111-8111-111111111111',
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
const state = {
|
||||
activeWorktreeId: 'wt-other',
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: layout
|
||||
}
|
||||
|
||||
it('owns its unfinished session while deferred, so a sweep does not launch it again', async () => {
|
||||
const { setPaneSpawnDeferral, clearPaneSpawnDeferral } =
|
||||
await import('./pane-spawn-deferral-registry')
|
||||
const { recordPaneIsOwnedByPreservedPane } = await import('./sleeping-agent-pane-ownership')
|
||||
const record = note({ paneKey, tabId: 'tab-1', origin: 'quit' })
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the predicate reads only these four store maps.
|
||||
expect(recordPaneIsOwnedByPreservedPane(record, state as never)).toBe(false)
|
||||
setPaneSpawnDeferral(paneKey, { reason: 'live_cap', resume: () => {} })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the predicate reads only these four store maps.
|
||||
expect(recordPaneIsOwnedByPreservedPane(record, state as never)).toBe(true)
|
||||
clearPaneSpawnDeferral(paneKey)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '../../../shared/terminal-tab-types'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import { isPaneSpawnDeferred } from './pane-spawn-deferral-registry'
|
||||
|
||||
type AppStoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
@@ -148,6 +149,11 @@ export function recordPaneIsOwnedByPreservedPane(
|
||||
if (activationTreatsNoteAsFinished(record)) {
|
||||
return true
|
||||
}
|
||||
// Why: a deferred pane has no PTY yet still resumes this session when opened; a sweep that
|
||||
// launched it elsewhere too would put two `--resume` on one transcript.
|
||||
if (isPaneSpawnDeferred(record.paneKey)) {
|
||||
return true
|
||||
}
|
||||
// Why: a pane with a live PTY owns its running session regardless of which
|
||||
// pane reconnects on activation; forking it would duplicate the session.
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { runtimeEnvironmentSupportsCapability } = vi.hoisted(() => ({
|
||||
runtimeEnvironmentSupportsCapability: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-rpc-client', () => ({ runtimeEnvironmentSupportsCapability }))
|
||||
|
||||
import { remoteRestoreSpawnIntentParams } from './remote-restore-spawn-intent'
|
||||
|
||||
describe('remoteRestoreSpawnIntentParams', () => {
|
||||
beforeEach(() => {
|
||||
runtimeEnvironmentSupportsCapability.mockReset()
|
||||
})
|
||||
|
||||
it('declares the intent to a host that advertises it', async () => {
|
||||
runtimeEnvironmentSupportsCapability.mockResolvedValue(true)
|
||||
await expect(remoteRestoreSpawnIntentParams('env-1', 'user-resume')).resolves.toEqual({
|
||||
spawnIntent: 'user-resume'
|
||||
})
|
||||
expect(runtimeEnvironmentSupportsCapability).toHaveBeenCalledWith(
|
||||
'env-1',
|
||||
'terminal.spawn-intent.v1'
|
||||
)
|
||||
})
|
||||
|
||||
it('sends nothing to an older host or when the probe fails', async () => {
|
||||
runtimeEnvironmentSupportsCapability.mockResolvedValueOnce(false)
|
||||
await expect(remoteRestoreSpawnIntentParams('env-1', 'user-resume')).resolves.toEqual({})
|
||||
runtimeEnvironmentSupportsCapability.mockRejectedValueOnce(new Error('offline'))
|
||||
await expect(remoteRestoreSpawnIntentParams('env-1', 'automatic-restore')).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('does not probe when the connect is not a restore', async () => {
|
||||
await expect(remoteRestoreSpawnIntentParams('env-1', undefined)).resolves.toEqual({})
|
||||
expect(runtimeEnvironmentSupportsCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RestoreSpawnIntent } from '../../../shared/host-spawn-admission'
|
||||
import { TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
|
||||
|
||||
/** The restore intent param for a remote create; empty for hosts whose strict params would reject it. */
|
||||
export async function remoteRestoreSpawnIntentParams(
|
||||
environmentId: string,
|
||||
intent: RestoreSpawnIntent | undefined
|
||||
): Promise<{ spawnIntent?: RestoreSpawnIntent }> {
|
||||
if (!intent) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
return (await runtimeEnvironmentSupportsCapability(
|
||||
environmentId,
|
||||
TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY
|
||||
))
|
||||
? { spawnIntent: intent }
|
||||
: {}
|
||||
} catch {
|
||||
// Why: a failed read-only probe proves nothing; an undeclared restore is only ever paced.
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export function createAgentStatusRecoveryActions(
|
||||
| 'captureSleepingAgentSessionsByWorktree'
|
||||
| 'captureAllSleepingAgentSessions'
|
||||
| 'clearSleepingAgentSession'
|
||||
| 'rememberSleepingAgentSession'
|
||||
| 'clearSleepingAgentSessionsByPaneKey'
|
||||
| 'clearSleepingAgentSessionsByWorktree'
|
||||
| 'pruneSleepingAgentSessions'
|
||||
@@ -106,6 +107,19 @@ export function createAgentStatusRecoveryActions(
|
||||
},
|
||||
|
||||
clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]),
|
||||
|
||||
rememberSleepingAgentSession: (record) => {
|
||||
set((s) =>
|
||||
s.sleepingAgentSessionsByPaneKey[record.paneKey]
|
||||
? s
|
||||
: {
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
...s.sleepingAgentSessionsByPaneKey,
|
||||
[record.paneKey]: record
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
clearSleepingAgentSessionsByPaneKey,
|
||||
|
||||
clearSleepingAgentSessionsByWorktree: (worktreeId) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
function record(overrides: Partial<SleepingAgentSessionRecord> = {}): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'deferred-session' },
|
||||
prompt: '',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live',
|
||||
restoreOnTabOpenOnly: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('rememberSleepingAgentSession', () => {
|
||||
it('keeps the resume handle of a pane whose restore was deferred', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().rememberSleepingAgentSession(record())
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toEqual(record())
|
||||
})
|
||||
|
||||
it('never replaces a record the pane already has', () => {
|
||||
const store = createTestStore()
|
||||
const existing = record({ prompt: 'the original task', origin: 'quit' })
|
||||
store.getState().rememberSleepingAgentSession(existing)
|
||||
store.getState().rememberSleepingAgentSession(record())
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(existing)
|
||||
})
|
||||
})
|
||||
@@ -153,6 +153,8 @@ export type AgentStatusSlice = {
|
||||
/** Capture resumable agent sessions across every worktree for crash recovery or quit; mode sets live/quit precedence. */
|
||||
captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void
|
||||
clearSleepingAgentSession: (paneKey: string) => void
|
||||
/** Keeps a resume handle for a pane whose restore the host deferred; never replaces a record. */
|
||||
rememberSleepingAgentSession: (record: SleepingAgentSessionRecord) => void
|
||||
clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void
|
||||
clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void
|
||||
pruneSleepingAgentSessions: (validWorktreeIds: Set<string>) => void
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createSpawnAdmissionState,
|
||||
decideSpawnAdmission,
|
||||
DEFAULT_SPAWN_ADMISSION_LIMITS as LIMITS,
|
||||
readTerminalSpawnDeferral,
|
||||
releaseLiveAutomatic,
|
||||
resolveSpawnCharge,
|
||||
settleSpawnAdmission,
|
||||
@@ -235,3 +236,22 @@ describe('decideSpawnAdmission', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTerminalSpawnDeferral', () => {
|
||||
it('reads the reason from an RPC message and from the local IPC wrapper', () => {
|
||||
expect(readTerminalSpawnDeferral(new Error('terminal_spawn_deferred:paused'))).toBe('paused')
|
||||
expect(
|
||||
readTerminalSpawnDeferral(
|
||||
new Error(
|
||||
"Error invoking remote method 'pty:spawn': Error: terminal_spawn_deferred:deadline"
|
||||
)
|
||||
)
|
||||
).toBe('deadline')
|
||||
})
|
||||
|
||||
it('ignores other errors and unknown reasons', () => {
|
||||
expect(readTerminalSpawnDeferral(new Error('ENOENT'))).toBeNull()
|
||||
expect(readTerminalSpawnDeferral(new Error('terminal_spawn_deferred:later'))).toBeNull()
|
||||
expect(readTerminalSpawnDeferral(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +72,13 @@ export function parseTerminalSpawnDeferral(message: string): SpawnDeferralReason
|
||||
: null
|
||||
}
|
||||
|
||||
/** Finds a deferral in a spawn error, whether an RPC failure or the local IPC's wrapped message. */
|
||||
export function readTerminalSpawnDeferral(error: unknown): SpawnDeferralReason | null {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
const index = message.indexOf(TERMINAL_SPAWN_DEFERRED_PREFIX)
|
||||
return index === -1 ? null : parseTerminalSpawnDeferral(message.slice(index).split(/\s/)[0])
|
||||
}
|
||||
|
||||
export type SpawnAdmissionDecision =
|
||||
| { kind: 'admit'; charge: SpawnCharge }
|
||||
/** `retryAt` null means "until a slot is released". */
|
||||
|
||||
Reference in New Issue
Block a user