From 3e38d67c14e776e172c27e15ff50a70958a13be0 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:05:34 -0700 Subject: [PATCH] fix(orchestration): launch the configured agent CLI, not the raw agent id worker-start passed the Orca agent id straight to the shell as the worker terminal command. That only worked for agents whose id matches their launch command; `--agent cursor` ran `cursor`, which on Windows resolves to Cursor IDE's cursor.cmd and opened the desktop app, leaving a blank shell that timed out at agent_readiness. The same gap hit continue/aug/kiro/qwen-code and the other agents whose CLI binary differs from their id. Add TerminalCreateOptions.startupAgent so a caller can name the agent outright. createTerminal then builds the launch through the TUI agent config (command, default args/env, preflight trust) instead of sniffing the command string, and this also covers repo-less folder workspaces, which previously skipped resolution entirely. Fixes #11926 --- src/main/runtime/orca-runtime.test.ts | 88 +++++++++++++++++++ src/main/runtime/orca-runtime.ts | 35 ++++++-- .../rpc/methods/orchestration-federation.ts | 4 +- .../methods/orchestration-worker-topology.ts | 5 +- .../runtime/rpc/methods/orchestration.test.ts | 27 +++++- 5 files changed, 146 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c91d324c109..1e30795ab61 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12550,6 +12550,94 @@ describe('OrcaRuntimeService', () => { ) }) + // Why: `cursor` on PATH is the Cursor desktop launcher; only `cursor-agent` is + // the CLI Orca can host (issue #11926). + it('launches the configured agent CLI for a startupAgent id, not the raw id', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: { cursor: '--force' }, + agentDefaultEnv: { cursor: { CURSOR_PROFILE: 'captured' } } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'cursor', + title: 'worker' + }) + + const spawnCall = spawn.mock.calls[0]?.[0] as + | { command?: string; launchAgent?: string; env?: Record } + | undefined + expect(spawnCall?.command).toBe("cursor-agent '--force'") + expect(spawnCall?.launchAgent).toBe('cursor') + expect(spawnCall?.env).toMatchObject({ CURSOR_PROFILE: 'captured' }) + expect(markCursorWorkspaceTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + }) + + it('resolves a startupAgent to the CLI binary on Windows, where `cursor` is the IDE', async () => { + setPlatform('win32') + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + terminalWindowsShell: 'cmd.exe', + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { startupAgent: 'cursor' }) + + const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined + expect(spawnCall?.command).toMatch(/^cursor-agent(\s|$)/) + }) + + it('rejects a startupAgent create for a disabled agent', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: ['cursor' as const], + agentCmdOverrides: {} + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { startupAgent: 'cursor' }) + ).rejects.toThrow(/disabled/) + expect(spawn).not.toHaveBeenCalled() + }) + it('quotes local Windows bare agent command defaults for cmd.exe terminal creates', async () => { setPlatform('win32') const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8c75a00d579..398f9428cfd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1273,6 +1273,10 @@ type TerminalCreateOptions = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + // Why: agent ids are not shell commands (`cursor` is the Cursor desktop app; its + // CLI is `cursor-agent`). Callers that know the agent name it here instead of + // guessing a command, and the runtime builds the configured launch. + startupAgent?: TuiAgent terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] @@ -23873,32 +23877,40 @@ export class OrcaRuntimeService { // Why: raw shell commands like `codex exec` must remain user-authored shell. // Only unmanaged, repo-backed, bare agent launches get Settings defaults. if ( - !opts.command || opts.env || opts.launchConfig || opts.launchAgent || opts.startupCommandDelivery || opts.claudeAgentTeamsSourceCommand || - !workspace.repo || !this.store ) { return opts } + // An explicit startupAgent is already the agent's identity, so it needs no + // command sniffing and works for repo-less folder workspaces too. + if (!opts.startupAgent && (!opts.command || !workspace.repo)) { + return opts + } const settings = this.store.getSettings() const platform = this.getAgentLaunchPlatformForWorkspace(workspace) - const isRemote = repoIsRemote(workspace.repo) + const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) const queuedShell = resolveLocalWindowsAgentStartupShell({ platform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) - const agent = resolveBareAgentLaunchCommand({ - command: opts.command, - settings, - platform, - isRemote - }) + if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { + throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) + } + const agent = + opts.startupAgent ?? + resolveBareAgentLaunchCommand({ + command: opts.command, + settings, + platform, + isRemote + }) if (!agent) { return opts } @@ -23915,6 +23927,11 @@ export class OrcaRuntimeService { allowEmptyPromptLaunch: true }) if (!startupPlan) { + // Why: an explicit agent that yields no plan would otherwise spawn a bare + // shell that never reaches agent readiness. + if (opts.startupAgent) { + throw new Error(`Could not build launch command for ${opts.startupAgent}.`) + } return opts } diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index a0e5b877b8e..7ee1022b5a7 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -191,7 +191,9 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ } else { failedStage = 'terminal_create' const terminal = await runtime.createTerminal(`id:${worktree.id}`, { - command: agent, + // Why: agent ids are not shell commands (`cursor` is the desktop app, + // its CLI is `cursor-agent`); resolve through the TUI agent config. + startupAgent: agent as TuiAgent, title: `worker-${params.taskId}`, presentation: 'background' }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 474d3cb0316..ffacf571d1b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -59,7 +59,10 @@ export async function createExistingWorktreeWorkerTerminal(args: { effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { const terminal = await args.runtime.createTerminal(`id:${args.worktreeId}`, { - command: args.agent, + // Why: the agent id is not a shell command — `cursor` resolves to the Cursor + // desktop app while its CLI is `cursor-agent`. Let the runtime build the + // configured launcher instead of executing the raw id. + startupAgent: args.agent, title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar // to the worker's workspace while the user is reading somewhere else. diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index cb79b823255..ad663269a37 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -2186,7 +2186,7 @@ describe('orchestration RPC methods', () => { // Why: dispatching a worker is background work — surfaceOwner:false adopts // the tab without scrolling the sidebar to the worker's workspace. expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', { - command: 'codex', + startupAgent: 'codex', title: `worker-${task.id}`, surfaceOwner: false }) @@ -2196,6 +2196,29 @@ describe('orchestration RPC methods', () => { ) }) + // Why: `cursor` on PATH is the Cursor desktop app; passing the agent id as a + // shell command opened the IDE and left a blank shell (issue #11926). + it('never passes the agent id to the worker terminal as a shell command', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'start a cursor worker' }) + + await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'cursor' + }) + + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'cursor' }) + ) + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.not.objectContaining({ command: expect.anything() }) + ) + }) + it('commits the launched worker token with its durable authority', async () => { setup() mockCurrentWorkerStart() @@ -2289,7 +2312,7 @@ describe('orchestration RPC methods', () => { 'id:repo::other', // Why: starting a worker in an existing worktree must not pull the sidebar // away from whatever the user is looking at. - expect.objectContaining({ command: 'codex', surfaceOwner: false }) + expect.objectContaining({ startupAgent: 'codex', surfaceOwner: false }) ) expect(createWorktree).not.toHaveBeenCalled() })