diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index f660dea4a17..aee91b9293d 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -77,9 +77,14 @@ export type AgentLaunchSurfaceFactory = { cwd?: string /** The one member of the `agent_started` triple the host cannot derive for itself. */ launchSource?: string - /** `paneKey` names the pane this create minted, for a caller that presents its own tabs; a - * factory whose runtime does not report one omits it rather than inventing a key. */ - }): Promise<{ handle: string; paneKey?: string; warning?: string }> + /** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */ + paneKey?: string + }): Promise<{ + handle: string + /** The pane this create minted; a factory whose runtime reports none omits it, never invents. */ + paneKey?: string + warning?: string + }> /** * Commits the launch text as the session's first turn, answering with the transcript row's id. * @@ -139,6 +144,7 @@ export type AgentLaunchWorkspaceFactory = { agentArgs?: string | null cwd?: string launchSource?: string + paneKey?: string }): Promise<{ worktreeId: string startupTerminalHandle: string | undefined @@ -304,7 +310,8 @@ async function resolveWorkspace( : { ...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}), ...(intent.cwd ? { cwd: intent.cwd } : {}), - ...(intent.launchSource ? { launchSource: intent.launchSource } : {}) + ...(intent.launchSource ? { launchSource: intent.launchSource } : {}), + ...(intent.paneKey ? { paneKey: intent.paneKey } : {}) }) }) // Only when a startup terminal actually came back: a create that produced none ran no command, @@ -386,7 +393,8 @@ async function createTerminalSurface( // `null` is a value the caller meant, so this tests for absence rather than falsiness. ...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}), ...(intent.cwd ? { cwd: intent.cwd } : {}), - ...(intent.launchSource ? { launchSource: intent.launchSource } : {}) + ...(intent.launchSource ? { launchSource: intent.launchSource } : {}), + ...(intent.paneKey ? { paneKey: intent.paneKey } : {}) }) return { outcome: { diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 282c7c79c39..73669b5a0c3 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -8,6 +8,7 @@ export { export { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' export { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' export { randomUUID } from 'node:crypto' +export { admitStablePaneAdoption } from './runtime-terminal-pane-identity' export { copySleepingAgentLaunchConfig, inferCapturedClaudeAgentTeamsMode, diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 022ef810c84..81d4dc94dbb 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -180,7 +180,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate if (!result.stablePaneOwner) { reportPtySpawnCommitted() } - const adoptedStablePane = Boolean(result.stablePaneOwner) + // Why here: refused before a handle, snapshot or reveal could pose the live PTY as new. + const adoptedStablePane = dependencies.admitStablePaneAdoption(result, launchOpts) if (result.agentSessionEnsure) { const canonicalSurface = result.agentSessionEnsure.owner.surface preAllocatedHandle = canonicalSurface.terminalHandle diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts index f7f5638f015..3b3b17c3331 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts @@ -1,6 +1,51 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' -import { store, syncSinglePty } from '../orca-runtime-test-fixtures.spec' +import { + TEST_WORKTREE_ID, + TEST_WORKTREE_PATH, + store, + syncSinglePty +} from '../orca-runtime-test-fixtures.spec' + +const RESERVED_TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const RESERVED_LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +// A spawn that found the reserved pane already live answers with its owner instead of a new PTY. +const LIVE_PANE_SPAWN = { + id: 'pty-live', + stablePaneOwner: { handle: 'term_live', tabId: RESERVED_TAB_ID, leafId: RESERVED_LEAF_ID } +} + +function runtimeWithDesktopWindow(spawnResult: Record = { id: 'pty-launch' }) { + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: RESERVED_TAB_ID }) + const releaseStablePaneCreate = vi.fn() + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + claimStablePaneCreate: () => releaseStablePaneCreate, + spawn: vi.fn().mockResolvedValue(spawnResult), + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + return { runtime, revealTerminalSession, releaseStablePaneCreate, kill } +} describe('OrcaRuntimeService', () => { it('bounds retained work for many newline-separated huge ANSI cursor movements', async () => { @@ -74,4 +119,74 @@ describe('OrcaRuntimeService', () => { expect(retained).not.toContain('Gi=31337') expect(retained).not.toContain('AAAA') }) + + // agent.launch's reserved pane lands here: the host reveal stays the only tab creator, and it + // must create the tab under the caller's id so the caller's placement lookup can find it. + it('reveals an agent terminal under the pane the caller reserved', async () => { + const { runtime, revealTerminalSession } = runtimeWithDesktopWindow() + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'claude', + tabId: RESERVED_TAB_ID, + leafId: RESERVED_LEAF_ID + }) + + expect(revealTerminalSession).toHaveBeenCalledTimes(1) + expect(revealTerminalSession).toHaveBeenCalledWith( + TEST_WORKTREE_ID, + expect.objectContaining({ tabId: RESERVED_TAB_ID, leafId: RESERVED_LEAF_ID }) + ) + expect(created.paneKey).toBe(`${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`) + }) + + it('mints its own pane when the reserved leaf is malformed, and reports the one it used', async () => { + const { runtime, revealTerminalSession } = runtimeWithDesktopWindow() + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'claude', + tabId: RESERVED_TAB_ID, + leafId: 'not-a-uuid' + }) + + // Still exactly one tab — the caller's reservation lost, and the reported pane says so. + expect(revealTerminalSession).toHaveBeenCalledTimes(1) + expect(created.tabId).not.toBe(RESERVED_TAB_ID) + expect(created.paneKey?.startsWith(`${created.tabId}:`)).toBe(true) + }) + + it('refuses a live reserved pane before issuing a handle or revealing it', async () => { + const { runtime, revealTerminalSession, releaseStablePaneCreate, kill } = + runtimeWithDesktopWindow(LIVE_PANE_SPAWN) + + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'claude', + tabId: RESERVED_TAB_ID, + leafId: RESERVED_LEAF_ID, + requireFreshPane: true + }) + ).rejects.toThrow('agent_launch_pane_already_live') + + // Nothing registered, so no handle was issued for the live PTY. + expect((await runtime.listTerminals()).terminals).toEqual([]) + // No reveal means no renderer launch-config re-registration over the running agent's. + expect(revealTerminalSession).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + expect(releaseStablePaneCreate).toHaveBeenCalled() + }) + + it('still attaches to a live pane when the caller did not require a fresh one', async () => { + // `terminal.create` keeps its reattach; only agent.launch opts into the refusal. + const { runtime, revealTerminalSession } = runtimeWithDesktopWindow(LIVE_PANE_SPAWN) + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'claude', + tabId: RESERVED_TAB_ID, + leafId: RESERVED_LEAF_ID + }) + + expect(created.isReattach).toBe(true) + expect((await runtime.listTerminals()).terminals.map((t) => t.handle)).toEqual([created.handle]) + expect(revealTerminalSession).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index ec1d40081c8..5101a375a70 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -25,6 +25,7 @@ import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owne import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../shared/worktree/archive-hook-removal-gate' import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker-depth' import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/worktree-create-collision' +import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launch-pane-already-live' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -56,6 +57,7 @@ export function errorResponse( // change user-visible error codes. const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ WORKTREE_CREATE_COLLISION_CODE, + AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE, 'agent_launch_replay_unsupported', 'runtime_unavailable', 'selector_not_found', diff --git a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts new file mode 100644 index 00000000000..22d025106f1 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -0,0 +1,300 @@ +/** + * The pane a caller reserves for the terminal `agent.launch` creates. + * + * A client that places its own tabs mints the pane id first and records where the tab should go + * under it; the host still reveals the tab, and the renderer finds the placement by that id. So the + * only thing that crosses the wire is identity — and the outcome's `paneKey` says which pane really + * exists, so a caller whose reservation lost (older host, replay, structured route) can tell. + */ + +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 type { RpcContext } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { + CAPABLE_CLIENT, + STRUCTURED_PREFERENCE, + methodNamed, + rpcContext, + runtimeStub, + type AgentLaunchRuntimeStub as RuntimeStub +} 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 = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` + +/** No structured preference, so every launch here settles as a terminal unless it says otherwise. */ +const TERMINAL_ONLY = {} + +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' } } +} + +async function launch(params: unknown, runtime: RuntimeStub, context: Partial = {}) { + const parsed = AGENT_LAUNCH.params.safeParse(params) + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? 'invalid') + } + return AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, { ...CAPABLE_CLIENT, ...context })) +} + +function terminalOptions(runtime: RuntimeStub): Record { + return runtime.createTerminal.mock.calls[0]?.[1] ?? {} +} + +describe('a launch into an existing workspace', () => { + it('creates the terminal under the pane the caller reserved', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + await launch({ ...EXISTING_LAUNCH, paneKey: PANE_KEY }, runtime) + + expect(terminalOptions(runtime)).toMatchObject({ + tabId: TAB_ID, + leafId: LEAF_ID, + requireFreshPane: true + }) + }) + + it('leaves the pane to the runtime when the caller reserved none', async () => { + // Every shipped caller sends nothing; its options must not gain a key. + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + await launch(EXISTING_LAUNCH, runtime) + + expect(terminalOptions(runtime)).not.toHaveProperty('tabId') + expect(terminalOptions(runtime)).not.toHaveProperty('leafId') + expect(terminalOptions(runtime)).not.toHaveProperty('requireFreshPane') + }) + + it('keeps the reservation through a downgrade from structured to terminal', async () => { + const runtime = runtimeStub({ + settings: STRUCTURED_PREFERENCE, + createSupport: { supported: false, reason: 'wsl' } + }) + + const result = await launch({ ...EXISTING_LAUNCH, paneKey: PANE_KEY }, runtime) + + expect(result.receipt).toMatchObject({ mode: 'terminal' }) + expect(terminalOptions(runtime)).toMatchObject({ tabId: TAB_ID, leafId: LEAF_ID }) + }) + + it('creates no terminal for a launch the host settles as a chat', async () => { + // The reservation simply goes unused; the structured outcome tells the caller to burn it. + const runtime = runtimeStub({ settings: STRUCTURED_PREFERENCE }) + + const result = await launch({ ...EXISTING_LAUNCH, paneKey: PANE_KEY }, runtime) + + expect(result.outcome.kind).toBe('structured') + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + + it('creates no terminal when it reuses a running one', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + const result = await launch( + { ...EXISTING_LAUNCH, paneKey: PANE_KEY, reuseTerminal: { handle: 'term_live' } }, + runtime + ) + + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_live' }) + }) +}) + +describe('a reserved pane that is already live', () => { + // The runtime attaches to a live pane rather than spawning, which is right for `terminal.create` + // but here would report an agent that never started and paste into whatever runs there. + it('refuses the launch and delivers no prompt', async () => { + // An agent that takes its prompt as a paste after start, so a missing refusal would reach it. + const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneAlreadyLive: true }) + const prompt = { + waitForTerminal: vi.fn(async () => ({ satisfied: true })), + sendTerminalAgentPrompt: vi.fn(async () => true) + } + Object.assign(runtime, prompt) + + await expect( + launch( + { + ...EXISTING_LAUNCH, + agent: 'aider', + paneKey: PANE_KEY, + prompt: { text: 'hi', delivery: 'submit' } + }, + runtime + ) + ).rejects.toThrow('agent_launch_pane_already_live') + expect(prompt.waitForTerminal).not.toHaveBeenCalled() + expect(prompt.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('launches normally when the runtime spawned a fresh pane', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneKey: PANE_KEY }) + + const result = await launch({ ...EXISTING_LAUNCH, paneKey: PANE_KEY }, runtime) + + expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1', paneKey: PANE_KEY }) + }) +}) + +describe('a launch that creates its workspace', () => { + it('carries the reservation to the startup terminal the create spawns', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + await launch({ ...CREATE_LAUNCH, paneKey: PANE_KEY }, runtime) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startupPaneKey: PANE_KEY }) + ) + }) + + it('sends no startup pane when the caller reserved none', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + await launch(CREATE_LAUNCH, runtime) + + expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).not.toHaveProperty('startupPaneKey') + }) + + it('keeps the reservation out of the worktree.create payload', async () => { + // A sibling of the payload, like the other startup inputs: it names this launch's pane, not + // something a `worktree.create` caller can ask for. + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + await launch({ ...CREATE_LAUNCH, paneKey: PANE_KEY }, runtime) + + expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).not.toHaveProperty('paneKey') + }) +}) + +describe('the reservation at the wire', () => { + it.each([ + ['no leaf', TAB_ID], + ['a leaf that is not a UUID', `${TAB_ID}:leaf-1`], + ['an empty tab id', `:${LEAF_ID}`], + ['an extra separator', `${TAB_ID}:${LEAF_ID}:x`] + ])('refuses a pane key with %s rather than letting the runtime mint silently', (_, paneKey) => { + expect(AGENT_LAUNCH.params.safeParse({ ...EXISTING_LAUNCH, paneKey }).success).toBe(false) + }) + + it.each([ + ['a tab id the runtime would trim', ` ${TAB_ID}:${LEAF_ID}`], + ['a tab id that trims to nothing', ` :${LEAF_ID}`], + ['a tab id longer than the spawn reservation keys', `${'t'.repeat(513)}:${LEAF_ID}`] + ])('refuses a pane key with %s, which the runtime would not adopt verbatim', (_, paneKey) => { + expect(AGENT_LAUNCH.params.safeParse({ ...EXISTING_LAUNCH, paneKey }).success).toBe(false) + }) + + it('accepts a well-formed pane key', () => { + expect(AGENT_LAUNCH.params.safeParse({ ...EXISTING_LAUNCH, paneKey: PANE_KEY }).success).toBe( + true + ) + }) +}) + +describe('a live-pane refusal under a named operation', () => { + // The ledger admits against `Date.now()`, so the id must be dated now. + const OPERATION_ID = `${Date.now()}-000000000000000000000000000000bb` + let directory: string + let store: AgentSessionRecordStore + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-pane-')) + 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 dispatch(runtime: RuntimeStub, method: string, params: unknown) { + 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, params }) + } + + it('records the refusal as a failure, so a retry is answered rather than left unknown', async () => { + const params = { ...EXISTING_LAUNCH, paneKey: PANE_KEY, operationId: OPERATION_ID } + await expect( + launch(params, runtimeStub({ settings: TERMINAL_ONLY, terminalPaneAlreadyLive: true })) + ).rejects.toThrow('agent_launch_pane_already_live') + expect(outcomeOf(OPERATION_ID)).toMatchObject({ + status: 'failed', + code: 'agent_launch_pane_already_live' + }) + + const retry = runtimeStub({ settings: TERMINAL_ONLY }) + await expect(launch(params, retry)).rejects.toThrow('agent_launch_pane_already_live') + expect(retry.createTerminal).not.toHaveBeenCalled() + }) + + it('answers agent.launchReplay with the refusal code, not operation_unknown', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneAlreadyLive: true }) + const params = AGENT_LAUNCH_REPLAY.params.parse({ + ...EXISTING_LAUNCH, + paneKey: PANE_KEY, + operationId: OPERATION_ID + }) + + const response = await dispatch(runtime, 'agent.launchReplay', params) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_launch_pane_already_live' } + }) + expect(outcomeOf(OPERATION_ID)).toMatchObject({ status: 'failed' }) + }) + + it('leaves a create-worktree launch unknown, because its workspace was already created', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneAlreadyLive: true }) + // No startup terminal came back, so the launch builds its own in the new workspace. + runtime.createManagedWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-new' }, + startupTerminal: undefined + }) + const params = AGENT_LAUNCH_REPLAY.params.parse({ + ...CREATE_LAUNCH, + paneKey: PANE_KEY, + operationId: OPERATION_ID + }) + + const response = await dispatch(runtime, 'agent.launchReplay', params) + + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_unknown' } + }) + expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown') + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index eb1ba13f360..01ab89bea9e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -32,6 +32,7 @@ import { structuredCallerFor } from './structured-agent-session-gate' import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create' import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' import { deliverTerminalAgentLaunchPrompt } from './agent-launch-terminal-prompt' +import { paneIdentity } from '../../runtime-terminal-pane-identity' /** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */ export function agentLaunchSurfaceFactory( @@ -96,7 +97,8 @@ export function agentLaunchSurfaceFactory( startupPrompt, agentArgs, cwd, - launchSource + launchSource, + paneKey }) => { const terminal = await context.runtime.createTerminal(`id:${worktreeId}`, { // The agent id is not a shell command — `cursor` is the desktop app, its CLI is @@ -107,6 +109,8 @@ export function agentLaunchSurfaceFactory( ...(startupPrompt ? { startupPrompt } : {}), ...(agentArgs !== undefined ? { agentArgs } : {}), ...(cwd ? { cwd } : {}), + // A live reserved pane would be attached, not launched into, so the runtime refuses it. + ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), ...agentLaunchTelemetry(agent, launchSource) }) return { diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index d5d8a79ba5e..4858692b6e4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -41,7 +41,8 @@ export function agentLaunchWorkspaceFactory( startupPrompt, agentArgs, cwd, - launchSource + launchSource, + paneKey }) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`; the executor only removed the reserved agent fields, so the rest of the payload is the parsed shape. const params = create as WorktreeCreateParams @@ -78,6 +79,7 @@ export function agentLaunchWorkspaceFactory( ...(agentArgs !== undefined ? { startupAgentArgs: agentArgs } : {}), ...(cwd ? { startupCwd: cwd } : {}), ...(launchSource ? { startupLaunchSource: launchSource } : {}), + ...(paneKey ? { startupPaneKey: paneKey } : {}), // The launch owns the agent whichever surface it settles on, so the workspace records // it even when no startup terminal was created for it. createdWithAgent: agent, diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 4b437010e10..365c843c1ef 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -7,6 +7,7 @@ import { vi } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch-pane-already-live' import type { RpcContext } from '../core' export const STRUCTURED_PREFERENCE = { @@ -32,6 +33,8 @@ export type AgentLaunchRuntimeStubOptions = { terminalPaneKey?: string /** The pane minted with an agent-first worktree's startup terminal. */ startupTerminalPaneKey?: string + /** The reserved pane is already live, so a create that requires a fresh pane is refused. */ + terminalPaneAlreadyLive?: boolean } export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { @@ -75,11 +78,16 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { ...(options.createWarning ? { warning: options.createWarning } : {}) })), // Args are declared so a test can assert what the launch asked for, not merely that it asked. - createTerminal: vi.fn(async (_selector: string, _options?: Record) => ({ - handle: 'term_1', - ...(options.terminalPaneKey ? { paneKey: options.terminalPaneKey } : {}), - ...(options.terminalWarning ? { warning: options.terminalWarning } : {}) - })), + createTerminal: vi.fn(async (_selector: string, createOptions?: Record) => { + if (options.terminalPaneAlreadyLive && createOptions?.requireFreshPane === true) { + throw new AgentLaunchPaneAlreadyLiveError() + } + return { + handle: 'term_1', + ...(options.terminalPaneKey ? { paneKey: options.terminalPaneKey } : {}), + ...(options.terminalWarning ? { warning: options.terminalWarning } : {}) + } + }), showTerminal: vi.fn(async (handle: string) => ({ handle, worktreeId: 'wt-7' })), isTerminalRunningAgent: vi.fn(async () => true), showManagedTerminalWorkspace: vi.fn(async (selector: string) => ({ diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 9101b9ba96d..89d22ebf96a 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -31,6 +31,10 @@ 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 { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcContext } from '../core' @@ -91,7 +95,8 @@ async function agentLaunchIntent( // `null` means "no arguments" and must survive; only absence falls back to the settings default. ...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {}), ...(params.cwd ? { cwd: params.cwd } : {}), - ...(params.launchSource ? { launchSource: params.launchSource } : {}) + ...(params.launchSource ? { launchSource: params.launchSource } : {}), + ...(params.paneKey ? { paneKey: params.paneKey } : {}) } } @@ -192,6 +197,24 @@ function agentLaunchFailureCode(error: unknown): string { 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 proves it only for an existing workspace; on create-worktree the workspace + * already exists by the time the terminal 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 + } + return null +} + type ActiveAgentLaunch = { fingerprint: string promise: Promise @@ -237,13 +260,13 @@ async function executeReplaySafeAgentLaunch( await settleQuietly(admission.fail(agentLaunchFailureCode(error))) throw error } - // Only a typed pre-creation collision proves that the claimed launch had no effects. let result: AgentLaunchResult try { result = await runAgentLaunch(intent, context, admission.attachOperationId, admission.callerKey) } catch (error) { - if (error instanceof WorktreeCreateCollisionError) { - await settleQuietly(admission.fail(WORKTREE_CREATE_COLLISION_CODE)) + const failedWithoutEffects = launchFailureWithoutEffectsCode(error, intent.target.kind) + if (failedWithoutEffects) { + await settleQuietly(admission.fail(failedWithoutEffects)) } throw new AgentLaunchExecutionError(error) } @@ -296,6 +319,9 @@ export const AGENT_LAUNCH_METHODS = [ code: WORKTREE_CREATE_COLLISION_CODE }) } + if (launchFailureWithoutEffectsCode(error.cause, params.target.kind)) { + throw error.cause + } throw new Error('agent_session_operation_unknown', { cause: error.cause }) } throw error diff --git a/src/main/runtime/runtime-folder-worktree-create.test.ts b/src/main/runtime/runtime-folder-worktree-create.test.ts new file mode 100644 index 00000000000..885e61010dd --- /dev/null +++ b/src/main/runtime/runtime-folder-worktree-create.test.ts @@ -0,0 +1,74 @@ +/** The startup terminal a folder workspace's create spawns; this path forwards a reserved pane itself. */ + +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' + +type CreateArgs = Parameters[0] + +const repo: Repo = { + id: 'repo-1', + path: '/folder', + displayName: 'folder', + badgeColor: 'blue', + addedAt: 1 +} + +function createDeps() { + const createTerminal = vi + .fn() + .mockResolvedValue({ handle: 'term-1', worktreeId: 'folder-1', title: null }) + const store = { + getSettings: () => ({ workspaceDir: '/ws', nestWorkspaces: false }), + setWorktreeMeta: (_id: string, meta: Record) => meta, + getProjectHostSetups: () => [] + } + const deps: CreateArgs['deps'] = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create reads only these three store methods; any other would throw on call rather than read a wrong value. + store: store as unknown as CreateArgs['deps']['store'], + ptySpawnAvailable: true, + createTerminal, + markTrusted: vi.fn(), + pasteDraft: vi.fn(), + sendFollowup: vi.fn(), + invalidateResolvedWorktrees: vi.fn(), + notifyWorktreesChanged: vi.fn(), + emitCreated: vi.fn(), + activate: vi.fn() + } + return { createTerminal, deps } +} + +const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +async function startupTerminalOptions(startupPaneKey?: string): Promise> { + const { createTerminal, deps } = createDeps() + await createRuntimeFolderWorktree({ + request: { + repoSelector: `id:${repo.id}`, + name: 'task', + ...(startupPaneKey ? { startupPaneKey } : {}) + }, + repo, + createdWithAgent: 'codex', + startup: { command: 'codex' }, + deps + }) + return createTerminal.mock.calls[0]?.[1] ?? {} +} + +describe('a folder workspace create with a startup agent', () => { + it('creates the startup terminal under the pane the caller reserved', async () => { + expect(await startupTerminalOptions(`${TAB_ID}:${LEAF_ID}`)).toMatchObject({ + tabId: TAB_ID, + leafId: LEAF_ID + }) + }) + + it('leaves the pane to the runtime when none was reserved', async () => { + const options = await startupTerminalOptions() + expect(options).not.toHaveProperty('tabId') + expect(options).not.toHaveProperty('leafId') + }) +}) diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 2089e74c095..fd13253c488 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -1,3 +1,4 @@ +import { paneIdentity } from './runtime-terminal-pane-identity' import { randomUUID } from 'node:crypto' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup' import { resolveWorktreeCreateDisplayNameRequest } from '../ipc/worktree-logic' @@ -137,6 +138,7 @@ export async function createRuntimeFolderWorktree(args: { const terminal = await deps.createTerminal(`id:${worktree.id}`, { command: args.startup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), + ...paneIdentity(request.startupPaneKey), env: args.startup.env, ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts index 1153b173553..1e590f22973 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -51,6 +51,43 @@ function createPorts() { return { createTerminal, ports } } +const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +async function startupTerminalOptions(startupPaneKey?: string) { + const { createTerminal, ports } = createPorts() + await startRuntimeLocalWorktreeTerminals({ + request: { + repoSelector: `id:${repo.id}`, + name: worktree.displayName, + ...(startupPaneKey ? { startupPaneKey } : {}) + }, + repo, + worktree, + createdWithAgent: 'codex', + startup: { command: 'codex' }, + ports + }) + return createTerminal.mock.calls[0]?.[1] ?? {} +} + +describe('startRuntimeLocalWorktreeTerminals reserved startup pane', () => { + // The local, folder and remote creates each forward this separately, so nothing above them + // catches the one that stops. + it('creates the startup terminal under the pane the caller reserved', async () => { + expect(await startupTerminalOptions(`${TAB_ID}:${LEAF_ID}`)).toMatchObject({ + tabId: TAB_ID, + leafId: LEAF_ID + }) + }) + + it('leaves the pane to the runtime when none was reserved', async () => { + const options = await startupTerminalOptions() + expect(options).not.toHaveProperty('tabId') + expect(options).not.toHaveProperty('leafId') + }) +}) + describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { it.each([ ['Blank Terminal', undefined, 1], diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index fa3221fbe1e..b3cb5e71850 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -1,3 +1,4 @@ +import { paneIdentity } from './runtime-terminal-pane-identity' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' @@ -100,6 +101,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { const terminal = await ports.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), + ...paneIdentity(request.startupPaneKey), ...(setup && startup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 4f46cd6d142..bdc8c2a0a12 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -55,6 +55,8 @@ export type RuntimeManagedWorktreeCreateArgs = { startupAgentArgs?: string | null startupCwd?: string startupLaunchSource?: string + /** A caller-minted `tabId:leafId` for the startup terminal's pane. */ + startupPaneKey?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.test.ts b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts new file mode 100644 index 00000000000..4dfa1ba2647 --- /dev/null +++ b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts @@ -0,0 +1,84 @@ +/** The startup terminal an SSH-remote create spawns; each create path forwards a reserved pane itself. */ + +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' + +const requestRuntimeRemoteWorktree = vi.hoisted(() => + vi.fn( + async (): Promise<{ + worktree: { id: string; path: string } + setup?: WorktreeSetupLaunch + }> => ({ worktree: { id: 'wt-remote', path: '/remote/wt' } }) + ) +) + +vi.mock('./runtime-remote-worktree-create-request', () => ({ requestRuntimeRemoteWorktree })) + +const { createRuntimeRemoteManagedWorktree } = + await import('./runtime-remote-managed-worktree-create') + +type CreateParams = Parameters + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'conn-1' +} + +function createDeps() { + const createTerminal = vi + .fn() + .mockResolvedValue({ handle: 'term-1' }) + const deps: CreateParams[2] = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create only forwards the store to the mocked request helper, which never reads it. + store: {} as unknown as CreateParams[2]['store'], + canSpawn: () => true, + markTrusted: vi.fn(), + createTerminal, + pasteDraft: vi.fn(), + sendFollowup: vi.fn(), + provision: vi.fn().mockResolvedValue({ setupSpawned: false, setupTerminalHandle: null }), + activate: vi.fn(), + invalidateResolvedWorktrees: vi.fn(), + invalidateWorktreeScan: vi.fn(), + notifyWorktreesChanged: vi.fn() + } + return { createTerminal, deps } +} + +const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +async function startupTerminalOptions(startupPaneKey?: string): Promise> { + const { createTerminal, deps } = createDeps() + await createRuntimeRemoteManagedWorktree( + repo, + { + name: 'task', + createdWithAgent: 'codex', + startup: { command: 'codex' }, + ...(startupPaneKey ? { startupPaneKey } : {}) + }, + deps + ) + return createTerminal.mock.calls[0]?.[1] ?? {} +} + +describe('a remote managed create with a startup agent', () => { + it('creates the startup terminal under the pane the caller reserved', async () => { + expect(await startupTerminalOptions(`${TAB_ID}:${LEAF_ID}`)).toMatchObject({ + tabId: TAB_ID, + leafId: LEAF_ID + }) + }) + + it('leaves the pane to the runtime when none was reserved', async () => { + const options = await startupTerminalOptions() + expect(options).not.toHaveProperty('tabId') + expect(options).not.toHaveProperty('leafId') + }) +}) diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 53551ae3ce6..85ff97f10a0 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -1,3 +1,4 @@ +import { paneIdentity } from './runtime-terminal-pane-identity' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import { getSetupRunnerCommandPlatformForPath } from '../../shared/setup-runner-command' @@ -111,6 +112,7 @@ export async function createRuntimeRemoteManagedWorktree( const terminal = await deps.createTerminal(`path:${result.worktree.path}`, { command: sequencedStartup.command, ...(args.startupCwd ? { cwd: args.startupCwd } : {}), + ...paneIdentity(args.startupPaneKey), ...(result.setup && args.startup ? { claudeAgentTeamsSourceCommand: args.startup.command } : {}), diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index ae4d902c888..8f0461e522a 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -69,6 +69,8 @@ export type TerminalCreateOptions = { surfaceOwner?: false tabId?: string leafId?: string + /** Refuse, rather than attach, when `tabId`/`leafId` name a pane whose PTY is already live. */ + requireFreshPane?: boolean sessionId?: string isNewSession?: boolean preAllocatedHandle?: string diff --git a/src/main/runtime/runtime-terminal-pane-identity.ts b/src/main/runtime/runtime-terminal-pane-identity.ts new file mode 100644 index 00000000000..90909f6ef35 --- /dev/null +++ b/src/main/runtime/runtime-terminal-pane-identity.ts @@ -0,0 +1,20 @@ +import { AgentLaunchPaneAlreadyLiveError } from '../../shared/agent-launch-pane-already-live' +import { parsePaneKey } from '../../shared/stable-pane-id' + +/** A caller-minted `tabId:leafId` as the pair `createTerminal` adopts; an unparsable key yields + * nothing, so the runtime mints its own and the reported `paneKey` shows the caller it lost. */ +export function paneIdentity(paneKey: string | undefined): { tabId?: string; leafId?: string } { + const pane = paneKey ? parsePaneKey(paneKey) : null + return pane ? { tabId: pane.tabId, leafId: pane.leafId } : {} +} + +/** Whether spawn attached to a live pane; refused when the caller required a fresh one. */ +export function admitStablePaneAdoption( + result: { stablePaneOwner?: unknown }, + opts: { requireFreshPane?: boolean } +): boolean { + if (result.stablePaneOwner && opts.requireFreshPane) { + throw new AgentLaunchPaneAlreadyLiveError() + } + return Boolean(result.stablePaneOwner) +} diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index b8695f3e1d2..89b49723fcc 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -83,6 +83,9 @@ export type AgentLaunchIntent = { * it cannot know. */ launchSource?: string + /** The `tabId:leafId` a terminal launch creates its pane under, for a caller that places its own + * tabs. Not a route input; refused when that pane is already live. */ + paneKey?: string } /** The surface the host actually created. */ diff --git a/src/shared/agent-launch-operation.test.ts b/src/shared/agent-launch-operation.test.ts index 3ab501ec906..6b24b934fb3 100644 --- a/src/shared/agent-launch-operation.test.ts +++ b/src/shared/agent-launch-operation.test.ts @@ -19,6 +19,8 @@ const BASE = { agent: 'claude', target: { kind: 'existing' as const, worktree: 'wt-1' } } +const PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const OTHER_PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:6fa459ea-ee8a-4ca4-894e-db77e160355e' /** * The handler digests the whole params object, `launchSource` included — excess properties are only @@ -52,6 +54,16 @@ describe('fields the launch fingerprint covers', () => { computeAgentLaunchFingerprint({ ...BASE, cwd: '/repo/packages/b' }) ) }) + + it('separates two launches that reserved different panes', () => { + // The key is baked into the pane's env; replaying another pane's key strands the new reservation. + expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe( + computeAgentLaunchFingerprint({ ...BASE, paneKey: OTHER_PANE_KEY }) + ) + expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe( + computeAgentLaunchFingerprint(BASE) + ) + }) }) describe('fields the launch fingerprint deliberately ignores', () => { @@ -89,6 +101,22 @@ describe('compatibility with rows written before these fields existed', () => { expect(computeAgentLaunchFingerprint(BASE)).toBe(previousBuild) }) + it('matches the digest from before the pane key existed when no pane is reserved', () => { + const beforePaneKey = canonicalAgentSessionDigest({ + method: 'agent.launch', + agent: BASE.agent, + target: BASE.target, + prompt: undefined, + sessionOptions: undefined, + reuseTerminal: undefined, + agentArgs: '--model opus', + cwd: '/repo/packages/a' + }) + expect( + computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus', cwd: '/repo/packages/a' }) + ).toBe(beforePaneKey) + }) + it('still matches when the caller sends only telemetry the digest excludes', () => { const previousBuild = canonicalAgentSessionDigest({ method: 'agent.launch', diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index 85127d4c9ec..4ef9cf1ea50 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -41,6 +41,9 @@ export type AgentLaunchFingerprintInput = { /** In: it decides both where the agent runs and, through `tui_launch_command`, which surface it * gets. Two launches differing only in `cwd` are genuinely two operations. */ cwd?: string + /** In: it is baked into the pane's PTY env and names the tab the caller placed, so a retry that + * reserved another pane must conflict rather than replay a key its placement cannot find. */ + paneKey?: string /** * `launchSource` is deliberately absent, and this is the reasoned exclusion rather than an * oversight: it is telemetry, so two launches differing only in which button produced them do the @@ -62,7 +65,9 @@ export function computeAgentLaunchFingerprint(input: AgentLaunchFingerprintInput sessionOptions: input.sessionOptions, reuseTerminal: input.reuseTerminal, agentArgs: input.agentArgs, - cwd: input.cwd + cwd: input.cwd, + // Absent keys are dropped by the canonical form, so every digest without one is unchanged. + paneKey: input.paneKey }) } diff --git a/src/shared/agent-launch-pane-already-live.ts b/src/shared/agent-launch-pane-already-live.ts new file mode 100644 index 00000000000..0fa231f8fc3 --- /dev/null +++ b/src/shared/agent-launch-pane-already-live.ts @@ -0,0 +1,9 @@ +export const AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE = 'agent_launch_pane_already_live' as const + +// The reserved pane already backs a live terminal; refused before any handle, snapshot or reveal. +export class AgentLaunchPaneAlreadyLiveError extends Error { + constructor() { + super(AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE) + this.name = 'AgentLaunchPaneAlreadyLiveError' + } +} diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index c2485fb5002..5e7daad24da 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -13,9 +13,12 @@ import { z } from 'zod' import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority' +import { parsePaneKey } from '../stable-pane-id' +import { isValidHostTerminalTabId } from '../terminal-tab-id' import { isTuiAgent } from '../tui-agent-config' import type { TuiAgent } from '../tui-agent' import { WorktreeCreate } from './worktree-create-params' +import { TerminalTabIdParam } from './agent-session-params' const LaunchAgent = z .unknown() @@ -80,7 +83,28 @@ export const AgentLaunch = z.object({ * action, so the arm set stays open here and the host parses it leniently at the point it is * actually used — the same `safeParse`-and-skip the PTY spawn already does. */ - launchSource: z.string().optional() + launchSource: z.string().optional(), + /** + * The pane a terminal launch should create, minted by a caller that places its own tabs. + * + * Identity, never placement: the host still reveals the tab, and the caller finds its placement + * by this key. Refused here unless the runtime would adopt it verbatim (it trims, and mints its + * own for an invalid one), so the caller's reservation always matches. Ignored by a structured + * launch and a reused terminal, which create no pane; the outcome's `paneKey` says which pane + * really exists. Tab ids are global across workspaces, so the caller mints a fresh UUID for each. + */ + paneKey: z + .string() + .refine((value) => { + const pane = parsePaneKey(value) + return ( + pane !== null && + pane.tabId === pane.tabId.trim() && + TerminalTabIdParam.safeParse(pane.tabId).success && + isValidHostTerminalTabId(pane.tabId) + ) + }, 'Malformed launch pane key') + .optional() }) export type AgentLaunchParams = z.infer diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index d82f0c4d2b4..b425b08b126 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -36,14 +36,16 @@ export const WorktreeSelector = StrictNonEmptyString( export const Presentation = z.enum(['background', 'focused']) +/** A caller-chosen terminal tab id; the length bound is the one the PTY spawn reservation keys on. */ +export const TerminalTabIdParam = z + .string() + .min(1) + .max(512) + .refine(isValidTerminalTabId, 'Invalid terminal tab ID') + export const Placement = z .object({ - tabId: z - .string() - .min(1) - .max(512) - .refine(isValidTerminalTabId, 'Invalid terminal tab ID') - .optional(), + tabId: TerminalTabIdParam.optional(), leafId: z.string().min(1).max(128).optional() }) .strict()