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:
OrcaWin
2026-10-11 05:59:46 -07:00
committed by GitHub
co-authored by m4air
parent 68c46fa07f
commit d3417b257f
30 changed files with 1263 additions and 2 deletions
@@ -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 } : {}),
@@ -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>
@@ -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)) {
+8
View File
@@ -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
+20
View File
@@ -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()
})
})
+7
View File
@@ -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". */