From b9cf69a2d35fed97d085fe4ccded7231975b398c Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:37:42 -0700 Subject: [PATCH] fix(orchestration): fail loudly when a startupAgent launch cannot resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard ordering let a caller that set startupAgent together with a caller-supplied launch (env/launchConfig/launchAgent/startupCommandDelivery/ claudeAgentTeamsSourceCommand), a raw command, a resume session, or that hit an unavailable store, fall through with an unresolved command and spawn a bare shell — the exact silent timeout at agent_readiness that startupAgent exists to prevent. No current caller does this, so it was latent rather than live. Those combinations now throw instead. A raw `command` counts as a contradiction because it would be silently overwritten by the built launch, and `resumeProviderSession` because it survives to spawn and would pair resume identity with a freshly built fresh-launch command. The non-startupAgent path is unchanged: verified bit-for-bit over all 256 combinations of the eight guard inputs. Also covers the repo-less folder-workspace branch with a test, which the new isRemote fallback made reachable for the first time. --- src/main/runtime/orca-runtime.test.ts | 70 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 31 ++++++++---- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1e30795ab61..3699c8a74fe 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12614,6 +12614,76 @@ describe('OrcaRuntimeService', () => { expect(spawnCall?.command).toMatch(/^cursor-agent(\s|$)/) }) + // Why: folder workspaces have no repo, so command sniffing skipped them entirely + // and spawned the bare string; an explicit agent must still resolve. + it('resolves a startupAgent in a repo-less folder workspace', async () => { + const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-startup-agent-')) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const folderWorkspace = makeFolderWorkspace({ folderPath }) + const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) + const runtime = new OrcaRuntimeService({ + ...createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup), + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: { cursor: '--force' }, + agentDefaultEnv: {} + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`id:${TEST_FOLDER_WORKSPACE_KEY}`, { startupAgent: 'cursor' }) + + const spawnCall = spawn.mock.calls[0]?.[0] as + | { command?: string; launchAgent?: string } + | undefined + expect(spawnCall?.command).toBe("cursor-agent '--force'") + expect(spawnCall?.launchAgent).toBe('cursor') + }) + + // Why: silently returning the caller's opts would spawn a bare shell that can + // only time out at agent readiness — the failure startupAgent exists to stop. + it('rejects a startupAgent create that also supplies its own launch', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: {} + }) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + for (const conflicting of [ + { env: { SOME_VAR: 'set' } }, + // Why: a raw command would be silently overwritten by the built launch. + { command: 'cursor-agent --resume' }, + // Why: resume identity paired with a freshly built launch is incoherent. + { resumeProviderSession: { key: 'session_id', id: 'prior-session' } as never }, + { launchAgent: 'cursor' as const } + ]) { + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'cursor', + ...conflicting + }) + ).rejects.toThrow(/cannot combine/) + } + expect(spawn).not.toHaveBeenCalled() + }) + it('rejects a startupAgent create for a disabled agent', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 398f9428cfd..cb4f31a8642 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23876,23 +23876,34 @@ export class OrcaRuntimeService { ): Promise { // Why: raw shell commands like `codex exec` must remain user-authored shell. // Only unmanaged, repo-backed, bare agent launches get Settings defaults. - if ( + const callerSuppliedLaunch = opts.env || opts.launchConfig || opts.launchAgent || opts.startupCommandDelivery || - opts.claudeAgentTeamsSourceCommand || - !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)) { + opts.claudeAgentTeamsSourceCommand + const store = this.store + if (opts.startupAgent) { + // Why: returning opts unresolved here would spawn a bare shell that can only + // time out waiting for an agent — the silent failure startupAgent exists to + // prevent. An explicit agent needs no command sniffing and no repo, so the + // only unresolvable cases are a contradictory caller or a dead runtime. + // `command` and `resumeProviderSession` count as contradictions too: the + // first would be silently overwritten, the second would pair resume identity + // with a freshly built launch. + if (callerSuppliedLaunch || opts.command || opts.resumeProviderSession) { + throw new Error( + `startupAgent ${opts.startupAgent} cannot combine with a caller-supplied launch.` + ) + } + if (!store) { + throw new Error('runtime_unavailable') + } + } else if (callerSuppliedLaunch || !store || !opts.command || !workspace.repo) { return opts } - const settings = this.store.getSettings() + const settings = store.getSettings() const platform = this.getAgentLaunchPlatformForWorkspace(workspace) const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) const queuedShell = resolveLocalWindowsAgentStartupShell({