diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml index 58033f26c4e0..32bf3da335b3 100644 --- a/.github/workflows/performance-contracts.yml +++ b/.github/workflows/performance-contracts.yml @@ -16,6 +16,7 @@ on: - '!config/scripts/renderer-scrollbar-style-plugin.test.mjs' # Keep in sync with the contract list in config/vitest.performance.config.ts; # without these a rename lands green and only breaks the next nightly. + - 'src/main/ipc/pty-spawn-scaling.performance.test.ts' - 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts' - 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts' - 'src/main/runtime/hook-status-resume-sleep-ordering.test.ts' diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index fed668dfafd8..26813d404e92 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -5,6 +5,7 @@ import baseConfig from './vitest.config' import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool' const contracts = [ + 'src/main/ipc/pty-spawn-scaling.performance.test.ts', 'src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts', 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts', diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts index 0914a451ea0d..5bb7522d0cc2 100644 --- a/src/main/ipc/pty-attach-view-joins-main-start.test.ts +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -2,6 +2,25 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider } from './pty/provider/registry' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RuntimeStore } from '../runtime/runtime-store-contract' +import { TerminalSurfaceIndex } from '../runtime/terminal/surface/terminal-surface-index' +import { createPaneBindingStore } from './pty-launch-race-test-fixture' +import { + pendingRuntimePaneCreatesByOwnerKey, + makePaneSpawnReservationKey +} from './pty/pane/spawn-reservation' +import { makePaneKey } from '../../shared/stable-pane-id' + +import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../shared/agent-session-record.test-fixture' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' +import { forgetRefusedPaneResumeMock } from './pty-ipc-mock-registry' +import { agentSessionOwners } from './pty/pane/agent-session-owners' +import type { TerminalCreateOptions } from '../runtime/runtime-terminal-contracts' vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -49,11 +68,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => ) type PaneArgs = { worktreeId: string; connectionId: string | null; tabId: string; leafId: string } -type RuntimeSpawnController = { - claimStablePaneCreate(args: PaneArgs): () => void - spawn(args: Record): Promise<{ id: string }> -} - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test doubles implement only the members these spawn paths read. const testDouble = (value: unknown): T => value as T @@ -67,11 +81,38 @@ const PANE: PaneArgs = { describe('a window view of a pane main is starting', () => { const { handlers, mainWindow } = setupPtyIpcSuite() - async function viewDuringMainStart(startResult: Record) { - const providerSpawn = vi.fn(async (opts: { sessionId?: string }) => ({ - id: opts.sessionId ?? 'pty-main', - ...startResult - })) + async function viewDuringMainStart( + startResult: Record, + resume?: 'fresh' | 'cold' + ) { + let release!: () => void + const gate = new Promise((done) => { + release = done + }) + const providerSpawn = vi.fn(async (opts: { sessionId?: string; attachOnly?: boolean }) => { + if (resume) { + expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled() + expect(cleanup).not.toHaveBeenCalled() + expect(agentSessionOwners.find(claim)).toBeNull() + expect(opts).not.toHaveProperty('command') + expect(opts).not.toHaveProperty('agentSessionEnsure') + expect(opts).not.toHaveProperty('startupCommandDelivery') + expect(opts).not.toHaveProperty('startupIngress') + expect(opts).not.toHaveProperty('launchAgent') + expect(opts).not.toHaveProperty('launchToken') + const env = testDouble<{ env?: Record }>(opts).env + expect(env?.AGENT_ONLY).toBeUndefined() + expect(env?.ORCA_AGENT_LAUNCH_TOKEN).toBeUndefined() + expect(env?.ORCA_SEQUENCED_STARTUP_COMMAND).toBeUndefined() + } + await gate + return { + id: opts.sessionId ?? 'pty-main', + incarnationId: 'main-inc', + ...(opts.attachOnly ? { isReattach: true } : {}), + ...startResult + } + }) setLocalPtyProvider( testDouble({ spawn: providerSpawn, @@ -97,23 +138,160 @@ describe('a window view of a pane main is starting', () => { getProfiles: vi.fn() }) ) - let controller: RuntimeSpawnController | null = null - const runtime = { - setPtyController: vi.fn((value: RuntimeSpawnController) => { - controller = value + const bindingStore = createPaneBindingStore() + type WriteListener = Parameters>[0] + const written = new Set() + const notifyWritten = (): void => { + for (const listener of written) { + listener('local') + } + } + const store = { + ...bindingStore, + getSettings: () => ({}), + getWorkspaceSessionHostIds: (): ReturnType< + NonNullable + > => ['local'], + setWorkspaceSession: (...args: Parameters) => { + bindingStore.setWorkspaceSession(...args) + notifyWritten() + }, + persistPtyBinding: async (...args: Parameters) => { + const persisted = await bindingStore.persistPtyBinding(...args) + if (persisted) { + notifyWritten() + } + return persisted + }, + onWorkspaceSessionWritten: (listener: WriteListener) => { + written.add(listener) + return () => written.delete(listener) + } + } + const runtime = new OrcaRuntimeService() + Object.assign(runtime, { + store, + resolveTerminalWorkspaceLaunchScope: async () => ({ + id: PANE.worktreeId, + path: '/tmp/view-join', + connectionId: null }), - createPreAllocatedTerminalHandle: vi.fn(() => 'term_main'), - getAgentProviderSessionRows: vi.fn(() => []), - registerPreAllocatedHandleForPty: vi.fn(), - registerPty: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn(), - seedHeadlessTerminal: vi.fn() + resolveAgentTerminalCreateOptions: async (_workspace: unknown, opts: unknown) => opts, + resolveWorkspaceTerminalStartupCwd: () => '/tmp/view-join' + }) + registerPtyHandlers( + testDouble(mainWindow), + runtime, + undefined, + undefined, + undefined, + testDouble(store) + ) + const mainSurfaces = new TerminalSurfaceIndex({ + reads: () => runtime.workspaceLayoutReads, + hostIds: () => store.getWorkspaceSessionHostIds(), + homeHostId: (key) => runtime['tryGetWorkspaceSessionHostIdForWorktree'](key), + generatedTitlesEnabled: () => false, + onChanged: () => {} + }) + const registerPty = runtime.registerPty.bind(runtime) + const registrations = vi.spyOn(runtime, 'registerPty').mockImplementation((...args) => { + expect(mainSurfaces.surfaces().leaves.find((leaf) => leaf.ptyId === args[0])).toMatchObject({ + worktreeId: PANE.worktreeId, + tabId: PANE.tabId, + leafId: PANE.leafId + }) + registerPty(...args) + }) + const paneKey = makePaneKey(PANE.tabId, PANE.leafId) + const providerSession = { key: 'session_id' as const, id: 'provider-session-alpha-1' } + const saved: SleepingAgentSessionRecord = { + paneKey, + worktreeId: PANE.worktreeId, + agent: 'claude', + providerSession, + prompt: '', + state: 'working', + capturedAt: 20, + updatedAt: 21, + connectionId: null } - registerPtyHandlers(testDouble(mainWindow), testDouble(runtime)) - const main = testDouble(controller) - const releaseClaim = main.claimStablePaneCreate(PANE) + const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) + ) + const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + const list = vi.fn(() => [chat]) + records.listRecords = list + records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] }) + const capture = vi.spyOn(runtime, 'readSavedResumeSource').mockResolvedValue(saved) + const cleanup = vi.spyOn(runtime, 'forgetRefusedSavedResume').mockResolvedValue() + if (resume) { + vi.spyOn(runtime, 'openAgentSessionRecordStore').mockResolvedValue(records) + vi.spyOn(runtime, 'getAgentProviderSessionRows').mockReturnValue([ + { + paneKey, + connectionId: null, + agentType: 'claude', + state: 'working', + prompt: '', + receivedAt: 30, + stateStartedAt: 31, + providerSession + } + ]) + } + const claim = { + agent: 'claude' as const, + digestVersion: 1 as const, + keyId: 'composition-key', + identityDigest: 'composition-C', + worktreeScopeDigest: 'composition-wt' + } + const launch: TerminalCreateOptions = + resume === 'fresh' + ? { + command: 'claude --resume provider-session-alpha-1', + launchAgent: 'claude', + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { AGENT_ONLY: 'yes' } + }, + launchToken: 'composition-token', + resumeProviderSession: providerSession, + resumeOrigin: 'saved', + env: { + AGENT_ONLY: 'yes', + ORCA_AGENT_LAUNCH_TOKEN: 'composition-token', + ORCA_SEQUENCED_STARTUP_COMMAND: 'claude --resume provider-session-alpha-1' + }, + agentSessionClaim: claim + } + : {} + const started = runtime.createTerminal(`id:${PANE.worktreeId}`, { + tabId: PANE.tabId, + leafId: PANE.leafId, + sessionId: 'pty-main', + presentation: 'background', + ...launch + }) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) + const key = makePaneSpawnReservationKey( + PANE.worktreeId, + null, + makePaneKey(PANE.tabId, PANE.leafId) + )! + const pending = pendingRuntimePaneCreatesByOwnerKey.get(key)! + const barrier = pending.promise + // Deliver the captured result after the producer's second registration settles. + const settled = Promise.all([barrier, started]).then(([result]) => result) + const waiting = vi.fn(() => settled) + Object.defineProperty(pending, 'promise', { get: waiting }) const view = handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -121,11 +299,59 @@ describe('a window view of a pane main is starting', () => { sessionId: 'pty-main', attachOnlyView: true }) - await main.spawn({ cols: 80, rows: 24, cwd: '/tmp/view-join', ...PANE, sessionId: 'pty-main' }) - releaseClaim() - return { view: await view, providerSpawn } + const completed = Promise.all([started, view]) + await vi.waitFor(() => expect(waiting).toHaveBeenCalled()) + release() + const [created, attached] = await completed + expect(registrations.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(pending.operation?.acquisition?.reservation.outcome?.kind).toBe('committed') + expect(pendingRuntimePaneCreatesByOwnerKey.has(key)).toBe(false) + if (resume) { + const decision = { agent: 'claude', providerSession, verdict: 'chat-home' } + expect(created).toMatchObject({ + ptyId: 'pty-main', + agentResumeRefused: 'chat-home', + savedResumeDecision: decision + }) + expect(attached).toMatchObject({ + id: 'pty-main', + isReattach: true, + agentResumeRefused: 'chat-home', + savedResumeDecision: decision + }) + expect(list).toHaveBeenCalledOnce() + expect(capture).toHaveBeenCalledOnce() + expect(forgetRefusedPaneResumeMock).toHaveBeenCalledOnce() + expect(cleanup).toHaveBeenCalledExactlyOnceWith( + PANE.worktreeId, + paneKey, + expect.objectContaining({ + agent: 'claude', + providerSession, + expectedStatus: { paneKey, receivedAt: 30, stateStartedAt: 31 }, + expectedSavedRecord: { paneKey, capturedAt: 20, updatedAt: 21 } + }), + saved + ) + expect(runtime.getTerminalLaunchResult('pty-main')).toMatchObject({ + savedResumeDecision: decision + }) + expect(agentSessionOwners.find(claim)).toBeNull() + } + return { view: attached, providerSpawn } } + it.each(['fresh', 'cold'] as const)( + 'inherits the %s saved refusal through the actual producer and pane join exactly once', + async (mode) => { + const { providerSpawn } = await viewDuringMainStart( + { coldRestore: { scrollback: 'saved output', cwd: '/tmp/view-join' } }, + mode + ) + expect(providerSpawn).toHaveBeenCalledOnce() + } + ) + it('attaches to the terminal main starts, with one provider request', async () => { const coldRestore = { scrollback: 'restored history', cwd: '/tmp/view-join' } const { view, providerSpawn } = await viewDuringMainStart({ coldRestore }) diff --git a/src/main/ipc/pty-spawn-scaling.performance.test.ts b/src/main/ipc/pty-spawn-scaling.performance.test.ts new file mode 100644 index 000000000000..62b47828ec73 --- /dev/null +++ b/src/main/ipc/pty-spawn-scaling.performance.test.ts @@ -0,0 +1,577 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import * as adoption from './pty/pane/adopt-stable' +import * as runtimePreflight from './pty/runtime/spawn-preflight' +import * as ipcPreflight from './pty/ipc/spawn-preflight' +import type { RuntimeStore } from '../runtime/runtime-store-contract' +import type * as JcodeRuntimeDir from '../../shared/jcode-runtime-dir' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { TerminalCreateOptions } from '../runtime/runtime-terminal-contracts' +import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty/register-handlers' +import { createPaneBindingStore, launchRacePane } from './pty-launch-race-test-fixture' +import { clearProviderPtyState } from './pty/provider/state-cleanup' +import * as reservations from './pty/pane/spawn-reservation' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { makePaneKey } from '../../shared/stable-pane-id' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node:fs/promises', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).fsPromisesModuleMock(await importOriginal()) +) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) +// Why: the host lane's real mkdir is thread-pool I/O, so a joiner could miss flushMacrotasks' turns under load. +vi.mock('../../shared/jcode-runtime-dir', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ensureJcodeRuntimeDir: async (paneKey: string) => actual.buildJcodeRuntimeDirEnv(paneKey) + } +}) + +const consumeExited = adoption.consumeExitedPaneOperation +const N = 4 +const treePasses = ORCA_LAYOUT_OWNER_IS_MAIN ? 2 : 1 +const snapshot = 'final output\r\n'.repeat(256) + +function gate() { + let release = () => {} + const promise = new Promise((done) => { + release = done + }) + return { promise, release } +} + +// Count actual comparisons/visited nodes without replacing lookup or traversal decisions. +function observeSession( + store: Pick, 'getWorkspaceSession'> +) { + const counts = { tabs: 0, nodes: 0, reads: 0, fallback: false } + const arrays = new WeakSet() + const nodes = new WeakSet() + const observeTree = (node: TerminalPaneLayoutNode | null) => { + if (!node || nodes.has(node)) { + return + } + nodes.add(node) + if (node.type === 'split') { + observeTree(node.first) + observeTree(node.second) + } + const type = node.type + Object.defineProperty(node, 'type', { + enumerable: true, + configurable: true, + get: () => { + if (counts.fallback) { + counts.nodes++ + } + return type + } + }) + } + const read = store.getWorkspaceSession.bind(store) + vi.spyOn(store, 'getWorkspaceSession').mockImplementation(() => { + counts.reads++ + const session = read() + for (const tabs of Object.values(session.tabsByWorktree)) { + if (arrays.has(tabs)) { + continue + } + arrays.add(tabs) + vi.spyOn(tabs, 'find').mockImplementation((predicate, thisArg) => + Array.prototype.find.call(tabs, (tab, index) => { + counts.tabs++ + return predicate.call(thisArg, tab, index, tabs) + }) + ) + } + for (const layout of Object.values(session.terminalLayoutsByTabId)) { + observeTree(layout.root) + } + return session + }) + return counts +} + +function mapRoots() { + const roots = [ + ...reservations.pendingRuntimePaneCreatesByOwnerKey.values(), + ...reservations.paneSpawnReservationsByOwnerKey.values() + ] + const payloads = new Set() + for (const root of roots) { + const result = 'count' in root ? root.result : root.outcome?.result + if (result?.coldRestore) { + payloads.add(result.coldRestore) + } + const canonical = root.operation?.acquisition?.reservation.outcome?.result.coldRestore + if (canonical) { + payloads.add(canonical) + } + } + return { roots: roots.length, payloads: payloads.size } +} + +describe('shared terminal spawn operation/retention scaling contract', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + async function run( + size: number, + dimension: 'tabs' | 'layout' | 'callers' | 'panes', + outcome: 'success' | 'exit' | 'absent' | 'cancelled' | 'refused' | 'successor' = 'success' + ) { + const panes = Array.from({ length: dimension === 'panes' ? size : 1 }, (_, i) => + launchRacePane( + `scale-${dimension}-${size}-${outcome}-${i}`, + `94949494-9494-4494-8494-${String(i).padStart(12, '0')}` + ) + ) + const binding = createPaneBindingStore() + type WriteListener = Parameters>[0] + const listeners = new Set() + const notify = () => { + for (const listener of listeners) { + listener('local') + } + } + const store = { + ...binding, + getSettings: () => ({}), + getWorkspaceSessionHostIds: () => ['local'] as const, + setWorkspaceSession: (...args: Parameters) => { + binding.setWorkspaceSession(...args) + notify() + }, + persistPtyBinding: async (...args: Parameters) => { + const accepted = await binding.persistPtyBinding(...args) + if (accepted) { + notify() + } + return accepted + }, + onWorkspaceSessionWritten: (listener: WriteListener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + } + const runtime = new OrcaRuntimeService() + Object.assign(runtime, { + store, + resolveTerminalWorkspaceLaunchScope: async (selector: string) => { + const pane = panes.find((p) => `id:${p.worktreeId}` === selector)! + return { id: pane.worktreeId, path: pane.args.cwd, connectionId: null } + }, + resolveAgentTerminalCreateOptions: async (_workspace: unknown, opts: TerminalCreateOptions) => + opts, + resolveWorkspaceTerminalStartupCwd: (workspace: { path: string }) => workspace.path + }) + const installed = vi.spyOn(runtime, 'setPtyController') + registerPtyHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: existing IPC fixture implements the window ports reached by these handlers. + mainWindow as never, + runtime, + undefined, + undefined, + undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: existing mutable durable Store fixture implements this flow's I/O boundary. + store as never + ) + const hostStart = installed.mock.calls.at(-1)?.[0]?.spawn + if (!hostStart) { + throw new Error('missing runtime controller') + } + for (const pane of panes) { + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }) + const session = store.getWorkspaceSession() + let root: TerminalPaneLayoutNode = { type: 'leaf', leafId: pane.leafId } + for (let i = 1; i < (dimension === 'layout' ? size : N); i++) { + root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: `85858585-8585-4585-8585-${String(i).padStart(12, '0')}` }, + second: root + } + } + session.terminalLayoutsByTabId[pane.tabId]!.root = root + } + const counts = observeSession(store) + const exitWork = { calls: 0, tabs: 0, nodes: 0 } + const exited = vi + .spyOn(adoption, 'consumeExitedPaneOperation') + .mockImplementation((...args) => { + const before = { tabs: counts.tabs, nodes: counts.nodes } + try { + return consumeExited(...args) + } finally { + exitWork.calls++ + exitWork.tabs += counts.tabs - before.tabs + exitWork.nodes += counts.nodes - before.nodes + } + }) + onTestFinished(() => exited.mockRestore()) + const hasView = runtime.hasTerminalPaneView.bind(runtime) + vi.spyOn(runtime, 'hasTerminalPaneView').mockImplementation((...args) => { + const found = hasView(...args) + counts.fallback = !found + return found + }) + const providerGate = gate() + const preparation = vi.spyOn(runtimePreflight, 'prepareRuntimePtySpawn').mockClear() + const ipcPreparation = vi.spyOn(ipcPreflight, 'preparePtyIpcSpawnPreflight').mockClear() + const commits = vi.spyOn(runtime, 'noteTerminalSpawnCommit') + const payloads = panes.map((pane) => ({ scrollback: snapshot, cwd: pane.args.cwd })) + const provider = vi.fn( + async (opts: { cwd?: string; sessionId?: string; attachOnly?: boolean }) => { + await providerGate.promise + const index = Math.max( + 0, + panes.findIndex((p) => p.args.cwd === opts.cwd) + ) + return { + id: opts.sessionId ?? `scale-pty-${index}`, + incarnationId: `scale-inc-${index}`, + snapshot, + coldRestore: payloads[index], + ...(opts.attachOnly ? { isReattach: true } : {}) + } + } + ) + installDaemonTestProvider({ spawn: provider }) + if (outcome === 'exit' || outcome === 'absent' || outcome === 'successor') { + vi.spyOn(runtime, 'registerPty').mockImplementation(() => { + throw new Error('agent_session_exited_during_start') + }) + vi.spyOn(runtime, 'getPtyLivenessVerdict').mockReturnValue({ status: 'exited' }) + } + if (outcome === 'refused') { + provider.mockImplementation(async () => { + await providerGate.promise + throw new Error('provider_refused') + }) + } + const settled = vi.fn() + const observe = (promise: Promise) => + promise.then( + (value) => { + settled() + return value + }, + (error: unknown) => { + settled() + return error + } + ) + const signal = new AbortController() + if (outcome === 'cancelled') { + signal.abort() + } + const producers = panes.map((pane) => + observe( + runtime.createTerminal(`id:${pane.worktreeId}`, { + tabId: pane.tabId, + leafId: pane.leafId, + sessionId: `scale-pty-${panes.indexOf(pane)}`, + presentation: 'background', + signal: signal.signal + }) + ) + ) + onTestFinished(async () => { + providerGate.release() + await Promise.all(producers) + }) + if (outcome === 'cancelled') { + const results = await Promise.all(producers) + for (const result of results) { + expect(result).toMatchObject({ message: 'client_disconnected' }) + } + expect(provider).not.toHaveBeenCalled() + expect(preparation).not.toHaveBeenCalled() + expect(mapRoots()).toEqual({ roots: 0, payloads: 0 }) + return + } + await vi.waitFor(() => expect(provider).toHaveBeenCalledTimes(panes.length)) + const retained = { peakRoots: 0, peakPayloads: 0 } + const pending = [...reservations.pendingRuntimePaneCreatesByOwnerKey.values()] + const waiting = vi.fn() + for (const claim of pending) { + const promise = claim.promise + Object.defineProperty(claim, 'promise', { + configurable: true, + get: () => { + waiting() + return promise + } + }) + let result = claim.result + Object.defineProperty(claim, 'result', { + configurable: true, + get: () => result, + set: (next) => { + result = next + const roots = mapRoots() + retained.peakRoots = Math.max(retained.peakRoots, roots.roots) + retained.peakPayloads = Math.max(retained.peakPayloads, roots.payloads) + } + }) + } + expect(mapRoots()).toEqual({ roots: 2 * panes.length, payloads: 0 }) + // Populate after admitted producer startup; count only requests against this fixed saved snapshot. + const session = store.getWorkspaceSession() + for (const pane of panes) { + const target = session.tabsByWorktree[pane.worktreeId]!.find((tab) => tab.id === pane.tabId)! + session.tabsByWorktree[pane.worktreeId] = [ + ...Array.from({ length: (dimension === 'tabs' ? size : N) - 1 }, (_, i) => ({ + ...target, + id: `unrelated-${i}` + })), + outcome === 'absent' && dimension === 'tabs' ? { ...target, id: 'absent-target' } : target + ] + } + const beforeJoin = counts.tabs + const requests = dimension === 'callers' ? size : N + const views = panes.flatMap((pane) => + Array.from({ length: requests }, (_, i) => { + const args = { ...pane.args, sessionId: `scale-pty-${panes.indexOf(pane)}` } + return observe( + i % 2 ? hostStart(args) : Promise.resolve(handlers.get('pty:spawn')!(null, args)) + ) + }) + ) + await vi.waitFor(() => expect(waiting).toHaveBeenCalledTimes(requests * panes.length)) + const joinComparisons = counts.tabs - beforeJoin + if (outcome === 'exit' || outcome === 'successor') { + const mutate = store.runDurableMutation.bind(store) + const root = store.getWorkspaceSession().terminalLayoutsByTabId[panes[0]!.tabId]!.root + vi.spyOn(store, 'runDurableMutation').mockImplementation(async (write) => { + const value = await mutate(write) + // A retained saved view may arrive after retirement; it carries topology, no live binding. + const layout = store.getWorkspaceSession().terminalLayoutsByTabId[panes[0]!.tabId] + if (layout && outcome === 'exit') { + layout.root = root + } + if (layout && outcome === 'successor') { + layout.ptyIdsByLeafId = { [panes[0]!.leafId]: 'successor' } + const session = store.getWorkspaceSession() + session.terminalPtyIncarnationsByPaneKey ??= {} + session.terminalPtyIncarnationsByPaneKey[makePaneKey(panes[0]!.tabId, panes[0]!.leafId)] = + 'successor-inc' + } + return value + }) + } + providerGate.release() + const results = await Promise.all([...producers, ...views]) + expect(joinComparisons).toBe(requests * panes.length * (dimension === 'tabs' ? size : N)) + expect(settled).toHaveBeenCalledTimes((requests + 1) * panes.length) + expect(provider.mock.calls.filter(([opts]) => !opts.attachOnly)).toHaveLength(panes.length) + expect(provider.mock.calls.length).toBeLessThanOrEqual(3 * panes.length) + expect(preparation).toHaveBeenCalledTimes(panes.length) + expect(ipcPreparation).not.toHaveBeenCalled() + expect(commits).toHaveBeenCalledTimes(outcome === 'success' ? 2 * panes.length : 0) + expect(retained.peakPayloads).toBe( + outcome === 'refused' || (outcome === 'absent' && dimension === 'tabs') ? 0 : panes.length + ) + expect(retained.peakRoots).toBeLessThanOrEqual(2 * panes.length) + expect(mapRoots()).toEqual({ roots: 0, payloads: 0 }) + for (const pane of panes) { + const key = reservations.makePaneSpawnReservationKey( + pane.worktreeId, + null, + makePaneKey(pane.tabId, pane.leafId) + )! + expect(reservations.pendingRuntimePaneCreatesByOwnerKey.has(key)).toBe(false) + expect(reservations.paneSpawnReservationsByOwnerKey.has(key)).toBe(false) + } + const windowResults = results.slice(panes.length).filter((_, i) => (i % requests) % 2 === 0) + const hostResults = results.slice(panes.length).filter((_, i) => (i % requests) % 2 === 1) + if (outcome === 'exit') { + // Host consumers scan saved tabs once; window consumers also validate the saved view. + expect(exitWork).toEqual({ + calls: requests, + tabs: (requests + requests / 2) * (dimension === 'tabs' ? size : N), + nodes: treePasses * (requests / 2) * (2 * (dimension === 'layout' ? size : N) - 1) + }) + expect(results.slice(0, panes.length)).toEqual( + panes.map(() => expect.objectContaining({ message: 'agent_session_exited_during_start' })) + ) + for (const result of windowResults) { + expect(result).toMatchObject({ snapshot, isReattach: true }) + } + for (const result of hostResults) { + expect(result).toMatchObject({ message: 'agent_session_exited_during_start' }) + } + expect(counts.nodes).toBe( + treePasses * (requests / 2) * panes.length * (2 * (dimension === 'layout' ? size : N) - 1) + ) + } else if (outcome === 'success') { + for (const result of results.slice(0, panes.length)) { + expect(result).toMatchObject({ ptyId: expect.stringContaining('scale-pty-') }) + } + for (const result of windowResults) { + expect(result).toMatchObject({ snapshot, isReattach: true }) + } + for (const result of hostResults) { + expect(result).toMatchObject({ id: expect.stringContaining('scale-pty-') }) + expect(result).toMatchObject({ snapshot, isReattach: true }) + } + } else if (outcome === 'absent' || outcome === 'successor') { + for (const result of windowResults) { + expect(result).toMatchObject({ + message: + dimension === 'tabs' ? 'terminal_split_source_not_found' : 'terminal_pane_owner_changed' + }) + } + for (const result of hostResults) { + expect(result).toMatchObject({ + message: + dimension === 'tabs' + ? 'terminal_split_source_not_found' + : outcome === 'successor' + ? 'terminal_pane_owner_changed' + : 'agent_session_exited_during_start' + }) + } + if (outcome === 'absent' && dimension === 'layout') { + expect(exitWork).toEqual({ + calls: requests, + tabs: (requests + requests / 2) * N, + nodes: treePasses * (requests / 2) * (2 * size - 3) + }) + } + } else if (outcome === 'refused') { + for (const result of results) { + expect(result).toMatchObject({ message: 'provider_refused' }) + } + } + for (const claim of pending) { + if (outcome !== 'refused' && !(outcome === 'absent' && dimension === 'tabs')) { + expect(claim.result?.coldRestore).toBe(payloads[pending.indexOf(claim)]) + expect(claim.result).not.toHaveProperty('isReattach') + expect(claim.operation?.acquisition?.reservation.outcome?.kind).toBe( + outcome === 'success' ? 'committed' : 'registration-exited' + ) + } + } + for (let i = 0; i < panes.length; i++) { + clearProviderPtyState(`scale-pty-${i}`) + } + const responsePayloads = new Set( + results + .slice(panes.length) + .flatMap((result) => + typeof result === 'object' && result !== null && 'coldRestore' in result + ? [result.coldRestore] + : [] + ) + ) + if (outcome === 'success' || outcome === 'exit') { + expect(responsePayloads.size).toBe(panes.length) + for (const result of windowResults) { + if (!result || typeof result !== 'object' || !('coldRestore' in result)) { + throw new Error('missing joined payload') + } + expect(payloads).toContain(result.coldRestore) + } + } + console.info( + JSON.stringify({ + size, + dimension, + outcome, + joinComparisons, + exitWork, + retained, + mapRoots: mapRoots(), + callerPayloads: responsePayloads.size, + preparations: preparation.mock.calls.length, + commits: commits.mock.calls.length, + processCalls: provider.mock.calls.filter(([opts]) => !opts.attachOnly).length, + providerCalls: provider.mock.calls.length + }) + ) + return { counts, retained } + } + + it.each(['tabs', 'layout', 'callers', 'panes'] as const)( + 'N/10N %s visits and exact outcome roots', + async (dimension) => { + for (const size of [N, 10 * N]) { + await run(size, dimension, dimension === 'layout' ? 'exit' : 'success') + } + } + ) + it.each(['tabs', 'callers'] as const)( + 'exited N/10N %s keeps O(R*(T+L)) consumer work', + async (dimension) => { + for (const size of [N, 10 * N]) { + await run(size, dimension, 'exit') + } + } + ) + it('N/10N absent fallback visits every saved layout node and refuses retired output', async () => { + for (const size of [N, 10 * N]) { + await run(size, 'layout', 'absent') + } + }) + it('N/10N absent saved tabs visit the full population before refusing retired output', async () => { + for (const size of [N, 10 * N]) { + await run(size, 'tabs', 'absent') + } + }) + it.each(['cancelled', 'refused', 'successor'] as const)( + '%s settles every consumer and releases exact roots', + async (outcome) => { + await run(N, 'callers', outcome) + } + ) +}) diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 958d637b8c1a..06a89a4c1fb7 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -1,44 +1,20 @@ -import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' -import { attachOnlyViewOf } from '../pane/attach-only-view' -import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { resolveEarlyPaneKey as resolvePaneCoordinates } from '../spawn-pane-coordinates' import { resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launch-pane-attachment' import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' import { agentLaunchPaneLiveness } from '../pane/agent-launch-pane-evidence' -import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' -import { createPtySpawnTiming } from '../pty-spawn-timing' -import { allocatePtyLifecycleSequence } from '../host-env/types' -import { snapshotCodexPaneHomeRoutes } from '../host-env/codex-home' -import { getAppPtyId } from '../provider/registry' -import { - makePaneSpawnReservationKey, - pendingRuntimePaneCreatesByOwnerKey, - type PaneSpawnReservationResult -} from '../pane/spawn-reservation' -import { resolveStablePaneOwner } from '../pane/stable-owner' -import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' +import { makePaneSpawnReservationKey } from '../pane/spawn-reservation' import type { PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs } from './spawn-types' export function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { - const leafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - return typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - leafId - ? makePaneKey(args.tabId, leafId) - : null + return typeof args.worktreeId === 'string' ? resolvePaneCoordinates(args) : null } export function resolveEarlyPaneSpawnReservationKey(args: PtySpawnIpcArgs): string | null { return makePaneSpawnReservationKey(args.worktreeId, args.connectionId, resolveEarlyPaneKey(args)) } -export async function beginPtyIpcSpawn( - ctx: PtyIpcSpawnState -): Promise { +export async function checkPtyIpcLaunchPane(ctx: PtyIpcSpawnState): Promise { const args = ctx.args // Launch panes adopt their agent instead of starting a shell; explicit restarts already own the reservation. const launchPaneKey = ctx.paneSpawnReservation ? null : resolveEarlyPaneKey(args) @@ -64,87 +40,4 @@ export async function beginPtyIpcSpawn( throw new Error(AGENT_LAUNCH_PANE_REFUSED_CODE) } } - ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - ctx.codexHomeLaunchStartedSequence = !args.connectionId - ? allocatePtyLifecycleSequence() - : undefined - const initialPaneKey = resolveEarlyPaneKey(args) - const initialStablePanePtyId = (() => { - try { - return !args.connectionId && initialPaneKey - ? excludeReplacedPaneOwner( - resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId - ), - ctx.replacedPaneOwner - )?.ptyId - : undefined - } catch { - return undefined - } - })() - ctx.reattachedCodexHomeRoutes = !args.connectionId - ? new Map( - snapshotCodexPaneHomeRoutes([ - initialStablePanePtyId, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - ) - : new Map() - ctx.spawnTiming = createPtySpawnTiming() - ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - - const earlyReservationKey = resolveEarlyPaneSpawnReservationKey(args) - // Replacement holds the reservation before stopping; joining itself would deadlock. - const heldReservation = ctx.paneSpawnReservation - if (!heldReservation) { - const pendingRuntimeCreate = earlyReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) - : undefined - if (pendingRuntimeCreate) { - const created = await pendingRuntimeCreate.promise - // Reuse main's current start payload when available to avoid a second provider attach round trip. - if ( - attachOnlyViewOf(args) && - created && - created.id === args.sessionId && - (created.coldRestore || typeof created.snapshot === 'string') && - ptyOwnership.has(created.id) && - (created.incarnationId === undefined || - ptyIncarnationById.get(created.id) === created.incarnationId) - ) { - return created - } - } - } - ctx.earlyStablePaneOwner = - initialPaneKey && args.worktreeId - ? excludeReplacedPaneOwner( - resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId - ), - ctx.replacedPaneOwner - ) - : null - ctx.earlyWorktreeId = args.worktreeId - ctx.paneSpawnReservationKey = earlyReservationKey - ctx.paneSpawnReservation = heldReservation ?? null - ctx.finishTerminalInstall = (): void => {} - ctx.stablePaneOwner = null - ctx.stablePaneBindingPersisted = false - ctx.rejectedRegistrationCandidate = null - ctx.pendingRegistrationPtyId = null - // Carry renderer-domain sequence reconciliation into the spawn reply beside proven kitty flags. - ctx.reconciledSnapshotSeq = null - // Bytes arriving during spawn can postdate proven flags; do not let old flags erase their live negotiation. - ctx.snapshotKittyFlagsCoverReconciledSeq = true - ctx.preparedProvisionalExecutionContext = false } diff --git a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts index 3463c55414ac..d70a3556b618 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts @@ -7,11 +7,6 @@ import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcDeps } from './spawn-types' -import { - claimRuntimePaneCreate, - pendingRuntimePaneCreatesByOwnerKey, - reservePaneSpawn -} from '../pane/spawn-reservation' vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) @@ -101,7 +96,8 @@ describe('renderer spawn commit: run facts', () => { } }) expect(outcome).toBe('committed') - expect(reply).toMatchObject({ + expect(reply?.kind).toBe(earlyExit ? 'registration-exited' : 'committed') + expect(reply?.result).toMatchObject({ id: PTY_ID, incarnationId: INCARNATION_ID, agentResumeUnavailable: true, @@ -121,25 +117,4 @@ describe('renderer spawn commit: run facts', () => { expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['replaced']) }) - - it('gives a runtime-create waiter the window result before the reservation waiter resumes', async () => { - const key = 'window-commit-runtime-waiter' - const reservation = reservePaneSpawn(key) - const release = claimRuntimePaneCreate(key) - const pending = pendingRuntimePaneCreatesByOwnerKey.get(key)! - const joined = reservation.promise.then(() => { - release() - return pending.promise - }) - try { - const { outcome } = await commit(vi.fn().mockResolvedValue(true), (ctx) => { - ctx.paneSpawnReservationKey = key - ctx.paneSpawnReservation = reservation - }) - expect(outcome).toBe('committed') - await expect(joined).resolves.toMatchObject({ id: PTY_ID, incarnationId: INCARNATION_ID }) - } finally { - release() - } - }) }) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 929a2aaf147b..f5bdc0b46318 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,4 +1,4 @@ -import type { PtySpawnResult } from '../../../providers/pty-provider-contract' +import { provenSpawnResponse, type PtySpawnOutcome } from '../spawn-outcome' import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { startupCwdFallbackOutcome } from '../spawn-startup-target' @@ -9,10 +9,10 @@ import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-pla import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' import { resumeSpawnRefusal, savedResumeResult } from '../host-env/saved-resume-admission' -export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { +export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { return commitPtyWithOpenCodePromptIntent(ctx, () => { const args = ctx.args - // Bound untrusted IPC identity strings before they enter runtime registration maps. + // IPC workspace identity is untrusted; the actual provider result supplies memory identity. const worktreeId = typeof args.worktreeId === 'string' && args.worktreeId.length > 0 && @@ -20,7 +20,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise({ + return commitPtySpawn({ state: ctx, nativeWindowsConptySpawn: ctx.nativeWindowsConptySpawn, seedPaneKey: ctx.validatedPaneKey, @@ -47,18 +47,11 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0 && - args.sessionId.length <= 256 - ? args.sessionId - : null, resumeArgvDropped: ctx.codexResumeLaunch.droppedResumeArgv, codexLaunchEnv: ctx.baseEnv, initiallyHidden: ctx.initiallyHidden, hiddenDelivery: 'renderer-scoped', - earlyExit: 'drain', - joinPayload: { reservation: response, reply: response } + response }) }) } @@ -102,7 +95,7 @@ function ipcSpawnResponse(ctx: PtyIpcSpawnState) { const { startupCwdSource: _startupCwdSource, ...providerResult } = ctx.result const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result) return { - ...providerResult, + ...provenSpawnResponse(providerResult, ctx.provedSnapshotSeq), ...(!ctx.result.isReattach && !resumeSpawnRefusal(ctx.resumeAdmission) ? { launchAgent: args.launchAgent, @@ -112,13 +105,6 @@ function ipcSpawnResponse(ctx: PtyIpcSpawnState) { : args.resumeProviderSession } : {}), - // Why both or neither: a pane can only adopt proven kitty flags together - // with the sequence boundary they describe. - ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && - ctx.reconciledSnapshotSeq !== null && - ctx.snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: ctx.reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), ...(!ctx.result.isReattach && ctx.effectiveLaunchConfig ? { launchConfig: ctx.effectiveLaunchConfig } : {}), diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index c4c21359f318..6cb4b1fa2fde 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -23,7 +23,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis isDaemonHostSpawn: ctx.isDaemonHostSpawn, sessionId: ctx.effectiveSessionId, callerNamedSession: args.sessionId !== undefined, - mintedSessionId: ctx.isMintedSessionId, + mintedSessionId: ctx.ownsNewIncarnation, resumeOnSelectedAccount: Boolean(args.replacesPtyId), emptySessionIdSkipsHostEnv: false, requestedAgentTeamsPath: ctx.requestedAgentTeamsPath, diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts deleted file mode 100644 index c3967b6f1879..000000000000 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { - captureSavedResumeCleanup, - chooseSavedResumeFreshLaunch, - type ResumeSpawnAdmission, - resumeSpawnRefusal -} from '../host-env/saved-resume-admission' -import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' -import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate' -import { resolveStablePaneOwner } from '../pane/stable-owner' -import { - assertPaneStillExists, - excludeAbsentOwnerReplay, - PaneOwnerContentionError -} from '../pane/pane-spawn-operation' -import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' -import { assertSpawnReplyWasLive } from '../pane/agent-session-owners' -import { failPtySpawnExecute } from '../spawn-execute-failure' -import { attachOnlyViewOf, attachViewWithoutOwner } from '../pane/attach-only-view' -import type { PtyIpcSpawnState } from './spawn-state' - -export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { - const args = ctx.args - const captured: ResumeSpawnAdmission | null = - args.launchAgent && args.resumeProviderSession - ? { - verdict: 'admitted', - refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession } - } - : null - if (args.resumeOrigin === 'saved') { - await captureSavedResumeCleanup( - ctx.deps.runtime, - ctx.deps.store, - ctx.stablePaneKey, - captured, - args.worktreeId - ) - } - const chooseFresh = async (): Promise => { - ctx.spawnOptions.env = { ...ctx.spawnOptions.env } - args.launchConfig = ctx.effectiveLaunchConfig - ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( - ctx.deps.runtime, - args, - ctx.expectedWslDistro, - ctx.spawnOptions - ) - if (captured && ctx.resumeAdmission) { - ctx.resumeAdmission.refused = captured.refused - ctx.resumeAdmission.sourceRecord = captured.sourceRecord - } - if (resumeSpawnRefusal(ctx.resumeAdmission)) { - ctx.launchCommand = undefined - ctx.env = ctx.spawnOptions.env - ctx.baseEnv = ctx.spawnOptions.env - ctx.spawnEnv = ctx.spawnOptions.env - } - } - try { - if (ctx.preAllocatedHandle) { - ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) - } - ctx.spawnTiming.mark('options') - // Restart must replace the stopped owner instead of reattaching its retained binding. - const resolveOwner = () => - excludeReplacedPaneOwner( - resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.reservationPaneKey, - args.worktreeId, - args.connectionId - ), - ctx.replacedPaneOwner - ) - const stablePaneOwnerCandidate = excludeAbsentOwnerReplay( - resolveOwner(), - ctx.paneSpawnOperation?.absentOwner - ) - if (stablePaneOwnerCandidate) { - throw new PaneOwnerContentionError() - } - if (ctx.paneSpawnOperation?.mustExist && args.worktreeId && args.tabId && args.leafId) { - assertPaneStillExists( - ctx.deps.store, - args.worktreeId, - args.tabId, - args.leafId, - args.connectionId - ) - } - const expectedPtyId = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId - if (expectedPtyId) { - ctx.pendingRegistrationClaim = ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) - ctx.pendingRegistrationPtyId = expectedPtyId - } - if (ctx.isDaemonHostSpawn && expectedPtyId) { - ctx.preparedProvisionalExecutionContext = - ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { - resetIncarnation: ctx.isMintedSessionId && !stablePaneOwnerCandidate, - preserveExisting: !ctx.isMintedSessionId || Boolean(stablePaneOwnerCandidate) - }) ?? false - } - const sequenceBeforeProviderSpawn = expectedPtyId - ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) - : 0 - if (!ctx.preAdoptedStablePane && ctx.paneAgentLaunch) { - ctx.paneAgentLaunch.dispatched = true - } - if (attachOnlyViewOf(args)) { - ctx.result = await attachViewWithoutOwner(ctx.provider, ctx.spawnOptions) - } else { - await chooseFresh() - ctx.result = await ctx.provider.spawn(ctx.spawnOptions) - } - if (ctx.result.isReattach) { - ctx.resumeAdmission = null - Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id)) - } - if (resumeSpawnRefusal(ctx.resumeAdmission)) { - ctx.effectiveLaunchConfig = undefined - } - ctx.stablePaneOwner = null - ctx.rejectedRegistrationCandidate = ctx.result - if (ctx.pendingRegistrationPtyId !== ctx.result.id) { - if (ctx.pendingRegistrationPtyId) { - ctx.deps.runtime?.cancelPendingPtyRegistration?.( - ctx.pendingRegistrationPtyId, - undefined, - ctx.pendingRegistrationClaim - ) - } - ctx.pendingRegistrationClaim = ctx.deps.runtime?.beginPtyRegistration?.( - ctx.result.id, - ctx.result.incarnationId - ) - ctx.pendingRegistrationPtyId = ctx.result.id - } - assertSpawnReplyWasLive(ctx.result) - if ( - ctx.pendingRegistrationClaim && - !ctx.deps.runtime?.narrowPtyRegistration( - ctx.result.id, - ctx.pendingRegistrationClaim, - ctx.result.incarnationId - ) - ) { - throw new PaneOwnerContentionError() - } - ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId) - if (ctx.result.providerSequence) { - const runtimeSequenceBeforeReconcile = - ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 - // Preserve the renderer-domain boundary needed to reconcile daemon snapshot kitty flags. - ctx.reconciledSnapshotSeq = - ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( - ctx.result.id, - ctx.result.providerSequence, - sequenceBeforeProviderSpawn - ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - ctx.snapshotKittyFlagsCoverReconciledSeq = false - } - } - ensureWslHookRelayForReattach(ctx.result) - const spawnedWslDistro = - ctx.result.wslDistro === undefined ? ctx.expectedWslDistro : ctx.result.wslDistro - if (spawnedWslDistro && !args.connectionId) { - // A local WSL terminal is user-initiated, so background work held for this distro may run. - noteWslDistrosRunning([spawnedWslDistro]) - } - ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.result.id, spawnedWslDistro) - ctx.spawnTiming.mark('provider_spawn') - } catch (err) { - throw failPtySpawnExecute(ctx, err, { - sessionId: ctx.effectiveSessionId, - ownsNewSession: ctx.isMintedSessionId, - telemetry: args.telemetry - }) - } finally { - if (ctx.preAllocatedHandle) { - ctx.deps.trustedTerminalHandleEnv.delete(ctx.preAllocatedHandle) - } - } -} diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 24717b1554f0..d7740ccdbcd2 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,5 +1,4 @@ import { isRestoreSpawnIntent } from '../../../../shared/host-spawn-admission' -import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' import { makePaneSpawnReservationKey } from '../pane/spawn-reservation' import { @@ -36,7 +35,7 @@ export async function buildPtyIpcSpawnOptions(ctx: PtyIpcSpawnState): Promise { - ctx.spawnOptions = { ...ctx.spawnOptions, attachOnly: true } - } - } - ) - : undefined } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 67bb9831c709..147c43ddd330 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -17,7 +17,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.provider = getProvider(getConnectionExecutionHostId(args.connectionId)) ctx.isDaemonHostSpawn = !(ctx.provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(ctx.provider) - ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.ownsNewIncarnation = args.sessionId === undefined && ctx.isDaemonHostSpawn ctx.effectiveSessionId = args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) ctx.effectiveSessionAppId = ctx.effectiveSessionId @@ -52,7 +52,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis const previousHiddenMarkId = ctx.preSpawnHiddenMarkId ctx.isDaemonHostSpawn = !(ctx.provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(ctx.provider) - ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.ownsNewIncarnation = args.sessionId === undefined && ctx.isDaemonHostSpawn ctx.effectiveSessionId = args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) ctx.effectiveSessionAppId = ctx.effectiveSessionId diff --git a/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts b/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts index bbb84d6bbdd2..21edc76dad86 100644 --- a/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts +++ b/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts @@ -35,7 +35,8 @@ function makeCtx(args: PtySpawnIpcArgs, runtime: ReturnType) ctx.isDaemonHostSpawn = true ctx.effectiveSessionId = SESSION_ID ctx.effectiveSessionAppId = SESSION_ID - ctx.isMintedSessionId = args.sessionId === undefined + // Mirrors spawn-preflight: a caller-supplied sessionId is an attach, never a fresh mint. + ctx.ownsNewIncarnation = args.sessionId === undefined return ctx } diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index e0c6011833fe..8da47c4aea5f 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -1,18 +1,6 @@ -import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { codexReattachedHomeRouteField } from '../host-env/codex-home' -import type { PaneSpawnOperation } from '../pane/pane-spawn-operation' -import { - rejectPaneSpawnReservation, - reserveIdlePaneSpawn, - pendingRuntimePaneCreatesByOwnerKey -} from '../pane/spawn-reservation' -import { restoreProvisionalPtySize } from '../spawn-options-assembly' -import { - beginPtyIpcSpawn, - resolveEarlyPaneKey, - resolveEarlyPaneSpawnReservationKey -} from './spawn-begin' -import { releaseStoppedPaneBinding, stopReplacedPaneOwner } from '../pane/pane-owner-replacement' +import { runPtySpawn, normalizePtySpawnSessionId } from '../spawn-operation' +import { checkPtyIpcLaunchPane, resolveEarlyPaneKey } from './spawn-begin' +import { stopReplacedPaneOwner } from '../pane/pane-owner-replacement' import { keepAbsentBindingForSwap } from './spawn-absent-binding-swap' import { planPaneAgentLaunch } from './spawn-agent-launch' import { endPaneAgentLaunch } from './spawn-agent-launch-end' @@ -20,228 +8,105 @@ import { preparePtyIpcSpawnPreflight } from './spawn-preflight' import { assemblePtyIpcSpawnEnv } from './spawn-env' import { preparePtyIpcQoderCommand } from './spawn-qoder-command' import { buildPtyIpcSpawnOptions } from './spawn-options' -import { executePtyIpcSpawn } from './spawn-execute' import { attachOnlyViewOf } from '../pane/attach-only-view' import { commitPtyIpcSpawn } from './spawn-commit' -import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import { createPtyIpcSpawnState } from './spawn-state' import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { refuseClosedPaneSpawn } from './closed-pane-spawn-refusal' -import { - captureSavedResumeCleanup, - admitColdRestoreReattach, - forgetRefusedResume, - resumeSpawnRefusal, - savedResumeRecord -} from '../host-env/saved-resume-admission' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' -function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { - if (!ctx.agentTeamsLeaderHandle) { - return - } - ctx.deps.runtime?.releaseClaudeAgentTeamsLeaderForHandle?.(ctx.agentTeamsLeaderHandle) - ctx.agentTeamsLeaderHandle = null -} - -export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) { +export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, request: PtySpawnIpcArgs) { + const args = { ...request, sessionId: normalizePtySpawnSessionId(request.sessionId) } const ctx = createPtyIpcSpawnState(deps, args) - const replacedPaneKey = - args.replacesPtyId !== undefined ? resolveEarlyPaneSpawnReservationKey(args) : null - if (replacedPaneKey) { - // Reserve through stop so concurrent reveals join the replacement instead of the dying owner. - ctx.paneSpawnReservation = await reserveIdlePaneSpawn(replacedPaneKey) - ctx.paneSpawnReservationKey = replacedPaneKey + const admit = () => { + triggerPtySpawnPushTargetMaterialization(deps, args) + return checkPtyIpcLaunchPane(ctx) } - const fresh = async (operation: PaneSpawnOperation) => { - // Retire the previous preparation before replacing its resource slots or reacquiring its lease. - releaseAbandonedAgentTeamsLeader(ctx) - ctx.finishTerminalInstall() - ctx.finishTerminalInstall = () => {} - ctx.releaseWorktreeSpawn?.() - ctx.releaseWorktreeSpawn = undefined - ctx.paneSpawnOperation = operation - ctx.paneSpawnReservation = null - ctx.earlyStablePaneOwner = null - // Avoid a new await for ordinary spawns; launch planning alone needs this yield. - if (ctx.args.agentLaunch !== undefined) { - await planPaneAgentLaunch(ctx) - } - await preparePtyIpcSpawnPreflight(ctx) - await assemblePtyIpcSpawnEnv(ctx) - await preparePtyIpcQoderCommand(ctx) - await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { - restoreProvisionalPtySize({ - sessionId: ctx.effectiveSessionId, - sessionAppId: ctx.effectiveSessionAppId, - hadSessionSizeBeforeAttach: ctx.hadSessionSizeBeforeAttach, - sessionSizeBeforeAttach: ctx.sessionSizeBeforeAttach - }) - throw error - }) - const complete = async (scope: PaneSpawnOperation) => { - ctx.paneSpawnOperation = scope - // Check immediately before spawn, with no intervening await, so closed panes cannot start commands. - ctx.paneAgentLaunch?.refuseIfClosed() - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - refuseClosedPaneSpawn(deps.store, args.tabId, args.leafId) + const admission = args.replacesPtyId === undefined ? admit() : undefined + const outcome = await runPtySpawn({ + state: ctx, + admission, + prepare: async () => { + ctx.earlyStablePaneOwner = excludeReplacedPaneOwner( + resolveStablePaneOwner( + deps.runtime, + deps.store, + resolveEarlyPaneKey(ctx.args), + ctx.args.worktreeId, + ctx.args.connectionId + ), + ctx.replacedPaneOwner + ) + ctx.earlyWorktreeId = ctx.args.worktreeId + if (ctx.args.agentLaunch !== undefined) { + await planPaneAgentLaunch(ctx) } - try { - await executePtyIpcSpawn(ctx) - if (!ctx.resumeAdmission) { - ctx.resumeAdmission = await admitColdRestoreReattach( - deps.runtime, - ctx.result, - { paneKey: ctx.stablePaneKey, saved: savedResumeRecord(deps.store, ctx.stablePaneKey) }, - ctx.expectedWslDistro - ) - await captureSavedResumeCleanup( - deps.runtime, - deps.store, - ctx.stablePaneKey, - ctx.resumeAdmission, - args.worktreeId - ) - } - const committed = await commitPtyIpcSpawn(ctx) - const refusal = resumeSpawnRefusal(ctx.resumeAdmission) - forgetRefusedResume(ctx.stablePaneKey, refusal) - if (args.worktreeId && ctx.stablePaneKey && refusal && refusal.verdict !== 'unverifiable') { - void deps.runtime - ?.forgetRefusedSavedResume?.( - args.worktreeId, - ctx.stablePaneKey, - refusal.refused, - refusal.sourceRecord - ) - .catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error)) - } - endPaneAgentLaunch( - ctx, - ctx.exitedBeforeRegistration ? { exitedDuringStart: true } : { started: true } - ) - if (!ctx.result.isReattach) { - deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + await preparePtyIpcQoderCommand(ctx) + await buildPtyIpcSpawnOptions(ctx) + }, + facts: () => ({ + validateDispatch: () => { + ctx.paneAgentLaunch?.refuseIfClosed() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + refuseClosedPaneSpawn(deps.store, ctx.args.tabId, ctx.args.leafId) } - return committed - } finally { - if (ctx.pendingRegistrationPtyId) { - deps.runtime?.cancelPendingPtyRegistration?.( - ctx.pendingRegistrationPtyId, - ctx.rejectedRegistrationCandidate?.incarnationId, - ctx.pendingRegistrationClaim - ) - ctx.pendingRegistrationPtyId = null + }, + sessionId: ctx.effectiveSessionId, + savedResumePaneKey: ctx.stablePaneKey, + launchConfig: ctx.effectiveLaunchConfig, + suppressRefusedLaunch: () => { + ctx.launchCommand = undefined + ctx.env = ctx.baseEnv = ctx.spawnEnv = ctx.spawnOptions.env + ctx.effectiveLaunchConfig = undefined + }, + paneKey: ctx.reservationPaneKey, + preAllocatedHandle: ctx.preAllocatedHandle, + replacedPaneOwner: ctx.replacedPaneOwner, + attachOnlyView: attachOnlyViewOf(ctx.args), + beforeProviderSpawn: () => { + if (ctx.paneAgentLaunch) { + ctx.paneAgentLaunch.dispatched = true } } - } - const latePane = - !resolveEarlyPaneKey(args) && ctx.reservationPaneKey - ? parsePaneKey(ctx.reservationPaneKey) - : null - if (latePane) { - const pendingCreate = ctx.paneSpawnReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(ctx.paneSpawnReservationKey) - : undefined - if (pendingCreate) { - await pendingCreate.promise + }), + drainsExitedStart: true, + commit: () => commitPtyIpcSpawn(ctx), + onOutcome: (settled) => + endPaneAgentLaunch( + ctx, + settled.kind === 'registration-exited' ? { exitedDuringStart: true } : { started: true } + ), + onError: (error) => endPaneAgentLaunch(ctx, { error }), + releasePreparation: () => { + if (ctx.agentTeamsLeaderHandle) { + deps.runtime?.releaseClaudeAgentTeamsLeaderForHandle?.(ctx.agentTeamsLeaderHandle) + ctx.agentTeamsLeaderHandle = null } - ctx.args = { ...ctx.args, ...latePane } - let completed: Awaited> | undefined - const adopted = await deps.adoptStablePane({ - cols: args.cols, - rows: args.rows, - cwd: ctx.cwd, - worktreeId: args.worktreeId, - ...latePane, - connectionId: args.connectionId, - renderer: true, - keepAbsentBinding: keepAbsentBindingForSwap(ctx), - attachOnlyView: attachOnlyViewOf(args), - spawnFresh: async (scope) => (completed = await complete(scope)) - }) - if (adopted) { - releaseAbandonedAgentTeamsLeader(ctx) - } - return adopted - ? { ...adopted.result, ...deps.runtime?.getTerminalLaunchResult?.(adopted.result.id) } - : completed! - } - return complete(operation) - } - try { - if (args.replacesPtyId !== undefined) { - ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, { - replacesPtyId: args.replacesPtyId, - paneKey: resolveEarlyPaneKey(args), - worktreeId: args.worktreeId, - connectionId: args.connectionId - }) - } - triggerPtySpawnPushTargetMaterialization(deps, args) - const early = await beginPtyIpcSpawn(ctx) - if (early) { - return early - } - const paneKey = resolveEarlyPaneKey(args) - if (!paneKey || !args.worktreeId || !args.tabId || !args.leafId) { - return await fresh({ mustExist: false }) - } - let freshResult: Awaited> | undefined - const adopted = await deps.adoptStablePane({ - cols: args.cols, - rows: args.rows, - cwd: ctx.cwd, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId, - connectionId: args.connectionId, - renderer: true, - reservation: ctx.paneSpawnReservation ?? undefined, - stoppedPtyId: ctx.replacedPaneOwner?.ptyId, - keepAbsentBinding: keepAbsentBindingForSwap(ctx), - attachOnlyView: attachOnlyViewOf(args), - spawnFresh: async (operation) => (freshResult = await fresh(operation)) - }) - if (adopted) { - releaseAbandonedAgentTeamsLeader(ctx) + }, + releaseHiddenMark: () => { if (ctx.preSpawnHiddenMarkId !== null) { deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) } - deps.options?.onCodexHomePtySpawned?.({ - id: adopted.result.id, - codexHomePath: null, - reattached: true, - startedAt: ctx.codexHomeLaunchStartedAt, - startedSequence: ctx.codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, adopted.result.id, true) - }) - endPaneAgentLaunch(ctx, { started: true }) - return { ...adopted.result, ...deps.runtime?.getTerminalLaunchResult?.(adopted.result.id) } - } - return freshResult! - } catch (caught) { - const err = endPaneAgentLaunch(ctx, { error: caught }) ?? caught - releaseAbandonedAgentTeamsLeader(ctx) - if (ctx.preSpawnHiddenMarkId !== null) { - ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) - } - if (ctx.pendingRegistrationPtyId) { - deps.runtime?.cancelPendingPtyRegistration?.( - ctx.pendingRegistrationPtyId, - ctx.rejectedRegistrationCandidate?.incarnationId, - ctx.pendingRegistrationClaim - ) - ctx.pendingRegistrationPtyId = null - } - if (ctx.replacedPaneOwner) { - await releaseStoppedPaneBinding(deps.store, ctx.replacedPaneOwner) - } - // Settle reservations on every later failure, including post-spawn saves/helpers, or future pane spawns hang forever. - rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) - throw err - } finally { - ctx.releaseWorktreeSpawn?.() - ctx.finishTerminalInstall() - } + }, + adoption: () => ({ renderer: true, keepAbsentBinding: keepAbsentBindingForSwap(ctx) }), + ...(args.replacesPtyId !== undefined + ? { + replace: async () => { + ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, { + replacesPtyId: args.replacesPtyId!, + paneKey: resolveEarlyPaneKey(args), + worktreeId: args.worktreeId, + connectionId: args.connectionId + }) + await admit() + } + } + : {}) + }) + return outcome.result } diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index 7330cff69218..b8687560e7ad 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -1,3 +1,4 @@ +import type { PtySpawnSizeSeed } from '../spawn-options-assembly' import type { StartupCwdFallback } from '../../../../shared/startup-cwd-fallback' import type { PtyRegistrationClaim } from '../../../runtime/runtime-terminal-state-records' import type { PaneSpawnOperation } from '../pane/pane-spawn-operation' @@ -47,14 +48,12 @@ export type PtyIpcSpawnState = { rejectedRegistrationCandidate: PtySpawnResult | null pendingRegistrationPtyId: string | null pendingRegistrationClaim?: PtyRegistrationClaim - reconciledSnapshotSeq: number | null - snapshotKittyFlagsCoverReconciledSeq: boolean + provedSnapshotSeq: number | undefined preparedProvisionalExecutionContext: boolean releaseWorktreeSpawn: (() => void) | undefined provider: IPtyProvider preAdoptedStablePane: AdoptStablePaneResult | null paneAgentLaunch: PaneAgentLaunch | null - exitedBeforeRegistration: boolean isClaudeLaunch: boolean claudeAuth: ClaudeRuntimeAuthPreparation | null terminalRuntimeOptions: { @@ -62,7 +61,7 @@ export type PtyIpcSpawnState = { terminalWindowsWslDistro?: string | null } isDaemonHostSpawn: boolean - isMintedSessionId: boolean + ownsNewIncarnation: boolean effectiveSessionId: string | undefined effectiveSessionAppId: string | undefined expectedWslDistro: string | null @@ -98,8 +97,7 @@ export type PtyIpcSpawnState = { skipCodexHomeEnv: boolean stripInheritedOrcaCodexHome: boolean codexResumeHomeSelected: boolean - hadSessionSizeBeforeAttach: boolean - sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + provisionalPtySize: PtySpawnSizeSeed | undefined initiallyHidden: boolean preSpawnHiddenMarkId: string | null } @@ -131,19 +129,17 @@ export function createPtyIpcSpawnState( stablePaneBindingPersisted: false, rejectedRegistrationCandidate: null, pendingRegistrationPtyId: null, - reconciledSnapshotSeq: null, - snapshotKittyFlagsCoverReconciledSeq: true, + provedSnapshotSeq: undefined, preparedProvisionalExecutionContext: false, releaseWorktreeSpawn: undefined, provider: localProvider, preAdoptedStablePane: null, paneAgentLaunch: null, - exitedBeforeRegistration: false, isClaudeLaunch: false, claudeAuth: null, terminalRuntimeOptions: {}, isDaemonHostSpawn: false, - isMintedSessionId: false, + ownsNewIncarnation: false, effectiveSessionId: undefined, effectiveSessionAppId: undefined, expectedWslDistro: null, @@ -177,8 +173,7 @@ export function createPtyIpcSpawnState( skipCodexHomeEnv: false, stripInheritedOrcaCodexHome: false, codexResumeHomeSelected: false, - hadSessionSizeBeforeAttach: false, - sessionSizeBeforeAttach: undefined, + provisionalPtySize: undefined, initiallyHidden: false, preSpawnHiddenMarkId: null } diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 02b9a9b39b7c..354968204cef 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -1,3 +1,4 @@ +import type { PtySpawnOutcome } from '../spawn-outcome' import type { PaneSpawnReservation } from '../pane/spawn-reservation' import type { PaneSpawnOperation } from '../pane/pane-spawn-operation' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -81,6 +82,7 @@ export type AdoptStablePaneArgs = { tabId: string leafId: string onAdopted?: (result: PtySpawnResult) => void + onOutcome?: (outcome: PtySpawnOutcome, operation: PaneSpawnOperation) => void completed?: AdoptStablePaneResult ownsPaneSpawnReservation?: true reservation?: PaneSpawnReservation @@ -94,7 +96,9 @@ export type AdoptStablePaneArgs = { } export type AdoptStablePaneResult = { + operation?: PaneSpawnOperation result: PtySpawnResult + outcome?: PtySpawnOutcome owner: StablePaneOwner materialized?: true } diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts index 525f641ffa5b..ae38099f32b5 100644 --- a/src/main/ipc/pty/pane/adopt-stable.ts +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -1,10 +1,16 @@ +import type { PtySpawnOutcome } from '../spawn-outcome' +import { isClosedPaneFenced } from '../../../persistence/terminal-topology/closed-pane-binding-fence' +import { hasClosedTerminalTabRecord } from '../../../../shared/closed-terminal-tab-tombstones' +import { + toSshExecutionHostId, + getConnectionExecutionHostId +} from '../../../../shared/execution-host' import { ptyIncarnationById } from '../provider/ownership-state' import { reconcilePaneOwner } from './stable-pane-attach' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { Store } from '../../../persistence' import { makePaneKey } from '../../../../shared/stable-pane-id' import { getProvider } from '../provider/registry' -import { getConnectionExecutionHostId } from '../../../../shared/execution-host' import { joinPaneSpawn, makePaneSpawnReservationKey, @@ -15,7 +21,11 @@ import { } from './spawn-reservation' import { resolveStablePaneOwner } from './stable-owner' import { completePaneAdoption } from './complete-pane-adoption' -import { assertPaneStillExists, type AbsentPaneBinding } from './pane-spawn-operation' +import { + assertPaneStillExists, + type AbsentPaneBinding, + type PaneSpawnOperation +} from './pane-spawn-operation' import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types' /** Owns attachment through publication, including the lazy fresh path, under one reservation. */ @@ -40,6 +50,12 @@ export async function adoptStablePane( return owner?.ptyId === args.stoppedPtyId ? null : owner } let remaining = 1 + if (args.completed?.outcome?.kind === 'registration-exited') { + if (!args.completed.operation) { + throw new Error('terminal_pane_owner_changed') + } + return consumeExitedPaneOperation(runtime, store, args, args.completed.operation) + } if (args.completed?.materialized) { const owner = resolveOwner() if (!owner) { @@ -71,6 +87,12 @@ export async function adoptStablePane( if (!joined) { continue } + if (pending.outcome?.kind === 'registration-exited') { + const operation = pending.operation! + const exited = consumeExitedPaneOperation(runtime, store, args, operation) + args.onOutcome?.(pending.outcome, operation) + return exited + } if (!args.worktreeId) { const successor = paneSpawnReservationsByOwnerKey.get(key) if (successor && successor !== pending) { @@ -109,6 +131,15 @@ export async function adoptStablePane( // Recheck after the await; a new holder may already have reserved the pane. } } + const settle = (outcome: PtySpawnOutcome, operation: PaneSpawnOperation) => { + if (reservation.outcome) { + return + } + reservation.outcome = outcome + reservation.operation = operation + resolvePaneSpawnReservation(key, reservation, outcome.result) + args.onOutcome?.(outcome, operation) + } let mustExist = false let absentOwner: AbsentPaneBinding | undefined try { @@ -138,20 +169,25 @@ export async function adoptStablePane( spawnOptions: { cols: args.cols, rows: args.rows, cwd: args.cwd, signal: args.signal }, resolveOwner, preAllocatedHandle: args.preAllocatedHandle, - renderer: args.renderer, keepAbsentBinding: args.keepAbsentBinding, attachOnlyView: args.attachOnlyView }) if (adopted) { - args.onAdopted?.(adopted.result) - resolvePaneSpawnReservation(key, reservation, adopted.result) - } else { - absentOwner = { - ptyId: owner.ptyId, - incarnationIds: [owner.runtimeIncarnationId, owner.persistedIncarnationId].filter( - (id): id is string => id !== undefined - ) + const operation: PaneSpawnOperation = { + mustExist, + acquisition: { key, reservation, settle: (outcome) => settle(outcome, operation) } + } + if (adopted.outcome.kind !== 'registration-exited') { + args.onAdopted?.(adopted.result) } + settle(adopted.outcome, operation) + return { ...adopted, operation } + } + absentOwner = { + ptyId: owner.ptyId, + incarnationIds: [owner.runtimeIncarnationId, owner.persistedIncarnationId].filter( + (id): id is string => id !== undefined + ) } return adopted }, @@ -166,8 +202,19 @@ export async function adoptStablePane( rejectPaneSpawnReservation(key, reservation, new Error('terminal_pane_unbound')) return null } - const result = await args.spawnFresh({ mustExist, ...(absentOwner ? { absentOwner } : {}) }) - resolvePaneSpawnReservation(key, reservation, result) + const operation: PaneSpawnOperation = { + mustExist, + ...(absentOwner ? { absentOwner } : {}), + acquisition: { key, reservation, settle: (outcome) => settle(outcome, operation) } + } + const result = await args.spawnFresh(operation) + settle( + { + kind: result.agentSessionEnsure?.disposition === 'adopted' ? 'adopted' : 'committed', + result + }, + operation + ) return null } }) @@ -176,3 +223,56 @@ export async function adoptStablePane( throw error } } + +/** An observed exit needs the same surface and acquisition, rather than a now-retired live owner. */ +export function consumeExitedPaneOperation( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + args: Pick, + operation: PaneSpawnOperation +): AdoptStablePaneResult { + const acquisition = operation.acquisition + const outcome = acquisition?.reservation.outcome + if (!acquisition || outcome?.kind !== 'registration-exited') { + throw new Error('terminal_pane_owner_changed') + } + const successor = paneSpawnReservationsByOwnerKey.get(acquisition.key) + const incarnation = ptyIncarnationById.get(outcome.result.id) + const owner = resolveStablePaneOwner( + runtime, + store, + makePaneKey(args.tabId, args.leafId), + args.worktreeId, + args.connectionId + ) + if ( + (successor && successor !== acquisition.reservation) || + (incarnation && outcome.result.incarnationId && incarnation !== outcome.result.incarnationId) || + (owner && + (owner.ptyId !== outcome.result.id || owner.incarnationId !== outcome.result.incarnationId)) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (args.renderer && args.worktreeId) { + const session = store?.getWorkspaceSession( + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if ( + acquisition.surfaceWithdrawn || + isClosedPaneFenced(makePaneKey(args.tabId, args.leafId)) || + hasClosedTerminalTabRecord(session?.closedTerminalTabTombstonesByTabId, args.tabId) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (!runtime?.hasTerminalPaneView?.(args.worktreeId, args.tabId, args.leafId)) { + assertPaneStillExists(store, args.worktreeId, args.tabId, args.leafId, args.connectionId) + } + } + return { + operation, + outcome, + result: outcome.result, + owner: { ptyId: outcome.result.id, tabId: args.tabId, leafId: args.leafId }, + materialized: true + } +} diff --git a/src/main/ipc/pty/pane/complete-pane-adoption.ts b/src/main/ipc/pty/pane/complete-pane-adoption.ts index 7f5f032f38b6..882a9785267c 100644 --- a/src/main/ipc/pty/pane/complete-pane-adoption.ts +++ b/src/main/ipc/pty/pane/complete-pane-adoption.ts @@ -17,6 +17,7 @@ import { import { seedHeadlessTerminalFromSpawnResult } from './terminal-spawn-restore' import { admitProviderReattachLaunchIdentity } from './launch-authority' import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { provenSpawnResponse } from '../spawn-outcome' import { ptySizes } from '../delivery/visibility-state' import { resolveCommittedPtySize, @@ -129,18 +130,23 @@ export async function completePaneAdoption( providerReattachLaunchIdentity: admitProviderReattachLaunchIdentity(result) ?? undefined } ) + const response = provenSpawnResponse( + result, + beforeReconcile <= beforeAttach ? snapshotSeq : undefined + ) if (rejected) { try { await rejected } catch (error) { - if ( - !args.renderer || - !(error instanceof Error) || - error.message !== 'agent_session_exited_during_start' - ) { + if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') { throw error } - return { result, owner, materialized: true as const } + return { + result: response, + outcome: { kind: 'registration-exited' as const, result: response }, + owner, + materialized: true as const + } } } runtime?.noteTerminalSpawnCommit?.(result) @@ -176,12 +182,8 @@ export async function completePaneAdoption( pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) } return { - result: { - ...result, - ...(typeof snapshotSeq === 'number' && beforeReconcile <= beforeAttach - ? { snapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }) - }, + result: response, + outcome: { kind: 'adopted' as const, result: response }, owner: { ...owner, ...(terminalHandle ? { handle: terminalHandle } : {}) }, materialized: true as const } diff --git a/src/main/ipc/pty/pane/pane-spawn-operation.ts b/src/main/ipc/pty/pane/pane-spawn-operation.ts index d8338c99e4bb..e4abca6338aa 100644 --- a/src/main/ipc/pty/pane/pane-spawn-operation.ts +++ b/src/main/ipc/pty/pane/pane-spawn-operation.ts @@ -2,6 +2,8 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { toSshExecutionHostId } from '../../../../shared/execution-host' import type { Store } from '../../../persistence' import { ptyBindingIsRefused } from '../../../persistence/loading-store/pty-binding-refusals' +import type { PtySpawnOutcome } from '../spawn-outcome' +import type { PaneSpawnReservation } from './spawn-reservation' export class PaneOwnerContentionError extends Error { constructor() { @@ -13,7 +15,16 @@ export class PaneOwnerContentionError extends Error { export type AbsentPaneBinding = { ptyId: string; incarnationIds: readonly string[] } /** In-process authority of the enclosing operation; never sent over IPC/RPC or saved. */ -export type PaneSpawnOperation = { mustExist: boolean; absentOwner?: AbsentPaneBinding } +export type PaneSpawnOperation = { + mustExist: boolean + absentOwner?: AbsentPaneBinding + acquisition?: { + key: string + reservation: PaneSpawnReservation + surfaceWithdrawn?: true + settle: (outcome: PtySpawnOutcome) => void + } +} // A delayed window save can replay a proven-dead binding without creating a new owner. export function isAbsentBindingReplay( diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts index c16104bf3167..c63a4b0a4061 100644 --- a/src/main/ipc/pty/pane/spawn-reservation.ts +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -1,11 +1,15 @@ import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { isWorktreeTerminalsSleepingError } from '../../../runtime/worktree-terminals-sleeping-error' +import type { PtySpawnOutcome } from '../spawn-outcome' +import type { PaneSpawnOperation } from './pane-spawn-operation' export type PaneSpawnReservation = { promise: Promise resolve: (result: PaneSpawnReservationResult) => void reject: (error: unknown) => void + outcome?: PtySpawnOutcome + operation?: PaneSpawnOperation } export type PaneSpawnReservationResult = { id: string @@ -25,6 +29,7 @@ export type PendingRuntimePaneCreate = { resolve: () => void /** The terminal the create started, for a view that waited on it (layout-owner build). */ result?: PaneSpawnReservationResult + operation?: PaneSpawnOperation } export const pendingRuntimePaneCreatesByOwnerKey = new Map() @@ -91,11 +96,15 @@ export function makePaneSpawnReservationKey( return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null } -export function claimRuntimePaneCreate(ownerKey: string): () => void { +export type RuntimePaneCreateClaim = (() => void) & { + acceptOutcome?: (outcome: PtySpawnOutcome, operation: PaneSpawnOperation) => void +} + +export function claimRuntimePaneCreate(ownerKey: string): RuntimePaneCreateClaim { const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) if (existing) { existing.count += 1 - return () => releaseRuntimePaneCreate(ownerKey, existing) + return runtimePaneCreateRelease(ownerKey, existing) } let resolve!: (result: PaneSpawnReservationResult | undefined) => void const claim: PendingRuntimePaneCreate = { @@ -106,7 +115,18 @@ export function claimRuntimePaneCreate(ownerKey: string): () => void { resolve: () => resolve(claim.result) } pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) - return () => releaseRuntimePaneCreate(ownerKey, claim) + return runtimePaneCreateRelease(ownerKey, claim) +} + +function runtimePaneCreateRelease(ownerKey: string, claim: PendingRuntimePaneCreate) { + return Object.assign(() => releaseRuntimePaneCreate(ownerKey, claim), { + acceptOutcome: (outcome: PtySpawnOutcome, operation: PaneSpawnOperation) => { + if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) === claim && !claim.operation) { + claim.operation = operation + claim.result = outcome.result + } + } + }) } export function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { @@ -149,13 +169,3 @@ export function resolvePaneSpawnReservation import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -80,6 +87,7 @@ describe('a pane that starts its own agent launch', () => { providerSpawn: ReturnType, plan: () => Promise> = async () => ({ command: 'claude' }) ) { + classifyErrorMock.mockReturnValue({ error_class: 'unknown' }) installDaemonTestProvider({ spawn: providerSpawn }) const runtime = { ...createLaunchRaceRuntime(), @@ -280,4 +288,43 @@ describe('a pane that starts its own agent launch', () => { expect(providerSpawn).not.toHaveBeenCalled() expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() }) + + it('starts nothing and releases its preparation when the pane closes while its lease waits', async () => { + let resolveLease!: (release: () => void) => void + const lease = new Promise<() => void>((resolve) => { + resolveLease = resolve + }) + const releaseLease = vi.fn() + const acquireWorktreeTerminalSpawn = vi.fn(() => lease) + const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' })) + const runtime = register(providerSpawn) + Object.assign(runtime, { acquireWorktreeTerminalSpawn }) + + const spawned = spawnPane().catch((error: unknown) => error) + await vi.waitFor(() => expect(acquireWorktreeTerminalSpawn).toHaveBeenCalledOnce()) + const key = makePaneSpawnReservationKey(worktreeId, null, makePaneKey(tabId, leafId))! + const reservation = paneSpawnReservationsByOwnerKey.get(key)! + expect(reservation).toBeDefined() + const closedPane = { kind: 'pane', tabId, leafId } as const + expect(isAgentLaunchRunningIn(worktreeId, closedPane)).toBe(true) + const removal = acquireWatcherRemovalGate('/tmp/pane-launch') + onTestFinished(() => removal.release()) + const drained = vi.fn() + const preparationDone = removal.ready.then(drained) + await Promise.resolve() + expect(drained).not.toHaveBeenCalled() + + markAgentLaunchesClosedByUser(worktreeId, closedPane) + resolveLease(releaseLease) + + expect(await spawned).toMatchObject({ message: 'agent_launch_tab_closed' }) + expect(providerSpawn).not.toHaveBeenCalled() + expect(releaseLease).toHaveBeenCalledOnce() + await expect(reservation.promise).rejects.toThrow('agent_launch_tab_closed') + expect(paneSpawnReservationsByOwnerKey.has(key)).toBe(false) + expect(isAgentLaunchRunningIn(worktreeId, closedPane)).toBe(false) + await preparationDone + expect(drained).toHaveBeenCalledOnce() + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/pty/pty-daemon-spawn-session-identity.test.ts b/src/main/ipc/pty/pty-daemon-spawn-session-identity.test.ts index f33b8857db80..9c5410e77e2f 100644 --- a/src/main/ipc/pty/pty-daemon-spawn-session-identity.test.ts +++ b/src/main/ipc/pty/pty-daemon-spawn-session-identity.test.ts @@ -1,3 +1,8 @@ +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { TerminalCreateOptions } from '../../runtime/runtime-terminal-contracts' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { createPaneBindingStore, launchRacePane } from '../pty-launch-race-test-fixture' +import { clearProviderPtyState } from './provider/state-cleanup' import { describe, expect, it, vi } from 'vitest' import { openCodeClearPtyMock, piBuildPtyEnvMock, piClearPtyMock } from '../pty-ipc-mock-registry' import { awaitExplicitAgentPluginGuestReadiness } from '../../agent-hooks/wsl-agent-plugin-readiness' @@ -58,9 +63,161 @@ vi.mock('../../agent-hooks/wsl-agent-plugin-readiness', () => ({ awaitExplicitAgentPluginGuestReadiness: vi.fn(async () => true) })) +class ProducerRuntime extends OrcaRuntimeService { + publishedTabs(worktreeId: string) { + return this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs + } + + publishedPty(ptyId: string) { + return this.ptysById.get(ptyId) + } + + override assertPtyDidNotExitBeforeRegistration(ptyId: string, incarnationId?: string): void { + super.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId) + } +} + describe('registerPtyHandlers', () => { - const { handlers, mainWindow, recoveredAgentClaim, recoveredAgentSurface } = setupPtyIpcSuite() + const { + handlers, + mainWindow, + recoveredAgentClaim, + recoveredAgentSurface, + installDaemonTestProvider + } = setupPtyIpcSuite() + it.each([ + { mode: 'reconnect', rejectProducerRegistration: false }, + { mode: 'claim', rejectProducerRegistration: false }, + { mode: 'reconnect', rejectProducerRegistration: true }, + { mode: 'claim', rejectProducerRegistration: true } + ] as const)( + 'preserves producer registration without publishing a reconnect as a new launch for $mode (reject=$rejectProducerRegistration)', + async ({ mode, rejectProducerRegistration }) => { + const pane = launchRacePane(`producer-${mode}`, '96969696-9696-4696-8696-969696969696') + const store = Object.assign(createPaneBindingStore(), { getSettings: () => ({}) }) + const runtime = new ProducerRuntime() + Object.assign(runtime, { + store, + resolveTerminalWorkspaceLaunchScope: async () => ({ + id: pane.worktreeId, + path: pane.args.cwd, + connectionId: null + }), + resolveAgentTerminalCreateOptions: async ( + _workspace: unknown, + opts: TerminalCreateOptions + ) => opts, + resolveWorkspaceTerminalStartupCwd: () => pane.args.cwd + }) + const claim = recoveredAgentClaim + const surface = { ...recoveredAgentSurface, worktreeId: pane.worktreeId } + const id = `producer-${mode}` + const incarnationId = `${id}-inc` + const spawn = vi.fn(async () => ({ + id, + incarnationId, + isReattach: true, + ...(mode === 'claim' + ? { + agentSessionEnsure: { + disposition: 'adopted' as const, + owner: { + claim, + surface, + ptyId: id, + generation: 'claim-generation', + phase: 'live' as const + } + } + } + : {}) + })) + installDaemonTestProvider({ + spawn, + listProcesses: vi.fn(async () => [ + { id, incarnationId, cwd: pane.args.cwd, title: 'Codex' } + ]), + providesAgentSessionOwnerListings: () => false + }) + // SAFETY: the shared fixture supplies the durable store interface used by the real producer. + registerPtyHandlers( + mainWindow as never, + runtime, + undefined, + undefined, + undefined, + store as never + ) + const register = vi.spyOn(runtime, 'registerPty') + const originalAssert = runtime.assertPtyDidNotExitBeforeRegistration.bind(runtime) + const producerAssert = vi.fn() + const releaseFence = vi.spyOn(runtime, 'releaseRejectedPtyRegistrationFence') + vi.spyOn(runtime, 'assertPtyDidNotExitBeforeRegistration').mockImplementation( + (id, incarnation) => { + if (register.mock.calls.length === 1 && register.mock.results[0]?.type === 'return') { + producerAssert(id, incarnation) + if (rejectProducerRegistration) { + throw new Error('agent_session_exited_during_start') + } + } + originalAssert(id, incarnation) + } + ) + const expected = mode === 'claim' ? surface : { ...pane, terminalHandle: 'producer-handle' } + const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + const creating = runtime.createTerminal(`id:${pane.worktreeId}`, { + tabId: pane.tabId, + leafId: pane.leafId, + preAllocatedHandle: 'producer-handle', + sessionId: id, + title: 'Requested title', + launchAgent: 'codex', + launchConfig, + launchToken: 'producer-launch-token', + presentation: 'background', + ...(mode === 'claim' ? { agentSessionClaim: claim } : {}) + }) + if (rejectProducerRegistration) { + await expect(creating).rejects.toThrow('agent_session_exited_during_start') + expect(producerAssert).toHaveBeenCalledExactlyOnceWith(id, incarnationId) + expect(releaseFence).toHaveBeenCalledExactlyOnceWith(id, incarnationId) + expect(register).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() + clearProviderPtyState(id) + return + } + const created = await creating + expect(spawn).toHaveBeenCalledOnce() + expect(created).toMatchObject({ + ptyId: id, + tabId: expected.tabId, + paneKey: makePaneKey(expected.tabId, expected.leafId), + title: null, + isReattach: true + }) + expect(created.launchConfig).toBeUndefined() + expect(created.launchToken).toBeUndefined() + expect(created.launchAgent).toBeUndefined() + expect(producerAssert).toHaveBeenCalledExactlyOnceWith(id, incarnationId) + expect(releaseFence).not.toHaveBeenCalled() + expect(register).toHaveBeenCalledTimes(2) + expect(register).toHaveBeenLastCalledWith(id, pane.worktreeId, null, { + tabId: expected.tabId, + leafId: expected.leafId, + terminalHandle: expected.terminalHandle, + incarnationId + }) + expect(runtime.publishedPty(id)).toMatchObject({ + title: null, + launchConfig: null, + launchToken: null, + launchAgent: null + }) + expect(runtime.publishedTabs(pane.worktreeId)).toBeUndefined() + clearProviderPtyState(id) + } + ) describe('spawn environment', () => { describe('daemon-active provider (parity with LocalPtyProvider)', () => { const { setupDaemonAdapter, daemonSpawnAndGetEnv } = createDaemonActiveProviderFixtures({ @@ -384,16 +541,18 @@ describe('registerPtyHandlers', () => { expect(piClearPtyMock).toHaveBeenCalledTimes(c.swept ? 1 : 0) }) - // Current host/window difference: the host accepts an empty ID without host env; the window rejects it. - it('an empty session id: the window refuses it; the host spawns without host env', async () => { - const lanes = startLanes() - await expect(lanes.spawn('window', { env: {}, sessionId: '' })).rejects.toThrow( - /Invalid PTY session id/ - ) - await lanes.spawn('host', { env: {}, sessionId: '' }) - expect(lanes.daemonSpawn.mock.calls.at(-1)![0].env.ORCA_AGENT_HOOK_PORT).toBeUndefined() - expect(piBuildPtyEnvMock).not.toHaveBeenCalled() - }) + it.each(['', ' '])( + 'normalizes a blank id %j before building either host env', + async (sessionId) => { + const lanes = startLanes() + for (const lane of ['window', 'host'] as const) { + await lanes.spawn(lane, { env: {}, sessionId }) + expect(lanes.daemonSpawn.mock.calls.at(-1)![0].sessionId).toBeTruthy() + expect(lanes.daemonSpawn.mock.calls.at(-1)![0].env.ORCA_AGENT_HOOK_PORT).toBeDefined() + } + expect(piBuildPtyEnvMock).toHaveBeenCalled() + } + ) it('both daemon lanes run the WSL Pi/OMP guest readiness wait once', async () => { const lanes = startLanes() diff --git a/src/main/ipc/pty/pty-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty/pty-hidden-at-spawn-mark.test.ts index b0fc04d8a99f..e11cb1989651 100644 --- a/src/main/ipc/pty/pty-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty/pty-hidden-at-spawn-mark.test.ts @@ -96,8 +96,8 @@ describe('registerPtyHandlers', () => { sessionId: 'daemon-session', initiallyHidden: true }) as Promise<{ id: string }> - // Let the handler run up to the awaited provider.spawn. - await Promise.resolve() + // The hidden mark must precede the provider, regardless of preparation awaits. + await vi.waitFor(() => expect(daemon.spawn).toHaveBeenCalledOnce()) mainWindow.webContents.send.mockClear() // Daemon output can precede spawn resolution; the hidden mark must already apply. diff --git a/src/main/ipc/pty/pty-launch-race-parity.test.ts b/src/main/ipc/pty/pty-launch-race-parity.test.ts index 59551209a1d0..8eccf4e89d99 100644 --- a/src/main/ipc/pty/pty-launch-race-parity.test.ts +++ b/src/main/ipc/pty/pty-launch-race-parity.test.ts @@ -1,6 +1,10 @@ +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { TerminalCreateOptions } from '../../runtime/runtime-terminal-contracts' +import { registerPtyHandlers } from './register-handlers' +import { makePaneKey } from '../../../shared/stable-pane-id' import { launchWorkspaceId } from '../../../shared/launch-parity-window-request.test-fixture' import { POSIX_PATH } from '../../../shared/launch-parity-window-cases.test-fixture' -import { statSyncMock } from '../pty-ipc-mock-registry' +import { statSyncMock, forgetRefusedPaneResumeMock } from '../pty-ipc-mock-registry' import { spawnBoth, startParityLanes, @@ -18,6 +22,7 @@ import { WORKTREE_TERMINALS_SLEEPING_ERROR } from '../../runtime/worktree-termin import { RestoredPaneStarter } from '../../runtime/terminal/recovery/restored-pane-start' import { LAUNCH_RACE_LANES, + createPaneBindingStore, createLaunchRaceRuntime, gatedClaudeAuth, launchRacePane, @@ -26,6 +31,12 @@ import { type LaunchRaceLane } from '../pty-launch-race-test-fixture' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../../shared/agent-session-record.test-fixture' + vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock())) vi.mock('node:fs/promises', async (importOriginal) => @@ -88,6 +99,281 @@ describe('launch race parity, both spawn lanes', () => { const suite = setupPtyIpcSuite() const { handlers, mainWindow, installDaemonTestProvider } = suite + it.each([ + { winner: 'producer', surface: 'existing' }, + { winner: 'producer', surface: 'new' }, + { winner: 'window', surface: 'existing' }, + { winner: 'producer', surface: 'successor' } + ])( + '$winner registration exit crosses the real producer/admission barrier ($surface)', + async ({ winner, surface }) => { + const pane = launchRacePane( + `cross-${winner}-${surface}`, + '94949494-9494-4494-8494-949494949494' + ) + const store = Object.assign(createPaneBindingStore(), { getSettings: () => ({}) }) + const runtime = new OrcaRuntimeService() + Object.assign(runtime, { + store, + resolveTerminalWorkspaceLaunchScope: async () => ({ + id: pane.worktreeId, + path: pane.args.cwd, + connectionId: null + }), + resolveAgentTerminalCreateOptions: async ( + _workspace: unknown, + opts: TerminalCreateOptions + ) => opts, + resolveWorkspaceTerminalStartupCwd: () => pane.args.cwd + }) + // SAFETY: the handler fixture supplies window ports; only the real mutable session and binding store are used. + registerPtyHandlers( + mainWindow as never, + runtime, + undefined, + undefined, + undefined, + store as never + ) + const admission = { + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + } as const + if (surface !== 'new') { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: pane.tabId, + worktreeId: pane.worktreeId, + title: 'terminal', + activeLeafId: pane.leafId, + layout: null + } + ], + leaves: [ + { + tabId: pane.tabId, + worktreeId: pane.worktreeId, + leafId: pane.leafId, + paneRuntimeId: 1, + ptyId: null + } + ] + }) + await store.admitTerminalPane(admission) + } + let release!: () => void + const gate = new Promise((done) => { + release = done + }) + const spawn = vi.fn(async () => { + await gate + return { + id: 'cross-exited', + incarnationId: 'cross-inc', + snapshot: 'final output', + snapshotKittyKeyboardFlags: 3 + } + }) + installDaemonTestProvider({ spawn }) + vi.spyOn(runtime, 'registerPty').mockImplementation(() => { + throw new Error('agent_session_exited_during_start') + }) + vi.spyOn(runtime, 'getPtyLivenessVerdict').mockReturnValue({ status: 'exited' }) + const withdraw = vi.spyOn(store, 'withdrawTerminalPane') + const windowStart = () => Promise.resolve(handlers.get('pty:spawn')!(null, pane.args)) + const producerStart = () => + runtime.createTerminal(`id:${pane.worktreeId}`, { tabId: pane.tabId, leafId: pane.leafId }) + const first = winner === 'producer' ? producerStart() : windowStart() + const firstObserved = first.catch((error: unknown) => error) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const joined = vi.spyOn(paneSpawnReservations, 'joinPaneSpawn') + onTestFinished(() => joined.mockRestore()) + const second = winner === 'producer' ? windowStart() : producerStart() + const secondObserved = second.catch((error: unknown) => error) + const key = paneSpawnReservations.makePaneSpawnReservationKey( + pane.worktreeId, + null, + makePaneKey(pane.tabId, pane.leafId) + )! + const canonical = paneSpawnReservations.paneSpawnReservationsByOwnerKey.get(key)! + if (winner === 'producer') { + const pending = paneSpawnReservations.pendingRuntimePaneCreatesByOwnerKey.get(key)! + const barrier = pending.promise + const waiting = vi.fn(() => barrier) + Object.defineProperty(pending, 'promise', { configurable: true, get: waiting }) + await vi.waitFor(() => expect(waiting).toHaveBeenCalled()) + } else { + await vi.waitFor(() => expect(joined).toHaveBeenCalledOnce()) + } + if (surface === 'successor') { + const mutate = store.runDurableMutation.bind(store) + vi.spyOn(store, 'runDurableMutation').mockImplementation(async (write) => { + const value = await mutate(write) + if (!store.getWorkspaceSession().terminalLayoutsByTabId[pane.tabId]) { + await store.admitTerminalPane(admission) + await store.persistPtyBinding({ + worktreeId: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId, + ptyId: 'successor', + incarnationId: 'successor-inc' + }) + } + return value + }) + } + release() + const [firstResult, secondResult] = await Promise.all([firstObserved, secondObserved]) + const host = winner === 'producer' ? firstResult : secondResult + const window = winner === 'producer' ? secondResult : firstResult + expect(canonical.outcome?.kind).toBe('registration-exited') + expect(canonical.outcome?.result).not.toHaveProperty('isReattach') + expect(host).toMatchObject({ message: 'agent_session_exited_during_start' }) + if (surface === 'existing') { + expect(window).toMatchObject({ + id: 'cross-exited', + snapshot: 'final output', + snapshotKittyKeyboardFlags: undefined, + ...(winner === 'producer' ? { isReattach: true } : {}) + }) + if (winner === 'window') { + expect(window).not.toHaveProperty('isReattach') + } + } else { + expect(window).toMatchObject({ message: 'terminal_pane_owner_changed' }) + } + expect(spawn).toHaveBeenCalledOnce() + if (surface === 'new') { + expect(withdraw).toHaveBeenCalledOnce() + expect(store.getWorkspaceSession().terminalLayoutsByTabId[pane.tabId]).toBeUndefined() + } + clearProviderPtyState('cross-exited') + } + ) + it('restores a persisted window owner without imposing the runtime handle requirement', async () => { + const pane = launchRacePane('restore-no-handle', '95959595-9595-4595-8595-959595959595') + const id = 'persisted-no-handle' + const store = createPaneBindingStore() + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }) + await store.persistPtyBinding({ + worktreeId: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId, + ptyId: id + }) + const runtime = createLaunchRaceRuntime({ + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }) + }) + installDaemonTestProvider({ spawn: vi.fn(async () => ({ id, isReattach: true })) }) + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime, store }) + await expect(lanes.spawn.runtime(pane.args)).rejects.toThrow('terminal_pane_owner_unknown') + await expect(lanes.spawn.ipc(pane.args)).resolves.toMatchObject({ id, isReattach: true }) + clearProviderPtyState(id) + }) + it.each(LAUNCH_RACE_LANES)( + '%s lane: auth failure before geometry acquisition preserves the existing grid', + async (lane) => { + const id = `pre-seed-auth-${lane}` + const cached = { cols: 180, rows: 50 } + ptySizes.set(id, cached) + onTestFinished(() => { + ptySizes.delete(id) + }) + const spawn = vi.fn() + installDaemonTestProvider({ spawn }) + const prepareClaudeAuth = vi.fn(async () => { + throw new Error('auth unavailable') + }) + const lanes = registerLaunchRaceLanes({ + handlers, + mainWindow, + runtime: createLaunchRaceRuntime(), + prepareClaudeAuth + }) + await expect( + lanes.spawn[lane]({ cols: 80, rows: 24, sessionId: id, command: 'claude' }) + ).rejects.toThrow('auth unavailable') + expect(prepareClaudeAuth).toHaveBeenCalledOnce() + expect(spawn).not.toHaveBeenCalled() + expect(ptySizes.get(id)).toEqual(cached) + } + ) + + it('runtime joins known pane coordinates without workspace before auth preparation', async () => { + const id = 'no-workspace-winner' + let release!: () => void + const gate = new Promise((done) => { + release = done + }) + const spawn = vi.fn(async () => { + await gate + return { id } + }) + installDaemonTestProvider({ spawn }) + const auth = vi.fn(async () => { + throw new Error('duplicate auth') + }) + const lanes = registerLaunchRaceLanes({ + handlers, + mainWindow, + runtime: createLaunchRaceRuntime(), + prepareClaudeAuth: auth + }) + const args = { + cols: 80, + rows: 24, + tabId: 'tab-no-workspace', + leafId: '96969696-9696-4696-8696-969696969696' + } + const winner = lanes.spawn.runtime(args) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const joining = vi.spyOn(paneSpawnReservations, 'joinPaneSpawn') + onTestFinished(() => joining.mockRestore()) + const joiner = lanes.spawn.runtime({ ...args, command: 'claude' }) + const observed = joiner.catch((error: unknown) => error) + try { + await vi.waitFor(() => expect(joining).toHaveBeenCalledOnce()) + } finally { + release() + } + await expect(winner).resolves.toMatchObject({ id }) + await expect(observed).resolves.toMatchObject({ id }) + expect(auth).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledOnce() + clearProviderPtyState(id) + }) + + it('seeds a fresh same-id incarnation before provider byte zero and restores a failed start', async () => { + const id = 'fresh-same-id-grid' + const old = { cols: 91, rows: 31 } + ptySizes.set(id, old) + const spawn = vi.fn(async () => { + expect(ptySizes.get(id)).toEqual({ cols: 132, rows: 45 }) + throw new Error('provider refused') + }) + installDaemonTestProvider({ spawn }) + const lanes = registerLaunchRaceLanes({ + handlers, + mainWindow, + runtime: createLaunchRaceRuntime() + }) + await expect( + lanes.spawn.runtime({ cols: 132, rows: 45, sessionId: id, isNewSession: true }) + ).rejects.toThrow('provider refused') + expect(ptySizes.get(id)).toEqual(old) + ptySizes.delete(id) + }) it('allows fallback for an unreadable request without healing its saved folder or mobile snapshot', async () => { setMainPlatform('darwin') onTestFinished(restoreMainPlatform) @@ -221,7 +507,11 @@ describe('launch race parity, both spawn lanes', () => { leafId: pane.leafId }) const automatic = lanes.spawn - .runtime({ ...pane.args, refuseSleptWorktree: true }) + .runtime({ + ...pane.args, + refuseSleptWorktree: true, + paneSpawnOperation: { mustExist: false } + }) .finally(release) await vi.waitFor(() => expect(lock.acquireWorktreeTerminalSpawn).toHaveBeenCalledOnce()) const mount = lanes.spawn.ipc(pane.args) @@ -456,14 +746,48 @@ describe('launch race parity, both spawn lanes', () => { async (lane) => { const pane = launchRacePane(`race-exited-${lane}`, '83838383-8383-4383-8383-838383838383') const ptyId = `pty-exited-${lane}` + const paneKey = makePaneKey(pane.tabId, pane.leafId) + const providerSession = { key: 'session_id' as const, id: 'provider-session-alpha-1' } + const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) + ) + const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + records.listRecords = () => [chat] + records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] }) + const forget = vi.fn(async () => {}) + const remember = vi.fn() installDaemonTestProvider({ - spawn: vi.fn(async () => ({ id: ptyId, incarnationId: 'inc-exited' })) + spawn: vi.fn(async () => ({ + id: ptyId, + incarnationId: 'inc-exited', + coldRestore: { scrollback: 'final', cwd: '/tmp' } + })) }) const runtime = createLaunchRaceRuntime({ registerPty: vi.fn(() => { throw new Error('agent_session_exited_during_start') }), - getPtyLivenessVerdict: vi.fn(() => ({ status: 'exited' })) + getPtyLivenessVerdict: vi.fn(() => ({ status: 'exited' })), + openAgentSessionRecordStore: async () => records, + getAgentProviderSessionRows: () => [ + { + paneKey, + connectionId: null, + agentType: 'claude', + state: 'working', + prompt: '', + receivedAt: 1, + stateStartedAt: 1, + providerSession + } + ], + forgetRefusedSavedResume: forget, + rememberSavedTerminalLaunchResult: remember }) const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime }) forgetPtyAfterTest(ptyId) @@ -471,7 +795,12 @@ describe('launch race parity, both spawn lanes', () => { const spawn = lanes.spawn[lane](pane.args) await (EXITED_DURING_REGISTRATION[lane] === 'resolves' - ? expect(spawn).resolves.toMatchObject({ id: ptyId, incarnationId: 'inc-exited' }) + ? expect(spawn).resolves.toMatchObject({ + id: ptyId, + incarnationId: 'inc-exited', + agentResumeRefused: 'chat-home', + savedResumeDecision: { agent: 'claude', providerSession, verdict: 'chat-home' } + }) : expect(spawn).rejects.toThrow('agent_session_exited_during_start')) expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith( ptyId, @@ -480,6 +809,11 @@ describe('launch race parity, both spawn lanes', () => { ) expect(runtime.noteTerminalSpawnCommit).not.toHaveBeenCalled() expect(runtime.reflowHeadlessTerminalToPtyGrid).not.toHaveBeenCalled() + expect([ + forget.mock.calls.length, + remember.mock.calls.length, + forgetRefusedPaneResumeMock.mock.calls.length + ]).toEqual(lane === 'ipc' ? [1, 1, 1] : [0, 0, 0]) } ) diff --git a/src/main/ipc/pty/pty-pane-materialization-race.test.ts b/src/main/ipc/pty/pty-pane-materialization-race.test.ts index f15938f805f0..34d1f728ad33 100644 --- a/src/main/ipc/pty/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty/pty-pane-materialization-race.test.ts @@ -148,7 +148,7 @@ describe('registerPtyHandlers', () => { expect(providerSpawn).toHaveBeenCalledOnce() expect(preparedAfterRelease).toEqual([true]) }) - it('adopts a runtime reservation created during renderer preflight', async () => { + it('runtime joins the window acquisition before awaited authentication', async () => { type RuntimeSpawnController = { spawn(args: { cols: number @@ -242,22 +242,28 @@ describe('registerPtyHandlers', () => { const runtimeSpawn = (controller as unknown as RuntimeSpawnController).spawn({ ...paneArgs, + cols: 132, + rows: 45, command: 'printf runtime' }) - await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) + await new Promise((resolve) => setImmediate(resolve)) + expect(providerSpawn).not.toHaveBeenCalled() releaseAuth({ configDir: '/tmp/claude', envPatch: {}, provenance: 'managed:test' }) - await new Promise((resolve) => setImmediate(resolve)) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) resolveProviderSpawn({ id: 'pty-runtime-reservation' }) - await expect(runtimeSpawn).resolves.toEqual({ id: 'pty-runtime-reservation' }) - await expect(rendererSpawn).resolves.toMatchObject({ + await expect(runtimeSpawn).resolves.toMatchObject({ id: 'pty-runtime-reservation', isReattach: true }) + const windowResult = await rendererSpawn + expect(windowResult).toMatchObject({ id: 'pty-runtime-reservation' }) + expect(windowResult).not.toHaveProperty('isReattach') + expect(prepareClaudeAuth).toHaveBeenCalledOnce() expect(providerSpawn).toHaveBeenCalledOnce() await expect( handlers.get('pty:getSize')!(null, { id: 'pty-runtime-reservation' }) diff --git a/src/main/ipc/pty/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty/pty-spawn-runtime-handle-binding.test.ts index 2a80c7fba07a..2402c9ef4377 100644 --- a/src/main/ipc/pty/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty/pty-spawn-runtime-handle-binding.test.ts @@ -413,7 +413,7 @@ describe('registerPtyHandlers', () => { hostId: 'local' }) }) - it('releases the Agent Teams leader of a spawn that loses the pane mid-preflight', async () => { + it('joins an early pane winner without allocating a second Agent Teams leader', async () => { const leafId = '22222222-2222-4222-8222-222222222222' const teams = new ClaudeAgentTeamsService() let handleSeq = 0 @@ -441,7 +441,6 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(mainWindow as never, runtime as never) - // Without worktreeId, reservation identity arrives only after environment assembly, allowing a concurrent winner. const spawnArgs = { cols: 80, rows: 24, @@ -472,9 +471,9 @@ describe('registerPtyHandlers', () => { clearPaneSpawnReservation(reservationKey, winnerReservation) } - const loserHandle = runtime.prepareClaudeAgentTeamsLeaderForHandle.mock.calls[1]?.[0].handle - expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledTimes(2) - expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(loserHandle) + expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledOnce() + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).not.toHaveBeenCalled() + // Joining before preparation owns no new allocation to release. expect(teams.getActiveTeamCount()).toBe(1) }) it('releases the Agent Teams leader when its provider spawn fails', async () => { diff --git a/src/main/ipc/pty/pty-write-ipc-validation.test.ts b/src/main/ipc/pty/pty-write-ipc-validation.test.ts index 4d8440cbb210..f066af355f03 100644 --- a/src/main/ipc/pty/pty-write-ipc-validation.test.ts +++ b/src/main/ipc/pty/pty-write-ipc-validation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { spawnMock, recordCodexPaneAccountMock } from '../pty-ipc-mock-registry' import { setupPtyIpcSuite } from '../pty-ipc-test-harness' import { @@ -333,6 +334,55 @@ describe('registerPtyHandlers', () => { expect(reply.snapshotKittyKeyboardFlags).toBeUndefined() expect(reply.snapshotSeq).toBeUndefined() }) + it.each(['interleaved', 'covered', 'zero', 'no-flags'] as const)( + 'constructs an image response from real runtime sequence evidence: %s', + async (proof) => { + const id = `image-${proof}` + const runtime = new OrcaRuntimeService() + const boundary = proof === 'zero' ? 0 : 900 + runtime.synchronizePtyOutputSequenceFromProvider(id, { + value: boundary, + generation: 'continued' + }) + const spawn = vi.fn(async () => { + if (proof === 'interleaved') { + expect(runtime.onPtyData(id, 'NEW', Date.now())).toBe(903) + } + return { + id, + isReattach: true, + snapshot: 'OLD', + providerSequence: { value: boundary, generation: 'continued' as const }, + ...(proof !== 'no-flags' ? { snapshotKittyKeyboardFlags: 0 } : {}) + } + }) + setLocalPtyProvider({ + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + registerPtyHandlers(mainWindow as never, runtime) + const reply = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: id }) + expect(reply).toMatchObject({ id, snapshot: 'OLD' }) + if (proof === 'covered' || proof === 'zero') { + expect(reply).toHaveProperty('snapshotSeq', boundary) + } else { + expect(reply).not.toHaveProperty('snapshotSeq') + } + expect(reply).toHaveProperty( + 'snapshotKittyKeyboardFlags', + proof === 'covered' || proof === 'zero' ? 0 : undefined + ) + expect(runtime.getPtyOutputSequence(id)).toBe(proof === 'interleaved' ? 903 : boundary) + expect(spawn).toHaveBeenCalledOnce() + } + ) it('records the launch Codex account for a fresh spawn but not for a reattach', async () => { const spawn = vi .fn() diff --git a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts index 39a394cb546b..91e85b7bd701 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts @@ -1,3 +1,4 @@ +import { restoreProvisionalPtySize, seedPtySpawnSize } from '../spawn-options-assembly' import { afterEach, describe, expect, it, vi } from 'vitest' import { ptySizes } from '../delivery/visibility-state' import { commitRuntimePtySpawn } from './spawn-commit' @@ -44,6 +45,35 @@ describe('runtime spawn commit: adopted agent-session claim', () => { return { runtime, ctx } } + it('preserves a same-id winner grid when releasing a provisional loser seed', async () => { + const winnerGrid = { cols: 132, rows: 45 } + ptySizes.set(PTY_ID, { cols: 91, rows: 31 }) + const loser = { + provisionalPtySize: seedPtySpawnSize({ + sessionId: PTY_ID, + sessionAppId: PTY_ID, + isFreshSessionId: true, + requestIsUnmeasured: false, + cols: 80, + rows: 24 + }) + } + expect(ptySizes.get(PTY_ID)).toEqual({ cols: 80, rows: 24 }) + const { ctx } = makeAdoptedCtx({ + isReattach: true, + snapshotCols: winnerGrid.cols, + snapshotRows: winnerGrid.rows + }) + await commitRuntimePtySpawn(ctx) + expect(ptySizes.get(PTY_ID)).toEqual(winnerGrid) + + restoreProvisionalPtySize(loser, { reattachId: PTY_ID }) + expect(loser.provisionalPtySize).toBeUndefined() + expect(ptySizes.get(PTY_ID)).toEqual(winnerGrid) + restoreProvisionalPtySize(loser) + expect(ptySizes.get(PTY_ID)).toEqual(winnerGrid) + }) + it('commits the live grid from the adoption reply before the early return', async () => { const { runtime, ctx } = makeAdoptedCtx({ isReattach: true, @@ -55,7 +85,8 @@ describe('runtime spawn commit: adopted agent-session claim', () => { const reply = await commitRuntimePtySpawn(ctx) - expect(reply).toMatchObject({ + expect(reply.kind).toBe('adopted') + expect(reply.result).toMatchObject({ id: PTY_ID, isReattach: true, launchAgent: 'codex', @@ -75,7 +106,14 @@ describe('runtime spawn commit: adopted agent-session claim', () => { it('treats an adoption without a reattach flag as an attach and keeps the held size', async () => { ptySizes.set(PTY_ID, LIVE_GRID) const { runtime, ctx } = makeAdoptedCtx({}) - ctx.sessionSizeBeforeAttach = LIVE_GRID + ctx.provisionalPtySize = seedPtySpawnSize({ + sessionId: PTY_ID, + sessionAppId: PTY_ID, + isFreshSessionId: false, + requestIsUnmeasured: false, + cols: 120, + rows: 40 + }) await commitRuntimePtySpawn(ctx) diff --git a/src/main/ipc/pty/runtime/spawn-commit-response.ts b/src/main/ipc/pty/runtime/spawn-commit-response.ts index 7831b1c3094e..2ea9dcfc435d 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-response.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-response.ts @@ -1,4 +1,3 @@ -import type { StartupCwdFallback } from '../../../../shared/startup-cwd-fallback' import type { RuntimePtySpawnState } from './spawn-state' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import type { PaneResumeOutcome } from '../../../../shared/workspace-layout/workspace-layout-published' @@ -29,41 +28,3 @@ export function reportRuntimeResumeOutcome(ctx: RuntimeResumeOutcomeState): void ctx.args.onResumeOutcome?.(ctx.result.id, outcome) } } - -/** Joiners receive attach payload at its proven kitty boundary; the caller also receives saved folder/host fields. */ -export function runtimeSpawnJoinPayload( - ctx: RuntimePtySpawnState, - startupCwdFallback: StartupCwdFallback | undefined, - correctStartupCwd: string | undefined -) { - const args = ctx.args - const savedStartupCwd = correctStartupCwd ?? ctx.requestedCwd - const response = { - savedStartupCwd, - id: ctx.result.id, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - ...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle) - ? { - stablePaneOwner: { - handle: ctx.stablePaneOwner.handle ?? args.preAllocatedHandle!, - tabId: ctx.stablePaneOwner.tabId, - leafId: ctx.stablePaneOwner.leafId - } - } - : {}), - ...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {}), - ...(startupCwdFallback ? { startupCwdFallback } : {}) - } - return { - reservation: { - ...ctx.result, - ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && - ctx.reconciledSnapshotSeq !== null && - ctx.snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: ctx.reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), - isReattach: true - }, - reply: { ...ctx.result, ...response, wslDistro: ctx.result.wslDistro ?? undefined } - } -} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index e0fe111daec3..9b661e4a718c 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,4 +1,4 @@ -import { runtimeSpawnJoinPayload } from './spawn-commit-response' +import { provenSpawnResponse } from '../spawn-outcome' import { isNativeWindowsLocalPtySpawn } from '../../../runtime/terminal-model-query-authority' import { normalizeValidPaneKey } from '../pane/key-state' import type { RuntimePtySpawnState } from './spawn-state' @@ -35,13 +35,15 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { preAllocatedHandle: args.preAllocatedHandle, // Runtime spawns bypass renderer IPC; publish reverse pane lookup for hooks and cleanup. paneKey: normalizeValidPaneKey(ctx.env?.ORCA_PANE_KEY), - memorySessionId: ctx.sessionId ?? null, resumeArgvDropped: ctx.codexResumeArgvDropped, codexLaunchEnv: ctx.env, initiallyHidden: args.initiallyHidden === true, hiddenDelivery: 'runtime-owned', - earlyExit: 'throw', - joinPayload: runtimeSpawnJoinPayload(ctx, startupCwdFallback, correctStartupCwd) + response: { + ...provenSpawnResponse(ctx.result, ctx.provedSnapshotSeq), + savedStartupCwd: correctStartupCwd ?? ctx.requestedCwd, + ...(startupCwdFallback ? { startupCwdFallback } : {}) + } }) }) } diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index e84ed72d37ed..dcb5d2568f77 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -1,75 +1,23 @@ -import type { PtySpawnResult } from '../../../providers/pty-provider-contract' -import { getAppPtyId } from '../provider/registry' -import { allocatePtyLifecycleSequence } from '../host-env/types' -import { snapshotCodexPaneHomeRoutes, codexReattachedHomeRouteField } from '../host-env/codex-home' -import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' -import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { RuntimePtySpawnState } from './spawn-state' -import { getConnectionExecutionHostId } from '../../../../shared/execution-host' +import { requireAcceptedSpawn } from '../spawn-outcome' +import { beginPtySpawn, reportPtyPaneAdoption } from '../spawn-begin' -export function adoptMaterializedRuntimePtySpawn( - ctx: RuntimePtySpawnState, - startupAlreadyAwaited = false -): Promise | PtySpawnResult | null { - const args = ctx.args - // Re-entry after startup must retain the original lifecycle sequence and routing snapshot. - if (!startupAlreadyAwaited) { - ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - ctx.codexHomeLaunchStartedSequence = !args.connectionId - ? allocatePtyLifecycleSequence() - : undefined - ctx.preAdoptedStablePane = args.adoptedStablePane ?? null - ctx.reattachedCodexHomeRoutes = !args.connectionId - ? new Map( - snapshotCodexPaneHomeRoutes([ - ctx.preAdoptedStablePane?.result.id, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - ) - : new Map() - } - const startupPromise = ctx.deps.getLocalPtyStartupPromise( - getConnectionExecutionHostId(args.connectionId) - ) - if (startupPromise && !startupAlreadyAwaited) { - return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) - } - if (!ctx.preAdoptedStablePane?.materialized) { +/** Host reply projection only; acquisition and process lifetime belong to the pane operation. */ +export async function adoptMaterializedRuntimePtySpawn(ctx: RuntimePtySpawnState) { + const adopted = ctx.args.adoptedStablePane + if (!adopted?.materialized) { return null } - const handle = ctx.preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle + ctx.preAdoptedStablePane = adopted + await beginPtySpawn(ctx) + requireAcceptedSpawn(adopted.outcome ?? { kind: 'adopted', result: adopted.result }) + const handle = adopted.owner.handle ?? ctx.args.preAllocatedHandle if (!handle) { throw new Error('terminal_pane_owner_unknown') } - ctx.result = { - ...ctx.preAdoptedStablePane.result, - isReattach: true, - id: ctx.preAdoptedStablePane.result.id, - ...(ctx.preAdoptedStablePane.result.incarnationId - ? { incarnationId: ctx.preAdoptedStablePane.result.incarnationId } - : {}), - ...(typeof ctx.preAdoptedStablePane.result.wslDistro === 'string' - ? { wslDistro: ctx.preAdoptedStablePane.result.wslDistro } - : {}), - stablePaneOwner: { - handle, - tabId: ctx.preAdoptedStablePane.owner.tabId, - leafId: ctx.preAdoptedStablePane.owner.leafId - } - } - ensureWslHookRelayForReattach( - { isReattach: true, wslDistro: ctx.preAdoptedStablePane.result.wslDistro }, - args.connectionId - ) - if (!args.connectionId) { - ctx.deps.options?.onCodexHomePtySpawned?.({ - id: ctx.result.id, - codexHomePath: null, - reattached: true, - startedAt: ctx.codexHomeLaunchStartedAt, - startedSequence: ctx.codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true) - }) + reportPtyPaneAdoption(ctx, adopted) + return { + ...adopted.result, + stablePaneOwner: { handle, tabId: adopted.owner.tabId, leafId: adopted.owner.leafId } } - return ctx.result } diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts index 9b62577782fd..33a9ce172991 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -9,7 +9,7 @@ export function markRuntimeSpawnHiddenBeforeSpawn(ctx: RuntimePtySpawnState): vo // Reattached IDs may already back visible panes. if ( ctx.args.initiallyHidden !== true || - !ctx.isNewDaemonSession || + !ctx.ownsNewIncarnation || ctx.effectiveSessionAppId === undefined ) { return diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 9de67a179afe..10d3cad8b9ef 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -74,7 +74,7 @@ export async function buildRuntimePtySpawnOptions( prevalidatedCwd: ctx.prevalidatedCwd, isDaemonHostSpawn: ctx.isDaemonHostSpawn, preAdoptedStablePane: ctx.preAdoptedStablePane !== null, - isNewSession: ctx.isNewDaemonSession, + isNewSession: ctx.ownsNewIncarnation, codexRuntime: ctx.codexSelectionTarget.runtime, expectedWslDistro: ctx.expectedWslDistro, requestedAgentTeamsPath: ctx.requestedAgentTeamsPath, @@ -91,17 +91,15 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions = launch.spawnOptions ctx.env = launch.env ctx.launchCommand = launch.launchCommand - const seeded = seedPtySpawnSize({ + ctx.provisionalPtySize = seedPtySpawnSize({ sessionId: ctx.sessionId, sessionAppId: ctx.effectiveSessionAppId, - isFreshSessionId: ctx.isNewDaemonSession, - // Runtime requests are measured; only cached geometry can outrank them here. + isFreshSessionId: ctx.ownsNewIncarnation, + // Host requests, including defaults, are intentional; only a live cached grid outranks them. requestIsUnmeasured: false, cols: args.cols, rows: args.rows }) - ctx.hadSessionSizeBeforeAttach = seeded.hadSessionSizeBeforeAttach - ctx.sessionSizeBeforeAttach = seeded.sessionSizeBeforeAttach if ( !ctx.preAdoptedStablePane && args.agentSessionEnsure && @@ -151,10 +149,7 @@ export async function buildRuntimePtySpawnOptions( ctx.stablePaneBindingPersisted = false ctx.rejectedRegistrationCandidate = null ctx.pendingRegistrationPtyId = null - // Carry renderer-domain sequence reconciliation into the reply beside proven kitty flags. - ctx.reconciledSnapshotSeq = null - // Bytes arriving during spawn can postdate proven flags; preserve any negotiation scanned live. - ctx.snapshotKittyFlagsCoverReconciledSeq = true + ctx.provedSnapshotSeq = undefined ctx.preparedProvisionalExecutionContext = false return null diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index afe5f1ad48f5..5733957e44b3 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -15,7 +15,8 @@ import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { finishPtyShutdown } from '../provider/liveness' import { listStructuredProviderSessionOwnership } from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' import type { PtyRuntimeControllerDeps } from './controller-deps' -import { executeRuntimePtySpawn } from './spawn-execute' +import { executePtySpawn } from '../spawn-execute' +import { prepareSavedPtyResume } from '../spawn-resume' import { admitColdRestoreReattach, admitResumeSpawn, @@ -23,6 +24,20 @@ import { } from '../host-env/saved-resume-admission' import { createRuntimePtySpawnState } from './spawn-state' +async function executePreparedSpawn(ctx: ReturnType) { + const facts = { + sessionId: ctx.sessionId, + paneKey: ctx.spawnIdentityPaneKey, + savedResumePaneKey: ctx.spawnIdentityPaneKey, + launchConfig: ctx.args.launchConfig, + suppressRefusedLaunch: () => { + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + } + } + await executePtySpawn(ctx, facts, await prepareSavedPtyResume(ctx, facts)) +} + const forgetRefusedPaneResume = vi.hoisted(() => vi.fn()) vi.mock('../../../agent-hooks/server', () => ({ agentHookServer: { forgetRefusedPaneResume } })) @@ -91,7 +106,10 @@ function deps(runtime: ReturnType): PtyRuntimeControllerDeps { runtime: runtime as unknown as OrcaRuntimeService, store: undefined, getSettings: () => getDefaultSettings('/tmp'), - adoptStablePane: async () => null, + adoptStablePane: async (args) => { + await args.spawnFresh?.({ mustExist: false }) + return null + }, getLocalPtyStartupPromise: () => undefined, getLocalPtyProviderStartupPromise: () => undefined, prepareCodexResumeHome: () => null, @@ -179,7 +197,7 @@ describe('actual runtime saved-resume outcomes', () => { if (ordinary) { delete ctx.args.resumeOrigin } - await executeRuntimePtySpawn(ctx) + await executePreparedSpawn(ctx) return ctx } const spawn = vi.spyOn(localProvider, 'spawn').mockResolvedValue({ id: 'counted-shell' }) @@ -233,7 +251,7 @@ describe('actual runtime saved-resume outcomes', () => { const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'plain-shell' }) try { expect(ctx.args.command).toContain(C) - await executeRuntimePtySpawn(ctx) + await executePreparedSpawn(ctx) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ env: { WORKSPACE: 'keep' } @@ -263,7 +281,7 @@ describe('actual runtime saved-resume outcomes', () => { launchAgent: 'codex' }) try { - await executeRuntimePtySpawn(ctx) + await executePreparedSpawn(ctx) expect(ctx.result).toMatchObject({ id: 'already-live', isReattach: true, @@ -326,7 +344,7 @@ it('unwinds a refused fresh claim before spawning an unclaimed shell', async () const inventory = vi.spyOn(owners, 'reconcileAgentSessionOwnerListings').mockResolvedValue() const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'refused-shell' }) try { - await executeRuntimePtySpawn(ctx) + await executePreparedSpawn(ctx) expect(spawn).toHaveBeenCalledOnce() expect(ctx.spawnOptions.command).toBeUndefined() expect(ctx.result.agentSessionEnsure).toBeUndefined() diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 6b0b6a742bc2..05478f90fd82 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -45,7 +45,7 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command) ctx.isDaemonHostSpawn = !(ctx.provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(ctx.provider) - ctx.callerRequestedSessionId = args.sessionId?.trim() + ctx.callerRequestedSessionId = args.sessionId ctx.requestedSessionId = ctx.callerRequestedSessionId ?? (ctx.isDaemonHostSpawn && args.agentSessionCreateOperationId @@ -55,7 +55,7 @@ export async function prepareRuntimePtySpawn( ctx.requestedSessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) ctx.effectiveSessionAppId = ctx.sessionId - ctx.isNewDaemonSession = + ctx.ownsNewIncarnation = !ctx.preAdoptedStablePane && ctx.isDaemonHostSpawn && (ctx.callerRequestedSessionId === undefined || args.isNewSession === true) diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 93bf1708104d..57a29c177e05 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -1,3 +1,5 @@ +import type { PtySpawnSizeSeed } from '../spawn-options-assembly' +import type { PtySpawnOutcome } from '../spawn-outcome' import type { PtyRegistrationClaim } from '../../../runtime/runtime-terminal-state-records' import type { PaneSpawnOperation } from '../pane/pane-spawn-operation' import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' @@ -53,7 +55,7 @@ export type RuntimePtySpawnState = { requestedSessionId: string | undefined sessionId: string | undefined effectiveSessionAppId: string | undefined - isNewDaemonSession: boolean + ownsNewIncarnation: boolean expectedWslDistro: string | null /** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */ resumeAdmission: ResumeSpawnAdmission | null @@ -82,8 +84,7 @@ export type RuntimePtySpawnState = { skipCodexHomeEnv: boolean stripInheritedOrcaCodexHome: boolean spawnOptions: PtySpawnOptions - hadSessionSizeBeforeAttach: boolean - sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + provisionalPtySize: PtySpawnSizeSeed | undefined materializedPaneKey: string | null metadataLeafId: string | null metadataPaneKey: string | null @@ -100,8 +101,7 @@ export type RuntimePtySpawnState = { rejectedRegistrationCandidate: PtySpawnResult | null pendingRegistrationPtyId: string | null pendingRegistrationClaim?: PtyRegistrationClaim - reconciledSnapshotSeq: number | null - snapshotKittyFlagsCoverReconciledSeq: boolean + provedSnapshotSeq: number | undefined preparedProvisionalExecutionContext: boolean releaseWorktreeSpawn: (() => void) | undefined reportPtySpawnCommitted: () => void @@ -158,6 +158,8 @@ export type RuntimePtySpawnArgs = { onResumeOutcome?: (ptyId: string, outcome: PaneResumeOutcome) => void paneSpawnOperation?: PaneSpawnOperation adoptedStablePane?: { + operation?: PaneSpawnOperation + outcome?: PtySpawnOutcome result: PtySpawnResult owner: { handle?: string @@ -195,7 +197,7 @@ export function createRuntimePtySpawnState( requestedSessionId: undefined, sessionId: undefined, effectiveSessionAppId: undefined, - isNewDaemonSession: false, + ownsNewIncarnation: false, expectedWslDistro: null, resumeAdmission: null, codexSelectionTarget: { runtime: 'host' }, @@ -213,8 +215,7 @@ export function createRuntimePtySpawnState( skipCodexHomeEnv: false, stripInheritedOrcaCodexHome: false, spawnOptions: { cols: args.cols, rows: args.rows }, - hadSessionSizeBeforeAttach: false, - sessionSizeBeforeAttach: undefined, + provisionalPtySize: undefined, materializedPaneKey: null, metadataLeafId: null, metadataPaneKey: null, @@ -227,8 +228,7 @@ export function createRuntimePtySpawnState( stablePaneBindingPersisted: false, rejectedRegistrationCandidate: null, pendingRegistrationPtyId: null, - reconciledSnapshotSeq: null, - snapshotKittyFlagsCoverReconciledSeq: true, + provedSnapshotSeq: undefined, preparedProvisionalExecutionContext: false, releaseWorktreeSpawn: undefined, reportPtySpawnCommitted: () => {}, diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 71e27d10d0d7..506a503c5176 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -1,205 +1,74 @@ -import { - captureSavedResumeCleanup, - admitColdRestoreReattach, - savedResumeRecord, - savedResumeResult, - resumeSpawnRefusal, - forgetRefusedResume -} from '../host-env/saved-resume-admission' -import type { PtySpawnResult } from '../../../providers/pty-provider-contract' -import { isTerminalLeafId, parsePaneKey } from '../../../../shared/stable-pane-id' -import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' -import { restoreProvisionalPtySize } from '../spawn-options-assembly' +import { runPtySpawn, normalizePtySpawnSessionId } from '../spawn-operation' +import { requireAcceptedSpawn } from '../spawn-outcome' import type { PtyRuntimeControllerDeps } from './controller-deps' -import { adoptMaterializedRuntimePtySpawn } from './spawn-early' import { prepareRuntimePtySpawn } from './spawn-preflight' import { buildRuntimePtySpawnOptions } from './spawn-options' -import { executeRuntimePtySpawn } from './spawn-execute' import { commitRuntimePtySpawn } from './spawn-commit' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' import { commitRuntimeSpawnHiddenDelivery, + markRuntimeSpawnHiddenBeforeSpawn, releaseRuntimeSpawnPreSpawnHiddenMark } from './spawn-hidden-delivery' -function toRuntimeSpawnReply( - result: PtySpawnResult & { - stablePaneOwner?: { handle: string; tabId: string; leafId: string } - savedStartupCwd?: string - } -) { - return { ...result, wslDistro: result.wslDistro ?? undefined } -} - +/** Runtime projection stays inside the admitted start, so a rejected start withdraws its new pane. */ export async function spawnPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, - args: RuntimePtySpawnArgs -) { - if ( - args.paneSpawnOperation || - !args.tabId || - !args.leafId || - !isValidTerminalTabId(args.tabId) || - !isTerminalLeafId(args.leafId) - ) { - return spawnFreshPtyFromRuntimeController(deps, args) - } - let freshResult: Awaited> | undefined - const adopted = await deps.adoptStablePane({ - cols: args.cols, - rows: args.rows, - cwd: args.cwd, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId, - connectionId: args.connectionId, - preAllocatedHandle: args.preAllocatedHandle, - signal: args.signal, - completed: args.adoptedStablePane, - spawnFresh: async (operation) => { - freshResult = await spawnFreshPtyFromRuntimeController(deps, { - ...args, - paneSpawnOperation: operation - }) - return freshResult - } - }) - if (!adopted) { - return toRuntimeSpawnReply(freshResult!) - } - Object.assign(adopted.result, deps.runtime?.getTerminalLaunchResult?.(adopted.result.id)) - if (!args.worktreeId) { - return toRuntimeSpawnReply(adopted.result) - } - const completed = await adoptMaterializedRuntimePtySpawn( - createRuntimePtySpawnState(deps, { ...args, adoptedStablePane: adopted }) - ) - return toRuntimeSpawnReply(completed ?? adopted.result) -} - -async function spawnFreshPtyFromRuntimeController( - deps: PtyRuntimeControllerDeps, - args: RuntimePtySpawnArgs + request: RuntimePtySpawnArgs ) { + const args = { ...request, sessionId: normalizePtySpawnSessionId(request.sessionId) } const ctx = createRuntimePtySpawnState(deps, args) - - try { - const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) - const materialized = - materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise - if (materialized) { - return { ...materialized, ...deps.runtime?.getTerminalLaunchResult?.(materialized.id) } - } - const earlyAdopt = await prepareRuntimePtySpawn(ctx) - if (earlyAdopt) { - return { ...earlyAdopt, ...deps.runtime?.getTerminalLaunchResult?.(earlyAdopt.id) } - } - const earlyReserved = await buildRuntimePtySpawnOptions(ctx).catch((error: unknown) => { - restoreProvisionalPtySize({ - sessionId: ctx.sessionId, - sessionAppId: ctx.effectiveSessionAppId, - hadSessionSizeBeforeAttach: ctx.hadSessionSizeBeforeAttach, - sessionSizeBeforeAttach: ctx.sessionSizeBeforeAttach - }) - throw error - }) - if (earlyReserved) { - return { ...earlyReserved, ...deps.runtime?.getTerminalLaunchResult?.(earlyReserved.id) } - } - const complete = async (operation = args.paneSpawnOperation) => { - ctx.args.paneSpawnOperation = operation - await executeRuntimePtySpawn(ctx) - if (!ctx.resumeAdmission) { - ctx.resumeAdmission = await admitColdRestoreReattach( - deps.runtime, - ctx.result, - { - paneKey: ctx.spawnIdentityPaneKey, - saved: savedResumeRecord(deps.store, ctx.spawnIdentityPaneKey) - }, - ctx.expectedWslDistro - ) - await captureSavedResumeCleanup( - deps.runtime, - deps.store, - ctx.spawnIdentityPaneKey, - ctx.resumeAdmission, - args.worktreeId - ) - } + const outcome = await runPtySpawn({ + state: ctx, + prepare: async () => { + await prepareRuntimePtySpawn(ctx) + await buildRuntimePtySpawnOptions(ctx) + }, + facts: () => ({ + sessionId: ctx.sessionId, + savedResumePaneKey: ctx.spawnIdentityPaneKey, + launchConfig: ctx.args.launchConfig, + suppressRefusedLaunch: () => { + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + }, + paneKey: ctx.spawnIdentityPaneKey, + preAllocatedHandle: args.preAllocatedHandle, + beforeProviderSpawn: () => markRuntimeSpawnHiddenBeforeSpawn(ctx), + reportPtySpawnCommitted: () => ctx.reportPtySpawnCommitted() + }), + commit: () => { if (!ctx.result.isReattach) { - Object.assign(ctx.result, savedResumeResult(ctx.resumeAdmission), { + Object.assign(ctx.result, { ...(ctx.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), - launchConfig: args.launchConfig, - launchAgent: args.launchAgent, - launchToken: args.launchToken, - resumeProviderSession: ctx.codexResumeArgvDropped ? undefined : args.resumeProviderSession + launchConfig: ctx.args.launchConfig, + launchAgent: ctx.args.launchAgent, + launchToken: ctx.args.launchToken, + resumeProviderSession: ctx.codexResumeArgvDropped + ? undefined + : ctx.args.resumeProviderSession }) } - const committed = await commitRuntimePtySpawn(ctx) - const refusal = resumeSpawnRefusal(ctx.resumeAdmission) - forgetRefusedResume(ctx.spawnIdentityPaneKey, refusal) - if ( - args.worktreeId && - ctx.spawnIdentityPaneKey && - refusal && - refusal.verdict !== 'unverifiable' - ) { - void deps.runtime - ?.forgetRefusedSavedResume?.( - args.worktreeId, - ctx.spawnIdentityPaneKey, - refusal.refused, - refusal.sourceRecord - ) - .catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error)) - } - commitRuntimeSpawnHiddenDelivery(ctx) - if (!ctx.result.isReattach) { - deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) - } - return committed - } - const latePane = - !args.paneSpawnOperation && ctx.spawnIdentityPaneKey - ? parsePaneKey(ctx.spawnIdentityPaneKey) - : null - if (latePane) { - ctx.args = { ...ctx.args, ...latePane } - let result: Awaited> | undefined - const adopted = await deps.adoptStablePane({ - cols: args.cols, - rows: args.rows, - cwd: args.cwd, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - ...latePane, - signal: args.signal, - preAllocatedHandle: args.preAllocatedHandle, - spawnFresh: async (operation) => (result = await complete(operation)) - }) - if (adopted) { - Object.assign(adopted.result, deps.runtime?.getTerminalLaunchResult?.(adopted.result.id)) - } - return toRuntimeSpawnReply(adopted?.result ?? result!) - } - return await complete() - } catch (err) { - releaseRuntimeSpawnPreSpawnHiddenMark(ctx) - if (ctx.pendingRegistrationPtyId) { - deps.runtime?.cancelPendingPtyRegistration?.( - ctx.pendingRegistrationPtyId, - ctx.rejectedRegistrationCandidate?.incarnationId, - ctx.pendingRegistrationClaim - ) - ctx.pendingRegistrationPtyId = null + return commitRuntimePtySpawn(ctx) + }, + onCommitted: () => commitRuntimeSpawnHiddenDelivery(ctx), + releaseHiddenMark: () => releaseRuntimeSpawnPreSpawnHiddenMark(ctx), + adoption: () => ({ completed: args.adoptedStablePane }) + }) + const result = requireAcceptedSpawn(outcome) + let stablePaneOwner = result.stablePaneOwner + if (outcome.owner && args.worktreeId) { + const handle = stablePaneOwner?.handle ?? outcome.owner.handle ?? args.preAllocatedHandle + if (!handle) { + throw new Error('terminal_pane_owner_unknown') } - // Settle reservations on every post-reservation failure or future spawns for this pane wait forever. - rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) - throw err - } finally { - ctx.releaseWorktreeSpawn?.() - ctx.finishTerminalInstall() + stablePaneOwner = { handle, tabId: outcome.owner.tabId, leafId: outcome.owner.leafId } + } + return { + ...result, + wslDistro: result.wslDistro ?? undefined, + ...(stablePaneOwner ? { stablePaneOwner } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.startupCwdFallback ? { startupCwdFallback: result.startupCwdFallback } : {}) } } diff --git a/src/main/ipc/pty/spawn-begin.ts b/src/main/ipc/pty/spawn-begin.ts new file mode 100644 index 000000000000..ed5b6673ce88 --- /dev/null +++ b/src/main/ipc/pty/spawn-begin.ts @@ -0,0 +1,109 @@ +import type { PtySpawnOperationState } from './spawn-operation-state' +import type { AdoptStablePaneResult } from './ipc/spawn-types' +import { resolveEarlyPaneKey, resolveEarlyPaneSpawnReservationKey } from './spawn-pane-coordinates' +import { consumeExitedPaneOperation } from './pane/adopt-stable' +import { codexReattachedHomeRouteField, snapshotCodexPaneHomeRoutes } from './host-env/codex-home' +import { allocatePtyLifecycleSequence } from './host-env/types' +import { resolveStablePaneOwner } from './pane/stable-owner' +import { getAppPtyId } from './provider/registry' +import { ptyOwnership, ptyIncarnationById } from './provider/ownership-state' +import { attachOnlyViewOf } from './pane/attach-only-view' +import { pendingRuntimePaneCreatesByOwnerKey } from './pane/spawn-reservation' +import { getConnectionExecutionHostId } from '../../../shared/execution-host' +import { ensureWslHookRelayForReattach } from '../../agent-hooks/wsl-hook-relay-reattach' + +export async function beginPtySpawn( + ctx: PtySpawnOperationState, + renderer?: boolean +): Promise { + const args = ctx.args + // Capture lifecycle and home routes once, before startup can yield. + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + const paneKey = resolveEarlyPaneKey(args) + const owner = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + paneKey, + args.worktreeId, + args.connectionId + ) + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + ctx.preAdoptedStablePane?.result.id, + owner?.ptyId, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + const startup = ctx.deps.getLocalPtyStartupPromise( + getConnectionExecutionHostId(args.connectionId) + ) + if (startup) { + await startup + } + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + // Inherited producers and replacements already own this acquisition and must never await themselves. + if ( + ctx.paneSpawnOperation || + ctx.paneSpawnReservation || + ctx.preAdoptedStablePane?.materialized + ) { + return null + } + const key = resolveEarlyPaneSpawnReservationKey(args) + const pending = key ? pendingRuntimePaneCreatesByOwnerKey.get(key) : undefined + if (!pending) { + return null + } + const created = await pending.promise + if ( + pending.operation?.acquisition?.reservation.outcome?.kind === 'registration-exited' && + args.tabId && + args.leafId + ) { + return consumeExitedPaneOperation( + ctx.deps.runtime, + ctx.deps.store, + { ...args, tabId: args.tabId, leafId: args.leafId, renderer }, + pending.operation + ) + } + if ( + attachOnlyViewOf(args) && + created && + created.id === args.sessionId && + (created.coldRestore || typeof created.snapshot === 'string') && + ptyOwnership.has(created.id) && + (created.incarnationId === undefined || + ptyIncarnationById.get(created.id) === created.incarnationId) + ) { + return { + result: created, + owner: { ptyId: created.id, tabId: args.tabId!, leafId: args.leafId! }, + materialized: true + } + } + return null +} + +export function reportPtyPaneAdoption( + ctx: PtySpawnOperationState, + adopted: AdoptStablePaneResult +): void { + if (adopted.outcome?.kind === 'registration-exited') { + return + } + ensureWslHookRelayForReattach(adopted.result, ctx.args.connectionId) + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: adopted.result.id, + codexHomePath: null, + reattached: true, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, adopted.result.id, true) + }) +} diff --git a/src/main/ipc/pty/spawn-commit-assembly.ts b/src/main/ipc/pty/spawn-commit-assembly.ts index 12762fb36a58..0d1a6cdc16c4 100644 --- a/src/main/ipc/pty/spawn-commit-assembly.ts +++ b/src/main/ipc/pty/spawn-commit-assembly.ts @@ -1,3 +1,4 @@ +import type { PtySpawnSizeSeed } from './spawn-options-assembly' import { isValidTerminalTabId } from '../../../shared/terminal-tab-id' import type { AgentSessionClaimedSpawnResult } from '../../../shared/agent-session-host-authority' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -15,16 +16,12 @@ import { codexReattachedHomeRouteField, shouldSkipCodexHomeEnvForWindowsShell } from './host-env/codex-home' -import { commitAttachedPtySize, type PtyGrid } from './delivery/attached-pty-size' +import { commitAttachedPtySize } from './delivery/attached-pty-size' import { seedTerminalRestoreRecordsFromSpawnResult } from './pane/agent-session-owners' import { admitProviderReattachLaunchIdentity } from './pane/launch-authority' import type { ReplacedPaneOwner } from './pane/replaced-pane-owner' import { admitPtySpawnOwnership, registerPersistedPtySpawn } from './pane/spawn-registration' -import { - resolvePaneSpawnReservation, - type PaneSpawnReservation, - type PaneSpawnReservationResult -} from './pane/spawn-reservation' +import type { PtySpawnOutcome } from './spawn-outcome' import type { StablePaneOwner } from './pane/stable-owner' import { seedHeadlessTerminalFromSpawnResult } from './pane/terminal-spawn-restore' import type { RuntimePtySpawnArgs } from './runtime/spawn-state' @@ -59,11 +56,9 @@ export type PtySpawnCommitState = { metadataLeafId: string | null pendingRegistrationClaim?: PtyRegistrationClaim pendingRegistrationPtyId: string | null - paneSpawnReservationKey: string | null - paneSpawnReservation: PaneSpawnReservation | null effectiveSessionAppId: string | undefined stablePaneOwner: StablePaneOwner | null - sessionSizeBeforeAttach: PtyGrid | undefined + provisionalPtySize: PtySpawnSizeSeed | undefined isDaemonHostSpawn: boolean codexResumeHomeSelected: boolean codexResumeUnavailable?: boolean @@ -78,10 +73,10 @@ export type PtySpawnCommitState = { preSpawnHiddenMarkId: string | null replacedPaneOwner?: ReplacedPaneOwner | null agentTeamsLeaderHandle?: string | null - exitedBeforeRegistration?: boolean } -export type PtySpawnCommitInput = { +/** Process facts for the common commit; hidden delivery remains transport-owned. */ +export type PtySpawnCommitInput = { state: PtySpawnCommitState nativeWindowsConptySpawn: boolean /** Pane key the headless model seeds under. */ @@ -101,28 +96,17 @@ export type PtySpawnCommitInput = { /** Validated pane key; serializer readiness, aliases and resume identity read it. */ paneKey: string | null paneKeyAliases?: PtySpawnPaneKeyAliases - memorySessionId: string | null resumeArgvDropped: boolean - /** Window reports base environment; host reports its assembled environment. */ + /** TEMPORARY (rows 14–18): window reports base env, host assembled env; launch-env follow-up. */ codexLaunchEnv: Record | undefined initiallyHidden: boolean - /** Renderer-scoped marks refresh here; runtime-owned marks refresh after commit. */ + /** TEMPORARY: renderer marks refresh here, host marks after commit; terminal-transport follow-up. */ hiddenDelivery: 'renderer-scoped' | 'runtime-owned' - /** Drain returns early exit and settles registration; throw leaves cleanup to the runner. */ - earlyExit: 'drain' | 'throw' - joinPayload: { reservation: PaneSpawnReservationResult; reply: Reply } + response: PtySpawnOutcome['result'] } -/** The one commit for a freshly started process, run inside the OpenCode prompt-intent wrapper. */ -export function commitPtySpawn( - input: PtySpawnCommitInput & { earlyExit: 'throw' } -): Promise>> -export function commitPtySpawn( - input: PtySpawnCommitInput -): Promise -export async function commitPtySpawn( - input: PtySpawnCommitInput -): Promise { +/** The one commit for a freshly started process, run inside the prompt-intent wrapper. */ +export async function commitPtySpawn(input: PtySpawnCommitInput): Promise { const { state } = input const { result } = state const runtime = state.deps.runtime @@ -179,9 +163,6 @@ export async function commitPtySpawn( shouldSkipCodexHomeEnvForWindowsShell(registration.shellOverride, state.cwd) ) if (rejectedRegistration) { - if (input.earlyExit === 'throw') { - return await rejectedRegistration - } await rejectedRegistration.catch((error: unknown) => { if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') { throw error @@ -193,39 +174,25 @@ export async function commitPtySpawn( state.pendingRegistrationClaim ) state.pendingRegistrationPtyId = null - state.exitedBeforeRegistration = true - // The renderer drains this incarnation's buffered output and exit without publishing it live. - return resolvePaneSpawnReservation( - state.paneSpawnReservationKey, - state.paneSpawnReservation, - input.joinPayload.reply - ) + return { kind: 'registration-exited', result: input.response } } - } else if (input.earlyExit === 'throw') { - // Worktree-less PTYs have no later surface-registration step to clear admission. - runtime?.cancelPendingPtyRegistration?.( - result.id, - result.incarnationId, - state.pendingRegistrationClaim - ) } else if (state.pendingRegistrationPtyId) { runtime?.cancelPendingPtyRegistration?.( state.pendingRegistrationPtyId, result.incarnationId, state.pendingRegistrationClaim ) - } - if (input.earlyExit === 'drain') { state.pendingRegistrationPtyId = null } return publishCommittedPtySpawn(input) } -function commitAdoptedAgentSession( - input: PtySpawnCommitInput, +/** An adopted claim attaches to a live owner: it publishes the owner's surface, not a new pane. */ +async function commitAdoptedAgentSession( + input: PtySpawnCommitInput, adopted: AgentSessionClaimedSpawnResult, providerReattachLaunchIdentity: ReturnType -): PtySpawnResult | Promise { +): Promise { const { state } = input const { result } = state const runtime = state.deps.runtime @@ -264,13 +231,11 @@ function commitAdoptedAgentSession( commitAttachedPtySize({ result: adoptedResult, requested: { cols: state.args.cols, rows: state.args.rows }, - cachedBeforeAttach: state.sessionSizeBeforeAttach, + cachedBeforeAttach: state.provisionalPtySize?.cachedBeforeAttach, reflowHeadlessTerminalToPtyGrid: runtime?.reflowHeadlessTerminalToPtyGrid?.bind(runtime) }) - resolvePaneSpawnReservation( - state.paneSpawnReservationKey, - state.paneSpawnReservation, - adoptedResult - ) - return adoptedResult + return { + kind: 'adopted', + result: { ...input.response, isReattach: true, agentSessionEnsure: adopted } + } } diff --git a/src/main/ipc/pty/spawn-commit-publication.ts b/src/main/ipc/pty/spawn-commit-publication.ts index b52b75268a67..bb7203a26208 100644 --- a/src/main/ipc/pty/spawn-commit-publication.ts +++ b/src/main/ipc/pty/spawn-commit-publication.ts @@ -18,8 +18,7 @@ import { import { seedTerminalRestoreRecordsFromSpawnResult } from './pane/agent-session-owners' import { seedResumeLaunchIdentityFromSpawn } from './pane/resume-launch-identity' import { recordPtySpawnTelemetry } from './pane/spawn-telemetry' -import { noteRuntimePaneCreateResult, resolvePaneSpawnReservation } from './pane/spawn-reservation' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { PtySpawnOutcome } from './spawn-outcome' import type { PtySpawnCommitInput, PtySpawnCommitState } from './spawn-commit-assembly' /** Window migration inputs: legacy and reminted spawn keys alias the pane's proven key. */ @@ -30,9 +29,7 @@ export type PtySpawnPaneKeyAliases = { } /** Everything a registered spawn publishes, in one order for both lanes; nothing here awaits. */ -export function publishCommittedPtySpawn( - input: PtySpawnCommitInput -): Reply { +export function publishCommittedPtySpawn(input: PtySpawnCommitInput): PtySpawnOutcome { const { state } = input const { deps, args, result } = state const runtime = deps.runtime @@ -67,7 +64,7 @@ export function publishCommittedPtySpawn( commitAttachedPtySize({ result, requested: { cols: args.cols, rows: args.rows }, - cachedBeforeAttach: state.sessionSizeBeforeAttach, + cachedBeforeAttach: state.provisionalPtySize?.cachedBeforeAttach, reflowHeadlessTerminalToPtyGrid: runtime?.reflowHeadlessTerminalToPtyGrid?.bind(runtime) }) if (state.effectiveSessionAppId !== undefined && state.effectiveSessionAppId !== result.id) { @@ -92,7 +89,7 @@ export function publishCommittedPtySpawn( registerPty({ ptyId: result.id, worktreeId: input.registration.worktreeId ?? null, - sessionId: input.memorySessionId, + sessionId: result.id, paneKey: rememberedPaneKey, pid: typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0 @@ -128,16 +125,7 @@ export function publishCommittedPtySpawn( ? { launchEnv: input.codexLaunchEnv } : {}) }) - resolvePaneSpawnReservation( - state.paneSpawnReservationKey, - state.paneSpawnReservation, - input.joinPayload.reservation - ) - // A view waiting on this create attaches to the accepted result before its waiter resumes. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - noteRuntimePaneCreateResult(state.paneSpawnReservationKey, input.joinPayload.reservation) - } - return input.joinPayload.reply + return { kind: 'committed', result: input.response } } function markRendererScopedHiddenAtCommit(state: PtySpawnCommitState): void { diff --git a/src/main/ipc/pty/spawn-execute-failure.ts b/src/main/ipc/pty/spawn-execute-failure.ts index 956a7968d08d..e797d698bf87 100644 --- a/src/main/ipc/pty/spawn-execute-failure.ts +++ b/src/main/ipc/pty/spawn-execute-failure.ts @@ -6,15 +6,14 @@ import { track } from '../../telemetry/client' import { getCohortAtEmit } from '../../telemetry/cohort-classifier' import { agentKindSchema } from '../../../shared/telemetry-events' import { normalizeNodePtySpawnError } from './provider/liveness' -import { ptySizes } from './delivery/visibility-state' +import { restoreProvisionalPtySize, type PtySpawnSizeSeed } from './spawn-options-assembly' import { clearProviderPtyState } from './provider/state-cleanup' type PtySpawnExecuteState = { deps: { runtime?: OrcaRuntimeService } isClaudeLaunch: boolean effectiveSessionAppId: string | undefined - hadSessionSizeBeforeAttach: boolean - sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + provisionalPtySize: PtySpawnSizeSeed | undefined preparedProvisionalExecutionContext: boolean pendingRegistrationPtyId: string | null pendingRegistrationClaim?: PtyRegistrationClaim @@ -26,7 +25,7 @@ export function failPtySpawnExecute( err: unknown, lane: { sessionId: string | undefined - /** This spawn minted the session id, so the state built for it must go. */ + /** This start owns the new incarnation, including a fresh process using a supplied id. */ ownsNewSession: boolean telemetry: { agent_kind?: unknown } | undefined /** This request awaited another request's claimed start, whose own catch records its failure. */ @@ -52,18 +51,13 @@ export function failPtySpawnExecute( } const spawnError = normalizeNodePtySpawnError(err) const exitedBeforeSpawnReply = ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true - if (ctx.effectiveSessionAppId !== undefined) { - if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach && !exitedBeforeSpawnReply) { - ptySizes.set(ctx.effectiveSessionAppId, ctx.sessionSizeBeforeAttach) - } else { - ptySizes.delete(ctx.effectiveSessionAppId) - } - } - // Environment assembly may create per-PTY state; remove it only for this request's minted ID. + // Preparation for an unaccepted new incarnation must not leak into a later start. if (lane.ownsNewSession && lane.sessionId !== undefined) { clearProviderPtyState(lane.sessionId) } - // Prefer validated threaded agent kind; fallback detects Claude and never records raw error messages. + // Restore the pre-start grid after teardown, which also clears its provisional cache entry. + restoreProvisionalPtySize(ctx, { exitedBeforeSpawnReply }) + // Attribute validated agent identity; telemetry never receives raw provider errors. const threadedKind = lane.telemetry?.agent_kind !== undefined ? agentKindSchema.safeParse(lane.telemetry.agent_kind) diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/spawn-execute.ts similarity index 55% rename from src/main/ipc/pty/runtime/spawn-execute.ts rename to src/main/ipc/pty/spawn-execute.ts index 98d78632fed8..d24f59136455 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/spawn-execute.ts @@ -1,119 +1,63 @@ -import { - suppressSavedResumeLaunch, - captureSavedResumeCleanup, - chooseSavedResumeFreshLaunch, - type ResumeSpawnAdmission, - resumeSpawnRefusal, - SavedResumeFreshRefusal -} from '../host-env/saved-resume-admission' -import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' -import type { PtySpawnResult } from '../../../providers/pty-provider-contract' -import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' -import { ptyIncarnationById } from '../provider/ownership-state' -import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' -import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate' +import type { PtySpawnResult } from '../../providers/pty-provider-contract' +import { ptyIncarnationById } from './provider/ownership-state' +import { ensureWslHookRelayForReattach } from '../../agent-hooks/wsl-hook-relay-reattach' +import { noteWslDistrosRunning } from '../../wsl-background-distro-gate' import { agentSessionOwners, assertSpawnReplyWasLive, reconcileAgentSessionOwnerListings -} from '../pane/agent-session-owners' -import { resolveStablePaneOwner } from '../pane/stable-owner' +} from './pane/agent-session-owners' +import { resolveStablePaneOwner } from './pane/stable-owner' import { assertPaneStillExists, excludeAbsentOwnerReplay, PaneOwnerContentionError -} from '../pane/pane-spawn-operation' -import { isRegisteredAgentSessionOwnerLive } from '../provider/liveness' -import { failPtySpawnExecute } from '../spawn-execute-failure' -import type { RuntimePtySpawnState } from './spawn-state' -import { markRuntimeSpawnHiddenBeforeSpawn } from './spawn-hidden-delivery' +} from './pane/pane-spawn-operation' +import { isRegisteredAgentSessionOwnerLive } from './provider/liveness' +import type { AgentSessionClaimedSpawnResult } from '../../../shared/agent-session-host-authority' +import { + resumeSpawnRefusal, + SavedResumeFreshRefusal, + suppressSavedResumeLaunch, + type ResumeSpawnRefusal +} from './host-env/saved-resume-admission' +import { failPtySpawnExecute } from './spawn-execute-failure' +import type { PtySpawnOperationState, PtySpawnExecutionFacts } from './spawn-operation-state' +import { excludeReplacedPaneOwner } from './pane/replaced-pane-owner' +import { attachViewWithoutOwner } from './pane/attach-only-view' -export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { +export async function executePtySpawn( + ctx: PtySpawnOperationState, + facts: PtySpawnExecutionFacts, + chooseFresh: (inherited?: ResumeSpawnRefusal) => Promise +): Promise { const args = ctx.args - const captured: ResumeSpawnAdmission | null = - args.launchAgent && args.resumeProviderSession - ? { - verdict: 'admitted', - refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession } - } - : null - if (args.resumeOrigin === 'saved') { - await captureSavedResumeCleanup( - ctx.deps.runtime, - ctx.deps.store, - ctx.spawnIdentityPaneKey, - captured, - args.worktreeId - ) - } - const chooseFresh = async (): Promise => { - ctx.spawnOptions.env = { ...ctx.spawnOptions.env } - ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( - ctx.deps.runtime, - args, - ctx.expectedWslDistro, - ctx.spawnOptions - ) - if (captured && ctx.resumeAdmission) { - ctx.resumeAdmission.refused = captured.refused - ctx.resumeAdmission.sourceRecord = captured.sourceRecord - } - if (resumeSpawnRefusal(ctx.resumeAdmission)) { - ctx.launchCommand = undefined - ctx.env = ctx.spawnOptions.env - } - } - const runtime = ctx.deps.runtime - const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn - ctx.releaseWorktreeSpawn = acquireWorktreeSpawn - ? await acquireWorktreeSpawn.call( - runtime, - spawnWorktreeTarget(args.worktreeId, args.connectionId), - { - refuseSleptWorktree: args.refuseSleptWorktree, - // A pre-adopted stable pane attaches to its live owner, not a new terminal. - reattachSessionId: ctx.preAdoptedStablePane?.owner.ptyId ?? args.sessionId, - requireAttachOnly: () => { - ctx.spawnOptions = { ...ctx.spawnOptions, attachOnly: true } - } - } - ) - : undefined let awaitingAnotherStart = false try { - if (args.preAllocatedHandle) { - ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle) + if (facts.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.add(facts.preAllocatedHandle) } const stablePaneOwnerCandidate = ctx.preAdoptedStablePane ? ctx.preAdoptedStablePane.owner : args.agentSessionEnsure ? null : excludeAbsentOwnerReplay( - resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.spawnIdentityPaneKey, - args.worktreeId, - args.connectionId + excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + facts.paneKey, + args.worktreeId, + args.connectionId + ), + facts.replacedPaneOwner ?? null ), - args.paneSpawnOperation?.absentOwner + ctx.paneSpawnOperation?.absentOwner ) if (stablePaneOwnerCandidate) { throw new PaneOwnerContentionError() } - if (args.paneSpawnOperation?.mustExist && args.worktreeId && args.tabId && args.leafId) { - assertPaneStillExists( - ctx.deps.store, - args.worktreeId, - args.tabId, - args.leafId, - args.connectionId - ) - } - const expectedPtyId = ctx.effectiveSessionAppId ?? ctx.sessionId - if (!stablePaneOwnerCandidate) { - markRuntimeSpawnHiddenBeforeSpawn(ctx) - } + const expectedPtyId = ctx.effectiveSessionAppId ?? facts.sessionId if (expectedPtyId) { ctx.pendingRegistrationClaim = ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) ctx.pendingRegistrationPtyId = expectedPtyId @@ -121,17 +65,30 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise if (ctx.isDaemonHostSpawn && expectedPtyId) { ctx.preparedProvisionalExecutionContext = ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { - resetIncarnation: ctx.isNewDaemonSession && !stablePaneOwnerCandidate, - preserveExisting: !ctx.isNewDaemonSession || Boolean(stablePaneOwnerCandidate) + resetIncarnation: ctx.ownsNewIncarnation && !stablePaneOwnerCandidate, + preserveExisting: !ctx.ownsNewIncarnation || Boolean(stablePaneOwnerCandidate) }) ?? false } const sequenceBeforeProviderSpawn = expectedPtyId ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 - const assertClientStillConnected = (): void => { + ctx.spawnTiming?.mark('options') + const validateDispatch = (): void => { + facts.validateDispatch?.() + if (ctx.paneSpawnOperation?.mustExist && args.worktreeId && args.tabId && args.leafId) { + assertPaneStillExists( + ctx.deps.store, + args.worktreeId, + args.tabId, + args.leafId, + args.connectionId + ) + } + if (args.signal?.aborted) { throw new Error('client_disconnected') } + facts.beforeProviderSpawn?.() } if (args.agentSessionEnsure && !ctx.preAdoptedStablePane) { // Claims can outlive this controller; import authoritative owner listings before deciding absence. @@ -158,24 +115,23 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise claim: args.agentSessionEnsure.claim, surface: args.agentSessionEnsure.surface, spawn: async () => { - assertClientStillConnected() await chooseFresh() const refusal = resumeSpawnRefusal(ctx.resumeAdmission) if (refusal) { throw new SavedResumeFreshRefusal(refusal) } + validateDispatch() providerResult = await ctx.provider.spawn(ctx.spawnOptions) ctx.rejectedRegistrationCandidate = providerResult - // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. - ctx.reportPtySpawnCommitted() + // Lower-owner success proves physical commit even when registration observes an early exit. + facts.reportPtySpawnCommitted?.() assertSpawnReplyWasLive(providerResult) ctx.deps.runtime?.assertPtyRegistrationAllowed?.( providerResult.id, providerResult.incarnationId ) if (providerResult.incarnationId) { - // Why: local providers cannot serialize controller claims, so liveness proof - // needs the exact incarnation before the registry promotes the new owner. + // In-process providers cannot list controller claims; record exact incarnation before ownership promotion. ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) } const providerEnsure = providerResult.agentSessionEnsure @@ -198,19 +154,15 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise if (!(error instanceof SavedResumeFreshRefusal)) { throw error } - // The claim reservation rejected before spawn; this shell must never become its owner. - ctx.resumeAdmission = { - ...error.admission, - refused: captured?.refused ?? error.admission.refused, - sourceRecord: captured?.sourceRecord - } + // The claim has unwound; the refused shell must never become its agent owner. + await chooseFresh(error.admission) suppressSavedResumeLaunch(args, ctx.spawnOptions) - ctx.launchCommand = undefined - ctx.env = ctx.spawnOptions.env + facts.suppressRefusedLaunch?.() delete args.agentSessionEnsure - ctx.spawnOptions.agentSessionEnsure = undefined + delete ctx.spawnOptions.agentSessionEnsure + validateDispatch() providerResult = await ctx.provider.spawn(ctx.spawnOptions) - ctx.reportPtySpawnCommitted() + facts.reportPtySpawnCommitted?.() ctx.stablePaneOwner = null } awaitingAnotherStart = false @@ -220,7 +172,6 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ctx.result = { id: ensured.owner.ptyId, isReattach: true, - // A listing's incarnation rejects delayed exits from an older process. incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) } } else { @@ -230,18 +181,20 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ctx.result.agentSessionEnsure = ensured } } else { - assertClientStillConnected() - await chooseFresh() - ctx.result = await ctx.provider.spawn(ctx.spawnOptions) - ctx.reportPtySpawnCommitted() + if (!facts.attachOnlyView) { + await chooseFresh() + } + validateDispatch() + ctx.result = facts.attachOnlyView + ? await attachViewWithoutOwner(ctx.provider, ctx.spawnOptions) + : await ctx.provider.spawn(ctx.spawnOptions) + facts.reportPtySpawnCommitted?.() ctx.stablePaneOwner = null ctx.rejectedRegistrationCandidate = ctx.result } if (ctx.result.isReattach || ctx.result.agentSessionEnsure?.disposition === 'adopted') { ctx.result.isReattach = true ctx.resumeAdmission = null - } - if (ctx.result.isReattach) { Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id)) } ctx.rejectedRegistrationCandidate ??= ctx.result @@ -275,16 +228,18 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise if (ctx.result.providerSequence) { const runtimeSequenceBeforeReconcile = ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 - // Preserve the renderer-domain boundary needed to reconcile daemon snapshot kitty flags. - ctx.reconciledSnapshotSeq = + // Keyboard flags require the snapshot boundary translated into the renderer sequence domain. + const translatedSnapshotSeq = ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( ctx.result.id, ctx.result.providerSequence, sequenceBeforeProviderSpawn ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - ctx.snapshotKittyFlagsCoverReconciledSeq = false - } + ctx.provedSnapshotSeq = + runtimeSequenceBeforeReconcile <= sequenceBeforeProviderSpawn && + typeof ctx.result.snapshotKittyKeyboardFlags === 'number' + ? (translatedSnapshotSeq ?? undefined) + : undefined } ensureWslHookRelayForReattach(ctx.result) const spawnedWslDistro = @@ -294,16 +249,17 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise noteWslDistrosRunning([spawnedWslDistro]) } ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.result.id, spawnedWslDistro) + ctx.spawnTiming?.mark('provider_spawn') } catch (err) { throw failPtySpawnExecute(ctx, err, { - sessionId: ctx.sessionId, - ownsNewSession: ctx.isNewDaemonSession, + sessionId: facts.sessionId, + ownsNewSession: ctx.ownsNewIncarnation, telemetry: args.telemetry, joinedAnotherStart: awaitingAnotherStart }) } finally { - if (args.preAllocatedHandle) { - ctx.deps.trustedTerminalHandleEnv.delete(args.preAllocatedHandle) + if (facts.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.delete(facts.preAllocatedHandle) } } } diff --git a/src/main/ipc/pty/spawn-operation-state.ts b/src/main/ipc/pty/spawn-operation-state.ts new file mode 100644 index 000000000000..d563addcc143 --- /dev/null +++ b/src/main/ipc/pty/spawn-operation-state.ts @@ -0,0 +1,72 @@ +import type { PtyRuntimeControllerDeps } from './runtime/controller-deps' +import type { RuntimePtySpawnState, RuntimePtySpawnArgs } from './runtime/spawn-state' +import type { PtySpawnTiming } from './pty-spawn-timing' +import type { PaneSpawnOperation } from './pane/pane-spawn-operation' +import type { ReplacedPaneOwner } from './pane/replaced-pane-owner' +import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' + +export type PtySpawnOperationState = Pick< + RuntimePtySpawnState, + | 'spawnOptions' + | 'cwd' + | 'provider' + | 'result' + | 'codexHomeLaunchStartedAt' + | 'codexHomeLaunchStartedSequence' + | 'reattachedCodexHomeRoutes' + | 'preAdoptedStablePane' + | 'stablePaneOwner' + | 'stablePaneBindingPersisted' + | 'pendingRegistrationPtyId' + | 'pendingRegistrationClaim' + | 'rejectedRegistrationCandidate' + | 'provedSnapshotSeq' + | 'preparedProvisionalExecutionContext' + | 'paneSpawnReservationKey' + | 'paneSpawnReservation' + | 'finishTerminalInstall' + | 'releaseWorktreeSpawn' + | 'effectiveSessionAppId' + | 'isDaemonHostSpawn' + | 'expectedWslDistro' + | 'isClaudeLaunch' + | 'provisionalPtySize' + | 'ownsNewIncarnation' + | 'resumeAdmission' +> & { + deps: Pick< + PtyRuntimeControllerDeps, + | 'runtime' + | 'store' + | 'adoptStablePane' + | 'trustedTerminalHandleEnv' + | 'getLocalPtyStartupPromise' + | 'resolvePtySpawnStartupCwd' + | 'options' + > + args: Omit< + RuntimePtySpawnArgs, + 'spawnIntent' | 'terminalColorQueryReplies' | 'placement' | 'launchToken' | 'resumeOrigin' + > & { + attachOnlyView?: unknown + launchToken?: unknown + resumeOrigin?: unknown + } + replacedPaneOwner?: ReplacedPaneOwner | null + paneSpawnOperation?: PaneSpawnOperation + spawnTiming?: PtySpawnTiming +} + +export type PtySpawnExecutionFacts = { + sessionId: string | undefined + paneKey: string | null + preAllocatedHandle?: string | null + replacedPaneOwner?: ReplacedPaneOwner | null + attachOnlyView?: true + savedResumePaneKey?: string | null + launchConfig?: SleepingAgentLaunchConfig + suppressRefusedLaunch?: () => void + validateDispatch?: () => void + beforeProviderSpawn?: () => void + reportPtySpawnCommitted?: () => void +} diff --git a/src/main/ipc/pty/spawn-operation.ts b/src/main/ipc/pty/spawn-operation.ts new file mode 100644 index 000000000000..1b6ec2dc4e11 --- /dev/null +++ b/src/main/ipc/pty/spawn-operation.ts @@ -0,0 +1,273 @@ +import type { PaneSpawnOperation } from './pane/pane-spawn-operation' +import type { AdoptStablePaneArgs } from './ipc/spawn-types' +import { adoptedOutcome, type PtySpawnOutcome } from './spawn-outcome' +import type { PtySpawnOperationState, PtySpawnExecutionFacts } from './spawn-operation-state' +import { beginPtySpawn, reportPtyPaneAdoption } from './spawn-begin' +import { executePtySpawn } from './spawn-execute' +import { prepareSavedPtyResume } from './spawn-resume' +import { resolveEarlyPaneKey, resolveEarlyPaneSpawnReservationKey } from './spawn-pane-coordinates' +import { consumeExitedPaneOperation } from './pane/adopt-stable' +import { attachOnlyViewOf } from './pane/attach-only-view' +import { + pendingRuntimePaneCreatesByOwnerKey, + rejectPaneSpawnReservation, + reserveIdlePaneSpawn +} from './pane/spawn-reservation' +import { restoreProvisionalPtySize } from './spawn-options-assembly' +import { spawnWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { releaseStoppedPaneBinding } from './pane/pane-owner-replacement' +import { + admitColdRestoreReattach, + captureSavedResumeCleanup, + forgetRefusedResume, + resumeSpawnRefusal, + savedResumeRecord, + savedResumeResult +} from './host-env/saved-resume-admission' + +type SpawnAdapter = { + state: State + prepare: () => Promise + commit: () => Promise + facts: () => PtySpawnExecutionFacts + admission?: Promise + drainsExitedStart?: true + onCommitted?: () => void + onOutcome?: (outcome: PtySpawnOutcome) => void + onError?: (error: unknown) => unknown + releasePreparation?: () => void + releaseHiddenMark?: () => void + adoption?: () => Pick + replace?: () => Promise +} + +export function normalizePtySpawnSessionId(sessionId: string | undefined): string | undefined { + return typeof sessionId === 'string' ? sessionId.trim() || undefined : undefined +} + +function cancelRegistration(ctx: PtySpawnOperationState): void { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId, + ctx.pendingRegistrationClaim + ) + ctx.pendingRegistrationPtyId = null + } +} + +function retirePreparation( + adapter: SpawnAdapter, + reattachId?: string +): void { + const ctx = adapter.state + adapter.releasePreparation?.() + restoreProvisionalPtySize(ctx, { reattachId }) + ctx.finishTerminalInstall() + ctx.finishTerminalInstall = () => {} + ctx.releaseWorktreeSpawn?.() + ctx.releaseWorktreeSpawn = undefined +} + +/** One host operation owns preparation, dispatch, settlement and cleanup for every start entry. */ +export async function runPtySpawn( + adapter: SpawnAdapter +): Promise { + const ctx = adapter.state + const args = ctx.args + ctx.preAdoptedStablePane = args.adoptedStablePane ?? null + ctx.paneSpawnOperation = args.paneSpawnOperation + const replacementKey = adapter.replace ? resolveEarlyPaneSpawnReservationKey(args) : null + // Keep replacement authority across stopping the previous process. + if (replacementKey) { + ctx.paneSpawnReservation = await reserveIdlePaneSpawn(replacementKey) + ctx.paneSpawnReservationKey = replacementKey + } + let prepared = false + const finish = (outcome: PtySpawnOutcome) => { + ctx.paneSpawnOperation?.acquisition?.settle(outcome) + if (outcome.kind === 'registration-exited') { + adapter.releasePreparation?.() + adapter.releaseHiddenMark?.() + } + adapter.onOutcome?.(outcome) + return outcome + } + const inherit = (adopted: Parameters[0]) => { + const outcome = adoptedOutcome(adopted) + Object.assign(outcome.result, ctx.deps.runtime?.getTerminalLaunchResult?.(outcome.result.id)) + return finish(outcome) + } + const complete = async (operation: PaneSpawnOperation): Promise => { + ctx.paneSpawnOperation = operation + ctx.args.paneSpawnOperation = operation + const runtime = ctx.deps.runtime + const acquire = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquire + ? await acquire.call(runtime, spawnWorktreeTarget(args.worktreeId, args.connectionId), { + refuseSleptWorktree: args.refuseSleptWorktree, + reattachSessionId: ctx.preAdoptedStablePane?.owner.ptyId ?? args.sessionId, + requireAttachOnly: () => { + ctx.spawnOptions = { ...ctx.spawnOptions, attachOnly: true } + } + }) + : undefined + const facts = adapter.facts() + const chooseFresh = await prepareSavedPtyResume(ctx, facts) + await executePtySpawn(ctx, facts, chooseFresh) + if (!ctx.resumeAdmission) { + ctx.resumeAdmission = await admitColdRestoreReattach( + runtime, + ctx.result, + { + paneKey: facts.savedResumePaneKey ?? null, + saved: savedResumeRecord(ctx.deps.store, facts.savedResumePaneKey ?? null) + }, + ctx.expectedWslDistro + ) + await captureSavedResumeCleanup( + runtime, + ctx.deps.store, + facts.savedResumePaneKey ?? null, + ctx.resumeAdmission, + args.worktreeId + ) + } + if (!ctx.result.isReattach) { + Object.assign(ctx.result, savedResumeResult(ctx.resumeAdmission)) + } + const outcome = await adapter.commit() + if ( + outcome.kind === 'committed' || + (outcome.kind === 'registration-exited' && adapter.drainsExitedStart) + ) { + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + forgetRefusedResume(facts.savedResumePaneKey ?? null, refusal) + if ( + args.worktreeId && + facts.savedResumePaneKey && + refusal && + refusal.verdict !== 'unverifiable' + ) { + void runtime + ?.forgetRefusedSavedResume?.( + args.worktreeId, + facts.savedResumePaneKey, + refusal.refused, + refusal.sourceRecord + ) + .catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error)) + } + if (!ctx.result.isReattach) { + runtime?.rememberSavedTerminalLaunchResult?.(outcome.result.id, outcome.result) + } + } + finish(outcome) + // Runtime hidden P2 follows the awaited prompt-intent wrapper and never an exited result. + if (outcome.kind !== 'registration-exited') { + adapter.onCommitted?.() + } + return outcome + } + const acquire = async ( + paneKey: string, + fresh: (operation: PaneSpawnOperation) => Promise + ) => { + const pane = parsePaneKey(paneKey)! + let outcome: PtySpawnOutcome | undefined + const adopted = await ctx.deps.adoptStablePane({ + ...adapter.adoption?.(), + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + ...pane, + signal: args.signal, + preAllocatedHandle: adapter.facts().preAllocatedHandle ?? undefined, + reservation: ctx.paneSpawnReservation ?? undefined, + stoppedPtyId: adapter.facts().replacedPaneOwner?.ptyId, + attachOnlyView: attachOnlyViewOf(args), + spawnFresh: async (operation) => { + outcome = await fresh(operation) + return outcome.result + } + }) + if (!adopted) { + return outcome! + } + if (prepared) { + retirePreparation(adapter, adopted.result.id) + } + adapter.releaseHiddenMark?.() + reportPtyPaneAdoption(ctx, adopted) + return inherit(adopted) + } + const fresh = async (operation: PaneSpawnOperation): Promise => { + if (prepared) { + retirePreparation(adapter) + } + ctx.paneSpawnOperation = operation + ctx.args.paneSpawnOperation = operation + prepared = true + await adapter.prepare() + const latePane = + !resolveEarlyPaneKey(args) && !operation.acquisition ? adapter.facts().paneKey : null + if (latePane) { + const pending = ctx.paneSpawnReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(ctx.paneSpawnReservationKey) + : undefined + if (pending) { + await pending.promise + const pane = parsePaneKey(latePane)! + if (pending.operation?.acquisition?.reservation.outcome?.kind === 'registration-exited') { + const adopted = consumeExitedPaneOperation( + ctx.deps.runtime, + ctx.deps.store, + { ...args, ...pane, renderer: adapter.adoption?.().renderer }, + pending.operation + ) + retirePreparation(adapter, adopted.result.id) + return inherit(adopted) + } + } + ctx.args = { ...ctx.args, ...parsePaneKey(latePane)! } + return acquire(latePane, complete) + } + return complete(operation) + } + try { + if (adapter.replace) { + await adapter.replace() + } + await adapter.admission + const early = await beginPtySpawn(ctx, adapter.adoption?.().renderer) + if (early) { + return inherit(early) + } + if (adapter.adoption?.().completed?.materialized) { + return acquire(resolveEarlyPaneKey(args)!, fresh) + } + const paneKey = resolveEarlyPaneKey(args) + return await (paneKey && !ctx.paneSpawnOperation + ? acquire(paneKey, fresh) + : fresh(ctx.paneSpawnOperation ?? { mustExist: false })) + } catch (caught) { + const error = adapter.onError?.(caught) ?? caught + adapter.releasePreparation?.() + adapter.releaseHiddenMark?.() + cancelRegistration(ctx) + restoreProvisionalPtySize(ctx) + if (ctx.replacedPaneOwner) { + await releaseStoppedPaneBinding(ctx.deps.store, ctx.replacedPaneOwner) + } + // Every failure settles the exact reservation so later starts can acquire it. + rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, error) + throw error + } finally { + cancelRegistration(ctx) + ctx.releaseWorktreeSpawn?.() + ctx.finishTerminalInstall() + } +} diff --git a/src/main/ipc/pty/spawn-options-assembly.ts b/src/main/ipc/pty/spawn-options-assembly.ts index 7927f4aef80c..eb815371d2b1 100644 --- a/src/main/ipc/pty/spawn-options-assembly.ts +++ b/src/main/ipc/pty/spawn-options-assembly.ts @@ -241,56 +241,54 @@ export async function assemblePtySpawnLaunchOptions(input: PtySpawnLaunchOptions return { spawnOptions, env, launchCommand } } -/** Seed before early output; retain previous geometry to restore after a lost reservation race. */ +export type PtySpawnSizeSeed = { + cachedBeforeAttach: { cols: number; rows: number } | undefined + rollback: (options?: { reattachId?: string; exitedBeforeSpawnReply?: boolean }) => void +} + +/** Samples and seeds geometry only when preparation reaches this resource. */ export function seedPtySpawnSize(input: { sessionId: string | undefined sessionAppId: string | undefined isFreshSessionId: boolean - /** Hidden window requests are unmeasured; runtime requests are measured. */ requestIsUnmeasured: boolean cols: number rows: number -}): { - hadSessionSizeBeforeAttach: boolean - sessionSizeBeforeAttach: { cols: number; rows: number } | undefined -} { - const hadSessionSizeBeforeAttach = - input.sessionAppId !== undefined ? ptySizes.has(input.sessionAppId) : false - const sessionSizeBeforeAttach = - input.sessionAppId !== undefined ? ptySizes.get(input.sessionAppId) : undefined +}): PtySpawnSizeSeed { + const key = input.sessionAppId ?? input.sessionId + const cachedBeforeAttach = key !== undefined ? ptySizes.get(key) : undefined if ( input.sessionId !== undefined && shouldSeedPreAttachPtySize({ isFreshSessionId: input.isFreshSessionId, - hasCachedSize: hadSessionSizeBeforeAttach, + hasCachedSize: cachedBeforeAttach !== undefined, requestIsUnmeasured: input.requestIsUnmeasured }) ) { - // Early daemon output needs actual geometry before spawn resolves, avoiding default-grid TUI wrapping. - ptySizes.set(input.sessionAppId ?? input.sessionId, { cols: input.cols, rows: input.rows }) + // Daemon output can precede the reply; the grid must exist before byte zero. + ptySizes.set(key!, { cols: input.cols, rows: input.rows }) + } + return { + cachedBeforeAttach, + rollback: (options) => { + if (input.sessionId === undefined || key === options?.reattachId) { + return + } + if (cachedBeforeAttach && !options?.exitedBeforeSpawnReply) { + ptySizes.set(key!, cachedBeforeAttach) + } else { + ptySizes.delete(key!) + } + } } - return { hadSessionSizeBeforeAttach, sessionSizeBeforeAttach } } -/** Undoes a provisional size seed after a failed start, or a lost race that reattaches to another id. */ -export function restoreProvisionalPtySize(input: { - sessionId: string | undefined - sessionAppId: string | undefined - /** Set when a lost reservation race reattached; the winner may have committed this id's real size. */ - reattachId?: string - hadSessionSizeBeforeAttach: boolean - sessionSizeBeforeAttach: { cols: number; rows: number } | undefined -}): void { - if (input.sessionId === undefined) { - return - } - const provisionalSizeKey = input.sessionAppId ?? input.sessionId - if (provisionalSizeKey === input.reattachId) { - return - } - if (input.hadSessionSizeBeforeAttach && input.sessionSizeBeforeAttach) { - ptySizes.set(provisionalSizeKey, input.sessionSizeBeforeAttach) - } else { - ptySizes.delete(provisionalSizeKey) - } +/** Takes the acquired seed so teardown and outer failure cannot restore it twice. */ +export function restoreProvisionalPtySize( + state: { provisionalPtySize: PtySpawnSizeSeed | undefined }, + options?: Parameters[0] +): void { + const seed = state.provisionalPtySize + state.provisionalPtySize = undefined + seed?.rollback(options) } diff --git a/src/main/ipc/pty/spawn-outcome.ts b/src/main/ipc/pty/spawn-outcome.ts new file mode 100644 index 000000000000..4ff40a42faa7 --- /dev/null +++ b/src/main/ipc/pty/spawn-outcome.ts @@ -0,0 +1,36 @@ +import { terminalAttachResult } from '../../../shared/terminal-attach-result' +import type { AdoptStablePaneResult } from './ipc/spawn-types' +import type { StartupCwdFallback } from '../../../shared/startup-cwd-fallback' +import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' +import type { StablePaneOwner } from './pane/stable-owner' +import type { PtySpawnResult } from '../../providers/pty-provider-contract' + +/** One host result; reply adapters never decide how the owning operation settles. */ +export type PtySpawnOutcome = { + kind: 'committed' | 'adopted' | 'registration-exited' + owner?: StablePaneOwner + result: PtySpawnResult & { + savedStartupCwd?: string + startupCwdFallback?: StartupCwdFallback + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + launchConfig?: SleepingAgentLaunchConfig + agentResumeUnavailable?: true + } +} + +export { provenSpawnResponse } from '../../../shared/terminal-spawn-response' + +export function requireAcceptedSpawn(outcome: PtySpawnOutcome): PtySpawnOutcome['result'] { + if (outcome.kind === 'registration-exited') { + throw new Error('agent_session_exited_during_start') + } + return outcome.result +} + +export function adoptedOutcome(adopted: AdoptStablePaneResult): PtySpawnOutcome { + return { + ...(adopted.outcome ?? { kind: 'adopted' }), + result: terminalAttachResult(adopted.result), + owner: adopted.owner + } +} diff --git a/src/main/ipc/pty/spawn-pane-coordinates.ts b/src/main/ipc/pty/spawn-pane-coordinates.ts new file mode 100644 index 000000000000..c5c07c623b8d --- /dev/null +++ b/src/main/ipc/pty/spawn-pane-coordinates.ts @@ -0,0 +1,24 @@ +import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../shared/terminal-tab-id' +import { makePaneSpawnReservationKey } from './pane/spawn-reservation' + +type PtySpawnPaneCoordinates = { + tabId?: unknown + leafId?: unknown + worktreeId?: string + connectionId?: string | null +} + +export function resolveEarlyPaneKey(args: PtySpawnPaneCoordinates): string | null { + return typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + typeof args.leafId === 'string' && + isTerminalLeafId(args.leafId) + ? makePaneKey(args.tabId, args.leafId) + : null +} + +export function resolveEarlyPaneSpawnReservationKey(args: PtySpawnPaneCoordinates): string | null { + return makePaneSpawnReservationKey(args.worktreeId, args.connectionId, resolveEarlyPaneKey(args)) +} diff --git a/src/main/ipc/pty/spawn-resume.ts b/src/main/ipc/pty/spawn-resume.ts new file mode 100644 index 000000000000..e8252110e28d --- /dev/null +++ b/src/main/ipc/pty/spawn-resume.ts @@ -0,0 +1,52 @@ +import type { PtySpawnOperationState, PtySpawnExecutionFacts } from './spawn-operation-state' +import { + captureSavedResumeCleanup, + chooseSavedResumeFreshLaunch, + resumeSpawnRefusal, + type ResumeSpawnAdmission, + type ResumeSpawnRefusal +} from './host-env/saved-resume-admission' + +/** Capture the acquired pane's source once; only the physical-fresh branch judges its launch. */ +export async function prepareSavedPtyResume( + ctx: PtySpawnOperationState, + facts: PtySpawnExecutionFacts +) { + const args = ctx.args + args.launchConfig = facts.launchConfig + const captured: ResumeSpawnAdmission | null = + args.resumeOrigin === 'saved' && args.launchAgent && args.resumeProviderSession + ? { + verdict: 'admitted', + refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession } + } + : null + await captureSavedResumeCleanup( + ctx.deps.runtime, + ctx.deps.store, + facts.savedResumePaneKey ?? null, + captured, + args.worktreeId + ) + return async (inherited?: ResumeSpawnRefusal): Promise => { + ctx.spawnOptions.env = { ...ctx.spawnOptions.env } + ctx.resumeAdmission = + inherited ?? + (await chooseSavedResumeFreshLaunch( + ctx.deps.runtime, + args, + ctx.expectedWslDistro, + ctx.spawnOptions + )) + if (captured && ctx.resumeAdmission) { + ctx.resumeAdmission = { + ...ctx.resumeAdmission, + refused: captured.refused, + sourceRecord: captured.sourceRecord + } + } + if (resumeSpawnRefusal(ctx.resumeAdmission)) { + facts.suppressRefusedLaunch?.() + } + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts index 7d715e5476ee..b32d0aa0d939 100644 --- a/src/main/opencode/opencode-startup-prompt-owner.test.ts +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -72,12 +72,12 @@ function fixture() { } describe('native prompt admission after spawn commit', () => { - it('waits through delayed persistence and later runtime identity admission', async () => { + it('waits for canonical commit and runtime identity before claiming once', async () => { const f = fixture() const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { await f.waiting f.facts.recordSpawnCommit(f.result) - return f.result + return { kind: 'committed' as const, result: f.result } }) expect(control.claim(f.body)).toBe('pending') f.release() @@ -88,6 +88,23 @@ describe('native prompt admission after spawn commit', () => { expect(control.claim(f.body)).toBe(false) }) + it('cancels a registration-exited outcome despite later facts and identity admission', async () => { + const f = fixture() + const outcome = { kind: 'registration-exited' as const, result: f.result } + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + return outcome + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await expect(committed).resolves.toBe(outcome) + expect(control.claim(f.body)).toBe(false) + f.facts.recordSpawnCommit(f.result) + f.admit() + expect(control.claim(f.body)).toBe(false) + expect(control.claim({ ...f.body, requestId: 'late-operation' })).toBe(false) + }) + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { const f = fixture() const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts index 7dc24823af97..1eef58d008c7 100644 --- a/src/main/opencode/opencode-startup-prompt-owner.ts +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -1,3 +1,4 @@ +import type { PtySpawnOutcome } from '../ipc/pty/spawn-outcome' import { agentHookServer } from '../agent-hooks/server' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { IPtyProvider, PtySpawnResult } from '../providers/pty-provider-contract' @@ -27,7 +28,9 @@ export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boo return claims.register(nonce, digest, () => 'pending') } -export async function commitPtyWithOpenCodePromptIntent( +export async function commitPtyWithOpenCodePromptIntent< + Result extends PtySpawnResult | PtySpawnOutcome +>( context: { env?: Record spawnEnv?: Record @@ -49,7 +52,15 @@ export async function commitPtyWithOpenCodePromptIntent | undefined result: PtySpawnResult diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts index aeb574cb98de..0011701c7d54 100644 --- a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -20,13 +20,7 @@ import { registerPersistedPtySpawn } from '../../ipc/pty/pane/spawn-registration import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' import { ptyOwnership, ptyIncarnationById } from '../../ipc/pty/provider/ownership-state' import { ptySizes } from '../../ipc/pty/delivery/visibility-state' -import { - paneSpawnReservationsByOwnerKey, - reservePaneSpawn, - reserveIdlePaneSpawn, - resolvePaneSpawnReservation, - type PaneSpawnReservation -} from '../../ipc/pty/pane/spawn-reservation' +import { reservePaneSpawn, resolvePaneSpawnReservation } from '../../ipc/pty/pane/spawn-reservation' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -73,8 +67,6 @@ it.each( runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) } let commit: () => Promise - const reservationKey = JSON.stringify([connectionId, binding.worktreeId, binding.leafId]) - let reservation: PaneSpawnReservation | undefined if (owner) { const provider = new LocalPtyProvider() vi.spyOn(provider, 'spawn').mockResolvedValue({ @@ -93,7 +85,7 @@ it.each( renderer: controller === 'ipc', spawnOptions: { cols: 80, rows: 24 } }) - )?.result + )?.outcome } else if (controller === 'runtime') { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. const deps = { runtime, store, options: {} } as PtyRuntimeControllerDeps @@ -112,21 +104,14 @@ it.each( ctx.stablePaneOwner = owner ctx.metadataLeafId = binding.leafId ctx.validatedLeafId = binding.leafId - reservation = reservePaneSpawn(reservationKey) - ctx.paneSpawnReservationKey = reservationKey - ctx.paneSpawnReservation = reservation commit = () => commitPtyIpcSpawn(ctx) } const gate = authority.pause() - const pending = - controller === 'ipc' - ? expect(commit()).resolves.toMatchObject({ - id: binding.ptyId, - incarnationId: binding.incarnationId - }) - : expect(commit()).rejects.toThrow('agent_session_exited_during_start') + const pending = expect(commit()).resolves.toMatchObject({ + kind: 'registration-exited', + result: { id: binding.ptyId, incarnationId: binding.incarnationId } + }) await gate.started.promise - const nextReservation = reservation ? reserveIdlePaneSpawn(reservationKey) : undefined clearProviderPtyState(binding.ptyId) ptyOwnership.delete(binding.ptyId) await runtime.onPtyExit(binding.ptyId, exitCode, binding.incarnationId, { @@ -137,13 +122,6 @@ it.each( expect(ptyOwnership.has(binding.ptyId)).toBe(false) expect(ptyIncarnationById.has(binding.ptyId)).toBe(false) expect(ptySizes.has(binding.ptyId)).toBe(false) - if (reservation) { - await expect(reservation.promise).resolves.toMatchObject({ id: binding.ptyId }) - const next = await nextReservation - expect(next).not.toBe(reservation) - resolvePaneSpawnReservation(reservationKey, next, { id: 'next-spawn' }) - expect(paneSpawnReservationsByOwnerKey.has(reservationKey)).toBe(false) - } const session = readState().workspaceSession expect( session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] @@ -331,7 +309,7 @@ it.each(['success', 'new-exit', 'old-exit'] as const)( gate.finish.resolve() const result = await settled if (event === 'new-exit') { - expect.soft(result.error?.message).toBe('agent_session_exited_during_start') + expect.soft(result.value?.outcome?.kind).toBe('registration-exited') expect .soft( readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[ @@ -412,7 +390,7 @@ it.each(['joined', 'fresh'] as const)( ctx.result = result ctx.hostSessionBinding = { store, ...binding } ctx.metadataLeafId = binding.leafId - return commitRuntimePtySpawn(ctx) + return (await commitRuntimePtySpawn(ctx)).result } }) if (reservation) { diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index acef2449743a..d6b1110bc418 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -72,6 +72,10 @@ export class ClaudeAgentTeamsService { return { teamId, token, leaderPane, env } } + releaseTeam(teamId: string): void { + this.teams.delete(teamId) + } + removeTeamForLeaderHandle(handle: string): void { for (const [teamId, team] of this.teams) { if (team.leaderHandle === handle) { diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 2e2c64da6f65..daf302469b1b 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -1,12 +1,15 @@ /** Host creates need durable binding/runtime ownership to survive graph sync without a renderer writer. */ import { describe, expect, it, vi } from 'vitest' +import { ClaudeAgentTeamsService } from './claude-agent-teams-service' +import * as launchPlan from './terminal/launch/agent-teams-launch-plan' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' import './rpc/unused-default-rpc-methods.test-fixture' import { startDesktopMultiplexSubscribe, sendDesktopMultiplexSubscribe } from './rpc/terminal-multiplex-test-harness' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { OrcaRuntimeService } from './orca-runtime' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -20,10 +23,7 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function createRuntimeWithAttachedWindow(): { - runtime: OrcaRuntimeService - spawn: ReturnType -} { +function createRuntimeWithAttachedWindow() { const runtime = new OrcaRuntimeService() vi.spyOn( runtime as unknown as { @@ -37,7 +37,9 @@ function createRuntimeWithAttachedWindow(): { repo: null, folderWorkspace: null }) - const spawn = vi.fn().mockResolvedValue({ id: 'repo-1::/tmp/wt-cli@@a1b2c3d4' }) + const spawn = vi + .fn>() + .mockResolvedValue({ id: 'repo-1::/tmp/wt-cli@@a1b2c3d4' }) runtime.setPtyController({ spawn, write: () => true, @@ -78,6 +80,134 @@ describe('host-initiated terminal creation under an attached window', () => { expect(pty?.connected).toBe(true) expect(pty?.runtimeSessionOwned).toBe(true) }) + it.each(['cancelled', 'refused', 'accepted-then-presentation-error'])( + 'releases only this create allocation on %s with a reused handle', + async (failure) => { + const { runtime, spawn } = createRuntimeWithAttachedWindow() + const teams = new ClaudeAgentTeamsService() + const existing = teams.createLaunchEnv({ + leaderHandle: 'shared-handle', + baseEnv: {}, + shimDir: '/shim', + shimBin: '/orca' + }) + Object.assign(runtime, { claudeAgentTeams: teams }) + const signal = new AbortController() + const allocation = vi.spyOn(teams, 'createLaunchEnv') + const release = vi.spyOn(teams, 'releaseTeam') + const prepare = vi + .spyOn(launchPlan, 'buildRuntimeAgentTeamsLaunchPlan') + .mockImplementation(async (args) => { + const env = args.createTeamEnv('/shim', '/orca') + if (failure === 'cancelled') { + signal.abort() + } + return { plan: { env, command: 'claude' }, effectiveLaunchConfig: args.launchConfig } + }) + if (failure === 'refused') { + spawn.mockRejectedValue(new Error('execution_owner_unavailable')) + } + if (failure === 'accepted-then-presentation-error') { + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + adoptStablePane: async (args) => { + const operation = { mustExist: false } + const result = await args.spawnFresh!(operation) + args.onOutcome?.({ kind: 'committed', result }, operation) + return null + } + }) + vi.spyOn(runtime, 'assertPtyDidNotExitBeforeRegistration').mockImplementation(() => { + throw new Error('presentation failed') + }) + } + try { + await expect( + runtime.createTerminal('id:repo-1::/tmp/wt-cli', { + command: 'claude', + preAllocatedHandle: 'shared-handle', + signal: signal.signal + }) + ).rejects.toThrow() + const allocatedId = allocation.mock.results[0]!.value.teamId + expect(allocatedId).not.toBe(existing.teamId) + expect([teams.getActiveTeamCount(), release.mock.calls]).toEqual( + failure === 'accepted-then-presentation-error' ? [2, []] : [1, [[allocatedId]]] + ) + } finally { + prepare.mockRestore() + } + } + ) + + it('releases only the prepared allocation when a reused handle adopts a late winner', async () => { + const { runtime, spawn } = createRuntimeWithAttachedWindow() + const teams = new ClaudeAgentTeamsService() + const existing = teams.createLaunchEnv({ + leaderHandle: 'shared-handle', + baseEnv: {}, + shimDir: '/shim', + shimBin: '/orca' + }) + Object.assign(runtime, { claudeAgentTeams: teams }) + const allocation = vi.spyOn(teams, 'createLaunchEnv') + const release = vi.spyOn(teams, 'releaseTeam') + const prepare = vi + .spyOn(launchPlan, 'buildRuntimeAgentTeamsLaunchPlan') + .mockImplementation(async (args) => ({ + plan: { env: args.createTeamEnv('/shim', '/orca'), command: 'claude' }, + effectiveLaunchConfig: args.launchConfig + })) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + adoptStablePane: async (args) => { + const operation = { mustExist: false } + const result = { ...(await args.spawnFresh!(operation)), isReattach: true } + expect(allocation).toHaveBeenCalledTimes(1) + expect(teams.getActiveTeamCount()).toBe(2) + expect(release).not.toHaveBeenCalled() + args.onOutcome?.({ kind: 'adopted', result }, operation) + return { + result, + owner: { + handle: 'shared-handle', + tabId: args.tabId, + leafId: args.leafId, + ptyId: result.id + } + } + } + }) + try { + const result = await runtime.createTerminal('id:repo-1::/tmp/wt-cli', { + command: 'claude', + preAllocatedHandle: 'shared-handle' + }) + const allocatedId = allocation.mock.results[0]!.value.teamId + expect(allocatedId).not.toBe(existing.teamId) + expect(spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + env: expect.objectContaining({ ORCA_AGENT_TEAMS_TEAM_ID: allocatedId }) + }) + ) + expect(result).toMatchObject({ ptyId: 'repo-1::/tmp/wt-cli@@a1b2c3d4', isReattach: true }) + expect([teams.getActiveTeamCount(), release.mock.calls]).toEqual([1, [[allocatedId]]]) + await expect( + teams.handleTmuxCompat( + { ...existing, envPane: existing.leaderPane, argv: ['list-panes', '-F', '#{pane_id}'] }, + runtime + ) + ).resolves.toMatchObject({ ok: true, stdout: '%1\n', exitCode: 0 }) + } finally { + prepare.mockRestore() + } + }) }) it.each(['refused', 'adopted', 'stable-adopted', 'ensure-adopted', 'fresh-unavailable'] as const)( diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts index f50679155b1d..e0633ad9e415 100644 --- a/src/main/runtime/runtime-pane-admission.ts +++ b/src/main/runtime/runtime-pane-admission.ts @@ -198,6 +198,14 @@ export async function spawnInAdmittedPane( startStatus?.keepOnFailure ) } catch (error) { + if ( + admittedAs === 'admitted' && + !startStatus?.keepOnFailure && + !(error instanceof PaneOwnerContentionError) && + args.paneSpawnOperation?.acquisition + ) { + args.paneSpawnOperation.acquisition.surfaceWithdrawn = true + } // Retained panes show failure; user-closed panes stay gone and contended panes attach to the winner. if ( startStatus && diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 23fe58fe0675..c9d2a7e6e380 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -1,3 +1,5 @@ +import type { PtySpawnOutcome } from '../ipc/pty/spawn-outcome' +import type { RuntimePaneCreateClaim } from '../ipc/pty/pane/spawn-reservation' import type { PaneSpawnOperation } from '../ipc/pty/pane/pane-spawn-operation' import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' import type { @@ -38,8 +40,9 @@ export type RuntimePtyController = { connectionId: string | null tabId: string leafId: string - }): () => void + }): RuntimePaneCreateClaim adoptStablePane?(opts: { + onOutcome?: (outcome: PtySpawnOutcome, operation: PaneSpawnOperation) => void signal?: AbortSignal spawnFresh?: (operation: PaneSpawnOperation) => Promise cols: number diff --git a/src/main/runtime/terminal/launch/terminal-create-team-allocation.ts b/src/main/runtime/terminal/launch/terminal-create-team-allocation.ts new file mode 100644 index 000000000000..075a1792398b --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-create-team-allocation.ts @@ -0,0 +1,48 @@ +import type { ClaudeAgentTeamsService } from '../../claude-agent-teams-service' +import type { PtySpawnOutcome } from '../../../ipc/pty/spawn-outcome' +import { buildRuntimeAgentTeamsLaunchPlan } from './agent-teams-launch-plan' + +/** Only the exact allocation belongs to this start until the process accepts it. */ +export function terminalCreateTeamAllocation(teams: ClaudeAgentTeamsService) { + let teamId: string | undefined + const release = () => { + if (teamId) { + teams.releaseTeam(teamId) + teamId = undefined + } + } + return { + release, + prepare: ( + args: Omit[0], 'createTeamEnv'>, + leaderHandle: string + ) => { + release() + return buildRuntimeAgentTeamsLaunchPlan({ + ...args, + createTeamEnv: (shimDir, shimBin) => { + const allocation = teams.createLaunchEnv({ + leaderHandle, + baseEnv: args.baseEnv, + shimDir, + shimBin + }) + teamId = allocation.teamId + return allocation.env + } + }) + }, + acceptOutcome: (outcome: PtySpawnOutcome) => { + if (outcome.kind === 'committed' && !outcome.result.isReattach) { + teamId = undefined + } + }, + acceptFresh: ( + result: Pick + ) => { + if (result.agentSessionEnsure?.disposition !== 'adopted' && !result.stablePaneOwner) { + teamId = undefined + } + } + } +} diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 7a57eead2592..2eed770b0af9 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -8,7 +8,7 @@ import type { PaneSpawnOperation } from '../../../ipc/pty/pane/pane-spawn-operat import { OrcaRuntimeWithTerminalCreateDeduplication } from './terminal-create-deduplication' import * as dependencies from '../../orca-runtime-create-terminal-dependencies' import { createDesktopTerminal } from '../../orca-runtime-create-terminal-desktop' -import { buildRuntimeAgentTeamsLaunchPlan } from './agent-teams-launch-plan' +import { terminalCreateTeamAllocation } from './terminal-create-team-allocation' import { createIdempotentCallback } from '../../orca-runtime-report-pty-spawn-commit' import { recordPtySurface, spawnSurfaceClaimSequence } from '../../pty-recorded-surface-topology' import { agentLaunchTokenFor, withAgentLaunchToken } from '../../../agent-launch/agent-launch-token' @@ -74,35 +74,29 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi }) const releaseStablePaneCreate = createIdempotentCallback(claimedStablePaneCreate) const launchToken = agentLaunchTokenFor(launchOpts, dependencies.randomUUID) + const teamAllocation = terminalCreateTeamAllocation(this.claudeAgentTeams) let effectiveLaunchConfig = launchOpts.launchConfig let result!: Awaited>> const spawnFresh = async (operation: PaneSpawnOperation) => { const baseEnv = withAgentLaunchToken(launchOpts.env, launchToken) - let agentTeamsPlan: Awaited>['plan'] - let sequencedStartupCommand: string | undefined - try { - const agentTeams = await buildRuntimeAgentTeamsLaunchPlan({ - launchConfig: launchOpts.launchConfig, - command: launchOpts.command, - claudeAgentTeamsSourceCommand: launchOpts.claudeAgentTeamsSourceCommand, - claudeAgentTeamsMode: this.store?.getSettings?.().claudeAgentTeamsMode, - baseEnv: { ...process.env, ...baseEnv }, - adoptedBeforeLaunch: null, - createTeamEnv: (shimDir, shimBin) => - this.claudeAgentTeams.createLaunchEnv({ - leaderHandle: preAllocatedHandle, - baseEnv: { ...process.env, ...baseEnv }, - shimDir, - shimBin - }).env + const agentTeams = await teamAllocation + .prepare( + { + launchConfig: launchOpts.launchConfig, + command: launchOpts.command, + claudeAgentTeamsSourceCommand: launchOpts.claudeAgentTeamsSourceCommand, + claudeAgentTeamsMode: this.store?.getSettings?.().claudeAgentTeamsMode, + baseEnv: { ...process.env, ...baseEnv }, + adoptedBeforeLaunch: null + }, + preAllocatedHandle + ) + .catch((error: unknown) => { + releaseStablePaneCreate() + throw error }) - agentTeamsPlan = agentTeams.plan - sequencedStartupCommand = agentTeams.sequencedStartupCommand - effectiveLaunchConfig = agentTeams.effectiveLaunchConfig - } catch (error) { - releaseStablePaneCreate?.() - throw error - } + const { plan: agentTeamsPlan, sequencedStartupCommand } = agentTeams + effectiveLaunchConfig = agentTeams.effectiveLaunchConfig const env = this.buildTerminalWorkspaceEnv( workspace, { @@ -199,6 +193,10 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi tabId, leafId, spawnFresh, + onOutcome: (outcome, operation) => { + claimedStablePaneCreate?.acceptOutcome?.(outcome, operation) + teamAllocation.acceptOutcome(outcome) + }, signal: launchOpts.signal }) if (adoptedBeforeLaunch) { @@ -206,6 +204,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi Object.assign(result, this.getTerminalLaunchResult(result.id)) } else if (!ptyController.adoptStablePane) { result = await spawnFresh({ mustExist: false }) + teamAllocation.acceptFresh(result) } if (!result.stablePaneOwner) { reportPtySpawnCommitted() @@ -300,6 +299,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi ...(warning ? { warning } : {}) } } finally { + teamAllocation.release() releaseStablePaneCreate() } } diff --git a/src/main/runtime/terminal/launch/terminal-split-source-authority.ts b/src/main/runtime/terminal/launch/terminal-split-source-authority.ts index c1474a9f5071..0c154d732c1f 100644 --- a/src/main/runtime/terminal/launch/terminal-split-source-authority.ts +++ b/src/main/runtime/terminal/launch/terminal-split-source-authority.ts @@ -26,6 +26,18 @@ export abstract class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends > ): ReturnType + /** A mounted view can drain its exited process after durable retirement removed the binding. */ + hasTerminalPaneView(worktreeId: string, tabId: string, leafId: string): boolean { + const tab = this.tabs.get(tabId) + const leaf = this.leaves.get(this.getLeafKey(tabId, leafId)) + return Boolean( + tab && + leaf && + worktreeIdsEqual(tab.worktreeId, worktreeId) && + worktreeIdsEqual(leaf.worktreeId, worktreeId) + ) + } + protected resolveTerminalSplitSourceAuthority( worktreeId: string, tabId: string, diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index ada179976614..f212af0681fd 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -1,3 +1,7 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { PtyConnectionDeps } from './pty-connection-types' +import type { ConnectCallbacks } from './pty-connection-test-pane-fixtures' +import { terminalAttachResult } from '../../../../shared/terminal-attach-result' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' @@ -5,7 +9,12 @@ describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onData: ((payload: { id: string; data: string }) => void) | null = null let onExit: - | ((payload: { id: string; code: number; preserveRendererBinding?: boolean }) => void) + | ((payload: { + id: string + code: number + preserveRendererBinding?: boolean + incarnationId?: string + }) => void) | null = null beforeEach(() => { @@ -54,6 +63,51 @@ describe('createIpcPtyTransport', () => { } ) + it('drains an exited join with pre-request output and preserves its snapshot projection', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + const creator = { + kind: 'registration-exited' as const, + result: { + id: 'exited-join', + incarnationId: 'join-inc', + snapshot: 'final snapshot', + snapshotCols: 120, + snapshotRows: 40, + snapshotSeq: 7, + snapshotKittyKeyboardFlags: 3 + } + } + const joined = terminalAttachResult(creator.result) + ensurePtyDispatcher() + onData!({ id: creator.result.id, data: 'final output' }) + onExit!({ id: creator.result.id, code: 23, incarnationId: creator.result.incarnationId }) + vi.mocked(window.api.pty.spawn).mockResolvedValue(joined) + const onOutput = vi.fn() + const onEnd = vi.fn() + const transport = createIpcPtyTransport({}) + const result = await transport.connect({ + url: '', + callbacks: { onData: onOutput, onExit: onEnd } + }) + expect(onOutput).toHaveBeenCalledWith('final output') + expect(onEnd).toHaveBeenCalledWith(23) + expect(result).toEqual({ id: creator.result.id, exitedBeforeAttach: true }) + transport.detach?.() + const snapshotView = createIpcPtyTransport({}) + expect(await snapshotView.connect({ url: '', callbacks: {} })).toMatchObject({ + id: creator.result.id, + isReattach: true, + snapshot: 'final snapshot', + snapshotCols: 120, + snapshotRows: 40, + snapshotSeq: 7, + snapshotKittyKeyboardFlags: 3 + }) + expect(creator.result).not.toHaveProperty('isReattach') + snapshotView.detach?.() + }) + it('leaves title tracking to the PTY data stream (no OpenCode IPC channel)', async () => { // Why: the OpenCode status IPC channel is gone (now the agent-hooks server), so the transport has no per-agent status callback. const { createIpcPtyTransport } = await import('./pty-transport') @@ -381,4 +435,137 @@ describe('createIpcPtyTransport', () => { }) expect(writeMock).not.toHaveBeenCalled() }) + it('paints an old image and delayed live bytes once without claiming their newer frontier', async () => { + const { provenSpawnResponse } = await import('../../../../shared/terminal-spawn-response') + const { bindPtyConnectionTestMocks } = await import('./pty-connection-mocks.spec') + const { createInitialStoreState } = await import('./pty-connection-test-store-fixtures') + const { buildPaneConnectionDeps } = await import('./pty-connection-test-deps') + const { createMockTransport, createPaneContainer, LEAF_1 } = + await import('./pty-connection-test-pane-fixtures') + const { Terminal } = await import('@xterm/xterm') + const { FitAddon } = await import('@xterm/addon-fit') + const { SearchAddon } = await import('@xterm/addon-search') + const { SerializeAddon } = await import('@xterm/addon-serialize') + const { PaneManager } = await import('@/lib/pane-manager/pane-manager') + const { isTerminalLeafId } = await import('../../../../shared/stable-pane-id') + const { installTerminalTestGlobals, restoreTerminalTestGlobals } = + await import('./pty-connection-test-environment') + const { flushAsyncTicks } = await import('./pty-connection-test-async') + let state = createInitialStoreState(() => state) + const transports: ReturnType[] = [] + bindPtyConnectionTestMocks({ + getState: () => state, + getTransportQueue: () => transports, + getTransportOptions: () => [], + getStoreSubscribers: () => [], + setStoreSubscribers: () => {} + }) + await installTerminalTestGlobals() + try { + const { connectPanePty } = await import('./pty-connection') + for (const proof of ['interleaved', 'covered', 'no-flags'] as const) { + state = createInitialStoreState(() => state) + const result = provenSpawnResponse( + { + id: 'tab-pty', + isReattach: true, + snapshot: 'OLD', + providerSequence: { value: 900, generation: 'continued' as const }, + ...(proof !== 'no-flags' ? { snapshotKittyKeyboardFlags: 0 } : {}) + }, + proof === 'covered' ? 900 : undefined + ) + const transport = createMockTransport('tab-pty') + let callbacks: ConnectCallbacks | undefined + transport.connect.mockImplementation(async (options: { callbacks?: ConnectCallbacks }) => { + callbacks = options.callbacks + return result + }) + transports.push(transport) + const leafId: string = LEAF_1 + if (!isTerminalLeafId(leafId)) { + throw new Error('Expected a terminal leaf UUID') + } + const terminal = new Terminal({ + cols: 120, + rows: 40, + scrollback: 5_000, + allowProposedApi: true, + theme: { foreground: '#eeeeee', background: '#111111' }, + vtExtensions: { kittyKeyboard: true } + }) + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + vi.spyOn(terminal, 'write').mockImplementation((data, callback) => { + writes.push(typeof data === 'string' ? data : new TextDecoder().decode(data)) + if (callback) { + parseCallbacks.push(callback) + } + }) + const container = createPaneContainer() + Object.defineProperty(container, 'querySelectorAll', { value: () => [] }) + const pane: ManagedPane = { + id: 1, + leafId, + stablePaneId: leafId, + terminal, + container, + linkTooltip: createPaneContainer(), + fitAddon: new FitAddon(), + searchAddon: new SearchAddon(), + serializeAddon: new SerializeAddon() + } + vi.spyOn(pane.fitAddon, 'fit').mockImplementation(() => {}) + vi.spyOn(pane.fitAddon, 'proposeDimensions').mockReturnValue({ cols: 120, rows: 40 }) + const manager = new PaneManager(container, { linkOpenHint: () => '' }) + vi.spyOn(manager, 'getPanes').mockReturnValue([pane]) + vi.spyOn(manager, 'getLeafId').mockReturnValue(leafId) + vi.spyOn(manager, 'getNumericIdForLeaf').mockReturnValue(1) + const fixtureDeps = buildPaneConnectionDeps(() => state, { + isVisibleRef: { current: true }, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + const deps: PtyConnectionDeps = { + ...fixtureDeps, + paneTransportsRef: { current: new Map() }, + paneMode2031Ref: { current: new Map() }, + paneLastThemeModeRef: { current: new Map() }, + replayingPanesRef: { current: new Map() }, + syncPanePtyLayoutBinding: (paneId, ptyId) => { + if (ptyId !== null) { + fixtureDeps.syncPanePtyLayoutBinding(paneId, ptyId) + } + } + } + const binding = connectPanePty(pane, manager, deps) + try { + await flushAsyncTicks(20) + for (let step = 0; step < 40; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + // The stream reaches main at 903 before the control reply; its delivery to this view is delayed until after OLD paints. + expect(writes.join('')).toContain('OLD') + callbacks?.onData?.('NEW', { seq: 903, rawLength: 3 }) + for (let step = 0; step < 40; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + expect(writes.join('').match(/OLD/g), proof).toHaveLength(1) + expect(writes.join('').match(/NEW/g), proof).toHaveLength(1) + expect(result.snapshotSeq).toBe(proof === 'covered' ? 900 : undefined) + } finally { + binding.dispose() + manager.destroy() + pane.fitAddon.dispose() + pane.searchAddon.dispose() + pane.serializeAddon.dispose() + terminal.dispose() + } + } + } finally { + await restoreTerminalTestGlobals() + } + }) }) diff --git a/src/shared/terminal-attach-result.ts b/src/shared/terminal-attach-result.ts new file mode 100644 index 000000000000..320d3f440243 --- /dev/null +++ b/src/shared/terminal-attach-result.ts @@ -0,0 +1,3 @@ +export function terminalAttachResult(result: Result) { + return { ...result, isReattach: true } +} diff --git a/src/shared/terminal-spawn-response.ts b/src/shared/terminal-spawn-response.ts new file mode 100644 index 000000000000..aaf62c76d0a6 --- /dev/null +++ b/src/shared/terminal-spawn-response.ts @@ -0,0 +1,16 @@ +/** Only a boundary proved for this image can suppress subsequent replayed output. */ +export function provenSpawnResponse< + Result extends { + startupCwdSource?: unknown + snapshotSeq?: number + snapshotKittyKeyboardFlags?: number + } +>(result: Result, provedSnapshotSeq?: number) { + const { startupCwdSource: _source, ...response } = result + const snapshotSeq = provedSnapshotSeq ?? result.snapshotSeq + return { + ...response, + ...(provedSnapshotSeq !== undefined ? { snapshotSeq: provedSnapshotSeq } : {}), + ...(typeof snapshotSeq === 'number' ? {} : { snapshotKittyKeyboardFlags: undefined }) + } +}