From d9935c6a77448070ee9779bfb8d489f05902ad70 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sun, 30 Aug 2026 13:10:08 -0700 Subject: [PATCH] fix(worktree): sequence agent launch on provider setup exit --- src/main/providers/local-pty-utils.ts | 11 +- src/main/providers/pty-provider-contract.ts | 4 +- src/main/runtime/orca-runtime.test.ts | 185 ++++----- src/main/runtime/orca-runtime.ts | 357 ++++++++++++------ .../runtime/terminal-pty-exit-waiter.test.ts | 55 ++- src/shared/worktree/create-types.ts | 2 +- 6 files changed, 386 insertions(+), 228 deletions(-) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 1c4a10b0166..1ef21ed1509 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -168,16 +168,7 @@ export function spawnDirectPty(params: { env: Record ptySpawn: typeof pty.spawn }): ShellSpawnResult { - const { - executable, - argv, - termName = 'xterm-256color', - cols, - rows, - cwd, - env, - ptySpawn - } = params + const { executable, argv, termName = 'xterm-256color', cols, rows, cwd, env, ptySpawn } = params const process_ = ptySpawn(executable, [...argv], { name: termName, cols, diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 2301733d86f..7640ee761e1 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -243,7 +243,5 @@ export type IPtyProvider = { getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: PtyDataEvent) => void): () => void onReplay(callback: (payload: { id: string; data: string }) => void): () => void - onExit( - callback: (payload: PtyExitPayload) => void - ): () => void + onExit(callback: (payload: PtyExitPayload) => void): () => void } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e95d3e4e0a0..a4cb6351c57 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -127,8 +127,7 @@ import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { makePaneKey } from '../../shared/stable-pane-id' import { SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, - SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import type { AgentSessionExecutionClaim, @@ -6996,13 +6995,21 @@ describe('OrcaRuntimeService', () => { vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-remote-agent' }) .mockResolvedValueOnce({ id: 'pty-remote-setup' }) + .mockResolvedValueOnce({ id: 'pty-remote-agent' }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-remote' }) registerSshGitProvider('ssh-1', provider as never) registerSshFilesystemProvider('ssh-1', fsProvider as never) getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() }) const runtime = new OrcaRuntimeService(remoteStore as never) + const waitForSetupTerminalEvidence = vi + .spyOn(runtime, 'waitForSetupTerminalEvidence') + .mockResolvedValue({ + ptyId: 'pty-remote-setup', + exitCode: 0, + incarnationId: 'incarnation-setup', + cause: { kind: 'exited', exitCode: 0 } + }) runtime.setPtyController({ spawn, write: () => true, @@ -7040,19 +7047,10 @@ describe('OrcaRuntimeService', () => { `path:${result.worktree.path}`, expect.objectContaining({ viewMode: 'chat' }) ) + expect(waitForSetupTerminalEvidence).toHaveBeenCalledWith(expect.any(String)) await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - cwd: '/remote/mobile-setup', - env: expect.objectContaining({ - [SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining('exec claude') - }), - worktreeId: result.worktree.id - }) - ) - expect(spawn).toHaveBeenNthCalledWith( - 2, expect.objectContaining({ cwd: '/remote/mobile-setup', command: expect.stringContaining( @@ -7061,27 +7059,26 @@ describe('OrcaRuntimeService', () => { worktreeId: result.worktree.id }) ) - const startup = spawn.mock.calls[0]![0] as { - command: string - env: Record - } - const startupCommand = startup.command - const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]! + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + cwd: '/remote/mobile-setup', + command: expect.stringContaining('claude'), + worktreeId: result.worktree.id + }) + ) const setupSpawn = spawn.mock.calls[1]![0] as { command: string env: Record } - const setupCommand = setupSpawn.command - const setupScript = setupSpawn.env[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]! - const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) - expect(nonceMatch?.[1]).toBeTruthy() - const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done` - expect(startupCommand.length).toBeLessThan(256) - expect(setupCommand.length).toBeLessThan(1024) - expect(setupScript).toContain('printf') - expect(setupScript).toContain(`${nonceMatch![1]} "$1"`) - expect(startupScript).toContain(markerPath) - expect(setupScript).toContain(markerPath) + const startup = spawn.mock.calls[0]![0] as { + command: string + env: Record + } + expect(startup.command).toContain('claude') + expect(setupSpawn.command).toContain( + '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' + ) expect(revealTerminalSession).toHaveBeenLastCalledWith( result.worktree.id, expect.objectContaining({ @@ -45906,11 +45903,19 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const createTerminal = vi.spyOn(runtime, 'createTerminal') + const waitForSetupTerminalEvidence = vi + .spyOn(runtime, 'waitForSetupTerminalEvidence') + .mockResolvedValue({ + ptyId: 'pty-headless-setup', + exitCode: 0, + incarnationId: 'incarnation-setup', + cause: { kind: 'exited', exitCode: 0 } + }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' }) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-headless-startup' }) .mockResolvedValueOnce({ id: 'pty-headless-setup' }) + .mockResolvedValueOnce({ id: 'pty-headless-startup' }) runtime.setPtyController({ spawn, write: () => true, @@ -45963,7 +45968,8 @@ describe('OrcaRuntimeService', () => { repoSelector: 'id:repo-1', name: 'runtime-headless-startup-setup', setupDecision: 'run', - startup: { command: 'claude', viewMode: 'chat' } + startup: { command: 'claude', viewMode: 'chat' }, + awaitTerminalProvisioning: true }) expect(createSetupRunnerScript).toHaveBeenCalled() @@ -45972,29 +45978,23 @@ describe('OrcaRuntimeService', () => { `id:${result.worktree.id}`, expect.objectContaining({ viewMode: 'chat' }) ) - // Why: setup is provisioned fire-and-forget; the wait-for-setup guarantee comes from the shell nonce/marker, not JS spawn ordering. + expect(waitForSetupTerminalEvidence).toHaveBeenCalledWith(expect.any(String)) await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) - const startup = spawn.mock.calls[0]![0] as { + const setupSpawn = spawn.mock.calls[0]![0] as { command: string env: Record } - const startupCommand = startup.command - const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]! - const setupSpawn = spawn.mock.calls[1]![0] as { + const startup = spawn.mock.calls[1]![0] as { command: string env: Record } - const setupCommand = setupSpawn.command - const setupScript = setupSpawn.env[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]! - const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) - expect(nonceMatch?.[1]).toBeTruthy() - expect(startupCommand.length).toBeLessThan(256) - expect(startupScript).toContain('exec claude') - expect(startupScript).toContain('/mnt/c/tmp/repo/.git/orca/setup-runner.sh') - expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') - expect(setupScript).toContain('printf') - expect(setupScript).toContain(`${nonceMatch![1]} "$1"`) + expect(setupSpawn.command).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') + expect(startup.command).toContain('claude') expect(result.setup).toBeUndefined() + expect(result.setupReceipt).toMatchObject({ + state: 'succeeded', + terminalHandle: expect.stringMatching(/^term_/) + }) }) it('starts setup and startup side by side by default for local headless worktree creates', async () => { @@ -47261,7 +47261,7 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) - it('honors split setup placement for opted-in local startup-draft worktrees', async () => { + it('sequences setup before startup for opted-in local startup-draft worktrees', async () => { const metaById: Record = {} const runtimeStore = { ...store, @@ -47280,8 +47280,16 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-startup-split-main' }) .mockResolvedValueOnce({ id: 'pty-startup-split-setup' }) + .mockResolvedValueOnce({ id: 'pty-startup-split-main' }) + const waitForSetupTerminalEvidence = vi + .spyOn(runtime, 'waitForSetupTerminalEvidence') + .mockResolvedValue({ + ptyId: 'pty-startup-split-setup', + exitCode: 0, + incarnationId: 'incarnation-setup', + cause: { kind: 'exited', exitCode: 0 } + }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup-split' }) runtime.setPtyController({ spawn, @@ -47340,14 +47348,13 @@ describe('OrcaRuntimeService', () => { awaitTerminalProvisioning: true }) + expect(waitForSetupTerminalEvidence).toHaveBeenCalledWith(expect.any(String)) await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - env: expect.objectContaining({ - [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: expect.stringContaining('codex') - }), + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), worktreeId: result.worktree.id }) ) @@ -47355,65 +47362,25 @@ describe('OrcaRuntimeService', () => { 2, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), - env: expect.objectContaining({ - ORCA_ROOT_PATH: '/tmp/repo', - ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split', - ORCA_WORKTREE_ID: result.worktree.id - }), + command: expect.stringContaining('codex'), worktreeId: result.worktree.id }) ) - const startup = spawn.mock.calls[0]![0] as { - command: string - env: Record - } - const startupCommand = startup.command - const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]! - const setupSpawn = spawn.mock.calls[1]![0] as { - command: string - env: Record - } - const setupScript = setupSpawn.env[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]! - const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) - expect(nonceMatch?.[1]).toBeTruthy() - const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done` - expect(startupCommand.length).toBeLessThan(256) - expect(setupSpawn.command.length).toBeLessThan(1024) - expect(startupScript).toContain('--dangerously-bypass-approvals-and-sandbox') - expect(setupScript).toContain('printf') - expect(setupScript).toContain(`${nonceMatch![1]} "$1"`) - expect(startupScript).toContain(markerPath) - expect(setupScript).toContain(markerPath) - const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} - const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} + const setupCall = spawn.mock.calls[0]![0] as { env?: Record } + const startupCall = spawn.mock.calls[1]![0] as { command?: string } expect(result.setup).toBeUndefined() expect(result.setupReceipt).toMatchObject({ - state: 'running', + state: 'succeeded', terminalHandle: expect.stringMatching(/^term_/) }) - expect(mainEnv.ORCA_TAB_ID).toBeDefined() - expect(mainEnv.ORCA_PANE_KEY).toBeDefined() - expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) - const mainLeafId = mainEnv.ORCA_PANE_KEY!.slice(`${mainEnv.ORCA_TAB_ID!}:`.length) - expect(revealTerminalSession).toHaveBeenLastCalledWith( - result.worktree.id, - expect.objectContaining({ - ptyId: 'pty-startup-split-setup', - tabId: mainEnv.ORCA_TAB_ID, - activate: false, - splitFromLeafId: mainLeafId, - splitDirection: 'vertical' - }) - ) + expect(setupCall.env).not.toHaveProperty(SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV) + expect(startupCall.command).toContain('codex') + expect(revealTerminalSession).toHaveBeenCalled() }) - it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => { + it('does not start the agent when setup spawn fails', async () => { const runtime = new OrcaRuntimeService(store) - const spawn = vi - .fn() - .mockResolvedValueOnce({ id: 'pty-startup-main' }) - .mockRejectedValueOnce(new Error('setup spawn failed')) + const spawn = vi.fn().mockRejectedValueOnce(new Error('setup spawn failed')) const activateWorktree = vi.fn() runtime.setPtyController({ spawn, @@ -47472,13 +47439,21 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) - expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(1) expect(activateWorktree).toHaveBeenCalledWith( 'repo-1', expect.any(String), expect.objectContaining({ runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', - command: expect.stringContaining('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') + shell: { + family: 'posix', + executable: 'wsl.exe' + }, + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry' + }), + waitForAgentStartup: true }), undefined, undefined @@ -47486,9 +47461,7 @@ describe('OrcaRuntimeService', () => { const activationSetup = activateWorktree.mock.calls[0]?.[2] as | { command?: string; envVars?: Record } | undefined - // Why: the retry the renderer performs must carry the gate script alongside the command, - // or the Setup tab it opens records no outcome for the waiting agent terminal. - expect(activationSetup?.envVars?.[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]).toContain('printf') + expect(activationSetup?.envVars).not.toHaveProperty(SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV) }) it('lets explicit startup draft agents override the desktop default', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 78c1cc3c0f1..2692e2a3945 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -418,7 +418,7 @@ import type { ForceDeleteWorktreeBranchResult, RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' +import type { WorktreeSetupLaunch, WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { WorkspaceLineage, WorktreeLineage, @@ -616,11 +616,7 @@ import { buildSetupRunnerCommand, getSetupRunnerCommandPlatformForPath } from '../../shared/setup-runner-command' -import { - applySequencedSetupLaunch, - createSequencedSetupAgentCommands, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV -} from '../../shared/setup-agent-sequencing' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' import { @@ -1717,6 +1713,7 @@ type TerminalAgentStatusSnapshot = { type TerminalCreateOptions = { command?: string + directExec?: { executable: string; argv: string[] } claudeAgentTeamsSourceCommand?: string cwd?: string env?: Record @@ -2084,6 +2081,7 @@ type RuntimePtyController = { rows: number cwd?: string command?: string + directExec?: { executable: string; argv: string[] } launchAgent?: TuiAgent commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] @@ -3239,6 +3237,28 @@ function getSetupRunnerCommandPlatformForLaunch( return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) } +function getDirectSetupExec( + setup: WorktreeSetupLaunch, + platform: 'windows' | 'posix' +): { executable: string; argv: string[] } | undefined { + // WSL and SSH providers need their own host-specific command routing; keep those launches + // shell-mediated until the remote owner advertises the structured-create capability. + if (setup.shell?.executable?.toLowerCase().endsWith('wsl.exe')) { + return undefined + } + if (platform === 'posix' && setup.runnerScriptPath.startsWith('/')) { + return { executable: 'bash', argv: [setup.runnerScriptPath] } + } + if (platform === 'windows' && /\.(cmd|bat)$/i.test(setup.runnerScriptPath)) { + const comspec = process.env.ComSpec || 'cmd.exe' + return { + executable: comspec, + argv: ['/d', '/s', '/v:on', '/c', `""${setup.runnerScriptPath}""`] + } + } + return undefined +} + export type RuntimeRendererReloadFence = Readonly<{ revision: number recovery: 'renderer' | 'headless' | 'reloading' @@ -3254,6 +3274,20 @@ type ProviderSnapshotReadOptions = { visibleScreenOnly?: boolean } +/** Ordered, provider-backed PTY termination evidence exposed to runtime waiters. */ +export type RuntimePtyExitEvent = Readonly<{ + ptyId: string + exitCode: number + incarnationId: PtyIncarnationId + cause: TerminalExitCause +}> + +/** Setup sequencing admits exactly one terminal outcome; all inferred, signaled, or mismatched + * statuses fail closed so an agent can never start against an unproven environment. */ +export function isSuccessfulSetupExitEvidence(event: RuntimePtyExitEvent): boolean { + return event.cause.kind === 'exited' && event.cause.exitCode === 0 && event.exitCode === 0 +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -3416,7 +3450,7 @@ export class OrcaRuntimeService { private sessionTabsInventoryPublicationEpoch: number | null = null private sessionTabsInventoryWaiters = new Set<() => void>() private waitersByHandle = new Map>() - private ptyExitListenersByPtyId = new Map void>>() + private ptyExitListenersByPtyId = new Map void>>() private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() @@ -17776,7 +17810,6 @@ export class OrcaRuntimeService { pty?.incarnationId ?? `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}` this.advancePtyLifecycleGeneration(ptyId) - this.notifyPtyExitListeners(ptyId) const exactSurfaceByKey = new Map< string, Pick @@ -17929,6 +17962,14 @@ export class OrcaRuntimeService { exitedSurfaces.push({ handle: leafHandle, paneKey: `${leaf.tabId}:${leaf.leafId}` }) } } + // Publish only after the mutable PTY/leaf records carry the same evidence. Late subscribers + // and re-entrant observers therefore see one coherent exit certificate. + this.notifyPtyExitListeners(ptyId, { + ptyId, + exitCode, + incarnationId, + cause: exitCause + }) // Why: an explicit whole-tab close drops the leaf from the graph *before* // this exit lands, so a leaf-only walk found nothing and left the dispatch // reading 'dispatched' forever against a dead process. The PTY's own handle @@ -22529,10 +22570,17 @@ export class OrcaRuntimeService { }) } - subscribeToPtyExit(ptyId: string, listener: () => void): () => void { + subscribeToPtyExit(ptyId: string, listener: (event: RuntimePtyExitEvent) => void): () => void { const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) if (this.isPtyKnownExited(ptyId)) { - listener() + const pty = this.ptysById.get(ptyId) + const exitCode = pty?.lastExitCode ?? -1 + listener({ + ptyId, + exitCode, + incarnationId: pty?.incarnationId ?? `runtime:${this.runtimeId}:${lifecycleGeneration}`, + cause: pty?.lastExitCause ?? resolveUnreportedExitCause(exitCode) + }) return () => {} } let listeners = this.ptyExitListenersByPtyId.get(ptyId) @@ -22557,7 +22605,16 @@ export class OrcaRuntimeService { this.isPtyKnownExited(ptyId) ) { unsubscribe() - listener() + const pty = this.ptysById.get(ptyId) + const exitCode = pty?.lastExitCode ?? -1 + listener({ + ptyId, + exitCode, + incarnationId: + pty?.incarnationId ?? + `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}`, + cause: pty?.lastExitCause ?? resolveUnreportedExitCause(exitCode) + }) } return unsubscribe } @@ -22618,6 +22675,38 @@ export class OrcaRuntimeService { }) } + /** + * Returns the provider-owned termination certificate for a Setup PTY. This is intentionally + * separate from the legacy numeric waiter: sequencing must reject inferred/unknown status and + * only accept an explicit normal exit from the matching PTY incarnation. + */ + async waitForSetupTerminalEvidence(handle: string): Promise { + const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId + if (!ptyId) { + throw new Error('terminal_handle_stale') + } + return await new Promise((resolve, reject) => { + let settled = false + let unsubscribe: (() => void) | null = null + const finish = (event: RuntimePtyExitEvent): void => { + if (settled) return + settled = true + unsubscribe?.() + resolve(event) + } + const fail = (error: unknown): void => { + if (settled) return + settled = true + unsubscribe?.() + reject(error) + } + unsubscribe = this.subscribeToPtyExit(ptyId, finish) + if (this.isPtyKnownExited(ptyId) && !settled) { + fail(new Error('setup_exit_evidence_unavailable')) + } + }) + } + async getWorktreePs( limit = DEFAULT_WORKTREE_PS_LIMIT, sourceDefaultsSupported = true @@ -26812,6 +26901,10 @@ export class OrcaRuntimeService { // records. Pass the whole sequenced launch record — command plus the env carrying its // script — so the Setup tab runs the script the agent waits on, not a bare runner. sequencedSetup?: CreateWorktreeResult['setup'] + /** Reserve only the Setup surface. Sequenced agent launches create their agent pane + * after the provider reports a successful Setup exit, so a placeholder shell must not + * occupy the agent surface first. */ + reservePrimaryTerminal?: boolean // Why: a workspace provisioned in the background must not pull the sidebar // to itself; the user never asked to look at these tabs. surfaceOwner?: false @@ -26836,7 +26929,11 @@ export class OrcaRuntimeService { Pick > ).setupScriptLaunchMode ?? 'new-tab' - if (!args.hasStartupTerminal && !primaryTerminalHandle) { + if ( + !args.hasStartupTerminal && + !primaryTerminalHandle && + args.reservePrimaryTerminal !== false + ) { const terminal = await this.createTerminal(args.worktreeSelector, surfacing) primaryTerminalHandle = terminal.handle } @@ -26861,6 +26958,10 @@ export class OrcaRuntimeService { args.setup.shell ) const setupEnv = { ...setupLaunch.envVars, ...observedCommand?.env } + const directExec = + args.worktreeSelector.startsWith('id:') && !setupLaunch.command + ? getDirectSetupExec(args.setup, args.setupCommandPlatform) + : undefined const shouldSplitSetup = primaryTerminalHandle && (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') @@ -26875,6 +26976,7 @@ export class OrcaRuntimeService { : this.createTerminal(args.worktreeSelector, { title: 'Setup', command: setupCommand, + ...(directExec ? { directExec } : {}), env: setupEnv, ...surfacing })) @@ -27942,30 +28044,48 @@ export class OrcaRuntimeService { let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - let sequencedStartup = effectiveStartup - let sequencedSetup: CreateWorktreeResult['setup'] - if (effectiveStartup && setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForLaunch( - setup, - process.platform === 'win32' ? 'windows' : 'posix' - ) - const sequenced = createSequencedSetupAgentCommands({ - runnerScriptPath: setup.runnerScriptPath, - startupCommand: effectiveStartup.command, - platform, - shell: setup.shell - }) - sequencedStartup = { - ...effectiveStartup, - command: sequenced.startupCommand, - ...(sequenced.startupEnv - ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } } - : {}) + const setupMustGateStartup = Boolean(effectiveStartup && setup?.waitForAgentStartup === true) + let setupProvisioned = false + let setupWaitSucceeded = !setupMustGateStartup + + // Host-owned sequencing: Setup is a real PTY whose provider exit is the only authorization + // to create the Agent PTY. Unknown/lost setup outcomes fail closed; there is no timer that + // can silently launch an agent without a successful exit certificate. + if (setupMustGateStartup && setup && this.ptyController?.spawn) { + try { + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `id:${worktree.id}`, + worktreeId: worktree.id, + worktreePath, + setup, + ...(defaultTabs ? { defaultTabs } : {}), + hasStartupTerminal: false, + reservePrimaryTerminal: false, + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), + observeSetupCompletion: false, + ...(shouldActivate ? {} : { surfaceOwner: false }) + }) + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + setupProvisioned = provisioned.setupSpawned + if (!setupTerminalHandle) { + throw new Error('setup_spawn_failed') + } + const evidence = await this.waitForSetupTerminalEvidence(setupTerminalHandle) + setupWaitSucceeded = isSuccessfulSetupExitEvidence(evidence) + if (!setupWaitSucceeded) { + warning = `Setup did not complete successfully for ${worktreePath}; the agent was not started.` + } + } catch (err) { + setupWaitSucceeded = false + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also setup sequencing could not be proven for ${worktreePath}: ${message}` + : `Setup sequencing could not be proven for ${worktreePath}: ${message}` } - sequencedSetup = applySequencedSetupLaunch(setup, sequenced) } - if (sequencedStartup && this.ptyController?.spawn) { + if (effectiveStartup && setupWaitSucceeded && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane // mounting. Runtime-spawned PTYs run immediately and the UI adopts the @@ -27975,16 +28095,16 @@ export class OrcaRuntimeService { await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) } const terminal = await this.createTerminal(`id:${worktree.id}`, { - command: sequencedStartup.command, + command: effectiveStartup.command, ...(setup && effectiveStartup ? { claudeAgentTeamsSourceCommand: effectiveStartup.command } : {}), - env: sequencedStartup.env, - ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), + env: effectiveStartup.env, + ...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), - ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), - startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry, + ...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}), + startupCommandDelivery: effectiveStartup.startupCommandDelivery, + telemetry: effectiveStartup.telemetry, ...ownerSurfacing(shouldActivate) }) if (effectiveDraftPaste) { @@ -28010,7 +28130,8 @@ export class OrcaRuntimeService { // Why: plain CLI creates should not steal the user's current workspace. // Explicit activation and hook-running still use renderer activation so // the user can watch prompts/output in a visible pane. - const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs) + const runtimeWillProvisionTerminals = + !setupProvisioned && didSpawnStartup && Boolean(setup || defaultTabs) if (runtimeWillProvisionTerminals) { // Why: once runtime spawned the startup PTY, renderer activation may see // an existing terminal and skip setup/default tabs. Await provisioning so @@ -28026,10 +28147,7 @@ export class OrcaRuntimeService { primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), - observeSetupCompletion: args.observeSetupCompletion, - // Why: carry the wait-for-agent gated setup launch (#6298) so the - // Setup tab runs the same script the sequenced agent waits on. - ...(sequencedSetup ? { sequencedSetup } : {}) + observeSetupCompletion: args.observeSetupCompletion }) didSpawnSetup = provisioned.setupSpawned setupTerminalHandle = provisioned.setupTerminalHandle @@ -28037,13 +28155,9 @@ export class OrcaRuntimeService { // Why: when runtime spawned setup, omit it from activation. When setup // spawn failed, fall through with the wrapped command so renderer // activation retries it. - const activationSetup = didSpawnSetup - ? undefined - : didSpawnStartup && sequencedSetup - ? sequencedSetup - : setup + const activationSetup = didSpawnSetup ? undefined : setup const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs - if (effectiveStartup && !didSpawnStartup) { + if (effectiveStartup && !didSpawnStartup && !setupMustGateStartup) { this.notifyActivateWorktree(repo.id, worktree.id, { setup: activationSetup, startup: effectiveStartup, @@ -28057,7 +28171,13 @@ export class OrcaRuntimeService { navigationTarget: args.navigation }) } - } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { + } else if ( + !setupProvisioned && + this.ptyController?.spawn && + !didSpawnStartup && + !setup && + !defaultTabs + ) { // Why: inactive terminal materialization matches normal worktree creation, // but setup/default tab failures must not gate automation dispatch. const provisioning = this.provisionManagedWorktreeTerminals({ @@ -28070,7 +28190,6 @@ export class OrcaRuntimeService { hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, - ...(sequencedSetup ? { sequencedSetup } : {}), surfaceOwner: false }) // Why: runtime owns setup spawning here, so the RPC result must omit setup @@ -28085,7 +28204,7 @@ export class OrcaRuntimeService { didSpawnSetup = true } } - } else if (this.ptyController?.spawn) { + } else if (this.ptyController?.spawn && !setupProvisioned && !didSpawnStartup) { try { await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (err) { @@ -28096,11 +28215,7 @@ export class OrcaRuntimeService { console.warn(`[worktree-create] ${warning}`) } } - const returnedSetup = didSpawnSetup - ? undefined - : didSpawnStartup && sequencedSetup - ? sequencedSetup - : setup + const returnedSetup = didSpawnSetup ? undefined : setup this.emitWorktreeLifecycle({ kind: 'created', worktreeId: worktree.id, @@ -28132,9 +28247,13 @@ export class OrcaRuntimeService { ? ('skipped' as const) : // Why: the in-process hook is already executing, so reporting // spawn_failed would strand callers that retry on it. - didSpawnSetup || didStartInProcessSetupHook - ? ('running' as const) - : ('spawn_failed' as const), + setupMustGateStartup && setupProvisioned + ? setupWaitSucceeded + ? ('succeeded' as const) + : ('failed' as const) + : didSpawnSetup || didStartInProcessSetupHook + ? ('running' as const) + : ('spawn_failed' as const), ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) } } @@ -28283,25 +28402,48 @@ export class OrcaRuntimeService { let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - let sequencedStartup = args.startup - let sequencedSetup: CreateWorktreeResult['setup'] - if (args.startup && result.setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix') - const sequenced = createSequencedSetupAgentCommands({ - runnerScriptPath: result.setup.runnerScriptPath, - startupCommand: args.startup.command, - platform, - shell: result.setup.shell - }) - sequencedStartup = { - ...args.startup, - command: sequenced.startupCommand, - ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {}) + const setupMustGateStartup = Boolean(args.startup && result.setup?.waitForAgentStartup === true) + let setupProvisioned = false + let setupWaitSucceeded = !setupMustGateStartup + + // Host-owned sequencing: Setup is a real PTY whose provider exit is the only authorization + // to create the Agent PTY. Unknown/lost setup outcomes fail closed; there is no timer that + // can silently launch an agent without a successful exit certificate. + if (setupMustGateStartup && result.setup && this.ptyController?.spawn) { + try { + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `path:${result.worktree.path}`, + worktreeId: result.worktree.id, + worktreePath: result.worktree.path, + setup: result.setup, + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + hasStartupTerminal: false, + reservePrimaryTerminal: false, + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), + observeSetupCompletion: false, + ...(shouldActivate ? {} : { surfaceOwner: false }) + }) + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + setupProvisioned = provisioned.setupSpawned + if (!setupTerminalHandle) { + throw new Error('setup_spawn_failed') + } + const evidence = await this.waitForSetupTerminalEvidence(setupTerminalHandle) + setupWaitSucceeded = isSuccessfulSetupExitEvidence(evidence) + if (!setupWaitSucceeded) { + warning = `Setup did not complete successfully for ${result.worktree.path}; the agent was not started.` + } + } catch (err) { + setupWaitSucceeded = false + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also setup sequencing could not be proven for ${result.worktree.path}: ${message}` + : `Setup sequencing could not be proven for ${result.worktree.path}: ${message}` } - sequencedSetup = applySequencedSetupLaunch(result.setup, sequenced) } - if (sequencedStartup && this.ptyController?.spawn) { + if (args.startup && setupWaitSucceeded && this.ptyController?.spawn) { try { const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent if (startupTrustAgent) { @@ -28312,16 +28454,16 @@ export class OrcaRuntimeService { ) } const terminal = await this.createTerminal(`path:${result.worktree.path}`, { - command: sequencedStartup.command, + command: args.startup.command, ...(result.setup && args.startup ? { claudeAgentTeamsSourceCommand: args.startup.command } : {}), - env: sequencedStartup.env, - ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), + env: args.startup.env, + ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), - ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), - startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry, + ...(args.startup.viewMode ? { viewMode: args.startup.viewMode } : {}), + startupCommandDelivery: args.startup.startupCommandDelivery, + telemetry: args.startup.telemetry, ...ownerSurfacing(shouldActivate) }) if (args.startupDraftPaste) { @@ -28345,7 +28487,7 @@ export class OrcaRuntimeService { if (shouldActivate) { const runtimeWillProvisionTerminals = - didSpawnStartup && Boolean(result.setup || result.defaultTabs) + !setupProvisioned && didSpawnStartup && Boolean(result.setup || result.defaultTabs) if (runtimeWillProvisionTerminals) { // Why: remote/mobile task creates spawn the agent terminal in runtime, // so renderer activation may not materialize setup/default tabs. Await so @@ -28359,23 +28501,16 @@ export class OrcaRuntimeService { primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), - observeSetupCompletion: args.observeSetupCompletion, - // Why: carry the wait-for-agent gated setup launch (#6298) so the - // remote Setup tab runs the same script the sequenced agent waits on. - ...(sequencedSetup ? { sequencedSetup } : {}) + observeSetupCompletion: args.observeSetupCompletion }) didSpawnSetup = provisioned.setupSpawned setupTerminalHandle = provisioned.setupTerminalHandle } // Why: omit setup from activation when runtime spawned it; on spawn // failure fall through with the wrapped command so renderer retries. - const activationSetup = didSpawnSetup - ? undefined - : didSpawnStartup && sequencedSetup - ? sequencedSetup - : result.setup + const activationSetup = didSpawnSetup ? undefined : result.setup const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs - if (args.startup && !didSpawnStartup) { + if (args.startup && !didSpawnStartup && !setupMustGateStartup) { this.notifyActivateWorktree(repo.id, result.worktree.id, { setup: activationSetup, startup: args.startup, @@ -28394,7 +28529,10 @@ export class OrcaRuntimeService { if ( !shouldActivate && this.ptyController?.spawn && - (result.setup || result.defaultTabs || didSpawnStartup) + !setupProvisioned && + !didSpawnStartup && + !result.setup && + !result.defaultTabs ) { // Why: inactive terminal materialization matches normal worktree creation, // but setup/default tab failures must not gate automation dispatch. @@ -28408,7 +28546,6 @@ export class OrcaRuntimeService { hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, - ...(sequencedSetup ? { sequencedSetup } : {}), surfaceOwner: false }) // Why: runtime owns setup spawning here, so omit setup from the RPC result @@ -28423,7 +28560,12 @@ export class OrcaRuntimeService { didSpawnSetup = true } } - } else if (!shouldActivate && this.ptyController?.spawn) { + } else if ( + !shouldActivate && + this.ptyController?.spawn && + !setupProvisioned && + !didSpawnStartup + ) { try { await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) } catch (err) { @@ -28434,11 +28576,7 @@ export class OrcaRuntimeService { } } - const returnedSetup = didSpawnSetup - ? undefined - : didSpawnStartup && sequencedSetup - ? sequencedSetup - : result.setup + const returnedSetup = didSpawnSetup ? undefined : result.setup const resultForRenderer = returnedSetup ? { ...result, setup: returnedSetup } : (() => { @@ -28473,9 +28611,13 @@ export class OrcaRuntimeService { ? ('skipped' as const) : !result.setup ? ('not_configured' as const) - : didSpawnSetup - ? ('running' as const) - : ('spawn_failed' as const), + : setupMustGateStartup && setupProvisioned + ? setupWaitSucceeded + ? ('succeeded' as const) + : ('failed' as const) + : didSpawnSetup + ? ('running' as const) + : ('spawn_failed' as const), ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) } const resultWithSetupReceipt = args.awaitTerminalProvisioning @@ -31021,6 +31163,7 @@ export class OrcaRuntimeService { command: sequencedStartupCommand ? launchOpts.command : (agentTeamsPlan?.command ?? launchOpts.command), + ...(launchOpts.directExec ? { directExec: launchOpts.directExec } : {}), launchAgent: launchOpts.launchAgent, commandDelivery: 'provider', startupCommandDelivery: launchOpts.startupCommandDelivery, @@ -38666,13 +38809,13 @@ export class OrcaRuntimeService { return this.getLeavesForPty(ptyId).some((leaf) => getTerminalState(leaf) === 'exited') } - private notifyPtyExitListeners(ptyId: string): void { + private notifyPtyExitListeners(ptyId: string, event: RuntimePtyExitEvent): void { const listeners = this.ptyExitListenersByPtyId.get(ptyId) if (!listeners) { return } this.ptyExitListenersByPtyId.delete(ptyId) - notifyRuntimeListeners(listeners, (listener) => listener(), 'pty-exit') + notifyRuntimeListeners(listeners, (listener) => listener(event), 'pty-exit') } private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { diff --git a/src/main/runtime/terminal-pty-exit-waiter.test.ts b/src/main/runtime/terminal-pty-exit-waiter.test.ts index 8b6daa3c2b0..0f3aa1d9a3f 100644 --- a/src/main/runtime/terminal-pty-exit-waiter.test.ts +++ b/src/main/runtime/terminal-pty-exit-waiter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' +import { isSuccessfulSetupExitEvidence, OrcaRuntimeService } from './orca-runtime' type RuntimeInternals = { recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown @@ -16,6 +16,21 @@ function registerLivePty(runtime: OrcaRuntimeService): void { } describe('PTY exit subscription', () => { + it.each([ + [{ exitCode: 0, cause: { kind: 'exited', exitCode: 0 } }, true], + [{ exitCode: 1, cause: { kind: 'exited', exitCode: 1 } }, false], + [{ exitCode: 0, cause: { kind: 'signaled', signal: 9 } }, false], + [{ exitCode: -1, cause: { kind: 'unknown', reason: 'transport_lost' } }, false] + ])('admits only an explicit normal zero exit as setup success', (partial, expected) => { + expect( + isSuccessfulSetupExitEvidence({ + ptyId: 'pty-1', + incarnationId: 'incarnation-1', + ...(partial as { exitCode: number; cause: never }) + }) + ).toBe(expected) + }) + it('fires on the backing PTY exit', () => { const runtime = new OrcaRuntimeService() registerLivePty(runtime) @@ -31,6 +46,25 @@ describe('PTY exit subscription', () => { expect(internals(runtime).ptyExitListenersByPtyId.has('pty-1')).toBe(false) }) + it('delivers provider exit evidence instead of requiring a mutable record read', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + const listener = vi.fn() + + runtime.subscribeToPtyExit('pty-1', listener) + runtime.onPtyExit('pty-1', 0, 'incarnation-1', { + providerExitObserved: true, + cause: { kind: 'exited', exitCode: 0 } + }) + + expect(listener).toHaveBeenCalledWith({ + ptyId: 'pty-1', + exitCode: 0, + incarnationId: 'incarnation-1', + cause: { kind: 'exited', exitCode: 0 } + }) + }) + it('does not retain listeners across subscription churn', () => { const runtime = new OrcaRuntimeService() registerLivePty(runtime) @@ -109,4 +143,23 @@ describe('PTY exit subscription demands proof of exit, not loss of connection', expect(listener).toHaveBeenCalledOnce() }) + + it('replays the recorded cause for late subscribers', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + runtime.onPtyExit('pty-1', 0, 'incarnation-1', { + providerExitObserved: true, + cause: { kind: 'signaled', signal: 9 } + }) + const listener = vi.fn() + + runtime.subscribeToPtyExit('pty-1', listener) + + expect(listener).toHaveBeenCalledWith({ + ptyId: 'pty-1', + exitCode: 0, + incarnationId: 'incarnation-1', + cause: { kind: 'signaled', signal: 9 } + }) + }) }) diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index c3c5c24a876..c7fb43c0120 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -142,7 +142,7 @@ export type CreateWorktreeResult = { requested: 'run' | 'skip' | 'inherit' hookFound: boolean startupPolicy: 'start-immediately' | 'wait-for-setup' - state: 'running' | 'skipped' | 'not_configured' | 'spawn_failed' + state: 'running' | 'succeeded' | 'failed' | 'skipped' | 'not_configured' | 'spawn_failed' terminalHandle?: string } defaultTabs?: WorktreeDefaultTabsLaunch