From a375936c04ff216d13bc8890a29b030ea6814d82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:18:16 -0700 Subject: [PATCH] feat(agent-launch): let a caller reserve the pane its terminal launch creates (#22291) * feat(agent-launch): let a caller reserve the pane its terminal launch creates * fix(agent-launch): refuse a launch whose reserved pane is already live * fix(agent-launch): refuse a live reserved pane before it is revealed The live-pane refusal used to fire in the executor, after createTerminal had already issued a handle, published the mobile snapshot and revealed the tab. The reveal re-registered a fresh launch config over the running agent's. agent.launch now passes requireFreshPane with a reserved pane, and createTerminal throws AgentLaunchPaneAlreadyLiveError as soon as spawn reports it attached to a live pane. That is before any handle, snapshot or reveal. The spawn reattach itself is the one terminal.create already uses, so the live PTY is never killed, and the stable-pane create claim is still released in finally. The isReattach plumbing added to the launch factory for the old check is gone. A replay-safe launch refused this way on an existing workspace now records a failed ledger row, the same way a name collision does. Before, the row stayed claimed, so every retry got agent_session_operation_unknown. agent.launchReplay passes the code through. On create-worktree the workspace already exists when the terminal is refused, so the row stays unknown. The code is added to the runtime passthrough list so callers can branch on it. The pane key is now in the replay fingerprint, deliberately. It is not placement: group, anchor and focus still stay out of the request and out of the ledger. It is identity. It is written into the pane's PTY environment and names the tab the caller has placed. A retry that reserved a different pane is therefore a different request. Replaying the first answer would return a key the new reservation can never find. This matches terminal.createAgentSession, which also fingerprints its tab and leaf ids. The key is only folded in when present, so every existing digest is unchanged, and a test pins that. The wire schema now refuses a tab id the runtime would not adopt as sent: one with surrounding whitespace, which the runtime trims, and one longer than 512 characters, which the spawn reservation does not key on. It reuses the tab-id schema that Placement uses. * test(agent-launch): pin that a refused live pane issues no handle The refusal test named handle issuance but only asserted the reveal, so a throw moved to just before the reveal would still pass. Assert no terminal is registered, with the attach test as the positive control. --- .../agent-launch/agent-launch-executor.ts | 18 +- ...ca-runtime-create-terminal-dependencies.ts | 1 + .../runtime/orca-runtime-create-terminal.ts | 3 +- ...nal-creation-and-readiness-part-12.spec.ts | 119 ++++++- src/main/runtime/rpc/errors.ts | 2 + .../agent-launch-pane-reservation.test.ts | 300 ++++++++++++++++++ .../rpc/methods/agent-launch-surfaces.ts | 6 +- .../methods/agent-launch-worktree-creation.ts | 4 +- .../rpc/methods/agent-launch.test-fixture.ts | 18 +- src/main/runtime/rpc/methods/agent-launch.ts | 34 +- .../runtime-folder-worktree-create.test.ts | 74 +++++ .../runtime/runtime-folder-worktree-create.ts | 2 + ...me-local-worktree-terminal-startup.test.ts | 37 +++ ...runtime-local-worktree-terminal-startup.ts | 2 + .../runtime-managed-worktree-create-types.ts | 2 + ...ime-remote-managed-worktree-create.test.ts | 84 +++++ .../runtime-remote-managed-worktree-create.ts | 2 + .../runtime/runtime-terminal-contracts.ts | 2 + .../runtime/runtime-terminal-pane-identity.ts | 20 ++ src/shared/agent-launch-intent.ts | 3 + src/shared/agent-launch-operation.test.ts | 28 ++ src/shared/agent-launch-operation.ts | 7 +- src/shared/agent-launch-pane-already-live.ts | 9 + .../rpc-contract/agent-launch-params.ts | 26 +- .../rpc-contract/agent-session-params.ts | 14 +- 25 files changed, 790 insertions(+), 27 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts create mode 100644 src/main/runtime/runtime-folder-worktree-create.test.ts create mode 100644 src/main/runtime/runtime-remote-managed-worktree-create.test.ts create mode 100644 src/main/runtime/runtime-terminal-pane-identity.ts create mode 100644 src/shared/agent-launch-pane-already-live.ts 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()