From 2d79aeb23a0bc6584106a571eb9e6d01dae94431 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:28:08 -0700 Subject: [PATCH 1/3] feat(agent-launch): name the desktop's own launch on the activation of the workspace it creates The host already tells the window to open a workspace an agent.launch create makes, during the create and before the launch answers. That activation now carries an optional `launch: { operationId }` when the create was the desktop window's own launch and asked to open its workspace. The window then opens the workspace with no bare shell of its own (the launch brings the first tab) and tells whatever waits on that launch which workspace it got. A phone's or the CLI's launch never sets it; older clients ignore the field. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...-runtime-create-managed-remote-worktree.ts | 3 +- .../orca-runtime-create-managed-worktree.ts | 6 +- .../orca-runtime-notify-ssh-state-changed.ts | 39 +++++++-- ...orktree-removal-and-reconciliation.spec.ts | 48 +++++++++++ .../agent-launch-workspace-activation.test.ts | 82 +++++++++++++++++++ .../methods/agent-launch-worktree-creation.ts | 8 +- src/main/runtime/rpc/methods/agent-launch.ts | 4 +- .../runtime-managed-worktree-create-types.ts | 4 + src/main/runtime/runtime-notifier-contract.ts | 4 +- src/main/window/runtime-window-lifecycle.ts | 7 +- .../api/ui-bridge-tab-and-browser-commands.ts | 3 + src/preload/api/ui-command-event-api.ts | 2 + .../ipc-events/app-lifetime-ipc-bridge.ts | 33 ++++---- .../worktree-event-runtime-activation.test.ts | 60 ++++++++++++++ .../ipc-events/worktree-event-runtime.ts | 11 ++- .../lib/agent-launch-workspace-activation.ts | 34 ++++++++ src/shared/runtime-client-events.ts | 11 ++- 18 files changed, 326 insertions(+), 34 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts create mode 100644 src/renderer/src/lib/agent-launch-workspace-activation.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 69df486ddc82..abaf8c2b325e 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -165,6 +165,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts', 'src/main/runtime/rpc/methods/agent-launch-replay.test.ts', 'src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index 179d796d0e41..5385a31b3b74 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -40,7 +40,8 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC setup, startup, defaultTabs, - args.navigation + args.navigation, + args.launchActivation ), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 10eb7080e1f0..59f0ad80cd3d 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -111,7 +111,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork setup, startup, undefined, - args.navigation + args.navigation, + args.launchActivation ) } }) @@ -255,7 +256,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork activationSetup, startup, activationDefaultTabs, - args.navigation + args.navigation, + args.launchActivation ) } }) diff --git a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts index bcd9b1f46657..8d24b94e600f 100644 --- a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts +++ b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts @@ -7,7 +7,10 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation' -import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' +import { + toRuntimeActivateWorktreeEvent, + type RuntimeActivatedLaunch +} from '../../shared/runtime-client-events' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import type { EmulatorBridge } from '../emulator/emulator-bridge' @@ -163,11 +166,12 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigationTarget?: RuntimeNavigationTarget + navigationTarget?: RuntimeNavigationTarget, + launch?: RuntimeActivatedLaunch ): void { const navigation = navigationTarget ?? 'host' if (navigationTargetsHost(navigation)) { - this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs, launch) } if (navigationTargetsClients(navigation)) { this.notifyClientsActivateWorktree( @@ -176,7 +180,8 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat setup, startup, defaultTabs, - navigation + navigation, + launch ) } } @@ -186,9 +191,18 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + launch?: RuntimeActivatedLaunch ): void { - this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + // Every other activation keeps its call exactly as before. + this.notifier?.activateWorktree( + repoId, + worktreeId, + setup, + startup, + defaultTabs, + ...(launch ? [launch] : []) + ) } protected notifyClientsActivateWorktree( @@ -197,10 +211,19 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigation: RuntimeNavigationTarget = 'clients' + navigation: RuntimeNavigationTarget = 'clients', + launch?: RuntimeActivatedLaunch ): void { this.emitClientEvent( - toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs, navigation) + toRuntimeActivateWorktreeEvent( + repoId, + worktreeId, + setup, + startup, + defaultTabs, + navigation, + launch + ) ) } diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index d371ac3f96f0..0fbbe8e8f911 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -257,6 +257,54 @@ describe('OrcaRuntimeService', () => { ) }) + it('names the desktop launch that created the worktree on its activation', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-launch') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-launch') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-launch', + head: 'def', + branch: 'runtime-launch', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-launch', + activate: true, + launchActivation: { operationId: 'op-1' } + }) + + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + undefined, + undefined, + undefined, + { operationId: 'op-1' } + ) + }) + it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { // Why: without createdAt, ambient PTY bumps in other worktrees can push the new one below them in Recent sort. const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts b/src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts new file mode 100644 index 000000000000..5082603b8ee4 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts @@ -0,0 +1,82 @@ +/** + * A desktop `agent.launch` create names itself on the activation that opens its workspace, so the + * window that asked binds the workspace to that launch. A phone's or the CLI's create never does: + * a paired device's launch leaves the desktop window alone, and the CLI's window holds no launch. + */ + +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 type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import type { RpcContext } from '../core' +import { DESKTOP_RPC_CALLER } from '../rpc-caller-identity' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + setAgentLaunchRecordStore +} 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_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +// The ledger admits against `Date.now()`, so the id must be dated now. +const OPERATION_ID = `${Date.now()}-000000000000000000000000000000a7` + +let directory: string +let store: AgentSessionRecordStore + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-activation-')) + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) +}) + +afterEach(async () => { + setAgentLaunchRecordStore(null) + await rm(directory, { recursive: true, force: true }) +}) + +async function createArgsFor(context: Partial, activate: boolean) { + const runtime = runtimeStub() + await AGENT_LAUNCH_REPLAY.handler( + AGENT_LAUNCH_REPLAY.params.parse({ + agent: 'claude', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task', activate } }, + operationId: OPERATION_ID + }), + rpcContext(runtime, context) + ) + const [args] = runtime.createManagedWorktree.mock.calls[0] ?? [] + return args +} + +describe('the activation of a workspace an agent.launch creates', () => { + it('names the desktop’s own launch', async () => { + const args = await createArgsFor({ caller: DESKTOP_RPC_CALLER }, true) + expect(args?.launchActivation).toEqual({ operationId: OPERATION_ID }) + }) + + it.each([ + ['a paired phone', CAPABLE_CLIENT], + ['the CLI', {}] + ] as const)('never names %s’s launch', async (_who, context) => { + const args = await createArgsFor(context, true) + expect(args).not.toHaveProperty('launchActivation') + }) + + it('names nothing for a create that does not open its workspace', async () => { + const args = await createArgsFor({ caller: DESKTOP_RPC_CALLER }, false) + expect(args).not.toHaveProperty('launchActivation') + }) +}) 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 ff79e44b7642..666ff24a304e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -34,7 +34,9 @@ const STRUCTURED_SETUP_WAIT_TIMEOUT_MS = 60_000 export function agentLaunchWorkspaceFactory( context: RpcContext, - agent: TuiAgent + agent: TuiAgent, + /** The launch's own id, absent for an unrecorded launch. */ + operationId?: string ): AgentLaunchWorkspaceFactory { return { createWorktree: async ({ @@ -96,6 +98,10 @@ 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, + // Only the desktop's own launch opens the workspace in its window as that launch's. + ...(operationId && context.caller?.kind === 'desktop' && params.activate === true + ? { launchActivation: { operationId } } + : {}), // 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/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 15aeb28bd125..1e0f585e96d1 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -98,6 +98,7 @@ function callerRendersLaunchedChat( /** What a launch admitted under an operation id carries into its execution. */ type ReplaySafeLaunch = { + operationId: string attachOperationId: string callerKey: string terminalSpawn: TerminalSpawnDispatch @@ -125,7 +126,7 @@ async function runAgentLaunch( replaySafe?.terminalSpawn, view.early ), - workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + workspaces: agentLaunchWorkspaceFactory(context, intent.agent, replaySafe?.operationId), ...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }), // The tab is shown as it is published, not after a prompt that can take a minute to land. onSurfacePublished: (surface) => { @@ -247,6 +248,7 @@ async function executeAdmittedAgentLaunch( let result: AgentLaunchResult try { result = await runAgentLaunch(intent, context, view, { + operationId: params.operationId, attachOperationId: admission.attachOperationId, callerKey: admission.callerKey, terminalSpawn, diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 08a151bd88ee..4ca081d8dc92 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -13,6 +13,7 @@ import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { TaskSourceContext } from '../../shared/task-source-context' import type { WorktreeStartupDraftPaste } from './runtime-worktree-agent-startup' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' export type RuntimeManagedWorktreeCreateArgs = { repoSelector: string @@ -54,6 +55,9 @@ export type RuntimeManagedWorktreeCreateArgs = { /** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself, so the text * rides only a typed line that can carry it; reports whether it did. */ onStartupPromptCarry?: (carried: boolean) => void + /** Main-internal: the desktop's own `agent.launch` this create belongs to, named on the activation + * so its window binds the workspace to that launch and opens no tab of its own. */ + launchActivation?: RuntimeActivatedLaunch /** Per-launch inputs used when `startupAgent` is the created terminal surface. */ startupAgentArgs?: string | null startupCwd?: string diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 8a6b3d2bc467..259a55dc3b54 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -14,6 +14,7 @@ import type { WorktreeRemoteBranchConflictEvent } from '../../shared/worktree/base-ref-drift-types' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeBrowserDriverState, @@ -40,7 +41,8 @@ export type RuntimeNotifier = { worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + launch?: RuntimeActivatedLaunch ): void createTerminal( worktreeId: string, diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 857ed79d787e..8ad51affc271 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -4,6 +4,7 @@ import { ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' import type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult @@ -51,14 +52,16 @@ export function registerRuntimeWindowLifecycle( worktreeId, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + launch?: RuntimeActivatedLaunch ) => { send('ui:activateWorktree', { repoId, worktreeId, ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) + ...(defaultTabs ? { defaultTabs } : {}), + ...(launch ? { launch } : {}) }) }, 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 4967d3971d7e..66e22c711dd2 100644 --- a/src/preload/api/ui-bridge-tab-and-browser-commands.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -12,6 +12,7 @@ import type { } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' import type { PreloadApi } from '../api-types' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( @@ -198,6 +199,7 @@ export const uiTabAndBrowserCommandsApi = { setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch + launch?: RuntimeActivatedLaunch }) => void ): (() => void) => { const listener = ( @@ -208,6 +210,7 @@ export const uiTabAndBrowserCommandsApi = { setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch + launch?: RuntimeActivatedLaunch } ) => 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 9a9c8524ed3e..568ad2cda26d 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -7,6 +7,7 @@ import type { } from '../../shared/agent-launch-tab-publication' import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { WorktreeDefaultTabsLaunch, @@ -151,6 +152,7 @@ export type UiCommandEventApi = { setup?: WorktreeSetupLaunch startup?: WorktreeStartupLaunch defaultTabs?: WorktreeDefaultTabsLaunch + launch?: RuntimeActivatedLaunch }) => void ) => () => void onCreateTerminal: ( 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 240991417728..f11e36732c8b 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 @@ -99,21 +99,24 @@ export function installAppLifetimeIpcEvents( registerWorkspaceShortcutIpcBridge(unsubs) registerOsMarkdownFileOpenBridge(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, launch }) => { + void worktreeRuntime + .activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + ...(launch ? { launch } : {}) + }, + { 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-activation.test.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts index 606d0ef6adc7..f54ca15289e3 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { onAgentLaunchWorkspaceActivated } from '@/lib/agent-launch-workspace-activation' import { createWorktreeEventRuntime } from './worktree-event-runtime' const { activateAndRevealWorktree } = vi.hoisted(() => ({ @@ -58,3 +59,62 @@ it.each([false, true])( } } ) + +it('opens a launch-created workspace with no shell of its own and tells the waiting launch', async () => { + vi.spyOn(initialState, 'fetchWorktrees').mockResolvedValue(false) + useAppStore.setState({ + fetchWorktrees: initialState.fetchWorktrees, + getKnownWorktreeById: vi.fn(() => undefined) + }) + activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) + const heard = vi.fn() + const stopListening = onAgentLaunchWorkspaceActivated('op-1', heard) + const unsubs: (() => void)[] = [] + const runtime = createWorktreeEventRuntime(unsubs, () => false) + try { + await runtime.activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId: 'repo-1', + worktreeId: 'wt-new', + launch: { operationId: 'op-1' } + }, + { allowRuntimeEnvironment: false } + ) + + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { + providesInitialSurface: true, + notifyHostRuntime: false + }) + expect(heard).toHaveBeenCalledWith('wt-new') + } finally { + stopListening() + unsubs.forEach((unsubscribe) => unsubscribe()) + } +}) + +it('tells no launch about a workspace the window could not open', async () => { + vi.spyOn(initialState, 'fetchWorktrees').mockResolvedValue(false) + useAppStore.setState({ + fetchWorktrees: initialState.fetchWorktrees, + getKnownWorktreeById: vi.fn(() => undefined) + }) + activateAndRevealWorktree.mockReturnValue(false) + const heard = vi.fn() + const stopListening = onAgentLaunchWorkspaceActivated('op-1', heard) + const runtime = createWorktreeEventRuntime([], () => false) + try { + await runtime.activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId: 'repo-1', + worktreeId: 'wt-new', + launch: { operationId: 'op-1' } + }, + { allowRuntimeEnvironment: false } + ) + expect(heard).not.toHaveBeenCalled() + } finally { + stopListening() + } +}) 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 71ac01101b58..23a590eaca57 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -1,4 +1,5 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { noteAgentLaunchWorkspaceActivated } from '@/lib/agent-launch-workspace-activation' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' import type { AppState } from '../../store/types' @@ -144,7 +145,8 @@ export function createWorktreeEventRuntime( worktreeId, setup, startup, - defaultTabs + defaultTabs, + launch }: Extract, options: WorktreeEventActivationOptions ): Promise => { @@ -170,15 +172,20 @@ export function createWorktreeEventRuntime( useAppStore.getState().getKnownWorktreeById(worktreeId, options.executionHostId) ) // Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it. - activateAndRevealWorktree(worktreeId, { + const activated = activateAndRevealWorktree(worktreeId, { ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}), + // The launch that created this workspace brings its first tab; a bare shell beside it is noise. + ...(launch ? { providesInitialSurface: true } : {}), ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. notifyHostRuntime: false, ...(options.executionHostId ? { executionHostId: options.executionHostId } : {}) }) + if (launch && activated !== false) { + noteAgentLaunchWorkspaceActivated(launch.operationId, worktreeId) + } } return { worktreeChangeRefreshQueue, activateNotifiedWorktree } diff --git a/src/renderer/src/lib/agent-launch-workspace-activation.ts b/src/renderer/src/lib/agent-launch-workspace-activation.ts new file mode 100644 index 000000000000..10fc984a7a43 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-workspace-activation.ts @@ -0,0 +1,34 @@ +/** + * The workspace a desktop `agent.launch` create opened, by that launch's operation id. + * + * The host names the launch on the activation it already sends when the create opens the + * workspace, before the launch answers (which waits on prompt delivery). A window waiting on a + * launch hears its workspace here at that moment; the launch's answer carries the same + * `worktreeId`, so a window that misses this still learns it. + */ + +type Listener = (worktreeId: string) => void + +const listeners = new Map>() + +export function onAgentLaunchWorkspaceActivated( + operationId: string, + listener: Listener +): () => void { + const forLaunch = listeners.get(operationId) ?? new Set() + forLaunch.add(listener) + listeners.set(operationId, forLaunch) + return () => { + forLaunch.delete(listener) + if (forLaunch.size === 0 && listeners.get(operationId) === forLaunch) { + listeners.delete(operationId) + } + } +} + +/** A launch nobody here waits on (another window's, or one this window gave up on) is ignored. */ +export function noteAgentLaunchWorkspaceActivated(operationId: string, worktreeId: string): void { + for (const listener of listeners.get(operationId) ?? []) { + listener(worktreeId) + } +} diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 8e3350a8387d..63bec8522845 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -48,6 +48,9 @@ export type RuntimeClientEvent = defaultTabs?: WorktreeDefaultTabsLaunch /** Absent on older hosts; clients must not infer navigation intent from a broadcast. */ navigation?: RuntimeNavigationTarget + /** Absent on older hosts and on every other activation; a client that ignores it opens the + * workspace as it does for a CLI create. */ + launch?: RuntimeActivatedLaunch } export type RuntimeClientEventStreamMessage = @@ -60,6 +63,10 @@ export type RuntimeClientEventStreamMessage = | RuntimeClientEvent | { type: 'end' } +/** The desktop's own `agent.launch` that created this workspace. The launch brings the workspace's + * first tab itself, and the window that started it finds its launch by this id. */ +export type RuntimeActivatedLaunch = { operationId: string } + export type RuntimeActivateWorktreeEvent = Extract export type AutomationsChangedEvent = Extract @@ -75,13 +82,15 @@ export function toRuntimeActivateWorktreeEvent( setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigation?: RuntimeNavigationTarget + navigation?: RuntimeNavigationTarget, + launch?: RuntimeActivatedLaunch ): RuntimeActivateWorktreeEvent { return { type: 'activateWorktree', repoId, worktreeId, ...(navigation ? { navigation } : {}), + ...(launch ? { launch } : {}), ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}) From 1526d40fc29e54262fc344d5022e5c95ede40d9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:53:29 -0700 Subject: [PATCH 2/3] fix(agent-launch): the launch running in this window owns its workspace's activation The composer opens a new workspace only if the user is still watching its creation, and otherwise mounts it quietly with a toast. An activation that named a launch opened the workspace regardless. Now the launch running in this window takes the activation over and decides; with no such launch here the window opens the workspace as before, with no bare shell beside the launch's own tab. --- .../worktree-event-runtime-activation.test.ts | 73 +++++++------------ .../ipc-events/worktree-event-runtime.ts | 10 +-- .../lib/agent-launch-workspace-activation.ts | 39 +++++----- 3 files changed, 54 insertions(+), 68 deletions(-) diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts index f54ca15289e3..5e7ebb3eb7d6 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { onAgentLaunchWorkspaceActivated } from '@/lib/agent-launch-workspace-activation' +import { ownAgentLaunchWorkspaceActivation } from '@/lib/agent-launch-workspace-activation' import { createWorktreeEventRuntime } from './worktree-event-runtime' const { activateAndRevealWorktree } = vi.hoisted(() => ({ @@ -60,61 +60,42 @@ it.each([false, true])( } ) -it('opens a launch-created workspace with no shell of its own and tells the waiting launch', async () => { +function launchActivation() { vi.spyOn(initialState, 'fetchWorktrees').mockResolvedValue(false) useAppStore.setState({ fetchWorktrees: initialState.fetchWorktrees, getKnownWorktreeById: vi.fn(() => undefined) }) - activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) - const heard = vi.fn() - const stopListening = onAgentLaunchWorkspaceActivated('op-1', heard) - const unsubs: (() => void)[] = [] - const runtime = createWorktreeEventRuntime(unsubs, () => false) + return createWorktreeEventRuntime([], () => false).activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId: 'repo-1', + worktreeId: 'wt-new', + launch: { operationId: 'op-1' } + }, + { allowRuntimeEnvironment: false } + ) +} + +it('hands a launch-created workspace to the launch this window is running', async () => { + const owner = vi.fn() + const release = ownAgentLaunchWorkspaceActivation('op-1', owner) try { - await runtime.activateNotifiedWorktree( - { - type: 'activateWorktree', - repoId: 'repo-1', - worktreeId: 'wt-new', - launch: { operationId: 'op-1' } - }, - { allowRuntimeEnvironment: false } - ) + await launchActivation() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { - providesInitialSurface: true, - notifyHostRuntime: false - }) - expect(heard).toHaveBeenCalledWith('wt-new') + expect(owner).toHaveBeenCalledWith('wt-new') + // The launch decides whether to show it (the user may have moved on), not the activation. + expect(activateAndRevealWorktree).not.toHaveBeenCalled() } finally { - stopListening() - unsubs.forEach((unsubscribe) => unsubscribe()) + release() } }) -it('tells no launch about a workspace the window could not open', async () => { - vi.spyOn(initialState, 'fetchWorktrees').mockResolvedValue(false) - useAppStore.setState({ - fetchWorktrees: initialState.fetchWorktrees, - getKnownWorktreeById: vi.fn(() => undefined) +it('opens a launch-created workspace with no shell of its own when no launch here owns it', async () => { + await launchActivation() + + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { + providesInitialSurface: true, + notifyHostRuntime: false }) - activateAndRevealWorktree.mockReturnValue(false) - const heard = vi.fn() - const stopListening = onAgentLaunchWorkspaceActivated('op-1', heard) - const runtime = createWorktreeEventRuntime([], () => false) - try { - await runtime.activateNotifiedWorktree( - { - type: 'activateWorktree', - repoId: 'repo-1', - worktreeId: 'wt-new', - launch: { operationId: 'op-1' } - }, - { allowRuntimeEnvironment: false } - ) - expect(heard).not.toHaveBeenCalled() - } finally { - stopListening() - } }) 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 23a590eaca57..be8a92cc242b 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -1,5 +1,5 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { noteAgentLaunchWorkspaceActivated } from '@/lib/agent-launch-workspace-activation' +import { takeAgentLaunchWorkspaceActivation } from '@/lib/agent-launch-workspace-activation' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' import type { AppState } from '../../store/types' @@ -172,7 +172,10 @@ export function createWorktreeEventRuntime( useAppStore.getState().getKnownWorktreeById(worktreeId, options.executionHostId) ) // Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it. - const activated = activateAndRevealWorktree(worktreeId, { + if (launch && takeAgentLaunchWorkspaceActivation(launch.operationId, worktreeId)) { + return + } + activateAndRevealWorktree(worktreeId, { ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}), @@ -183,9 +186,6 @@ export function createWorktreeEventRuntime( notifyHostRuntime: false, ...(options.executionHostId ? { executionHostId: options.executionHostId } : {}) }) - if (launch && activated !== false) { - noteAgentLaunchWorkspaceActivated(launch.operationId, worktreeId) - } } return { worktreeChangeRefreshQueue, activateNotifiedWorktree } diff --git a/src/renderer/src/lib/agent-launch-workspace-activation.ts b/src/renderer/src/lib/agent-launch-workspace-activation.ts index 10fc984a7a43..b6d5c4897cde 100644 --- a/src/renderer/src/lib/agent-launch-workspace-activation.ts +++ b/src/renderer/src/lib/agent-launch-workspace-activation.ts @@ -2,33 +2,38 @@ * The workspace a desktop `agent.launch` create opened, by that launch's operation id. * * The host names the launch on the activation it already sends when the create opens the - * workspace, before the launch answers (which waits on prompt delivery). A window waiting on a - * launch hears its workspace here at that moment; the launch's answer carries the same - * `worktreeId`, so a window that misses this still learns it. + * workspace, before the launch answers (which waits on prompt delivery). The launch this window is + * running takes that activation over: it decides whether to show the workspace (the user may have + * moved on) and lays out its own first tab. With no such launch here (a reloaded window, another + * window's launch), the window opens the workspace as for any activation. The launch's answer + * carries the same `worktreeId`, so a launch that missed this still learns it. */ -type Listener = (worktreeId: string) => void +type ActivationOwner = (worktreeId: string) => void -const listeners = new Map>() +const owners = new Map() -export function onAgentLaunchWorkspaceActivated( +export function ownAgentLaunchWorkspaceActivation( operationId: string, - listener: Listener + owner: ActivationOwner ): () => void { - const forLaunch = listeners.get(operationId) ?? new Set() - forLaunch.add(listener) - listeners.set(operationId, forLaunch) + owners.set(operationId, owner) return () => { - forLaunch.delete(listener) - if (forLaunch.size === 0 && listeners.get(operationId) === forLaunch) { - listeners.delete(operationId) + if (owners.get(operationId) === owner) { + owners.delete(operationId) } } } -/** A launch nobody here waits on (another window's, or one this window gave up on) is ignored. */ -export function noteAgentLaunchWorkspaceActivated(operationId: string, worktreeId: string): void { - for (const listener of listeners.get(operationId) ?? []) { - listener(worktreeId) +/** True when a launch running in this window took the activation over. */ +export function takeAgentLaunchWorkspaceActivation( + operationId: string, + worktreeId: string +): boolean { + const owner = owners.get(operationId) + if (!owner) { + return false } + owner(worktreeId) + return true } From f172e6f960e13590e7f8abdfad2cf96cef956387 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:46:43 -0700 Subject: [PATCH 3/3] refactor(worktrees): move setup receipt construction into setup module --- .../orca-runtime-create-managed-worktree.ts | 29 +++--- .../runtime-local-worktree-setup.test.ts | 90 ++++++++++++++++++- .../runtime/runtime-local-worktree-setup.ts | 25 ++++++ 3 files changed, 126 insertions(+), 18 deletions(-) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 59f0ad80cd3d..8cd9c6c425f9 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -9,7 +9,10 @@ import { ExecutionHostNotDispatchableError } from '../providers/execution-host-p import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../worktree-create-preparation' -import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' +import { + buildRuntimeLocalWorktreeSetupReceipt, + prepareRuntimeLocalWorktreeSetup +} from './runtime-local-worktree-setup' import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' import { trackRuntimeWorkspaceCreate } from '../workspace-create-telemetry' @@ -281,23 +284,15 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ...(returnedSetup ? { setup: returnedSetup } : {}), ...(args.awaitTerminalProvisioning ? { - setupReceipt: { - requested: effectiveDecision, + setupReceipt: buildRuntimeLocalWorktreeSetupReceipt({ + effectiveDecision, hookFound, - startupPolicy: setup?.waitForAgentStartup - ? ('wait-for-setup' as const) - : ('start-immediately' as const), - state: !hookFound - ? ('not_configured' as const) - : effectiveDecision === 'skip' || !shouldRunSetup - ? ('skipped' as const) - : // Why: the in-process hook is already executing, so reporting - // spawn_failed would strand callers that retry on it. - didSpawnSetup || didStartInProcessSetupHook - ? ('running' as const) - : ('spawn_failed' as const), - ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) - } + setup, + shouldRunSetup, + didSpawnSetup, + didStartInProcessSetupHook, + setupTerminalHandle + }) } : {}), ...(defaultTabs ? { defaultTabs } : {}), diff --git a/src/main/runtime/runtime-local-worktree-setup.test.ts b/src/main/runtime/runtime-local-worktree-setup.test.ts index 87716eec98f0..f4c0555a604a 100644 --- a/src/main/runtime/runtime-local-worktree-setup.test.ts +++ b/src/main/runtime/runtime-local-worktree-setup.test.ts @@ -13,7 +13,10 @@ vi.mock('../hooks', () => ({ runHook: mocks.runHook })) -import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' +import { + buildRuntimeLocalWorktreeSetupReceipt, + prepareRuntimeLocalWorktreeSetup +} from './runtime-local-worktree-setup' const askRepo: Repo = { id: 'repo-1', @@ -49,6 +52,18 @@ describe('runtime create setup for a hook the new branch added', () => { expect(result.shouldRunSetup).toBe(false) expect(result.warning).toContain('pass --setup run to run it') expect(mocks.runHook).not.toHaveBeenCalled() + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + ...result, + didSpawnSetup: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'inherit', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'skipped' + }) }) it('still runs setup when the caller decided to', async () => { @@ -56,5 +71,78 @@ describe('runtime create setup for a hook the new branch added', () => { expect(result.shouldRunSetup).toBe(true) expect(mocks.runHook).toHaveBeenCalledOnce() + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + ...result, + didSpawnSetup: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'run', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'running' + }) + }) + + it('reports an absent hook without requiring a terminal', async () => { + mocks.effectiveHooks.mockReturnValue(null) + const result = await prepare({ setupDecision: 'run' }) + + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + ...result, + didSpawnSetup: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'run', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_configured' + }) + }) + + it('reports a failed runner spawn when no in-process hook is running', () => { + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + effectiveDecision: 'run', + hookFound: true, + shouldRunSetup: true, + didSpawnSetup: false, + didStartInProcessSetupHook: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'run', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'spawn_failed' + }) + }) + + it('reports the spawned setup terminal and its agent wait policy', () => { + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + effectiveDecision: 'run', + hookFound: true, + setup: { + command: 'setup-runner', + runnerScriptPath: '/worktrees/app/setup.sh', + envVars: {}, + waitForAgentStartup: true + }, + shouldRunSetup: true, + didSpawnSetup: true, + didStartInProcessSetupHook: false, + setupTerminalHandle: 'setup-terminal' + }) + ).toEqual({ + requested: 'run', + hookFound: true, + startupPolicy: 'wait-for-setup', + state: 'running', + terminalHandle: 'setup-terminal' + }) }) }) diff --git a/src/main/runtime/runtime-local-worktree-setup.ts b/src/main/runtime/runtime-local-worktree-setup.ts index f9da4312c346..9c769c305697 100644 --- a/src/main/runtime/runtime-local-worktree-setup.ts +++ b/src/main/runtime/runtime-local-worktree-setup.ts @@ -14,6 +14,31 @@ export function resolveRuntimeSetupDecision( return request.runHooks ? 'run' : (request.setupDecision ?? 'inherit') } +export function buildRuntimeLocalWorktreeSetupReceipt(args: { + effectiveDecision: SetupDecision + hookFound: boolean + setup?: CreateWorktreeResult['setup'] + shouldRunSetup: boolean + didSpawnSetup: boolean + didStartInProcessSetupHook: boolean + setupTerminalHandle: string | null +}): NonNullable { + return { + requested: args.effectiveDecision, + hookFound: args.hookFound, + startupPolicy: args.setup?.waitForAgentStartup ? 'wait-for-setup' : 'start-immediately', + state: !args.hookFound + ? 'not_configured' + : args.effectiveDecision === 'skip' || !args.shouldRunSetup + ? 'skipped' + : // An in-process hook is already running; spawn_failed would trigger needless retries. + args.didSpawnSetup || args.didStartInProcessSetupHook + ? 'running' + : 'spawn_failed', + ...(args.setupTerminalHandle ? { terminalHandle: args.setupTerminalHandle } : {}) + } +} + export async function prepareRuntimeLocalWorktreeSetup(args: { request: RuntimeManagedWorktreeCreateArgs repo: Repo