diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSleepingPortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSleepingPortals.tsx new file mode 100644 index 00000000000..0c81583b795 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSleepingPortals.tsx @@ -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( + , + pane.container, + `sleeping-pane-${pane.id}` + ) +} + +export function TerminalPaneSleepingPortals({ + controller +}: { + controller: TerminalPaneController +}): React.JSX.Element { + const { managedPanes, tabId } = controller + return ( + <> + {managedPanes.map((pane) => ( + + ))} + > + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 895d1d1e306..860e7f555f0 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -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} /> + ({ + translate: (_key: string, fallback: string) => fallback +})) + +afterEach(cleanup) + +describe('TerminalSleepingPaneOverlay', () => { + it('says the session is asleep, why, and that it is saved', () => { + render() + + 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() + + await user.click(screen.getByRole('button', { name: 'Resume' })) + expect(onResume).toHaveBeenCalledOnce() + await user.click(screen.getByText('Sleeping — click to resume')) + expect(onResume).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalSleepingPaneOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSleepingPaneOverlay.tsx new file mode 100644 index 00000000000..83fa2ba924d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalSleepingPaneOverlay.tsx @@ -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 ( + + + + + + + + {translate( + 'auto.components.terminal.pane.TerminalSleepingPaneOverlay.title', + 'Sleeping — click to resume' + )} + + {sleepingReasonDetail(reason)} + + { + // Why: the pane-wide click below would resume a second time. + event.stopPropagation() + onResume() + }} + > + + {translate('auto.components.terminal.pane.TerminalSleepingPaneOverlay.resume', 'Resume')} + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index dfc7f71755a..bd7024f36ce 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index 7961fed61ec..4ebd0318543 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -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 } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts new file mode 100644 index 00000000000..647863b2f5c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts @@ -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[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + 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>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('./terminal-pane-recovery', async (importOriginal) => ({ + ...(await importOriginal>()), + 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() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + 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) => { + 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>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +const RESUME = { key: 'session_id', id: 'provider-session-1' } as const + +function freshSpawns(transport: MockTransport): Record[] { + 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((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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index 76686c1120a..4702af868a5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -108,6 +108,9 @@ export type StoreState = { > getAgentLaunchConfigForStatusEntry: ReturnType clearSleepingAgentSession: ReturnType + rememberSleepingAgentSession?: ReturnType + pendingStartupByTabId?: Record + automaticAgentResumeClaimsByTabId?: Record registerAgentLaunchConfig: ReturnType clearAgentLaunchConfig: ReturnType markWorktreeUnread: ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 8c560ce30bd..8dc49710b90 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index ef85357e771..f8d4b33026c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -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) }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts index f7a6b4bee1a..35989fe3675 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts new file mode 100644 index 00000000000..8d416b5d4ba --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts @@ -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 => { + 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) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts index 6b77a95c084..e304e377efb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts @@ -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 + } } } } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-spawn-intent.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-spawn-intent.test.ts new file mode 100644 index 00000000000..ee0d636ef7e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-spawn-intent.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 6f321e50a50..0efedc935ae 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts new file mode 100644 index 00000000000..bf96cadbc60 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts @@ -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 } + +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 = {}): Promise { + 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 | 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 0d619306b64..6d89b1b8b17 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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)) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6465f696b3c..839c618c981 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } } }, diff --git a/src/renderer/src/lib/pane-spawn-deferral-registry.ts b/src/renderer/src/lib/pane-spawn-deferral-registry.ts new file mode 100644 index 00000000000..438a6f50dc9 --- /dev/null +++ b/src/renderer/src/lib/pane-spawn-deferral-registry.ts @@ -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() +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)) +} diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-deferred-pane.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-deferred-pane.test.ts new file mode 100644 index 00000000000..0b1d7ab31b8 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-deferred-pane.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index ab2f06b40ef..0db30d02d62 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -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 diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.test.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.test.ts index e1297857201..ca04f6843b6 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.test.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts index 73294da7c6e..0602f5bdce5 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts @@ -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 @@ -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 ( diff --git a/src/renderer/src/runtime/remote-restore-spawn-intent.test.ts b/src/renderer/src/runtime/remote-restore-spawn-intent.test.ts new file mode 100644 index 00000000000..2c685a72fcc --- /dev/null +++ b/src/renderer/src/runtime/remote-restore-spawn-intent.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/runtime/remote-restore-spawn-intent.ts b/src/renderer/src/runtime/remote-restore-spawn-intent.ts new file mode 100644 index 00000000000..7e8d560f303 --- /dev/null +++ b/src/renderer/src/runtime/remote-restore-spawn-intent.ts @@ -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 {} + } +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts index 0e256834a9f..ed0b3d13f3f 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -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) => { diff --git a/src/renderer/src/store/slices/agent-status-remember-sleeping-session.test.ts b/src/renderer/src/store/slices/agent-status-remember-sleeping-session.test.ts new file mode 100644 index 00000000000..632b55b52dd --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-remember-sleeping-session.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index 0cf46a22288..e3f5e37445c 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -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) => void diff --git a/src/shared/host-spawn-admission.test.ts b/src/shared/host-spawn-admission.test.ts index d33ed63336d..f9eea768a18 100644 --- a/src/shared/host-spawn-admission.test.ts +++ b/src/shared/host-spawn-admission.test.ts @@ -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() + }) +}) diff --git a/src/shared/host-spawn-admission.ts b/src/shared/host-spawn-admission.ts index 37e61fff829..6b7db0ae2ed 100644 --- a/src/shared/host-spawn-admission.ts +++ b/src/shared/host-spawn-admission.ts @@ -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". */