fix(workspaces): start task agents on remote hosts (#13412)
This commit is contained in:
parent
f3ea376b6b
commit
a3ca887a3c
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
Loading…
Reference in New Issue