diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 8a12ce7bf6e..03bac6611be 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -12,20 +12,19 @@ import { } from '../../../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' -export async function assertFolderWorkspacePtyPathUsable( +export function assertFolderWorkspacePtyPathUsable( store: Store | undefined, worktreeId: string | undefined -): Promise { +): Promise | void { const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null if (!store || workspaceScope?.type !== 'folder') { return } - const status = await getFolderWorkspacePathStatus( + return getFolderWorkspacePathStatus( store, { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) + ).then(assertFolderWorkspacePathUsable) } export function resolvePtySpawnStartupCwd( diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index d95d57772ba..74cd5fb64d0 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -9,6 +9,7 @@ import { visitPtyProcessListingsInBatches } from '../../../providers/pty-process-list-admission' import type { PtyListedSession } from '../../../../shared/pty-listed-session' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' import { ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, @@ -70,6 +71,7 @@ export function installPtyInspectIpcHandlers(deps: { runtime?.onPtyExit(args.id, -1, incarnationId) rememberSyntheticKillExit(args.id) sendPtyExitToRenderer({ id: args.id, code: -1 }) + runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) return } const shutdownProvider = provider ?? getProviderForPty(args.id) diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index dbc752a0db3..0b59d347e4d 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -97,8 +97,8 @@ export async function beginPtyIpcSpawn( ) : null ctx.earlyWorktreeId = args.worktreeId - ctx.paneSpawnReservationKey = - ctx.earlyStablePaneOwner && earlyReservationKey ? earlyReservationKey : null + // Reserve early so renderer/runtime materialization cannot start duplicate provider spawns. + ctx.paneSpawnReservationKey = earlyReservationKey ctx.paneSpawnReservation = ctx.paneSpawnReservationKey ? reservePaneSpawn(ctx.paneSpawnReservationKey) : null diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 3aa91b93172..26bdde4da32 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -156,6 +156,10 @@ export async function buildPtyIpcSpawnOptions( if (ctx.preSpawnHiddenMarkId !== null) { ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) } - ctx.releaseWorktreeSpawn = await ctx.deps.runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined return null } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 1e5ee57eb0f..f40b46e5dd5 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -20,10 +20,37 @@ import type { PtyIpcSpawnState } from './spawn-state' export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - if (!ctx.earlyStablePaneOwner) { - await ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) - } + // Establish daemon identity before the first await so hidden delivery is gated before byte zero. ctx.provider = getProvider(args.connectionId) + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.initiallyHidden = args.initiallyHidden === true + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + if (!ctx.earlyStablePaneOwner) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } ctx.spawnTiming.mark('stable_adoption_setup') ctx.preAdoptedStablePane = ctx.earlyStablePaneOwner && ctx.earlyWorktreeId @@ -40,7 +67,10 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis : null ctx.spawnTiming.mark('stable_adoption') if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { - await ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } } if (!ctx.preAdoptedStablePane) { // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. @@ -131,6 +161,34 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis : recoverFreshSpawnProviderRouting(ctx.provider, args.connectionId, args.sessionId) if (freshSpawnRecovery) { await freshSpawnRecovery + const previousHiddenMarkId = ctx.preSpawnHiddenMarkId + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (previousHiddenMarkId !== ctx.preSpawnHiddenMarkId) { + if (previousHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(previousHiddenMarkId, false) + } + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + } } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) @@ -147,24 +205,9 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis }) : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride - ctx.isDaemonHostSpawn = - !args.connectionId && - !(ctx.provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(ctx.provider) // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn - ctx.effectiveSessionId = - args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - ctx.effectiveSessionAppId = - ctx.effectiveSessionId !== undefined - ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) - : undefined - ctx.effectiveSessionRelayId = - ctx.effectiveSessionId !== undefined - ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) - : undefined ctx.expectedWslDistro = !args.connectionId ? (resolveWslSessionContext({ cwd: ctx.cwd, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 8a5f9f3f918..68e1cc62227 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -88,7 +88,7 @@ export type PtySpawnIpcDeps = { } getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined adoptStablePane: (args: AdoptStablePaneArgs) => Promise - assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 7b127f28518..ba4323520a5 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -174,8 +174,9 @@ export function registerPtyHandlers( mainWindow.webContents.on('did-finish-load', finishLoadHandler) } - const assertFolderWorkspacePtyPathUsable = (worktreeId: string | undefined): Promise => - assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) + const assertFolderWorkspacePtyPathUsable = ( + worktreeId: string | undefined + ): Promise | void => assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) const resolvePtySpawnStartupCwd = ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 25e839ece25..ab1f8da6e1f 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -46,7 +46,7 @@ export type PtyRuntimeControllerDeps = { env: T, launch: CodexResumeLaunch ) => T - assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 0b382eb067a..f018da9bcef 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -21,6 +21,7 @@ import { hasRendererSerializerFromRuntimeController, inspectProcessFromRuntimeController, listProcessesFromRuntimeController, + listProcessesWithHostScopeFromRuntimeController, probePtyLivenessFromRuntimeController, resizePtyFromRuntimeController, serializeProviderBufferFromRuntimeController, @@ -47,7 +48,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi // attach. Attach-only and false-on-doubt: never creates or resizes. attach: (ptyId) => attachPtyFromRuntimeController(deps, ptyId), kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), - retireRejectedPty: (ptyId) => retireRejectedPtyFromRuntimeController(deps, ptyId), + retireRejectedPty: (ptyId, stopConfirmed) => + retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), @@ -57,7 +59,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), hasPty: (ptyId) => hasPtyFromRuntimeController(ptyId), - listProcesses: (connectionId) => listProcessesFromRuntimeController(connectionId), + listProcesses: (connectionId) => listProcessesFromRuntimeController(deps, connectionId), + listProcessesWithHostScope: () => listProcessesWithHostScopeFromRuntimeController(deps), serializeBuffer: (ptyId, opts) => { // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. return requestSerializedBuffer(ptyId, opts) diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 81f43decf0a..a7bfe764957 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -1,4 +1,5 @@ import type { IPtyProvider } from '../../../providers/types' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { getProvider, getProviderForPty } from '../provider/registry' @@ -35,7 +36,8 @@ export function killPtyFromRuntimeController( runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + return false } return false } @@ -67,6 +69,12 @@ export function killPtyFromRuntimeController( // Why: close runtime tails without clearing provider ownership, so // a retry can still target a PTY that survived the failed shutdown. if (!retired) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) } }) @@ -90,7 +98,8 @@ export function killPtyFromRuntimeController( export function retireRejectedPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, - ptyId: string + ptyId: string, + stopConfirmed: boolean ): void { const { runtime, @@ -101,6 +110,19 @@ export function retireRejectedPtyFromRuntimeController( finishPtyShutdown } = deps rememberRetiredRejectedPty(ptyId) + if (!stopConfirmed) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + if (!ptyOwnership.has(ptyId)) { + return + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + return + } // Why: a completed stop already cleared provider state, tombstoned the lease and told the // renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an // SSH pane retires for good instead of staying preserved by the stop's negative exit. @@ -197,7 +219,7 @@ export async function stopAndWaitPtyFromRuntimeController( runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) } return false } @@ -210,6 +232,12 @@ export async function stopAndWaitPtyFromRuntimeController( }) } catch (err) { if (!isPtyAlreadyGoneError(err)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) @@ -218,9 +246,16 @@ export async function stopAndWaitPtyFromRuntimeController( } try { if (!(await verifyPtyStopped(provider, ptyId, opts))) { + runtime?.markPtyLivenessLive?.(ptyId) return false } } catch (err) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } console.warn( `[pty] Failed to verify PTY ${ptyId} stopped: ${ err instanceof Error ? err.message : String(err) @@ -230,9 +265,11 @@ export async function stopAndWaitPtyFromRuntimeController( } const incarnationId = finishPtyShutdown(ptyId, connectionId, store) if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1, incarnationId) + // The owning provider's fresh inventory observed absence, so this is a + // death certificate even when its exit event was missed. + runtime?.onPtyExit(ptyId, 0, incarnationId) rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) + sendPtyExitToRenderer({ id: ptyId, code: 0 }) } return true } diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 127992cb964..0ff23a3f92e 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -1,10 +1,21 @@ import type { IPtyProvider } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { PtyProcessInfo } from '../../../providers/pty-process-info' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { ptyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { rendererSerializerReadiness } from '../pane/serializer-state' -import { getProvider, getProviderForPty, localProvider, sshProviders } from '../provider/registry' +import { + getProvider, + getProviderForPty, + localProvider, + registeredPtyProviders +} from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' import type { PtyRuntimeControllerDeps } from './controller-deps' @@ -141,18 +152,61 @@ export function hasPtyFromRuntimeController(ptyId: string): boolean | null { } } -export async function listProcessesFromRuntimeController(connectionId?: string | null) { +function markSshInventoryUnverifiable( + runtime: PtyRuntimeControllerDeps['runtime'], + connectionId: string, + error: unknown +): void { + const reason = error instanceof Error ? error.message : String(error) + for (const [ptyId, ownerConnectionId] of ptyOwnership) { + if (ownerConnectionId === connectionId) { + runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) + } + } +} + +export async function listProcessesWithHostScopeFromRuntimeController( + deps: PtyRuntimeControllerDeps +): Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }> { + const providerSessions = await Promise.all( + registeredPtyProviders().map(async ({ provider, connectionId }) => { + const hostId: ExecutionHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + try { + return { processes: await provider.listProcesses(), hostId } + } catch (error) { + if (!connectionId) { + throw error + } + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + return null + } + }) + ) + const respondingSessions = providerSessions.filter((session) => session !== null) + return { + processes: respondingSessions.flatMap((session) => session.processes), + hostIds: respondingSessions.map((session) => session.hostId) + } +} + +export async function listProcessesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + connectionId?: string | null +) { if (connectionId === null) { return localProvider.listProcesses() } if (connectionId !== undefined) { - return getProvider(connectionId).listProcesses() + try { + return await getProvider(connectionId).listProcesses() + } catch (error) { + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + throw error + } } - const providerSessions = await Promise.all([ - localProvider.listProcesses(), - ...Array.from(sshProviders.values(), (provider) => provider.listProcesses()) - ]) - return providerSessions.flat() + return (await listProcessesWithHostScopeFromRuntimeController(deps)).processes } export function resizePtyFromRuntimeController(ptyId: string, cols: number, rows: number): boolean { diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index bfc4345f13e..ed412153dcb 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -6,9 +6,10 @@ import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-rel import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { RuntimePtySpawnState } from './spawn-state' -export async function adoptMaterializedRuntimePtySpawn( - ctx: RuntimePtySpawnState -): Promise { +export function adoptMaterializedRuntimePtySpawn( + ctx: RuntimePtySpawnState, + startupAlreadyAwaited = false +): Promise | PtySpawnResult | null { const args = ctx.args ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined ctx.codexHomeLaunchStartedSequence = !args.connectionId @@ -24,8 +25,8 @@ export async function adoptMaterializedRuntimePtySpawn( ) : new Map() const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise + if (startupPromise && !startupAlreadyAwaited) { + return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) } if (!ctx.preAdoptedStablePane?.materialized) { return null diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 6be11cda9db..5adb0437d77 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -19,7 +19,11 @@ import type { RuntimePtySpawnState } from './spawn-state' export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { const args = ctx.args - ctx.releaseWorktreeSpawn = await ctx.deps.runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined try { if (args.preAllocatedHandle) { ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index d4d4e6abd22..4eaeddbb16c 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -170,44 +170,53 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.onPtySpawnCommitted = ctx.reportPtySpawnCommitted } - ctx.paneSpawnReservationKey = makePaneSpawnReservationKey( + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( args.worktreeId, args.connectionId, ctx.spawnIdentityPaneKey ) - const existingPaneSpawn = ctx.paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(ctx.paneSpawnReservationKey) - : undefined - if (existingPaneSpawn) { - const concurrentResult = await existingPaneSpawn.promise - const concurrentOwner = resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - if ( - !concurrentOwner?.handle || - concurrentOwner.ptyId !== concurrentResult.id || - (concurrentOwner.incarnationId !== undefined && - concurrentResult.incarnationId !== undefined && - concurrentOwner.incarnationId !== concurrentResult.incarnationId) - ) { - throw new Error('terminal_pane_owner_unknown') - } - return { - id: concurrentOwner.ptyId, - ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), - stablePaneOwner: { - handle: concurrentOwner.handle, - tabId: concurrentOwner.tabId, - leafId: concurrentOwner.leafId + if ( + ctx.paneSpawnReservationKey && + ctx.paneSpawnReservationKey !== resolvedPaneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const existingPaneSpawn = ctx.spawnIdentityPaneKey + ? paneSpawnReservationsByOwnerKey.get(resolvedPaneSpawnReservationKey!) + : undefined + if (existingPaneSpawn) { + const concurrentResult = await existingPaneSpawn.promise + const concurrentOwner = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + if ( + !concurrentOwner?.handle || + concurrentOwner.ptyId !== concurrentResult.id || + (concurrentOwner.incarnationId !== undefined && + concurrentResult.incarnationId !== undefined && + concurrentOwner.incarnationId !== concurrentResult.incarnationId) + ) { + throw new Error('terminal_pane_owner_unknown') + } + return { + id: concurrentOwner.ptyId, + ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), + stablePaneOwner: { + handle: concurrentOwner.handle, + tabId: concurrentOwner.tabId, + leafId: concurrentOwner.leafId + } } } } ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) - ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ctx.paneSpawnReservation ??= ctx.paneSpawnReservationKey ? reservePaneSpawn(ctx.paneSpawnReservationKey) : null ctx.stablePaneOwner = null diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 4de4f24cca9..083cedca3aa 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -43,7 +43,10 @@ export async function prepareRuntimePtySpawn( ): Promise { const args = ctx.args if (!ctx.preAdoptedStablePane) { - await ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } } ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) ctx.provider = getProvider(args.connectionId) diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 88563916bfb..784e4c78a7e 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -1,5 +1,13 @@ import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' -import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { + makePaneSpawnReservationKey, + paneSpawnReservationsByOwnerKey, + rejectPaneSpawnReservation, + reservePaneSpawn +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' import type { PtyRuntimeControllerDeps } from './controller-deps' import { adoptMaterializedRuntimePtySpawn } from './spawn-early' import { prepareRuntimePtySpawn } from './spawn-preflight' @@ -29,7 +37,35 @@ export async function spawnPtyFromRuntimeController( args: RuntimePtySpawnArgs ) { const ctx = createRuntimePtySpawnState(deps, args) - const materialized = await adoptMaterializedRuntimePtySpawn(ctx) + if (!args.adoptedStablePane) { + const leafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const paneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + leafId + ? makePaneKey(args.tabId, leafId) + : null + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const existingOwner = paneKey + ? resolveStablePaneOwner( + deps.runtime, + deps.store, + paneKey, + args.worktreeId, + args.connectionId + ) + : null + if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) { + ctx.paneSpawnReservationKey = ownerKey + ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) + } + } + const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) + const materialized = + materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise if (materialized) { return toRuntimeSpawnReply(materialized) }