From a3ca887a3c08439a89c9ce7db95f480ef29c5384 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:09:49 -0700 Subject: [PATCH] fix(workspaces): start task agents on remote hosts (#13412) --- ...poserState-host-context-boundaries.test.ts | 2 ++ src/renderer/src/hooks/useComposerState.ts | 6 +++- .../src/lib/worktree-activation.test.ts | 32 +++++++++++++++++ src/renderer/src/lib/worktree-activation.ts | 28 +++++++++++---- .../src/lib/worktree-creation-flow.test.ts | 26 ++++++++++---- .../src/lib/worktree-creation-flow.ts | 13 +++++-- .../src/store/slices/worktree-helpers.ts | 2 ++ .../src/store/slices/worktrees.test.ts | 36 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 2 ++ 9 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index f7aa99c82..635399b63 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -750,6 +750,7 @@ describe('useComposerState host-context boundaries', () => { 'const submitQuick = useCallback' ) expect(fullSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') + expect(fullSubmit).toContain('startupDraft: startupPlan.draftPrompt') expect(fullSubmit).not.toContain('platform: CLIENT_PLATFORM') const quickSubmit = sourceBetween( @@ -771,6 +772,7 @@ describe('useComposerState host-context boundaries', () => { ) expect(activation).toContain('...(startupPlan && !backendSpawnedStartup') + expect(activation).toContain('backendStartupTerminalSpawned: true') expect(activation).toContain('command: startupPlan.launchCommand') expect(activation).toContain('launchAgent: tuiAgent') // The removed activation-time fallback must not come back through this caller. diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2dcfe314f..35f403c8e 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -3849,7 +3849,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS submitCompareBaseRef, { linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), - linkedTaskSourceContext: taskSourceContext + linkedTaskSourceContext: taskSourceContext, + ...(!backendStartup && startupPlan?.draftPrompt + ? { startupDraft: startupPlan.draftPrompt } + : {}) } ) const worktree = result.worktree @@ -3877,6 +3880,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, + ...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}), ...(startupPlan && !backendSpawnedStartup ? { startup: { diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index cdebf6493..923cecd5a 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -370,6 +370,38 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('does not create a fallback while a backend startup terminal awaits mirroring', () => { + useAppStore.setState({ + getKnownWorktreeById: ((id: string) => + id === 'wt-1' + ? { id: 'wt-1' } + : undefined) as unknown as AppStoreState['getKnownWorktreeById'] + } as Partial) + const store = createMockStore() + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + undefined, + { command: 'gh issue view 42' }, + undefined, + { backendStartupTerminalSpawned: true } + ) + + expect(result).toBeNull() + expect(store.createTab).not.toHaveBeenCalled() + expect(store.setActiveTab).not.toHaveBeenCalled() + + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } + } as unknown as Partial) + + expect(useAppStore.getState().pendingIssueCommandSplitByTabId['mirror-tab-1']).toEqual({ + command: 'gh issue view 42' + }) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 7e3170241..d27a18565 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -171,6 +171,11 @@ type WorktreeActivationStore = Partial & { settings?: Pick | null } +type InitialTerminalOptions = { + activateCreatedTabs?: boolean + backendStartupTerminalSpawned?: boolean +} + /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. * The caller passes only `worktreeId`; the helper derives `repoId` and returns early @@ -279,6 +284,7 @@ export function activateAndRevealWorktree( notifyHostRuntime?: boolean revealInSidebar?: boolean executionHostId?: ExecutionHostId + backendStartupTerminalSpawned?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -339,7 +345,8 @@ export function activateAndRevealWorktree( opts?.startup, opts?.setup, opts?.issueCommand, - opts?.defaultTabs + opts?.defaultTabs, + opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : undefined ) if (primaryTabId && opts?.initialCwd) { useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) @@ -371,7 +378,7 @@ export function activateAndRevealWorktree( } } - if (opts?.notifyHostRuntime !== false) { + if (opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned) { ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) } @@ -432,7 +439,7 @@ export function ensureWorktreeHasInitialTerminal( setup?: WorktreeSetupLaunch, issueCommand?: IssueCommandLaunch, defaultTabs?: WorktreeDefaultTabsLaunch, - opts?: { activateCreatedTabs?: boolean } + opts?: InitialTerminalOptions ): string | null { const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId) // Why: creating a terminal just because the legacy terminal slice is empty gives editor/browser-only worktrees an unexpected extra tab. @@ -459,8 +466,12 @@ export function ensureWorktreeHasInitialTerminal( wrappedSetupCommandStr = sequenced.setupCommand } - // Why: web clients mirror the server's session tabs, so avoid spawning a duplicate host terminal before the mirror lands. - if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { + const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true + // Why: explicit spawn evidence survives the new-worktree ownership race; active web sessions provide the same authority for later activations. + if ( + backendStartupTerminalSpawned || + isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId)) + ) { const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id if (existingTerminalTabId && (setup || issueCommand)) { queueSetupAndIssueCommands( @@ -474,6 +485,9 @@ export function ensureWorktreeHasInitialTerminal( ) return existingTerminalTabId } + if (existingTerminalTabId && backendStartupTerminalSpawned) { + return existingTerminalTabId + } if (setup || issueCommand) { // Why: runtime-owned worktrees mirror session tabs async, so hold commands for the first mirrored tab instead of dropping them. queueHookCommandsForFirstWorktreeTab({ @@ -586,7 +600,7 @@ function applyDefaultTerminalTabs( issueCommand: IssueCommandLaunch | undefined, defaultTabs: WorktreeDefaultTabsLaunch | undefined, wrappedSetupCommandStr: string | undefined, - opts: { activateCreatedTabs?: boolean } | undefined + opts: InitialTerminalOptions | undefined ): string | null { if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { return null @@ -675,7 +689,7 @@ function queueSetupAndIssueCommands( setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, wrappedSetupCommandStr: string | undefined, - opts: { activateCreatedTabs?: boolean } | undefined + opts: InitialTerminalOptions | undefined ): void { // Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent. if (setup) { diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index ad24a8250..f48591a8b 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -512,13 +512,14 @@ describe('staged background worktree creation', () => { expect(store.setSidebarOpen).not.toHaveBeenCalled() }) - it('does not reveal a completed staged create after the user leaves the creation surface', async () => { + it('keeps a backend startup terminal in the background after the user leaves', async () => { store.activeView = 'tasks' store.createWorktree.mockResolvedValueOnce({ worktree: { id: 'wt-1', repoId: 'repo-1' - } + }, + startupTerminal: { tabId: 'agent-tab', spawned: true } }) const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), { @@ -535,7 +536,7 @@ describe('staged background worktree creation', () => { undefined, undefined, undefined, - { activateCreatedTabs: false } + { activateCreatedTabs: false, backendStartupTerminalSpawned: true } ) expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { @@ -543,8 +544,11 @@ describe('staged background worktree creation', () => { }) }) - it('reveals the completed workspace after the user switches to another workspace', async () => { - let resolveCreate!: (result: { worktree: { id: string; repoId: string } }) => void + it('reveals a backend-owned startup after the user switches workspaces', async () => { + let resolveCreate!: (result: { + worktree: { id: string; repoId: string } + startupTerminal: { tabId: string; spawned: true } + }) => void store.createWorktree.mockReturnValueOnce( new Promise((resolve) => { resolveCreate = resolve @@ -560,11 +564,15 @@ describe('staged background worktree creation', () => { // Why: selecting a real workspace clears only the pending surface pointer; // completion should still finish the task-launch handoff once it is ready. store.activePendingCreationId = null - resolveCreate({ worktree: { id: 'wt-1', repoId: 'repo-1' } }) + resolveCreate({ + worktree: { id: 'wt-1', repoId: 'repo-1' }, + startupTerminal: { tabId: 'agent-tab', spawned: true } + }) await flushAsyncWorktreeCreation() expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - sidebarRevealBehavior: 'auto' + sidebarRevealBehavior: 'auto', + backendStartupTerminalSpawned: true }) expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { @@ -731,6 +739,10 @@ describe('staged background worktree creation', () => { expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'agent-tab' }) ) + const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined + expect(createCall?.[25]).toEqual({ + startupDraft: 'https://github.com/o/r/issues/12' + }) }) it.each([ diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 83451b8ce..132f4825d 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -140,6 +140,10 @@ async function executeWorktreeCreation( : {}), ...(preparedRequest.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } + : {}), + // Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell. + ...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt + ? { startupDraft: preparedRequest.launchDraftPrompt } : {}) } ) @@ -167,7 +171,6 @@ async function executeWorktreeCreation( } const worktree = result.worktree - // Why: if the user dismissed/cancelled while the create was in flight, the entry // is gone. Git already made the worktree on disk, but don't auto-provision (trust // write, terminal, agent, note) work they abandoned — it surfaces as a plain row @@ -210,7 +213,8 @@ async function executeWorktreeCreation( ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), - ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}) + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId } else { @@ -224,7 +228,10 @@ async function executeWorktreeCreation( result.setup, preparedRequest.issueCommand, result.defaultTabs, - { activateCreatedTabs: false } + { + activateCreatedTabs: false, + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } ) } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 16a58fd98..499a4865c 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -198,6 +198,8 @@ export type WorktreeSlice = { automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ + startupDraft?: string } ) => Promise /** Register an in-flight background creation and make it the active surface. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index b13a197b5..6709169da 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5913,6 +5913,42 @@ describe('worktree remote runtime mutations', () => { ) }) + it('passes task startup drafts only to the owning remote runtime', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/task-draft', + repoId: 'repo1', + path: '/path/task-draft' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { worktree: wt }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [] } + } as Partial) + const createWorktree = store.getState().createWorktree + const args: Parameters = ['repo1', 'task-draft', undefined, 'inherit'] + args[10] = 'codex' + args[25] = { startupDraft: 'https://github.com/stablyai/orca/issues/12' } + + await createWorktree(...args) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'worktree.create', + params: expect.objectContaining({ + createdWithAgent: 'codex', + startupDraft: 'https://github.com/stablyai/orca/issues/12' + }) + }) + ) + expect(mockApi.worktrees.create).not.toHaveBeenCalled() + }) + it('passes startup commands through local worktree creation IPC', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index f2efca801..51613ebdb 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3989,6 +3989,7 @@ export const createWorktreeSlice: StateCreator const automationProvenanceRequest = options?.automationProvenanceRequest const linkedWorkItem = options?.linkedWorkItem const linkedTaskSourceContext = options?.linkedTaskSourceContext + const startupDraft = options?.startupDraft try { for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { const candidateName = getClientWorktreeCreateCandidate(name, attempt) @@ -4095,6 +4096,7 @@ export const createWorktreeSlice: StateCreator ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(startupDraft ? { startupDraft } : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(startup ? {