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'
)
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.

View File

@ -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: {

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', () => {
useAppStore.setState((state) => ({
settings: state.settings

View File

@ -171,6 +171,11 @@ type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
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.
* 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) {

View File

@ -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([

View File

@ -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 } : {})
}
)
}

View File

@ -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<CreateWorktreeResult>
/** 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 () => {
const store = createTestStore()
const wt = makeWorktree({

View File

@ -3989,6 +3989,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(startupDraft ? { startupDraft } : {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
...(startup
? {