From 635d3853f1f6fc8db9d7b14154bd7d61fcb30fc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:21:24 -0700 Subject: [PATCH] fix(agent.launch): avoid blank shells when launching new workspaces (#28451) * fix: avoid blank shells beside new workspace chats * Carry promised agent chat through worktree creation * Use live agent launch inventory for new workspace surfaces * Type live agent launch owner from runtime contract * Assert one recovery shell after failed agent launch * Keep worktree activation adapters out of creation flow * Restore ordinary workspace setup handoff after launch correction * Cover the phone's agent.launch workspace create in the first-surface flow test --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../agent-launch-created-workspace.ts | 71 ++++++ .../agent-launch-executor.test.ts | 101 ++++++-- .../agent-launch/agent-launch-executor.ts | 238 ++++++++---------- src/main/agent-launch/agent-launch-mode.ts | 12 + .../agent-launch-surface-factories.ts | 5 + .../agent-launch-pane-reservation.test.ts | 12 +- .../agent-launch-prestart-failure.test.ts | 12 +- .../methods/agent-launch-worktree-creation.ts | 7 + .../worker/worker-start-agent-placement.ts | 16 +- ...orker-start-launch-executor-parity.test.ts | 4 +- .../worker/worker-worktree-creation.ts | 4 + src/main/runtime/rpc/methods/session-tabs.ts | 4 +- .../runtime-folder-worktree-create.test.ts | 25 ++ .../runtime/runtime-folder-worktree-create.ts | 17 +- ...me-local-worktree-terminal-startup.test.ts | 46 ++++ ...runtime-local-worktree-terminal-startup.ts | 35 ++- .../runtime-managed-worktree-create-types.ts | 4 + src/main/runtime/runtime-notifier-contract.ts | 3 +- ...ime-worktree-terminal-provisioning.test.ts | 33 ++- .../runtime-worktree-terminal-provisioning.ts | 34 ++- .../session-tabs-change-publication.ts | 16 +- .../session-tabs/session-tabs-inventory.ts | 7 +- .../workspaces/workspace-create-activation.ts | 44 ++++ .../runtime/workspaces/workspace-creation.ts | 34 ++- .../workspace-host-notifications.ts | 45 +++- .../worktree-agent-first-surface.test.ts | 189 ++++++++++++++ src/main/window/runtime-window-lifecycle.ts | 6 +- .../api/ui-bridge-tab-and-browser-commands.ts | 2 + src/preload/api/ui-command-event-api.ts | 1 + .../initial-terminal-admission-wait.test.tsx | 12 + .../ipc-events/app-lifetime-ipc-bridge.ts | 33 +-- .../ipc-events/worktree-event-runtime.ts | 4 +- .../worktree-agent-activation-gate.test.ts | 17 ++ .../src/lib/worktree-agent-activation-gate.ts | 6 + ...orktree-agent-structured-inventory.test.ts | 11 + .../worktree-agent-structured-inventory.ts | 5 +- .../lib/worktree-initial-terminal-seeding.ts | 20 +- src/shared/runtime-client-events.ts | 7 +- src/shared/runtime-session-contracts.ts | 3 + src/shared/worktree-first-surface.ts | 19 ++ .../agent-launch-setup-choice.unit.test.ts | 12 +- 42 files changed, 924 insertions(+), 253 deletions(-) create mode 100644 src/main/agent-launch/agent-launch-created-workspace.ts create mode 100644 src/main/runtime/workspaces/workspace-create-activation.ts create mode 100644 src/main/runtime/worktree-agent-first-surface.test.ts create mode 100644 src/shared/worktree-first-surface.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 56d944f2e78..6eca27a6abb 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -241,6 +241,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts', 'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts', 'src/main/runtime/orca-runtime.test.ts', + 'src/main/runtime/worktree-agent-first-surface.test.ts', 'tests/e2e/agent-launch-setup-choice.unit.test.ts', 'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts', 'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts', diff --git a/src/main/agent-launch/agent-launch-created-workspace.ts b/src/main/agent-launch/agent-launch-created-workspace.ts new file mode 100644 index 00000000000..3d606e97ab7 --- /dev/null +++ b/src/main/agent-launch/agent-launch-created-workspace.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'node:crypto' +import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { AgentLaunchExecution } from './agent-launch-execution' +import type { AgentLaunchWorkspaceFactory } from './agent-launch-surface-factories' + +export type InitialSurfaceLaunch = NonNullable< + RuntimeMobileSessionTabsResult['initialSurfaceLaunch'] +> + +type AgentLaunchRuntimeOwner = Pick< + AgentLaunchExecution['runtime'], + 'getStructuredAgentSessionCreateSupport' +> + +const liveLaunches = new WeakMap>() + +export function beginCreatedWorkspaceLaunch( + runtime: AgentLaunchRuntimeOwner, + agent: TuiAgent +): { + onWorkspaceCreated: (worktreeId: string) => void + createdWorktreeId: () => string | undefined + release: () => void +} { + const launch: InitialSurfaceLaunch = { launchId: randomUUID(), agent } + let worktreeId: string | undefined + return { + onWorkspaceCreated: (createdId) => { + worktreeId = createdId + let byWorktree = liveLaunches.get(runtime) + if (!byWorktree) { + byWorktree = new Map() + liveLaunches.set(runtime, byWorktree) + } + byWorktree.set(createdId, launch) + }, + createdWorktreeId: () => worktreeId, + release: () => { + if (!worktreeId) { + return + } + const byWorktree = liveLaunches.get(runtime) + if (byWorktree?.get(worktreeId) !== launch) { + return + } + byWorktree.delete(worktreeId) + if (byWorktree.size === 0) { + liveLaunches.delete(runtime) + } + } + } +} + +export function readCreatedWorkspaceLaunch( + runtime: AgentLaunchRuntimeOwner, + worktreeId: string +): InitialSurfaceLaunch | undefined { + return liveLaunches.get(runtime)?.get(worktreeId) +} + +export async function recoverCreatedWorkspace( + workspaces: AgentLaunchWorkspaceFactory | undefined, + worktreeId: string +): Promise { + try { + await workspaces?.createRecoveryShell?.(worktreeId) + } catch (error) { + console.warn('[agent-launch] Failed to create a recovery shell:', error) + } +} diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index e8d0917e317..1f46bc79d09 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -13,6 +13,7 @@ import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-ex import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories' import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { readCreatedWorkspaceLaunch } from './agent-launch-created-workspace' const STRUCTURED_PREFERENCE = { experimentalNativeChat: true @@ -23,10 +24,14 @@ function harness(options: { createSupport?: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } createSupportThrows?: boolean structuredCreateError?: Error + terminalCreateError?: Error deliveredMessageId?: string | null terminalPromptDelivered?: boolean /** Whether the surface reports that its typed line took the offered prompt. */ lineCarriesPrompt?: boolean + worktreeCreateError?: Error + onStructuredCreate?: () => void + onStructuredPromptDelivery?: () => void onSurfacePublished?: AgentLaunchExecution['onSurfacePublished'] /** A worktree-only factory, as orchestration workers and `worktree.create` pass. */ worktreeOnlyFactory?: boolean @@ -38,9 +43,15 @@ function harness(options: { async (args: { create: Record startupAgent: string | undefined + promisedAgentChat?: boolean startupPrompt?: string + onWorkspaceCreated?: (worktreeId: string) => void }) => { calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`) + args.onWorkspaceCreated?.('wt-new') + if (options.worktreeCreateError) { + throw options.worktreeCreateError + } return { worktreeId: 'wt-new', connectionId: null, @@ -53,6 +64,9 @@ function harness(options: { calls.push('createFolderWorkspace') return { worktreeId: 'folder:fw-new', connectionId: null } }) + const createRecoveryShell = vi.fn(async (_worktreeId: string) => { + calls.push('createRecoveryShell') + }) const getStructuredAgentSessionCreateSupport = vi.fn(async () => { calls.push('createSupport') if (options.createSupportThrows) { @@ -62,6 +76,7 @@ function harness(options: { }) const createStructuredSession = vi.fn(async () => { calls.push('createStructuredSession') + options.onStructuredCreate?.() if (options.structuredCreateError) { throw options.structuredCreateError } @@ -69,10 +84,14 @@ function harness(options: { }) const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => { calls.push('createTerminalAgent') + if (options.terminalCreateError) { + throw options.terminalCreateError + } return { handle: 'term_1', ...carried(args.startupPrompt) } }) const deliverStructuredPrompt = vi.fn(async () => { calls.push('deliverStructuredPrompt') + options.onStructuredPromptDelivery?.() return options.deliveredMessageId === undefined ? 'msg-1' : options.deliveredMessageId }) const deliverTerminalPrompt = vi.fn(async () => { @@ -86,8 +105,10 @@ function harness(options: { } return { calls, + runtime, createWorktree, createFolderWorkspace, + createRecoveryShell, createStructuredSession, createTerminalAgent, deliverStructuredPrompt, @@ -104,8 +125,8 @@ function harness(options: { deliverTerminalPrompt }, workspaces: options.worktreeOnlyFactory - ? { createWorktree } - : { createWorktree, createFolderWorkspace }, + ? { createWorktree, createRecoveryShell } + : { createWorktree, createFolderWorkspace, createRecoveryShell }, ...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {}) }) } @@ -117,6 +138,29 @@ const CREATE_INTENT: AgentLaunchIntent = { } describe('a structured launch that creates its own worktree', () => { + it('keeps its live inventory through publication and clears it before prompt delivery', async () => { + let duringCreate: unknown + let duringPrompt: unknown + const h = harness({ + onStructuredCreate: () => (duringCreate = readCreatedWorkspaceLaunch(h.runtime, 'wt-new')), + onStructuredPromptDelivery: () => + (duringPrompt = readCreatedWorkspaceLaunch(h.runtime, 'wt-new')) + }) + await h.run({ ...CREATE_INTENT, prompt: { text: 'start', delivery: 'submit' } }) + expect(duringCreate).toMatchObject({ agent: 'claude', launchId: expect.any(String) }) + expect(duringPrompt).toBeUndefined() + expect(readCreatedWorkspaceLaunch(h.runtime, 'wt-new')).toBeUndefined() + }) + + it('recovers once and releases intent when creation fails after metadata registration', async () => { + const failure = new Error('materialization failed') + const h = harness({ worktreeCreateError: failure }) + await expect(h.run(CREATE_INTENT)).rejects.toBe(failure) + expect(h.createRecoveryShell).toHaveBeenCalledExactlyOnceWith('wt-new') + expect(h.createStructuredSession).not.toHaveBeenCalled() + expect(readCreatedWorkspaceLaunch(h.runtime, 'wt-new')).toBeUndefined() + }) + it('creates the worktree with no startup agent, then asks the host, then opens a session', async () => { const h = harness({}) const result = await h.run(CREATE_INTENT) @@ -134,6 +178,9 @@ describe('a structured launch that creates its own worktree', () => { }) expect(result.worktreeId).toBe('wt-new') expect(result.receipt.mode).toBe('structured') + expect(h.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ promisedAgentChat: true }) + ) }) it('asks the host only after the workspace exists, never before', async () => { @@ -213,20 +260,41 @@ describe('a structured launch that creates its own worktree', () => { }) }) - it('does not create a duplicate terminal when structured creation is unknown', async () => { - const h = harness({ - structuredCreateError: new AgentLaunchStructuredSessionRefusedError( - 'agent_session_operation_unknown', - 'unknown' - ) - }) - - await expect(h.run(CREATE_INTENT)).rejects.toThrow('unknown') - expect(h.calls).toEqual([ - 'createWorktree(startupAgent=undefined)', - 'createSupport', - 'createStructuredSession' - ]) + it.each([ + [ + 'unknown chat creation', + { + structuredCreateError: new AgentLaunchStructuredSessionRefusedError( + 'agent_session_operation_unknown', + 'unknown' + ) + }, + 0 + ], + [ + 'host downgrade terminal failure', + { + createSupport: { supported: false, reason: 'wsl' as const }, + terminalCreateError: new Error('spawn failed') + }, + 1 + ], + [ + 'definitive refusal fallback failure', + { + structuredCreateError: new AgentLaunchStructuredSessionRefusedError( + 'structured_agent_session_unsupported', + 'unsupported' + ), + terminalCreateError: new Error('spawn failed') + }, + 1 + ] + ] as const)('leaves a shell after %s', async (_label, options, agentStarts) => { + const h = harness(options) + await expect(h.run(CREATE_INTENT)).rejects.toThrow() + expect(h.createRecoveryShell).toHaveBeenCalledWith('wt-new') + expect(h.createTerminalAgent).toHaveBeenCalledTimes(agentStarts) }) it('creates a folder workspace, then opens its structured session there', async () => { @@ -291,6 +359,7 @@ describe('a launch the user did not ask to be structured', () => { expect(h.calls).toEqual(['createWorktree(startupAgent=claude)']) expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_agent_first' }) expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'user_default' }) + expect(h.createWorktree.mock.calls[0]?.[0]).not.toHaveProperty('promisedAgentChat') }) }) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 4b37d240125..d2f08d1730d 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -1,39 +1,9 @@ -/** - * The one place an agent launch is sequenced — for the surfaces moved onto it: `agent.launch`, - * `worktree.create` (CLI and mobile create) through `createWorktreeWithStartupAgent`, whose - * `legacy-host` create still starts the agent itself, orchestration workers, local and - * federated, and server-run automations. The desktop agent tab still starts agents its own way; - * moving it here is later work. - * - * The mode decision is shared, not copied: `agent-launch-mode` owns it, and - * `orchestration-worker-start-mode` is a thin adapter over it supplying orchestration's receipt - * vocabulary. What this module adds is the *sequencing*, and the sequencing is where the bug - * was: - * - * create the worktree agent-first -> its startup terminal IS the agent - * -> the structured branch below it is unreachable - * - * so every new-worktree launch was a PTY no matter what the user's default said. The order here is - * the inverse, and it is the whole point of the module: when the preference is structured the - * worktree is created with NO startup agent, the executing host is then asked whether it can host - * a session for the workspace that now exists, and only then is a surface created. A refusal - * becomes a terminal agent in the worktree just created, never a failed launch. - * - * The host verdict cannot be hoisted above creation: `agentSession.createSupport` can only answer - * for a workspace it can resolve. That is why the decision is in two halves rather than one. - * - * What genuinely differs per surface is only how a surface is *built* — an orchestration worker's - * session takes a redrive subscription and a mailbox that a plain launch must not take — so that - * is injected as a factory instead of branched on here. - */ +/** Structured launches create the workspace without an agent, then ask its host for chat support. + * The host needs the created workspace to answer; a definitive refusal starts a terminal agent. */ import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan' import { parsePaneKey } from '../../shared/stable-pane-id' -import type { - AgentLaunchIntent, - AgentLaunchResult, - AgentLaunchTarget -} from '../../shared/agent-launch-intent' +import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent' import { withoutReservedAgentCreateFields } from '../../shared/agent-launch-intent' import { combineLaunchWarnings, @@ -54,8 +24,8 @@ import { readAgentLaunchModeSettings, resolveAgentLaunchModeOnHost, type AgentLaunchModeReceipt, - type AgentLaunchModeVocabulary, DEFAULT_LAUNCH_VOCABULARY, + downgradeAgentLaunchModeForStructuredRefusal, warnStructuredLaunchDowngrade } from './agent-launch-mode' import { @@ -72,6 +42,10 @@ import type { AgentLaunchPublishedSurface, AgentLaunchSurfaceExecution } from './agent-launch-execution' +import { + beginCreatedWorkspaceLaunch, + recoverCreatedWorkspace +} from './agent-launch-created-workspace' export type { AgentLaunchExecution, AgentLaunchPublishedSurface } from './agent-launch-execution' @@ -112,7 +86,7 @@ export async function executeAgentLaunch( if (intent.reuseTerminal) { const reused = published(execution, { outcome: { kind: 'terminal', handle: intent.reuseTerminal.handle }, - worktreeId: existingWorktreeId(intent.target), + worktreeId: intent.target.kind === 'existing' ? intent.target.worktree : '', receipt: preflight, ...promptReceipt(intent, settledAtCreation(intent, {})) }) @@ -127,85 +101,104 @@ export async function executeAgentLaunch( } } - const placed = await resolveWorkspace(execution, preflight) - // Agent-first creation already produced the agent, so the pre-flight verdict is final. - if (placed.startupTerminalHandle) { - const startup = published(execution, { - outcome: { - kind: 'terminal', - handle: placed.startupTerminalHandle, - ...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {}) - }, - worktreeId: placed.worktreeId, - receipt: preflight, - ...(placed.warning ? { warning: placed.warning } : {}), - ...promptReceipt(intent, settledAtCreation(intent, placed)) - }) - return { - ...startup, - ...promptReceipt( - intent, - placed.promptRodeLaunchCommand - ? HANDED_TO_TERMINAL - : await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, { - freshLaunch: true - }) - ) - } - } - - execution.onStage?.('mode_settle') - // A caller's own decision about an existing workspace already carries that host's answer. - let settled = - execution.decidedMode && intent.target.kind === 'existing' - ? preflight - : await resolveAgentLaunchModeOnHost( - runtime, - preflight, - placed.worktreeId, - intent.agent, - vocabulary - ) - - execution.onStage?.('surface_create') - let created: CreatedSurface + const launch = + intent.target.kind === 'create-worktree' && preflight.mode === 'structured' + ? beginCreatedWorkspaceLaunch(runtime, intent.agent) + : undefined + let placed: Awaited> | undefined + let surfacePublished = false + let recoverableWorktreeId: string | undefined try { - created = await createSurface(execution, placed, settled) - } catch (error) { - // The structured create path distinguishes a definitive pre-commit refusal from an unknown - // outcome. Only the former is safe to replace with a terminal in the same workspace; retrying - // after an unknown attach outcome could create two agents. - if ( - settled.mode !== 'structured' || - !(error instanceof AgentLaunchStructuredSessionRefusedError) || - !isDefinitiveAgentSessionCreateRefusal(error.code) - ) { - throw error + placed = await resolveWorkspace(execution, preflight, launch?.onWorkspaceCreated) + // Agent-first creation already produced the agent, so the pre-flight verdict is final. + if (placed.startupTerminalHandle) { + const startup = published(execution, { + outcome: { + kind: 'terminal', + handle: placed.startupTerminalHandle, + ...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {}) + }, + worktreeId: placed.worktreeId, + receipt: preflight, + ...(placed.warning ? { warning: placed.warning } : {}), + ...promptReceipt(intent, settledAtCreation(intent, placed)) + }) + surfacePublished = true + launch?.release() + return { + ...startup, + ...promptReceipt( + intent, + placed.promptRodeLaunchCommand + ? HANDED_TO_TERMINAL + : await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, { + freshLaunch: true + }) + ) + } } - settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary) - created = await createTerminalSurface(execution, placed) - } - // Both CAN be set, so neither may be dropped. The create warns precisely when it produced no - // startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same - // condition which skips the early return above, so the launch goes on to build a second surface, - // and that one can warn too. The other path is an untracked-copy warning followed by a structured - // refusal downgrading to a terminal that warns. `??` kept the first and lost the second silently. - // - // KNOWN GAP, deliberately not fixed here: a create warning about a FAILED startup terminal is - // stale once the launch recovers by building a working one, so the user can be told the agent did - // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop - // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. - const warning = combineLaunchWarnings(placed.warning, created.warning) - const surface = published(execution, { - outcome: created.outcome, - worktreeId: placed.worktreeId, - receipt: settled, - ...(warning ? { warning } : {}), - ...promptReceipt(intent, settledAtCreation(intent, created)) - }) - return { - ...surface, - ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) + + execution.onStage?.('mode_settle') + // A caller's own decision about an existing workspace already carries that host's answer. + let settled = + execution.decidedMode && intent.target.kind === 'existing' + ? preflight + : await resolveAgentLaunchModeOnHost( + runtime, + preflight, + placed.worktreeId, + intent.agent, + vocabulary + ) + + execution.onStage?.('surface_create') + recoverableWorktreeId = placed.worktreeId + let created: CreatedSurface + try { + created = await createSurface(execution, placed, settled) + } catch (error) { + if ( + settled.mode !== 'structured' || + !(error instanceof AgentLaunchStructuredSessionRefusedError) || + !isDefinitiveAgentSessionCreateRefusal(error.code) + ) { + throw error + } + settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary) + created = await createTerminalSurface(execution, placed) + } + // Both CAN be set, so neither may be dropped. The create warns precisely when it produced no + // startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same + // condition which skips the early return above, so the launch goes on to build a second surface, + // and that one can warn too. The other path is an untracked-copy warning followed by a structured + // refusal downgrading to a terminal that warns. `??` kept the first and lost the second silently. + // + // KNOWN GAP, deliberately not fixed here: a create warning about a FAILED startup terminal is + // stale once the launch recovers by building a working one, so the user can be told the agent did + // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop + // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. + const warning = combineLaunchWarnings(placed.warning, created.warning) + const surface = published(execution, { + outcome: created.outcome, + worktreeId: placed.worktreeId, + receipt: settled, + ...(warning ? { warning } : {}), + ...promptReceipt(intent, settledAtCreation(intent, created)) + }) + surfacePublished = true + launch?.release() + return { + ...surface, + ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) + } + } catch (error) { + const createdWorktreeId = recoverableWorktreeId ?? launch?.createdWorktreeId() + if (!surfacePublished && intent.target.kind !== 'existing' && createdWorktreeId) { + await recoverCreatedWorkspace(execution.workspaces, createdWorktreeId) + } + throw error + } finally { + launch?.release() } } @@ -218,21 +211,10 @@ function published( return surface } -function downgradeAgentLaunchModeForStructuredRefusal( - receipt: AgentLaunchModeReceipt, - vocabulary: AgentLaunchModeVocabulary -): AgentLaunchModeReceipt { - return { - mode: 'terminal', - preferred: receipt.preferred, - reason: 'structured_unsupported_on_host', - detail: `Your default is a structured chat session, but the host refused to create one here; started ${vocabulary.terminal} instead.` - } -} - async function resolveWorkspace( execution: AgentLaunchExecution, - preflight: AgentLaunchModeReceipt + preflight: AgentLaunchModeReceipt, + onWorkspaceCreated?: (worktreeId: string) => void ): Promise<{ worktreeId: string connectionId?: string | null @@ -265,6 +247,8 @@ async function resolveWorkspace( // in there would re-create the agent-first path this executor exists to replace. The launch // owns the prompt for the same reason, so it re-supplies its own rather than honouring theirs. create: withoutReservedAgentCreateFields(intent.target.create), + ...(onWorkspaceCreated ? { onWorkspaceCreated } : {}), + ...(preflight.mode === 'structured' ? { promisedAgentChat: true } : {}), startupAgent: preflight.mode === 'structured' ? undefined : intent.agent, ...createLaunchPromptInputs(execution, preflight.mode), ...(preflight.mode === 'structured' ? {} : terminalLaunchInputs(intent)) @@ -358,7 +342,3 @@ async function createTerminalSurface( ...(startupPrompt && terminal.promptRodeLaunchCommand ? { promptRodeLaunchCommand: true } : {}) } } - -function existingWorktreeId(target: AgentLaunchTarget): string { - return target.kind === 'existing' ? target.worktree : '' -} diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 33dd44831e5..14e42d1d0a8 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -242,6 +242,18 @@ export function downgradeAgentLaunchModeForHost( ) } +export function downgradeAgentLaunchModeForStructuredRefusal( + receipt: AgentLaunchModeReceipt, + vocabulary: AgentLaunchModeVocabulary +): AgentLaunchModeReceipt { + return { + mode: 'terminal', + preferred: receipt.preferred, + reason: 'structured_unsupported_on_host', + detail: `Your default is a structured chat session, but the host refused to create one here; started ${vocabulary.terminal} instead.` + } +} + function downgraded( reason: Exclude, vocabulary: AgentLaunchModeVocabulary diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 09fcfbb6ac4..93160d4b72a 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -105,6 +105,9 @@ export class AgentLaunchStructuredSessionRefusedError extends Error { export type AgentLaunchWorkspaceFactory = { createWorktree(args: { create: Readonly> + /** Main-internal registration at the metadata boundary, before the workspace is announced. */ + onWorkspaceCreated?: (worktreeId: string) => void + promisedAgentChat?: boolean /** Set only when the settled mode is a terminal agent: agent-first creation sequences the * agent's startup command behind the setup runner, which is how a PTY launch gets its * wait-for-setup gate for free. A structured launch has no startup command to sequence and @@ -138,6 +141,8 @@ export type AgentLaunchWorkspaceFactory = { /** Reported by the create that built the startup command's typed line. */ promptRodeLaunchCommand?: boolean }> + /** Leave a usable workspace when chat creation has an indeterminate outcome. */ + createRecoveryShell?: (worktreeId: string) => Promise /** A folder workspace has no startup terminal: the launch starts its agent there afterwards. * Only `agent.launch` creates folders; callers that only create worktrees omit it. */ createFolderWorkspace?(args: { 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 index 898f575408e..6910fef4c6e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -313,11 +313,21 @@ describe('a live-pane refusal under a named operation', () => { const response = await dispatch(runtime, 'agent.launchReplay', params) - expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(runtime.createTerminal).toHaveBeenNthCalledWith( + 1, + 'id:wt-new', + expect.objectContaining({ startupAgent: 'claude' }) + ) + expect(runtime.createTerminal).toHaveBeenNthCalledWith(2, 'id:wt-new', { + surfaceOwner: false + }) expect(response).toMatchObject({ ok: false, error: { code: 'agent_session_operation_unknown' } }) expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown') + expect(await dispatch(runtime, 'agent.launchReplay', params)).toMatchObject(response) + expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1) + expect(runtime.createTerminal).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts index 6ea556fecb1..ad1ae4b0494 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -172,11 +172,21 @@ describe('a launch whose terminal fails', () => { const response = await replay(runtime, CREATE_LAUNCH) - expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(runtime.createTerminal).toHaveBeenNthCalledWith( + 1, + 'id:wt-new', + expect.objectContaining({ startupAgent: 'claude' }) + ) + expect(runtime.createTerminal).toHaveBeenNthCalledWith(2, 'id:wt-new', { + surfaceOwner: false + }) expect(response).toMatchObject({ ok: false, error: { code: 'agent_session_operation_unknown' } }) + expect(await replay(runtime, CREATE_LAUNCH)).toMatchObject(response) + expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1) + expect(runtime.createTerminal).toHaveBeenCalledTimes(2) }) it.each([ { ...CREATE_LAUNCH, agent: 'opencode', sessionOptions: { model: 'private-proof/model-b' } }, 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 f30fa28e944..e5260db7fea 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -40,9 +40,14 @@ export function agentLaunchWorkspaceFactory( ): AgentLaunchWorkspaceFactory { return { createFolderWorkspace: agentLaunchFolderWorkspaceCreator(context, agent), + createRecoveryShell: async (worktreeId) => { + await context.runtime.createTerminal(`id:${worktreeId}`, { surfaceOwner: false }) + }, createWorktree: async ({ create, startupAgent, + promisedAgentChat, + onWorkspaceCreated, startupPrompt, agentArgs, cwd, @@ -101,6 +106,8 @@ export function agentLaunchWorkspaceFactory( // 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, + ...(promisedAgentChat ? { promisedAgentChat } : {}), + ...(onWorkspaceCreated ? { onWorkspaceCreated } : {}), // Structured sessions have no startup command to sequence behind setup. Provision the // setup terminal synchronously and attach a completion token so the launch can wait // before creating the chat surface. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts index 075f4ec04bf..b11b3e5d193 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts @@ -125,7 +125,19 @@ async function launchWorkerAgent( onStage: (stage) => args.onStage(stage === 'worktree_create' ? 'worktree_create' : 'terminal_create'), workspaces: { - createWorktree: async ({ startupAgent }) => { + createRecoveryShell: async (worktreeId) => { + const shell = await args.runtime.createTerminal(`id:${worktreeId}`, { + surfaceOwner: false + }) + args.effects.push({ + kind: 'terminal', + role: 'recovery_shell', + action: 'created', + id: shell.handle, + tabId: shell.tabId + }) + }, + createWorktree: async ({ startupAgent, promisedAgentChat, onWorkspaceCreated }) => { created = await createWorkerWorktree({ runtime: args.runtime, db: args.db, @@ -136,6 +148,8 @@ async function launchWorkerAgent( agent, // The executor withholds the startup agent exactly when the worker is to be a session. withAgentTerminal: startupAgent !== undefined, + promisedAgentChat, + onWorkspaceCreated, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), effects: args.effects }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts index c63c60d65c7..5b534d8b023 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts @@ -129,14 +129,14 @@ describe('a fresh worker through the launch executor', () => { expect(f.stages).toEqual(['terminal_creating']) }) - it('fails a refused structured worker in the worktree it created, with no terminal', async () => { + it('fails a refused structured worker in the worktree it created, leaving a shell', async () => { const f = fakes() sessionCreate.refusal = { code: 'structured_agent_session_unsupported' } await expect(place(f, { mode: STRUCTURED })).rejects.toMatchObject({ code: 'agent_unconfigured' }) - expect(f.runtime.createTerminal).not.toHaveBeenCalled() + expect(f.runtime.createTerminal).toHaveBeenCalledWith('id:wt_new', { surfaceOwner: false }) expect(f.failedStages).toEqual(['worktree_create', 'terminal_create', 'terminal_create']) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index 1d12166a47e..32e1ae2ed4f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -38,6 +38,8 @@ export async function createWorkerWorktree(args: { * does not need, since the trust preset exists so a PTY agent's menu does not eat the first * bracketed paste. The renderer's own structured worktree create skips both the same way. */ withAgentTerminal: boolean + promisedAgentChat?: boolean + onWorkspaceCreated?: (worktreeId: string) => void launchPreferences?: AgentLaunchPreferences effects: WorkerEffect[] }): Promise<{ @@ -61,6 +63,8 @@ export async function createWorkerWorktree(args: { awaitTerminalProvisioning: true, observeSetupCompletion: true, createdWithAgent: args.agent, + ...(args.promisedAgentChat ? { promisedAgentChat: true } : {}), + ...(args.onWorkspaceCreated ? { onWorkspaceCreated: args.onWorkspaceCreated } : {}), ...(args.withAgentTerminal ? { startupAgent: args.agent, diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index e3be5d2e374..5e2f9beeb46 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -28,7 +28,9 @@ export const SESSION_TAB_METHODS = [ handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) return projectSessionTabsForClient( - await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), + await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId, { + includeInitialSurfaceLaunch: true + }), clientKind, clientCapabilities ) diff --git a/src/main/runtime/runtime-folder-worktree-create.test.ts b/src/main/runtime/runtime-folder-worktree-create.test.ts index c72c226e452..af0d3b8b998 100644 --- a/src/main/runtime/runtime-folder-worktree-create.test.ts +++ b/src/main/runtime/runtime-folder-worktree-create.test.ts @@ -58,6 +58,31 @@ async function startupTerminalOptions(startupPaneKey?: string): Promise { + it('registers a folder launch before announcing its workspace', async () => { + const { deps } = createDeps() + const order: string[] = [] + vi.mocked(deps.notifyWorktreesChanged).mockImplementation(() => order.push('notify')) + const result = await createRuntimeFolderWorktree({ + request: { repoSelector: `id:${repo.id}`, name: 'chat', activate: true }, + repo, + promisedAgentChat: true, + onWorkspaceCreated: (id) => order.push(`register:${id}`), + deps + }) + expect(order).toEqual([`register:${result.worktree.id}`, 'notify']) + }) + + it('only promises a chat when the caller explicitly plans one', async () => { + const { deps } = createDeps() + await createRuntimeFolderWorktree({ + request: { repoSelector: `id:${repo.id}`, name: 'attributed', activate: true }, + repo, + createdWithAgent: 'codex', + deps + }) + expect(deps.activate).toHaveBeenCalledWith(repo.id, expect.any(String), undefined, undefined) + }) + it('seeds a headless activated folder workspace without a paired viewer', async () => { const { deps, createTerminal } = createDeps() deps.provisionInBackground = () => true diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index fea631b7515..495a80923be 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -1,4 +1,5 @@ import { paneIdentity } from './runtime-terminal-pane-identity' +import { shouldSeedWorktreePrimaryTerminal } from '../../shared/worktree-first-surface' import { randomUUID } from 'node:crypto' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup' import { resolveWorktreeCreateDisplayNameRequest } from '../ipc/worktree-logic' @@ -42,7 +43,8 @@ type RuntimeFolderWorktreeCreateDeps = { repoId: string, worktreeId: string, setup?: undefined, - startup?: WorktreeStartupLaunch + startup?: WorktreeStartupLaunch, + providesInitialSurface?: boolean ) => void } @@ -54,6 +56,8 @@ export async function createRuntimeFolderWorktree(args: { /** What a host-built startup was quoted for, or the repair error it fails with. */ startupLaunchRuntime?: AgentLaunchRuntime | Error createdWithAgent?: TuiAgent + promisedAgentChat?: boolean + onWorkspaceCreated?: (worktreeId: string) => void draftPaste?: WorktreeStartupDraftPaste deps: RuntimeFolderWorktreeCreateDeps }): Promise { @@ -120,6 +124,7 @@ export async function createRuntimeFolderWorktree(args: { ...(request.manualOrder !== undefined ? { manualOrder: request.manualOrder } : {}), ...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}) }) + args.onWorkspaceCreated?.(worktreeId) const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta) deps.invalidateResolvedWorktrees() deps.notifyWorktreesChanged(repo.id) @@ -177,14 +182,18 @@ export async function createRuntimeFolderWorktree(args: { repo.id, worktree.id, undefined, - args.startup && !didSpawnStartup ? args.startup : undefined + args.startup && !didSpawnStartup ? args.startup : undefined, + ...(args.promisedAgentChat && !args.startup ? ([true] as const) : []) ) } if ( (!shouldActivate || deps.provisionInBackground?.() === true) && deps.ptySpawnAvailable && - !didSpawnStartup && - !args.createdWithAgent + shouldSeedWorktreePrimaryTerminal({ + hasPrimarySurface: didSpawnStartup, + // Background attribution-only creates have historically omitted the shell. + callerProvidesSurface: args.promisedAgentChat === true || args.createdWithAgent !== undefined + }) ) { try { await deps.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) 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 343bf5c422c..a8bd5144bb3 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -152,4 +152,50 @@ describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { expect(createTerminal).toHaveBeenCalledWith(`id:${worktree.id}`, { surfaceOwner: false }) } }) + + it('hands failed startup and defaults to renderer before host provisioning', async () => { + const { ports, createTerminal } = createPorts() + createTerminal.mockRejectedValueOnce(new Error('pty unavailable')) + const setup = { runnerScriptPath: '/repo/setup.sh', envVars: {} } + const startup = { command: 'codex' } + const defaultTabs = { + runCommands: true, + tabs: [{ title: 'Dev', command: 'pnpm dev' }] + } + await startRuntimeLocalWorktreeTerminals({ + request: { + repoSelector: repo.id, + name: 'recover', + activate: true, + awaitTerminalProvisioning: true + }, + repo, + worktree, + startup, + setup, + defaultTabs, + ports + }) + expect(ports.provision).not.toHaveBeenCalled() + expect(ports.activate).toHaveBeenCalledWith(repo.id, worktree.id, startup, setup, defaultTabs) + expect(createTerminal).toHaveBeenCalledTimes(1) + }) + + it('does not treat agent attribution alone as a promised chat on activation', async () => { + const { ports } = createPorts() + await startRuntimeLocalWorktreeTerminals({ + request: { repoSelector: repo.id, name: 'attributed', activate: true }, + repo, + worktree, + createdWithAgent: 'codex', + ports + }) + expect(ports.activate).toHaveBeenCalledWith( + repo.id, + worktree.id, + undefined, + undefined, + undefined + ) + }) }) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index 2f1cea7159f..3a613f42d7c 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -1,4 +1,5 @@ import { paneIdentity } from './runtime-terminal-pane-identity' +import { shouldSeedWorktreePrimaryTerminal } from '../../shared/worktree-first-surface' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' @@ -37,7 +38,8 @@ type Ports = { startup?: WorktreeStartupLaunch, // Only work main tried to start and failed; the host window retries it where the user sees it. failedSetup?: CreateWorktreeResult['setup'], - failedDefaultTabs?: CreateWorktreeResult['defaultTabs'] + failedDefaultTabs?: CreateWorktreeResult['defaultTabs'], + providesInitialSurface?: boolean ) => void } @@ -64,6 +66,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { /** What a host-built startup was quoted for, or the repair error it fails with. */ startupLaunchRuntime?: AgentLaunchRuntime | Error createdWithAgent?: TuiAgent + promisedAgentChat?: boolean draftPaste?: WorktreeStartupDraftPaste warning?: string ports: Ports @@ -140,15 +143,14 @@ export async function startRuntimeLocalWorktreeTerminals(args: { ports.activate(repo.id, worktree.id, startup, setup, defaultTabs) } else { let failedSetup: CreateWorktreeResult['setup'] - // Why: main alone creates the setup and default tabs; the window draws them from the reveals. - if (ports.canSpawn && (provisionInBackground || setup || defaultTabs)) { + const runtimeWillProvision = + ports.canSpawn && + (request.awaitTerminalProvisioning === true || + provisionInBackground || + Boolean(setup || defaultTabs)) + if (runtimeWillProvision) { const provisioned = await ports.provision({ - ...provisionArgs( - args, - startupTerminalHandle, - didSpawnStartup, - didSpawnStartup ? wrappedSetupCommand : undefined - ), + ...provisionArgs(args, startupTerminalHandle, didSpawnStartup, wrappedSetupCommand), ...(provisionInBackground ? { surfaceOwner: false as const } : {}) }) didSpawnSetup = provisioned.setupSpawned @@ -164,7 +166,9 @@ export async function startRuntimeLocalWorktreeTerminals(args: { repo.id, worktree.id, startup && !didSpawnStartup ? startup : undefined, - failedSetup + failedSetup, + undefined, + ...(args.promisedAgentChat && !startup ? ([true] as const) : []) ) } } else if (ports.canSpawn && (setup || defaultTabs || didSpawnStartup)) { @@ -182,7 +186,14 @@ export async function startRuntimeLocalWorktreeTerminals(args: { didSpawnSetup = true } } - } else if (ports.canSpawn && !args.createdWithAgent) { + } else if ( + ports.canSpawn && + shouldSeedWorktreePrimaryTerminal({ + hasPrimarySurface: didSpawnStartup, + // Preserve background create behavior for attribution-only callers. + callerProvidesSurface: args.promisedAgentChat === true || args.createdWithAgent !== undefined + }) + ) { try { await ports.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (error) { @@ -225,6 +236,8 @@ function provisionArgs( primaryTerminalHandle, hasStartupTerminal, ...(args.request.setupOnly !== undefined ? { setupOnly: args.request.setupOnly } : {}), + callerProvidesSurface: args.promisedAgentChat === true, + startupNeedsTerminal: args.startup !== undefined, setupCommandPlatform: setupPlatform(args.setup, 'posix'), observeSetupCompletion: args.request.observeSetupCompletion, ...(wrappedSetupCommand ? { wrappedSetupCommand } : {}) diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 1ea39b11e49..05a346b2a42 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -51,6 +51,10 @@ export type RuntimeManagedWorktreeCreateArgs = { awaitTerminalProvisioning?: boolean observeSetupCompletion?: boolean createdWithAgent?: TuiAgent + /** Transient intent: this create will be followed by an agent chat surface. */ + promisedAgentChat?: boolean + /** Main-internal launch registration before catalog notification or activation. */ + onWorkspaceCreated?: (worktreeId: string) => void startupAgent?: TuiAgent startupLaunchPreferences?: AgentLaunchPreferences startupPrompt?: string diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 5f12d134e08..d03395dba65 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -47,7 +47,8 @@ export type RuntimeNotifier = { worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + providesInitialSurface?: boolean ): void createTerminal( worktreeId: string, diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts index 56252f1dd5b..b30651dd31f 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts @@ -124,7 +124,8 @@ function provisionWithSetup( host: WorktreeTerminalProvisioningHost, withSetup = true, setupOnly?: boolean, - defaultTabs?: typeof DEFAULT_TABS + defaultTabs?: typeof DEFAULT_TABS, + callerProvidesSurface = false ) { return provisionWorktreeTerminals(host, { worktreeSelector: 'id:wt-1', @@ -134,6 +135,7 @@ function provisionWithSetup( hasStartupTerminal: false, ...(setupOnly !== undefined ? { setupOnly } : {}), ...(defaultTabs ? { defaultTabs } : {}), + callerProvidesSurface, setupCommandPlatform: 'posix' }) } @@ -196,18 +198,23 @@ describe('provisionWorktreeTerminals with a cleared creation choice', () => { expect(titles()).toEqual([undefined]) }) - it('opens a blank terminal when the Setup tab fails to spawn', async () => { - const { host, titles } = createHost(none) - const createTerminal = vi.mocked(host.createTerminal) - createTerminal.mockRejectedValueOnce(new Error('spawn failed')) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(provisionWithSetup(host, true, true)).resolves.toEqual({ - setupSpawned: false, - setupTerminalHandle: null - }) - expect(titles()).toEqual(['Setup', undefined]) - warn.mockRestore() - }) + it.each([false, true])( + 'opens a failed-Setup recovery shell only when the caller owes the surface (%s)', + async (callerProvidesSurface) => { + const { host, titles } = createHost(none) + const createTerminal = vi.mocked(host.createTerminal) + createTerminal.mockRejectedValueOnce(new Error('spawn failed')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect( + provisionWithSetup(host, true, true, undefined, callerProvidesSurface) + ).resolves.toEqual({ + setupSpawned: false, + setupTerminalHandle: null + }) + expect(titles()).toEqual(callerProvidesSurface ? ['Setup'] : ['Setup', undefined]) + warn.mockRestore() + } + ) it('keeps the shell a split setup needs', async () => { const { host, titles } = createHost({ ...none, setupScriptLaunchMode: 'split-vertical' }) diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.ts index 229ac99f6ac..f2dc7160e45 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import { shouldSeedWorktreePrimaryTerminal } from '../../shared/worktree-first-surface' import { buildObservedSetupCommand } from './orchestration/setup-completion-signal' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import type { RuntimeStore } from './runtime-store-contract' @@ -42,6 +43,8 @@ export type WorktreeTerminalProvisioningArgs = { primaryTerminalHandle?: string | null hasStartupTerminal: boolean setupOnly?: boolean + callerProvidesSurface?: boolean + startupNeedsTerminal?: boolean setupCommandPlatform: 'windows' | 'posix' observeSetupCompletion?: boolean // Why: setup and startup must use the same wrapper when startup waits for setup. @@ -135,12 +138,19 @@ export async function provisionWorktreeTerminals( args.hasStartupTerminal ? (args.primaryTerminalHandle ?? undefined) : undefined ) let primaryHandle = args.primaryTerminalHandle ?? defaultHandles[0] ?? null - if (!args.hasStartupTerminal && !primaryHandle) { - if (setupOnly) { - shellSkippedForSetup = true - } else { - primaryHandle = (await host.createTerminal(args.worktreeSelector, surfacing)).handle - } + if (!args.hasStartupTerminal && !primaryHandle && setupOnly) { + shellSkippedForSetup = true + } + if ( + !setupOnly && + shouldSeedWorktreePrimaryTerminal({ + hasPrimarySurface: args.hasStartupTerminal || primaryHandle !== null, + callerProvidesSurface: args.callerProvidesSurface === true, + startupNeedsTerminal: args.startupNeedsTerminal, + ...(args.setup ? { setupLaunchMode } : {}) + }) + ) { + primaryHandle = (await host.createTerminal(args.worktreeSelector, surfacing)).handle } if (args.setup) { const completionToken = @@ -191,8 +201,16 @@ export async function provisionWorktreeTerminals( console.warn( `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` ) - // A cleared choice still needs a shell if Setup fails to spawn. - if (shellSkippedForSetup && !setupSpawned) { + // A cleared choice needs a shell after failed Setup only when this create owes the surface. + if ( + shellSkippedForSetup && + !setupSpawned && + shouldSeedWorktreePrimaryTerminal({ + hasPrimarySurface: false, + callerProvidesSurface: args.callerProvidesSurface === true, + startupNeedsTerminal: args.startupNeedsTerminal + }) + ) { await host .createTerminal(args.worktreeSelector, surfacing) .catch((fallbackError: unknown) => { diff --git a/src/main/runtime/session-tabs/session-tabs-change-publication.ts b/src/main/runtime/session-tabs/session-tabs-change-publication.ts index eaf9a14ca1f..ca1700818ea 100644 --- a/src/main/runtime/session-tabs/session-tabs-change-publication.ts +++ b/src/main/runtime/session-tabs/session-tabs-change-publication.ts @@ -8,6 +8,7 @@ import type { } from '../../../shared/runtime-types' import { UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from '../../../shared/runtime-types' import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' +import { readCreatedWorkspaceLaunch } from '../../agent-launch/agent-launch-created-workspace' export abstract class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab { protected scheduleMobileSessionTabsChanged(worktreeId: string): void { @@ -68,7 +69,8 @@ export abstract class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends Or protected getMobileSessionTabsForWorktree( worktreeId: string, - clientNavigationId?: string + clientNavigationId?: string, + options?: { includeInitialSurfaceLaunch?: true } ): RuntimeMobileSessionTabsResult { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { @@ -76,15 +78,23 @@ export abstract class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends Or if (publishedEpoch === null) { this.notifyEmptyWorktreeOnPublication(worktreeId) } - return this.projectMobileSessionTabsForClient( + const result = this.projectMobileSessionTabsForClient( this.emptyMobileSessionTabsResult(worktreeId, publishedEpoch), clientNavigationId ) + const launch = options?.includeInitialSurfaceLaunch + ? readCreatedWorkspaceLaunch(this, worktreeId) + : undefined + return launch ? { ...result, initialSurfaceLaunch: launch } : result } - return this.projectMobileSessionTabsForClient( + const result = this.projectMobileSessionTabsForClient( this.toMobileSessionTabsResult(snapshot), clientNavigationId ) + const launch = options?.includeInitialSurfaceLaunch + ? readCreatedWorkspaceLaunch(this, worktreeId) + : undefined + return launch ? { ...result, initialSurfaceLaunch: launch } : result } // Why: only an unpublished graph is "ask me later"; once it publishes, a worktree with no entry diff --git a/src/main/runtime/session-tabs/session-tabs-inventory.ts b/src/main/runtime/session-tabs/session-tabs-inventory.ts index ed2743d828b..9fc99724f7d 100644 --- a/src/main/runtime/session-tabs/session-tabs-inventory.ts +++ b/src/main/runtime/session-tabs/session-tabs-inventory.ts @@ -119,7 +119,8 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e async listMobileSessionTabs( worktreeSelector: string, - clientNavigationId?: string + clientNavigationId?: string, + options?: { includeInitialSurfaceLaunch?: true } ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) if (explicitWorktreeId) { @@ -130,7 +131,7 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) await this.refreshMobileSessionPtyRecords(explicitWorktreeId) this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId) - return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) + return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId, options) } const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { @@ -140,7 +141,7 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) await this.refreshMobileSessionPtyRecords() this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id) - return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) + return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId, options) } async listAllMobileSessionTabs( diff --git a/src/main/runtime/workspaces/workspace-create-activation.ts b/src/main/runtime/workspaces/workspace-create-activation.ts new file mode 100644 index 00000000000..febbb7a7c58 --- /dev/null +++ b/src/main/runtime/workspaces/workspace-create-activation.ts @@ -0,0 +1,44 @@ +import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation' +import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' +import type { WorktreeStartupLaunch } from '../../../shared/worktree/launch-types' +import type { createRuntimeFolderWorktree } from '../runtime-folder-worktree-create' +import type { startRuntimeLocalWorktreeTerminals } from '../runtime-local-worktree-terminal-startup' + +type FolderActivation = Parameters[0]['deps']['activate'] +type LocalActivation = Parameters[0]['ports']['activate'] + +type NotifyActivation = ( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'], + navigation?: RuntimeNavigationTarget, + providesInitialSurface?: boolean +) => void + +export function createdWorkspaceActivationCallbacks( + notify: NotifyActivation, + navigation?: RuntimeNavigationTarget +): { folder: FolderActivation; local: LocalActivation } { + const folder: FolderActivation = (repoId, worktreeId, setup, startup, providesInitialSurface) => + notify(repoId, worktreeId, setup, startup, undefined, navigation, providesInitialSurface) + const local: LocalActivation = ( + repoId, + worktreeId, + startup, + failedSetup, + failedDefaultTabs, + providesInitialSurface + ) => + notify( + repoId, + worktreeId, + failedSetup, + startup, + failedDefaultTabs, + navigation, + providesInitialSurface + ) + return { folder, local } +} diff --git a/src/main/runtime/workspaces/workspace-creation.ts b/src/main/runtime/workspaces/workspace-creation.ts index 6e8dc96678c..63130c9ee09 100644 --- a/src/main/runtime/workspaces/workspace-creation.ts +++ b/src/main/runtime/workspaces/workspace-creation.ts @@ -21,6 +21,7 @@ import { trackRuntimeWorkspaceCreate } from '../../workspace-create-telemetry' import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../opencode/opencode-model-startup-plan' import { resolveWorktreeCreateAgentStartup } from '../runtime-worktree-agent-startup' import type { RuntimeWorkspaceCreateEvents } from '../../workspace-create-telemetry' +import { createdWorkspaceActivationCallbacks } from './workspace-create-activation' import { assertWslMoveRepoWritable } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard' export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { @@ -98,6 +99,10 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi draftStartup?.agent ?? (requestedAgentEnabled ? requestedAgent : undefined)) const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste + const activationCallbacks = createdWorkspaceActivationCallbacks( + this.notifyActivateWorktree.bind(this), + args.navigation + ) // Match IPC routing: executionHostId-only SSH repos must not create locally. const createRoute = resolveWorktreeCreateRoute(repo) if (isFolderRepo(repo)) { @@ -109,6 +114,8 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi startupFollowup: effectiveStartupFollowup, startupLaunchRuntime, createdWithAgent: effectiveCreatedWithAgent, + promisedAgentChat: args.promisedAgentChat, + onWorkspaceCreated: args.onWorkspaceCreated, draftPaste: effectiveDraftPaste, deps: { store: this.store, @@ -120,15 +127,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), notifyWorktreesChanged: (repoId) => this.notifyWorktreesChanged(repoId), emitCreated: (event) => this.emitWorktreeLifecycle(event), - activate: (repoId, worktreeId, setup, startup) => - this.notifyActivateWorktree( - repoId, - worktreeId, - setup, - startup, - undefined, - args.navigation - ) + activate: activationCallbacks.folder } }) } @@ -158,8 +157,10 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi this.getOrStartRemoteTrackingBaseRefresh(path, base, ...options), fetchRemote: (path, remote, ...options) => this.fetchRemoteWithCache(path, remote, ...options), - onWorktreeMetadataPersisted: (persistedWorktree) => - this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution), + onWorktreeMetadataPersisted: (persistedWorktree) => { + args.onWorkspaceCreated?.(persistedWorktree.id) + return this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution) + }, rearm, timing }) @@ -223,6 +224,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi startupFollowup: effectiveStartupFollowup, startupLaunchRuntime, createdWithAgent: effectiveCreatedWithAgent, + promisedAgentChat: args.promisedAgentChat, draftPaste: effectiveDraftPaste, warning, ports: { @@ -232,15 +234,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), provision: (options) => this.provisionManagedWorktreeTerminals(options, worktree), - activate: (repoId, worktreeId, startup, failedSetup, failedDefaultTabs) => - this.notifyActivateWorktree( - repoId, - worktreeId, - failedSetup, - startup, - failedDefaultTabs, - args.navigation - ) + activate: activationCallbacks.local } }) warning = terminalWarning diff --git a/src/main/runtime/workspaces/workspace-host-notifications.ts b/src/main/runtime/workspaces/workspace-host-notifications.ts index ef7310035e8..04c8a5a749d 100644 --- a/src/main/runtime/workspaces/workspace-host-notifications.ts +++ b/src/main/runtime/workspaces/workspace-host-notifications.ts @@ -195,14 +195,28 @@ export abstract class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWi setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigationTarget?: RuntimeNavigationTarget + navigationTarget?: RuntimeNavigationTarget, + providesInitialSurface?: boolean ): void { const navigation = navigationTarget ?? 'host' if (navigationTargetsHost(navigation)) { - this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + this.notifyHostActivateWorktree( + repoId, + worktreeId, + setup, + startup, + defaultTabs, + providesInitialSurface + ) } if (navigationTargetsClients(navigation)) { - this.notifyClientsActivateWorktree(repoId, worktreeId, startup, navigation) + this.notifyClientsActivateWorktree( + repoId, + worktreeId, + startup, + navigation, + providesInitialSurface + ) } } @@ -211,19 +225,36 @@ export abstract class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWi worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + providesInitialSurface?: boolean ): void { - this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + this.notifier?.activateWorktree( + repoId, + worktreeId, + setup, + startup, + defaultTabs, + ...(providesInitialSurface ? ([true] as const) : []) + ) } protected notifyClientsActivateWorktree( repoId: string, worktreeId: string, startup?: WorktreeStartupLaunch, - navigation: RuntimeNavigationTarget = 'clients' + navigation: RuntimeNavigationTarget = 'clients', + providesInitialSurface?: boolean ): void { this.emitClientEvent( - toRuntimeActivateWorktreeEvent(repoId, worktreeId, undefined, startup, undefined, navigation) + toRuntimeActivateWorktreeEvent( + repoId, + worktreeId, + undefined, + startup, + undefined, + navigation, + providesInitialSurface + ) ) } diff --git a/src/main/runtime/worktree-agent-first-surface.test.ts b/src/main/runtime/worktree-agent-first-surface.test.ts new file mode 100644 index 00000000000..3903c8ef6c3 --- /dev/null +++ b/src/main/runtime/worktree-agent-first-surface.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import { beginCreatedWorkspaceLaunch } from '../agent-launch/agent-launch-created-workspace' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' + +const createStructured = vi.hoisted(() => + vi.fn(async (args: { envelope: { sessionId: string } }) => ({ + ok: true as const, + value: { sessionId: args.envelope.sessionId } + })) +) +vi.mock('./rpc/methods/structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: createStructured +})) + +const { + OrcaRuntimeService, + computeWorktreePathMock, + ensurePathWithinWorkspaceMock, + getEffectiveHooks, + getDefaultTabsLaunch, + createSetupRunnerScript, + listWorktrees +} = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { makeRuntimeStoreWithWorkspaceSession, store } = + await import('./orca-runtime-test-fixtures.spec') + +const DEFAULTS = { + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev' }, + { title: 'Tests', command: 'pnpm test' } + ] +} + +function chatCreateRuntime(args: { + setupScriptLaunchMode: 'new-tab' | 'split-horizontal' + withDefaults: boolean + wait: boolean + nativeChat?: true +}) { + const path = '/tmp/workspaces/chat-create' + const baseRepo = store.getRepo('repo-1') + if (!baseRepo) { + throw new Error('missing test repo') + } + const repo = { + ...baseRepo, + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: args.wait + ? ('wait-for-setup' as const) + : ('start-immediately' as const), + scripts: { setup: '', archive: '' } + } + } + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()) + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + getRepo: (id) => (id === repo.id ? repo : undefined), + getRepos: () => [repo], + getSettings: () => ({ + ...runtimeStore.getSettings(), + setupScriptLaunchMode: args.setupScriptLaunchMode, + ...(args.nativeChat ? { experimentalNativeChat: true } : {}) + }) + }) + const spawn = vi.fn>(async () => { + const ptyId = `pty-${spawn.mock.calls.length}` + return { id: ptyId } + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + computeWorktreePathMock.mockReturnValue(path) + ensurePathWithinWorkspaceMock.mockReturnValue(path) + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } }) + vi.mocked(getDefaultTabsLaunch).mockReturnValue(args.withDefaults ? DEFAULTS : undefined) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/repo/setup.sh', + envVars: {}, + ...(args.wait ? { waitForAgentStartup: true } : {}) + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { path, head: 'abc', branch: 'chat-create', isBare: false, isMainWorktree: false } + ]) + return { runtime, spawn } +} + +describe('host-created workspaces awaiting their agent chat', () => { + it.each([ + ['new-tab', false, false, false], + ['new-tab', true, true, false], + ['split-horizontal', false, false, false] + ] as const)( + 'provisions only the required terminals (%s, defaults=%s, wait=%s, activate=%s)', + async (setupScriptLaunchMode, withDefaults, wait, activate) => { + const { runtime, spawn } = chatCreateRuntime({ setupScriptLaunchMode, withDefaults, wait }) + const launch = beginCreatedWorkspaceLaunch(runtime, 'claude') + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'chat-create', + createdWithAgent: 'claude', + promisedAgentChat: true, + onWorkspaceCreated: launch.onWorkspaceCreated, + setupDecision: 'run', + awaitTerminalProvisioning: true, + observeSetupCompletion: true, + activate + }) + const commandCount = withDefaults ? 3 : setupScriptLaunchMode === 'new-tab' ? 1 : 2 + expect(spawn).toHaveBeenCalledTimes(commandCount) + expect(spawn.mock.calls.filter(([args]) => args.command === 'pnpm dev')).toHaveLength( + withDefaults ? 1 : 0 + ) + expect(spawn.mock.calls.filter(([args]) => args.command === 'pnpm test')).toHaveLength( + withDefaults ? 1 : 0 + ) + expect(result.setupReceipt).toMatchObject({ + state: 'running', + startupPolicy: wait ? 'wait-for-setup' : 'start-immediately', + terminalHandle: expect.any(String) + }) + const snapshot = await runtime.listMobileSessionTabs(`id:${result.worktree.id}`) + const query = await runtime + .listMobileSessionTabs(`id:${result.worktree.id}`, undefined, { + includeInitialSurfaceLaunch: true + }) + .then((sampled) => { + launch.release() + return sampled + }) + expect(query.initialSurfaceLaunch).toMatchObject({ agent: 'claude' }) + expect(snapshot.initialSurfaceLaunch).toBeUndefined() + expect( + ( + await runtime.listMobileSessionTabs(`id:${result.worktree.id}`, undefined, { + includeInitialSurfaceLaunch: true + }) + ).initialSurfaceLaunch + ).toBeUndefined() + expect(snapshot.tabs).toHaveLength(commandCount) + if (setupScriptLaunchMode === 'new-tab' && !withDefaults) { + expect(snapshot.tabs[0]?.title).toBe('Setup') + } + } + ) + + // The phone creates through agent.launch; a reported bug left a plain "Terminal 1" beside Setup. + it('gives a paired-device chat launch Setup and the chat with no plain shell', async () => { + const { runtime, spawn } = chatCreateRuntime({ + setupScriptLaunchMode: 'new-tab', + withDefaults: false, + wait: false, + nativeChat: true + }) + const { AGENT_LAUNCH_METHODS } = await import('./rpc/methods/agent-launch') + const method = AGENT_LAUNCH_METHODS.find((entry) => entry.name === 'agent.launch') + if (!method) { + throw new Error('missing agent.launch') + } + const params = method.params.parse({ + agent: 'claude', + target: { + kind: 'create-worktree', + create: { repo: 'id:repo-1', name: 'chat-create', setupDecision: 'run' } + } + }) + const result = await method.handler(params, { + runtime, + clientKind: 'mobile', + pairedDeviceId: 'device-1', + caller: { kind: 'paired-device', deviceId: 'device-1' }, + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] + }) + + expect(result.outcome).toMatchObject({ kind: 'structured' }) + expect(createStructured).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() + const tabs = (await runtime.listMobileSessionTabs(`id:${result.worktreeId}`)).tabs + expect(tabs.map((tab) => tab.title)).toEqual(['Setup']) + }) +}) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 95acdabeff7..dbd9a6921fb 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -53,14 +53,16 @@ export function registerRuntimeWindowLifecycle( worktreeId, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + providesInitialSurface?: boolean ) => { send('ui:activateWorktree', { repoId, worktreeId, ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) + ...(defaultTabs ? { defaultTabs } : {}), + ...(providesInitialSurface ? { providesInitialSurface } : {}) }) }, createTerminal: (worktreeId, opts) => diff --git a/src/preload/api/ui-bridge-tab-and-browser-commands.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts index 4967d3971d7..00535f1103c 100644 --- a/src/preload/api/ui-bridge-tab-and-browser-commands.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -198,6 +198,7 @@ export const uiTabAndBrowserCommandsApi = { setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch + providesInitialSurface?: boolean }) => void ): (() => void) => { const listener = ( @@ -208,6 +209,7 @@ export const uiTabAndBrowserCommandsApi = { setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch + providesInitialSurface?: boolean } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 963be524031..1a919ddeea4 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -163,6 +163,7 @@ export type UiCommandEventApi = { setup?: WorktreeSetupLaunch startup?: WorktreeStartupLaunch defaultTabs?: WorktreeDefaultTabsLaunch + providesInitialSurface?: boolean }) => void ) => () => void onCreateTerminal: ( diff --git a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx index fdc3b622202..7486a21de42 100644 --- a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx @@ -138,6 +138,18 @@ function gateAnsweredLater(): { // While this machine decides, a passive first-terminal seeder used to put "Terminal 1" beside the // chat, since no launch record exists until the host answers. describe('a local chat launch waiting on its host', () => { + it('defers the watcher shell for a live host launch, then permits a later ordinary mount', async () => { + mocks.gate.mockResolvedValueOnce('blocked').mockResolvedValueOnce('empty') + root = createRoot(document.createElement('div')) + await act(async () => root?.render(createElement(Watcher))) + expect(mocks.createTab).not.toHaveBeenCalled() + + await act(async () => root?.unmount()) + root = createRoot(document.createElement('div')) + await act(async () => root?.render(createElement(Watcher))) + expect(mocks.createTab).toHaveBeenCalledOnce() + }) + it('keeps a watched create free of a stray shell until its chat opens', async () => { const { finish } = gateAnsweredLater() // A watched create reveals the workspace, then waits for this machine's answer. diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 8b985d84e89..766e27c5e07 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -102,21 +102,24 @@ export function installAppLifetimeIpcEvents( registerOsMarkdownFileOpenBridge(unsubs) registerWslAccountNoticeIpcBridge(unsubs) unsubs.push( - window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { - void worktreeRuntime - .activateNotifiedWorktree( - { - type: 'activateWorktree', - repoId, - worktreeId, - ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) - }, - { allowRuntimeEnvironment: false } - ) - .catch((error) => console.error('Failed to activate CLI-created worktree:', error)) - }) + window.api.ui.onActivateWorktree( + ({ repoId, worktreeId, setup, startup, defaultTabs, providesInitialSurface }) => { + void worktreeRuntime + .activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + ...(providesInitialSurface ? { providesInitialSurface } : {}) + }, + { allowRuntimeEnvironment: false } + ) + .catch((error) => console.error('Failed to activate CLI-created worktree:', error)) + } + ) ) registerTerminalPresentationIpcBridge(unsubs) diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts index 3686e70bf50..e094964b9d2 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -182,7 +182,8 @@ export function createWorktreeEventRuntime( worktreeId, setup, startup, - defaultTabs + defaultTabs, + providesInitialSurface }: Extract, options: WorktreeEventActivationOptions ): Promise => { @@ -212,6 +213,7 @@ export function createWorktreeEventRuntime( ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}), + ...(providesInitialSurface ? { providesInitialSurface } : {}), ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. notifyHostRuntime: false, diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index ee54c4e32cb..4641b75084b 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -267,6 +267,23 @@ describe('worktree agent activation gate', () => { ) }) + it('blocks seeding while the queried snapshot names a live launch', async () => { + const { deps, resume } = testDeps({}) + const snapshot = { + ...runtimeSnapshot('tab', LIVE_LEAF_ID, []), + tabs: [], + initialSurfaceLaunch: { launchId: 'launch-1', agent: 'claude' as const } + } + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ snapshot, ownerBySessionId: new Map() }) + }) + ).resolves.toBe('blocked') + expect(deps.listSessions).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + it('blocks automatic resume when packaged restore never becomes ready', async () => { const { deps, createTab, resume } = testDeps({}) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 37c8c93ab4d..2790121523e 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -152,6 +152,12 @@ export async function runWorktreeAgentActivationGate( let structuredInventory: StructuredActivationInventory | null = null try { const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId) + if ( + typeof reportedStructuredSession === 'object' && + reportedStructuredSession.snapshot.initialSurfaceLaunch + ) { + return 'blocked' + } structuredInventory = typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null structured = Boolean( diff --git a/src/renderer/src/lib/worktree-agent-structured-inventory.test.ts b/src/renderer/src/lib/worktree-agent-structured-inventory.test.ts index c700dd1f34e..eb203bcff1c 100644 --- a/src/renderer/src/lib/worktree-agent-structured-inventory.test.ts +++ b/src/renderer/src/lib/worktree-agent-structured-inventory.test.ts @@ -39,6 +39,17 @@ describe('structured activation inventory scope', () => { expect(call).toHaveBeenCalledOnce() }) + it('retains a live launch with no chat row for the activation gate', async () => { + const call = install({ + worktree, + tabs: [], + initialSurfaceLaunch: { launchId: 'launch-1', agent: 'claude' } + }) + const result = await readWorktreeStructuredActivationInventory(worktree) + expect(result && result.snapshot.initialSurfaceLaunch?.launchId).toBe('launch-1') + expect(call).toHaveBeenCalledOnce() + }) + it.each([null, {}, { worktree: 'other::/workspace', tabs: [] }, { worktree }])( 'rejects unreadable or wrong-workspace replies', async (result) => { diff --git a/src/renderer/src/lib/worktree-agent-structured-inventory.ts b/src/renderer/src/lib/worktree-agent-structured-inventory.ts index 165ce1cfa70..d339be0fc8c 100644 --- a/src/renderer/src/lib/worktree-agent-structured-inventory.ts +++ b/src/renderer/src/lib/worktree-agent-structured-inventory.ts @@ -29,7 +29,10 @@ export async function readWorktreeStructuredActivationInventory( ) { throw new Error('structured session inventory scope unavailable') } - if (!snapshot.tabs.some((tab) => tab.type === 'agent-session')) { + if ( + !snapshot.initialSurfaceLaunch && + !snapshot.tabs.some((tab) => tab.type === 'agent-session') + ) { return false } const ownerBySessionId = new Map() diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index 4acaed2784b..928b7e330cf 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -7,6 +7,7 @@ import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones' +import { shouldSeedWorktreePrimaryTerminal } from '../../../shared/worktree-first-surface' import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' import { agentKindToTuiAgent } from '../../../shared/agent-kind' @@ -169,18 +170,17 @@ export function ensureWorktreeHasInitialTerminal( } const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand) - // Why: a caller opening its own primary surface (a structured native chat) asked for that surface - // alone. Setup launched in its own tab needs no shell to attach to, so seeding one leaves a stray - // "Terminal 1" beside the chat. Splits and issue automation still need a pane to split from. - const setupNeedsHostTerminal = - setup !== undefined && - (useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab') !== 'new-tab' if ( - opts?.callerProvidesSurface === true && renderableTabCount === 0 && - !sequencedStartup && - !issueCommand && - !setupNeedsHostTerminal && + !shouldSeedWorktreePrimaryTerminal({ + hasPrimarySurface: false, + callerProvidesSurface: opts?.callerProvidesSurface === true, + startupNeedsTerminal: sequencedStartup !== undefined, + issueCommandNeedsTerminal: issueCommand !== undefined, + ...(setup + ? { setupLaunchMode: useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' } + : {}) + }) && !defaultTabs?.tabs.length && opts?.createNewTerminalForStartup !== true ) { diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 8861de93da1..61c8862b7b9 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -46,6 +46,7 @@ export type RuntimeClientEvent = setup?: WorktreeSetupLaunch startup?: WorktreeStartupLaunch defaultTabs?: WorktreeDefaultTabsLaunch + providesInitialSurface?: boolean /** Absent on older hosts; clients must not infer navigation intent from a broadcast. */ navigation?: RuntimeNavigationTarget } @@ -75,7 +76,8 @@ export function toRuntimeActivateWorktreeEvent( setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigation?: RuntimeNavigationTarget + navigation?: RuntimeNavigationTarget, + providesInitialSurface?: boolean ): RuntimeActivateWorktreeEvent { return { type: 'activateWorktree', @@ -84,6 +86,7 @@ export function toRuntimeActivateWorktreeEvent( ...(navigation ? { navigation } : {}), ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) + ...(defaultTabs ? { defaultTabs } : {}), + ...(providesInitialSurface ? { providesInitialSurface } : {}) } } diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9c3ecf43e7b..9bb10488249 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -17,6 +17,7 @@ import type { } from './runtime-mobile-session-tab-contracts' import type { CliStatusCaller } from './orchestration-caller-status' import type { RuntimeHostDescriptor } from './runtime-host-descriptor' +import type { TuiAgent } from './tui-agent' export type * from './runtime-mobile-session-tab-contracts' @@ -262,6 +263,8 @@ export type RuntimeMobileSessionRetiredTerminalSurface = { export type RuntimeMobileSessionTabsResult = { worktree: string + /** Query-only truth about an executing launch that owns this workspace's first surface. */ + initialSurfaceLaunch?: { launchId: string; agent: TuiAgent } publicationEpoch: string snapshotVersion: number navigationIntent?: 'follow' diff --git a/src/shared/worktree-first-surface.ts b/src/shared/worktree-first-surface.ts new file mode 100644 index 00000000000..60943fa112e --- /dev/null +++ b/src/shared/worktree-first-surface.ts @@ -0,0 +1,19 @@ +import type { GlobalSettings } from './global-settings-types' + +/** Creation intent belongs to this call, never to persisted agent provenance. */ +export function shouldSeedWorktreePrimaryTerminal(args: { + hasPrimarySurface: boolean + callerProvidesSurface: boolean + startupNeedsTerminal?: boolean + issueCommandNeedsTerminal?: boolean + setupLaunchMode?: GlobalSettings['setupScriptLaunchMode'] +}): boolean { + return ( + !args.hasPrimarySurface && + (!args.callerProvidesSurface || + args.startupNeedsTerminal === true || + args.issueCommandNeedsTerminal === true || + args.setupLaunchMode === 'split-horizontal' || + args.setupLaunchMode === 'split-vertical') + ) +} diff --git a/tests/e2e/agent-launch-setup-choice.unit.test.ts b/tests/e2e/agent-launch-setup-choice.unit.test.ts index ebb00b24a30..eaa62321480 100644 --- a/tests/e2e/agent-launch-setup-choice.unit.test.ts +++ b/tests/e2e/agent-launch-setup-choice.unit.test.ts @@ -195,11 +195,21 @@ describe('CLI setup choice across durable launch admission', () => { if (!row) { throw new Error('Missing uncertain launch row') } + expect(runtime.createManagedWorktree).toHaveBeenCalledOnce() + expect(runtime.createTerminal).toHaveBeenCalledTimes(2) + expect(runtime.createTerminal).toHaveBeenNthCalledWith( + 1, + 'id:wt-new', + expect.objectContaining({ startupAgent: 'codex' }) + ) + expect(runtime.createTerminal).toHaveBeenNthCalledWith(2, 'id:wt-new', { + surfaceOwner: false + }) await expect(launch(client, { operationId: row.operationId })).rejects.toMatchObject({ code: 'agent_session_operation_unknown' }) expect(runtime.createManagedWorktree).toHaveBeenCalledOnce() - expect(runtime.createTerminal).toHaveBeenCalledOnce() + expect(runtime.createTerminal).toHaveBeenCalledTimes(2) }) it('does not infer an effect-free refusal from matching text alone', async () => {