fix(workspaces): start task agents on remote hosts (#13412)

This commit is contained in:
Jinwoo Hong 2026-08-09 19:09:49 -07:00 committed by GitHub
parent f3ea376b6b
commit a3ca887a3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 129 additions and 18 deletions

View File

@ -750,6 +750,7 @@ describe('useComposerState host-context boundaries', () => {
'const submitQuick = useCallback' 'const submitQuick = useCallback'
) )
expect(fullSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') expect(fullSubmit).toContain('platform: selectedRepoAgentLaunchPlatform')
expect(fullSubmit).toContain('startupDraft: startupPlan.draftPrompt')
expect(fullSubmit).not.toContain('platform: CLIENT_PLATFORM') expect(fullSubmit).not.toContain('platform: CLIENT_PLATFORM')
const quickSubmit = sourceBetween( const quickSubmit = sourceBetween(
@ -771,6 +772,7 @@ describe('useComposerState host-context boundaries', () => {
) )
expect(activation).toContain('...(startupPlan && !backendSpawnedStartup') expect(activation).toContain('...(startupPlan && !backendSpawnedStartup')
expect(activation).toContain('backendStartupTerminalSpawned: true')
expect(activation).toContain('command: startupPlan.launchCommand') expect(activation).toContain('command: startupPlan.launchCommand')
expect(activation).toContain('launchAgent: tuiAgent') expect(activation).toContain('launchAgent: tuiAgent')
// The removed activation-time fallback must not come back through this caller. // The removed activation-time fallback must not come back through this caller.

View File

@ -3849,7 +3849,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
submitCompareBaseRef, submitCompareBaseRef,
{ {
linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem),
linkedTaskSourceContext: taskSourceContext linkedTaskSourceContext: taskSourceContext,
...(!backendStartup && startupPlan?.draftPrompt
? { startupDraft: startupPlan.draftPrompt }
: {})
} }
) )
const worktree = result.worktree const worktree = result.worktree
@ -3877,6 +3880,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setup: result.setup, setup: result.setup,
defaultTabs: result.defaultTabs, defaultTabs: result.defaultTabs,
issueCommand, issueCommand,
...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}),
...(startupPlan && !backendSpawnedStartup ...(startupPlan && !backendSpawnedStartup
? { ? {
startup: { startup: {

View File

@ -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<AppStoreState>)
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<AppStoreState>)
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', () => { it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => {
useAppStore.setState((state) => ({ useAppStore.setState((state) => ({
settings: state.settings settings: state.settings

View File

@ -171,6 +171,11 @@ type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
settings?: Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'> | null settings?: Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'> | null
} }
type InitialTerminalOptions = {
activateCreatedTabs?: boolean
backendStartupTerminalSpawned?: boolean
}
/** /**
* Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. * 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 * The caller passes only `worktreeId`; the helper derives `repoId` and returns early
@ -279,6 +284,7 @@ export function activateAndRevealWorktree(
notifyHostRuntime?: boolean notifyHostRuntime?: boolean
revealInSidebar?: boolean revealInSidebar?: boolean
executionHostId?: ExecutionHostId executionHostId?: ExecutionHostId
backendStartupTerminalSpawned?: boolean
} }
): ActivateAndRevealResult | false { ): ActivateAndRevealResult | false {
const state = useAppStore.getState() const state = useAppStore.getState()
@ -339,7 +345,8 @@ export function activateAndRevealWorktree(
opts?.startup, opts?.startup,
opts?.setup, opts?.setup,
opts?.issueCommand, opts?.issueCommand,
opts?.defaultTabs opts?.defaultTabs,
opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : undefined
) )
if (primaryTabId && opts?.initialCwd) { if (primaryTabId && opts?.initialCwd) {
useAppStore.getState().queueTabInitialCwd(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) ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId)
} }
@ -432,7 +439,7 @@ export function ensureWorktreeHasInitialTerminal(
setup?: WorktreeSetupLaunch, setup?: WorktreeSetupLaunch,
issueCommand?: IssueCommandLaunch, issueCommand?: IssueCommandLaunch,
defaultTabs?: WorktreeDefaultTabsLaunch, defaultTabs?: WorktreeDefaultTabsLaunch,
opts?: { activateCreatedTabs?: boolean } opts?: InitialTerminalOptions
): string | null { ): string | null {
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId) 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. // 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 wrappedSetupCommandStr = sequenced.setupCommand
} }
// Why: web clients mirror the server's session tabs, so avoid spawning a duplicate host terminal before the mirror lands. const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true
if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { // 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 const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
if (existingTerminalTabId && (setup || issueCommand)) { if (existingTerminalTabId && (setup || issueCommand)) {
queueSetupAndIssueCommands( queueSetupAndIssueCommands(
@ -474,6 +485,9 @@ export function ensureWorktreeHasInitialTerminal(
) )
return existingTerminalTabId return existingTerminalTabId
} }
if (existingTerminalTabId && backendStartupTerminalSpawned) {
return existingTerminalTabId
}
if (setup || issueCommand) { if (setup || issueCommand) {
// Why: runtime-owned worktrees mirror session tabs async, so hold commands for the first mirrored tab instead of dropping them. // Why: runtime-owned worktrees mirror session tabs async, so hold commands for the first mirrored tab instead of dropping them.
queueHookCommandsForFirstWorktreeTab({ queueHookCommandsForFirstWorktreeTab({
@ -586,7 +600,7 @@ function applyDefaultTerminalTabs(
issueCommand: IssueCommandLaunch | undefined, issueCommand: IssueCommandLaunch | undefined,
defaultTabs: WorktreeDefaultTabsLaunch | undefined, defaultTabs: WorktreeDefaultTabsLaunch | undefined,
wrappedSetupCommandStr: string | undefined, wrappedSetupCommandStr: string | undefined,
opts: { activateCreatedTabs?: boolean } | undefined opts: InitialTerminalOptions | undefined
): string | null { ): string | null {
if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) {
return null return null
@ -675,7 +689,7 @@ function queueSetupAndIssueCommands(
setup: WorktreeSetupLaunch | undefined, setup: WorktreeSetupLaunch | undefined,
issueCommand: IssueCommandLaunch | undefined, issueCommand: IssueCommandLaunch | undefined,
wrappedSetupCommandStr: string | undefined, wrappedSetupCommandStr: string | undefined,
opts: { activateCreatedTabs?: boolean } | undefined opts: InitialTerminalOptions | undefined
): void { ): void {
// Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent. // Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent.
if (setup) { if (setup) {

View File

@ -512,13 +512,14 @@ describe('staged background worktree creation', () => {
expect(store.setSidebarOpen).not.toHaveBeenCalled() 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.activeView = 'tasks'
store.createWorktree.mockResolvedValueOnce({ store.createWorktree.mockResolvedValueOnce({
worktree: { worktree: {
id: 'wt-1', id: 'wt-1',
repoId: 'repo-1' repoId: 'repo-1'
} },
startupTerminal: { tabId: 'agent-tab', spawned: true }
}) })
const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), { const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), {
@ -535,7 +536,7 @@ describe('staged background worktree creation', () => {
undefined, undefined,
undefined, undefined,
undefined, undefined,
{ activateCreatedTabs: false } { activateCreatedTabs: false, backendStartupTerminalSpawned: true }
) )
expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled() expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled()
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { 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 () => { it('reveals a backend-owned startup after the user switches workspaces', async () => {
let resolveCreate!: (result: { worktree: { id: string; repoId: string } }) => void let resolveCreate!: (result: {
worktree: { id: string; repoId: string }
startupTerminal: { tabId: string; spawned: true }
}) => void
store.createWorktree.mockReturnValueOnce( store.createWorktree.mockReturnValueOnce(
new Promise((resolve) => { new Promise((resolve) => {
resolveCreate = resolve resolveCreate = resolve
@ -560,11 +564,15 @@ describe('staged background worktree creation', () => {
// Why: selecting a real workspace clears only the pending surface pointer; // Why: selecting a real workspace clears only the pending surface pointer;
// completion should still finish the task-launch handoff once it is ready. // completion should still finish the task-launch handoff once it is ready.
store.activePendingCreationId = null 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() await flushAsyncWorktreeCreation()
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
sidebarRevealBehavior: 'auto' sidebarRevealBehavior: 'auto',
backendStartupTerminalSpawned: true
}) })
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
@ -731,6 +739,10 @@ describe('staged background worktree creation', () => {
expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith( expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith(
expect.objectContaining({ tabId: 'agent-tab' }) 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([ it.each([

View File

@ -140,6 +140,10 @@ async function executeWorktreeCreation(
: {}), : {}),
...(preparedRequest.linkedTaskSourceContext !== undefined ...(preparedRequest.linkedTaskSourceContext !== undefined
? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } ? { 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 const worktree = result.worktree
// Why: if the user dismissed/cancelled while the create was in flight, the entry // 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 // 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 // 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.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(startupOpt ? { startup: startupOpt } : {}), ...(startupOpt ? { startup: startupOpt } : {}),
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}) ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}),
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}) })
primaryTabId = activation === false ? null : activation.primaryTabId primaryTabId = activation === false ? null : activation.primaryTabId
} else { } else {
@ -224,7 +228,10 @@ async function executeWorktreeCreation(
result.setup, result.setup,
preparedRequest.issueCommand, preparedRequest.issueCommand,
result.defaultTabs, result.defaultTabs,
{ activateCreatedTabs: false } {
activateCreatedTabs: false,
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}
) )
} }

View File

@ -198,6 +198,8 @@ export type WorktreeSlice = {
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
linkedWorkItem?: WorkspaceLinkedItem | null linkedWorkItem?: WorkspaceLinkedItem | null
linkedTaskSourceContext?: TaskSourceContext | null linkedTaskSourceContext?: TaskSourceContext | null
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
startupDraft?: string
} }
) => Promise<CreateWorktreeResult> ) => Promise<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */ /** Register an in-flight background creation and make it the active surface. */

View File

@ -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<AppState>)
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['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 () => { it('passes startup commands through local worktree creation IPC', async () => {
const store = createTestStore() const store = createTestStore()
const wt = makeWorktree({ const wt = makeWorktree({

View File

@ -3989,6 +3989,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const automationProvenanceRequest = options?.automationProvenanceRequest const automationProvenanceRequest = options?.automationProvenanceRequest
const linkedWorkItem = options?.linkedWorkItem const linkedWorkItem = options?.linkedWorkItem
const linkedTaskSourceContext = options?.linkedTaskSourceContext const linkedTaskSourceContext = options?.linkedTaskSourceContext
const startupDraft = options?.startupDraft
try { try {
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
const candidateName = getClientWorktreeCreateCandidate(name, attempt) const candidateName = getClientWorktreeCreateCandidate(name, attempt)
@ -4095,6 +4096,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(startupDraft ? { startupDraft } : {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
...(startup ...(startup
? { ? {