diff --git a/src/main/agent-launch/agent-launch-not-started.ts b/src/main/agent-launch/agent-launch-not-started.ts new file mode 100644 index 00000000000..ca40f53378f --- /dev/null +++ b/src/main/agent-launch/agent-launch-not-started.ts @@ -0,0 +1,37 @@ +/** + * A launch whose terminal failed before its spawn was requested: nothing was created, so the + * launch can settle as failed with its real cause instead of an unknown outcome. + * + * The original error is remembered rather than wrapped, so the caller still receives the host's own + * code and message. After the request leaves this process a failure proves nothing — an SSH or + * daemon spawn whose reply was lost may still have started — so only this earlier point counts. + * + * The verdict belongs to the launch, not to the error object: a failed pane spawn rejects the same + * error into the spawner (after its request left) and into a concurrent create waiting on that pane + * (before its own request left), so a mark on the error itself would clear both. + */ +export type TerminalSpawnDispatch = { + onPtySpawnDispatched: () => void + /** Rethrows the create's failure, remembering it when the spawn request had not yet left. */ + rethrow: (error: unknown) => never + /** True only for the error this launch's create threw before its spawn request left. */ + failedBeforeDispatch: (error: unknown) => boolean +} + +export function trackTerminalSpawnDispatch(): TerminalSpawnDispatch { + let dispatched = false + let notStarted: { error: unknown } | undefined + return { + onPtySpawnDispatched: () => { + dispatched = true + }, + rethrow: (error) => { + if (!dispatched) { + notStarted = { error } + } + throw error + }, + failedBeforeDispatch: (error) => + !dispatched && notStarted !== undefined && notStarted.error === error + } +} diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index c366b6e57ef..e95431dd51c 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -124,6 +124,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate } let result: Awaited>> try { + launchOpts.onPtySpawnDispatched?.() result = await this.ptyController.spawn({ cols: 120, rows: 40, @@ -292,6 +293,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate releaseStablePaneCreate() } } + // The renderer owns this spawn, so this process cannot see when it is requested. + opts.onPtySpawnDispatched?.() return createDesktopTerminal(this, worktreeSelector, opts, presentation, rendererWindow) } } diff --git a/src/main/runtime/orca-runtime-tests/terminal-spawn-dispatch.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-spawn-dispatch.spec.ts new file mode 100644 index 00000000000..ff340199770 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/terminal-spawn-dispatch.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_PATH, store } from '../orca-runtime-test-fixtures.spec' + +// `agent.launch` settles a launch as failed only when its create threw before this hook ran. +describe('OrcaRuntimeService createTerminal spawn dispatch', () => { + it('reports the spawn request before it leaves for the pty controller', async () => { + const dispatched = vi.fn() + const spawn = vi.fn(async () => { + expect(dispatched).toHaveBeenCalledOnce() + return { id: 'pty-dispatch' } + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + onPtySpawnDispatched: dispatched + }) + + expect(spawn).toHaveBeenCalledOnce() + }) + + it('reports it even when the spawn itself then fails', async () => { + const dispatched = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => { + throw new Error('ssh_channel_closed') + }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + onPtySpawnDispatched: dispatched + }) + ).rejects.toThrow('ssh_channel_closed') + expect(dispatched).toHaveBeenCalledOnce() + }) + + it('does not report it when the create fails before any spawn request', async () => { + const dispatched = vi.fn() + const spawn = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal('path:/no/such/workspace', { + command: 'codex', + onPtySpawnDispatched: dispatched + }) + ).rejects.toThrow() + expect(spawn).not.toHaveBeenCalled() + expect(dispatched).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 13796169be0..6d4cba4741d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -51,6 +51,7 @@ await import('./orca-runtime-tests/terminal-creation-and-readiness-part-09.spec' await import('./orca-runtime-tests/terminal-creation-and-readiness-part-10.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-11.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-12.spec') +await import('./orca-runtime-tests/terminal-spawn-dispatch.spec') await import('./orca-runtime-tests/terminal-output-and-worker-recovery.spec') await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec') await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec') diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts new file mode 100644 index 00000000000..eac21c7c00e --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -0,0 +1,57 @@ +import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import { + WorktreeCreateCollisionError, + WORKTREE_CREATE_COLLISION_CODE +} from '../../../../shared/new-workspace/worktree-create-collision' +import { + AgentLaunchPaneAlreadyLiveError, + AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE +} from '../../../../shared/agent-launch-pane-already-live' +import { + AgentLaunchSessionAlreadyExistsError, + AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE +} from '../../../../shared/agent-launch-session-already-exists' +import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' + +/** Long enough for every code this path raises, with room for one a later guard adds. */ +const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 + +/** + * This path raises its refusals as the thrown code, the way the method's own guards do — and the + * recorded code is what a replay answers with, so it is worth keeping. + * + * Bounded because a code is an identifier but `error.message` is free text: an errno sentence + * carrying an absolute path arrives here as one, and it would be written into a ledger file that is + * re-serialized whole on every subsequent operation. Bounded on the way IN only. A length check in + * `isAgentSessionOperationRow` would reject rows this same build wrote, and one rejected row costs + * the entire store. + */ +export function agentLaunchFailureCode(error: unknown): string { + const code = error instanceof Error ? error.message : '' + return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed' +} + +/** + * Only a typed refusal raised before anything was created proves the claimed launch had no effects. + * A live reserved pane or an existing reserved session proves it only for an existing workspace; on + * create-worktree the workspace already exists by the time the surface is refused. + */ +export function launchFailureWithoutEffectsCode( + error: unknown, + targetKind: AgentLaunchTarget['kind'], + terminalSpawn: TerminalSpawnDispatch +): string | null { + if (error instanceof WorktreeCreateCollisionError) { + return WORKTREE_CREATE_COLLISION_CODE + } + if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') { + return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE + } + if (error instanceof AgentLaunchSessionAlreadyExistsError && targetKind === 'existing') { + return AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE + } + if (terminalSpawn.failedBeforeDispatch(error) && targetKind === 'existing') { + return agentLaunchFailureCode(error) + } + return null +} diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 5551c4de354..1df2c1f7433 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -64,7 +64,7 @@ describe('agent.launch with the real floating workspace resolver', () => { expect(structuredHost).not.toHaveBeenCalled() expect(createTerminal).toHaveBeenCalledExactlyOnceWith( `id:${FLOATING_TERMINAL_WORKTREE_ID}`, - { startupAgent: 'claude' } + { startupAgent: 'claude', onPtySpawnDispatched: expect.any(Function) } ) expect(result).toMatchObject({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts new file mode 100644 index 00000000000..6b7adb8dbfc --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -0,0 +1,171 @@ +/** + * A terminal launch that fails before its spawn is requested — no launch command, runtime + * unavailable — created nothing, so a named operation settles as failed with its real cause. + * Once the request has left, a failure proves nothing and the outcome stays unknown. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { methodNamed, runtimeStub, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: async () => ({ + ok: true, + value: { sessionId: 'sess-1' } + }) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +const EXISTING_LAUNCH = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } +const CREATE_LAUNCH = { + agent: 'claude', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } +} +const NO_LAUNCH_COMMAND = 'Could not build launch command for claude.' +type Launch = typeof EXISTING_LAUNCH | typeof CREATE_LAUNCH + +/** The create throws; `afterDispatch` says whether the spawn request had already left. */ +function failingCreate(runtime: AgentLaunchRuntimeStub, error: Error, afterDispatch: boolean) { + runtime.createTerminal.mockImplementation( + async (_selector: string, options?: Record) => { + const dispatched = options?.onPtySpawnDispatched + if (afterDispatch && typeof dispatched === 'function') { + dispatched() + } + throw error + } + ) +} + +describe('a launch whose terminal fails', () => { + // The ledger admits against `Date.now()`, so the ids must be dated now. + const OPERATION_ID = `${Date.now()}-000000000000000000000000000000cc` + const OTHER_OPERATION_ID = `${Date.now()}-000000000000000000000000000000dd` + let directory: string + let store: AgentSessionRecordStore + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-prestart-')) + store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. + setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(directory, { recursive: true, force: true }) + }) + + function outcomeOf(operationId: string) { + return store.listOperationRows().find((row) => row.operationId === operationId)?.outcome + } + + async function replay( + runtime: AgentLaunchRuntimeStub, + launch: Launch, + operationId: string = OPERATION_ID + ) { + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method reached by agent.launch and dispatcher metadata. + runtime: { ...runtime, getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService, + methods: AGENT_LAUNCH_METHODS + }) + return dispatcher.dispatch({ + id: 'request-1', + authToken: 'token', + method: 'agent.launchReplay', + params: AGENT_LAUNCH_REPLAY.params.parse({ ...launch, operationId }) + }) + } + + it('reports a failure before the spawn request with its real cause and records it', async () => { + const runtime = runtimeStub({ settings: {} }) + failingCreate(runtime, new Error(NO_LAUNCH_COMMAND), false) + + const response = await replay(runtime, EXISTING_LAUNCH) + + expect(response).toMatchObject({ ok: false, error: { message: NO_LAUNCH_COMMAND } }) + expect(outcomeOf(OPERATION_ID)).toMatchObject({ status: 'failed', code: NO_LAUNCH_COMMAND }) + }) + + it('keeps a stable runtime code such as runtime_unavailable', async () => { + const runtime = runtimeStub({ settings: {} }) + failingCreate(runtime, new Error('runtime_unavailable'), false) + + const response = await replay(runtime, EXISTING_LAUNCH) + + expect(response).toMatchObject({ ok: false, error: { code: 'runtime_unavailable' } }) + }) + + it('answers a retry of the same operation from the record instead of launching again', async () => { + const first = runtimeStub({ settings: {} }) + failingCreate(first, new Error(NO_LAUNCH_COMMAND), false) + await replay(first, EXISTING_LAUNCH) + + const retry = runtimeStub({ settings: {} }) + const response = await replay(retry, EXISTING_LAUNCH) + + expect(retry.createTerminal).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: false, error: { message: NO_LAUNCH_COMMAND } }) + }) + + it('stays unknown when the failure came after the spawn request left', async () => { + // An SSH or daemon spawn whose reply was lost may still have started an agent. + const runtime = runtimeStub({ settings: {} }) + failingCreate(runtime, new Error('ssh_channel_closed'), true) + + const response = await replay(runtime, EXISTING_LAUNCH) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_unknown' } + }) + expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown') + }) + + it('stays unknown when another launch saw the same error before its own spawn request', async () => { + // A failed pane spawn rejects one error into the spawner and into a create waiting on that pane. + const shared = new Error('ssh_channel_closed') + const waiting = runtimeStub({ settings: {} }) + failingCreate(waiting, shared, false) + await replay(waiting, EXISTING_LAUNCH, OTHER_OPERATION_ID) + expect(outcomeOf(OTHER_OPERATION_ID)?.status).toBe('failed') + + const spawner = runtimeStub({ settings: {} }) + failingCreate(spawner, shared, true) + const response = await replay(spawner, EXISTING_LAUNCH) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_unknown' } + }) + expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown') + }) + + it('stays unknown for a launch that created its workspace first', async () => { + const runtime = runtimeStub({ settings: {} }) + // No startup terminal came back, so the launch builds its own in the new workspace. + runtime.createManagedWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-new' }, + startupTerminal: undefined + }) + failingCreate(runtime, new Error(NO_LAUNCH_COMMAND), false) + + const response = await replay(runtime, CREATE_LAUNCH) + + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_unknown' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 2926a92ad6b..49b0bd2bf40 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -36,6 +36,10 @@ import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-l import { createStructuredAgentSessionId } from '../../../../shared/structured-agent-session-create' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import { paneIdentity } from '../../runtime-terminal-pane-identity' +import { + trackTerminalSpawnDispatch, + type TerminalSpawnDispatch +} from '../../../agent-launch/agent-launch-not-started' /** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */ export function agentLaunchSurfaceFactory( @@ -43,7 +47,8 @@ export function agentLaunchSurfaceFactory( attachOperationId?: string, operationCallerKey?: string, // False when the launch selects the chat for its paired caller instead of for everyone. - activateChat = true + activateChat = true, + terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch() ): AgentLaunchSurfaceFactory { return { createStructuredSession: async ({ @@ -120,7 +125,7 @@ export function agentLaunchSurfaceFactory( options }) => { const launchPreferences = toAgentLaunchPreferences(options) - const terminal = await context.runtime.createTerminal(`id:${worktreeId}`, { + const created = context.runtime.createTerminal(`id:${worktreeId}`, { // The agent id is not a shell command — `cursor` is the desktop app, its CLI is // `cursor-agent` — so the runtime builds the configured launcher. startupAgent: agent, @@ -133,8 +138,10 @@ export function agentLaunchSurfaceFactory( ...(launchPreferences ? { launchPreferences } : {}), // A live reserved pane would be attached, not launched into, so the runtime refuses it. ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), - ...agentLaunchTelemetry(agent, launchSource) + ...agentLaunchTelemetry(agent, launchSource), + onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) + const terminal = await created.catch(terminalSpawn.rethrow) return { handle: terminal.handle, // The runtime already minted this pane and baked it into the PTY's env and its own reveal; diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index f44ddb4978b..b39dbc350f0 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -461,7 +461,10 @@ describe('the terminal factory', () => { const runtime = runtimeStub({ createSupport: { supported: false, reason: 'wsl' } }) const result = await launch(CREATE_LAUNCH, runtime) - expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { startupAgent: 'claude' }) + expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { + startupAgent: 'claude', + onPtySpawnDispatched: expect.any(Function) + }) expect(createStructuredSession).not.toHaveBeenCalled() expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) // Never a failed launch, and never a silent downgrade. @@ -481,7 +484,10 @@ describe('the terminal factory', () => { expect(runtime.showManagedTerminalWorkspace).not.toHaveBeenCalled() // Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the // runtime as `id:id:wt-7`. - expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { startupAgent: 'grok' }) + expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { + startupAgent: 'grok', + onPtySpawnDispatched: expect.any(Function) + }) expect(result.worktreeId).toBe('wt-7') }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index ff082c94a1b..1c7448e5ca2 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -31,20 +31,20 @@ import { WorktreeCreateCollisionError, WORKTREE_CREATE_COLLISION_CODE } from '../../../../shared/new-workspace/worktree-create-collision' -import { - AgentLaunchPaneAlreadyLiveError, - AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE -} from '../../../../shared/agent-launch-pane-already-live' -import { - AgentLaunchSessionAlreadyExistsError, - AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE -} from '../../../../shared/agent-launch-session-already-exists' import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' +import { + trackTerminalSpawnDispatch, + type TerminalSpawnDispatch +} from '../../../agent-launch/agent-launch-not-started' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcContext } from '../core' import { admitAgentLaunchOperation, agentLaunchOperationCallerKey } from './agent-launch-replay' import { AgentLaunch, AgentLaunchReplay, type AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchSurfaceFactory } from './agent-launch-surfaces' +import { + agentLaunchFailureCode, + launchFailureWithoutEffectsCode +} from './agent-launch-failure-code' import { agentLaunchCallerNavigationId, selectAgentLaunchTabForCaller @@ -146,7 +146,8 @@ async function runAgentLaunch( intent: AgentLaunchIntent, context: RpcContext, attachOperationId?: string, - operationCallerKey?: string + operationCallerKey?: string, + terminalSpawn?: TerminalSpawnDispatch ): Promise { const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) const result = await executeAgentLaunch({ @@ -156,7 +157,8 @@ async function runAgentLaunch( context, attachOperationId, operationCallerKey, - callerNavigationId === null + callerNavigationId === null, + terminalSpawn ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent) }) @@ -198,52 +200,17 @@ function settleQuietly(settlement: Promise): Promise { }) } -/** Long enough for every code this path raises, with room for one a later guard adds. */ -const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 - -/** - * This path raises its refusals as the thrown code, the way the method's own guards do — and the - * recorded code is what a replay answers with, so it is worth keeping. - * - * Bounded because a code is an identifier but `error.message` is free text: an errno sentence - * carrying an absolute path arrives here as one, and it would be written into a ledger file that is - * re-serialized whole on every subsequent operation. Bounded on the way IN only. A length check in - * `isAgentSessionOperationRow` would reject rows this same build wrote, and one rejected row costs - * the entire store. - */ -function agentLaunchFailureCode(error: unknown): string { - const code = error instanceof Error ? error.message : '' - return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed' -} - -/** - * Only a typed refusal raised before anything was created proves the claimed launch had no effects. - * A live reserved pane or an existing reserved session proves it only for an existing workspace; on - * create-worktree the workspace already exists by the time the surface is refused. - */ -function launchFailureWithoutEffectsCode( - error: unknown, - targetKind: AgentLaunchTarget['kind'] -): string | null { - if (error instanceof WorktreeCreateCollisionError) { - return WORKTREE_CREATE_COLLISION_CODE - } - if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') { - return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE - } - if (error instanceof AgentLaunchSessionAlreadyExistsError && targetKind === 'existing') { - return AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE - } - return null -} - type ActiveAgentLaunch = { fingerprint: string promise: Promise } class AgentLaunchExecutionError extends Error { - constructor(cause: unknown) { + constructor( + cause: unknown, + /** Decided once, by the launch that ran; a later reader cannot re-derive it from the error. */ + readonly failedWithoutEffects: boolean + ) { super('agent_session_operation_unknown', { cause }) } } @@ -282,15 +249,26 @@ async function executeReplaySafeAgentLaunch( await settleQuietly(admission.fail(agentLaunchFailureCode(error))) throw error } + const terminalSpawn = trackTerminalSpawnDispatch() let result: AgentLaunchResult try { - result = await runAgentLaunch(intent, context, admission.attachOperationId, admission.callerKey) + result = await runAgentLaunch( + intent, + context, + admission.attachOperationId, + admission.callerKey, + terminalSpawn + ) } catch (error) { - const failedWithoutEffects = launchFailureWithoutEffectsCode(error, intent.target.kind) + const failedWithoutEffects = launchFailureWithoutEffectsCode( + error, + intent.target.kind, + terminalSpawn + ) if (failedWithoutEffects) { await settleQuietly(admission.fail(failedWithoutEffects)) } - throw new AgentLaunchExecutionError(error) + throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null) } // Settlement is bookkeeping; failure leaves the truthful `unknown` refusal for later retries. await settleQuietly(admission.settle(result)) @@ -341,7 +319,7 @@ export const AGENT_LAUNCH_METHODS = [ code: WORKTREE_CREATE_COLLISION_CODE }) } - if (launchFailureWithoutEffectsCode(error.cause, params.target.kind)) { + if (error.failedWithoutEffects) { throw error.cause } throw new Error('agent_session_operation_unknown', { cause: error.cause }) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 5ee2cc8e7fc..0dcf355bfe0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -79,6 +79,8 @@ export type TerminalCreateOptions = { agentSessionCreateOperationId?: string signal?: AbortSignal onPtySpawnCommitted?: () => void + /** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */ + onPtySpawnDispatched?: () => void deferMobileSessionPublish?: boolean }