diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 6011813f2..ec51ba001 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -6389,6 +6389,53 @@ describe('connectPanePty', () => { expect(deps.updateTabPtyId).not.toHaveBeenCalledWith('tab-1', otherTabPtyId) }) + it('fresh-spawns a shell into any PTY-less tab, so agent launches must never publish one', async () => { + // Why: #2989 depends on PTY-less tabs taking this legitimate fresh-shell path. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { + if (opts.sessionId) { + return { id: opts.sessionId } + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('stray-shell-pty') + return 'stray-shell-pty' + }) + transportFactoryQueue.push(transport) + // Reproduce the pre-fix gap between createTab and PTY binding. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null + } + }, + agentLaunchConfigByPaneKey: { + [`tab-1:${LEAF_1}`]: { + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, + identity: { agentType: 'claude' } + } + } + } as StoreState + const deps = createDeps() + + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + // Launch registration alone cannot identify a PTY to attach. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ url: '', cols: expect.any(Number) }) + ) + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'stray-shell-pty') + }) + it('spawns a fresh PTY when a restored daemon split session cannot reattach', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 562bbbbf2..ae8f70d05 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -12,6 +12,9 @@ const mockOnDispatchRequested = vi.fn() const mockRendererReady = vi.fn() const mockFinalizeTerminalOwnership = vi.fn() const mockReleaseTerminalOwnership = vi.fn() +const mockSshNeedsPassphrasePrompt = vi.fn() +const mockSshGetState = vi.fn() +const mockSshConnect = vi.fn() const setupLaunch = { runnerScriptPath: '/tmp/setup.sh', @@ -25,15 +28,35 @@ const createdWorktree = { path: '/repo/worktree' } type TestWorktree = typeof createdWorktree +type TestRepo = { + id: string + connectionId: string | null + executionHostId: string | null + path: string +} const state = { activeView: 'terminal' as const, activeWorktreeId: 'wt-active', activeTabId: 'tab-active', activeTabType: 'terminal' as const, - repos: [{ id: 'repo-1', connectionId: null }], + repos: [{ id: 'repo-1', connectionId: null, executionHostId: null, path: '/repo' }] as TestRepo[], + folderWorkspaces: [] as { + id: string + projectGroupId: string + folderPath: string + connectionId: string | null + }[], + projectGroups: [] as { + id: string + connectionId: string | null + executionHostId?: string | null + }[], + worktreesByRepo: {} as Record, + detectedWorktreesByRepo: {}, agentStatusByPaneKey: {}, allWorktrees: vi.fn<() => TestWorktree[]>(() => []), + getKnownWorktreeById: vi.fn<(worktreeId: string) => TestWorktree | undefined>(() => undefined), createWorktree: mockCreateWorktree, subscribe: vi.fn(() => () => {}), setActiveView: vi.fn(), @@ -146,9 +169,13 @@ describe('useAutomationDispatchEvents setup launch', () => { state.activeWorktreeId = 'wt-active' state.activeTabId = 'tab-active' state.activeTabType = 'terminal' - state.repos = [{ id: 'repo-1', connectionId: null }] + state.repos = [{ id: 'repo-1', connectionId: null, executionHostId: null, path: '/repo' }] + state.folderWorkspaces = [] + state.projectGroups = [] + state.worktreesByRepo = {} state.agentStatusByPaneKey = {} state.allWorktrees.mockReturnValue([]) + state.getKnownWorktreeById.mockReturnValue(undefined) mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree, setup: setupLaunch }) mockLaunchWorktreeBackgroundTerminals.mockResolvedValue(undefined) mockLaunchAgentBackgroundSession.mockResolvedValue({ @@ -162,6 +189,9 @@ describe('useAutomationDispatchEvents setup launch', () => { } }) mockOnDispatchRequested.mockReturnValue(() => {}) + mockSshNeedsPassphrasePrompt.mockResolvedValue(false) + mockSshGetState.mockResolvedValue({ status: 'connected' }) + mockSshConnect.mockResolvedValue({ status: 'connected' }) vi.stubGlobal('window', { api: { automations: { @@ -172,9 +202,9 @@ describe('useAutomationDispatchEvents setup launch', () => { listRuns: vi.fn().mockResolvedValue([]) }, ssh: { - needsPassphrasePrompt: vi.fn().mockResolvedValue(false), - getState: vi.fn().mockResolvedValue({ status: 'connected' }), - connect: vi.fn() + needsPassphrasePrompt: mockSshNeedsPassphrasePrompt, + getState: mockSshGetState, + connect: mockSshConnect } }, dispatchEvent: vi.fn() @@ -319,6 +349,145 @@ describe('useAutomationDispatchEvents setup launch', () => { ) }) + it('dispatches an existing SSH folder workspace on its resolved host', async () => { + const folderWorkspace = { + id: 'folder:fw-1', + repoId: 'folder-workspace:group-1', + displayName: 'SSH folder', + path: '/srv/project' + } + state.repos = [ + { + id: 'repo-1', + connectionId: 'ssh-folder', + executionHostId: null, + path: '/srv/project/repo' + } + ] + state.folderWorkspaces = [ + { + id: 'fw-1', + projectGroupId: 'group-1', + folderPath: '/srv/project', + connectionId: 'ssh-folder' + } + ] + state.projectGroups = [{ id: 'group-1', connectionId: 'ssh-folder' }] + state.getKnownWorktreeById.mockReturnValue(folderWorkspace) + mockSshGetState.mockResolvedValue({ status: 'disconnected' }) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspace.id, + setupDecision: 'skip', + runContext: { repoId: 'repo-1', hostId: 'ssh:ssh-folder' } + }) + ) + + expect(state.allWorktrees).not.toHaveBeenCalled() + expect(mockSshConnect).toHaveBeenCalledWith({ targetId: 'ssh-folder' }) + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: folderWorkspace.id, + prompt: 'run this' + }) + ) + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'dispatched', + workspaceId: folderWorkspace.id, + workspaceDisplayName: folderWorkspace.displayName + }) + ) + }) + + it('dispatches a local folder workspace without SSH', async () => { + const folderWorkspace = { + id: 'folder:fw-local', + repoId: 'folder-workspace:group-local', + displayName: 'Local folder', + path: '/project' + } + state.folderWorkspaces = [ + { + id: 'fw-local', + projectGroupId: 'group-local', + folderPath: '/project', + connectionId: null + } + ] + state.projectGroups = [{ id: 'group-local', connectionId: null }] + state.getKnownWorktreeById.mockReturnValue(folderWorkspace) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspace.id, + runContext: { repoId: 'repo-1', hostId: 'local' } + }) + ) + + expect(mockSshNeedsPassphrasePrompt).not.toHaveBeenCalled() + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: folderWorkspace.id }) + ) + }) + + it('skips a folder workspace owned by a different host', async () => { + const folderWorkspace = { + id: 'folder:fw-other', + repoId: 'folder-workspace:group-other', + displayName: 'Other host', + path: '/srv/other' + } + state.folderWorkspaces = [ + { + id: 'fw-other', + projectGroupId: 'group-other', + folderPath: '/srv/other', + connectionId: 'ssh-other' + } + ] + state.projectGroups = [{ id: 'group-other', connectionId: 'ssh-other' }] + state.getKnownWorktreeById.mockReturnValue(folderWorkspace) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspace.id, + runContext: { repoId: 'repo-1', hostId: 'local' } + }) + ) + + expect(mockLaunchAgentBackgroundSession).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'skipped_unavailable' }) + ) + }) + + it('keeps detected-only non-folder workspaces unavailable', async () => { + state.getKnownWorktreeById.mockReturnValue({ + id: 'wt-detected', + repoId: 'repo-1', + displayName: 'Detected', + path: '/repo/detected' + }) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: 'wt-detected' + }) + ) + + expect(state.getKnownWorktreeById).not.toHaveBeenCalled() + expect(mockLaunchAgentBackgroundSession).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'skipped_unavailable' }) + ) + }) + it('finalizes a fresh non-reuse terminal only after completed result persistence', async () => { const order: string[] = [] let launchArgs: { onAgentStatus?: (payload: { state: string }) => void } = {} diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 1679aaf1a..d543d639d 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -24,6 +24,14 @@ import { import { translate } from '@/i18n/i18n' import { createBrowserUuid } from '@/lib/browser-uuid' import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' +import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() @@ -63,8 +71,11 @@ export function useAutomationDispatchEvents(): void { } const runRepoId = getAutomationRunRepoId(automation) const repo = state.repos.find((entry) => entry.id === runRepoId) + const automationWorkspaceScope = parseWorkspaceKey(automation.workspaceId ?? '') const automationWorktree = automation.workspaceId - ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId) + ? automationWorkspaceScope?.type === 'folder' + ? state.getKnownWorktreeById(automation.workspaceId) + : state.allWorktrees().find((entry) => entry.id === automation.workspaceId) : null let dispatchWorkspaceId = automation.workspaceId let dispatchWorkspaceDisplayName = @@ -97,9 +108,49 @@ export function useAutomationDispatchEvents(): void { } try { - if (repo.connectionId) { + const folderWorkspaceConnectionId = + automationWorkspaceScope?.type === 'folder' + ? getFolderWorkspaceConnectionId(state, automationWorkspaceScope.folderWorkspaceId) + : null + const folderWorkspaceHostId = + automationWorkspaceScope?.type === 'folder' && automationWorktree + ? folderWorkspaceConnectionId === undefined + ? null + : folderWorkspaceConnectionId + ? toSshExecutionHostId(folderWorkspaceConnectionId) + : getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) + : null + const runHostId = + parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo) + const workspaceMatchesRunTarget = + automationWorkspaceScope?.type === 'folder' + ? folderWorkspaceHostId !== null && folderWorkspaceHostId === runHostId + : !automation.runContext?.repoId || + automationWorktree?.repoId === automation.runContext.repoId + if ( + automation.workspaceMode === 'existing' && + automationWorktree && + !workspaceMatchesRunTarget + ) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) + }) + return + } + const sshTargetId = + automationWorkspaceScope?.type === 'folder' + ? (folderWorkspaceConnectionId ?? null) + : (repo.connectionId ?? null) + if (sshTargetId) { const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ - targetId: repo.connectionId + targetId: sshTargetId }) if (needsPrompt) { await markDispatchResult({ @@ -114,10 +165,10 @@ export function useAutomationDispatchEvents(): void { }) return } - const sshState = await window.api.ssh.getState({ targetId: repo.connectionId }) + const sshState = await window.api.ssh.getState({ targetId: sshTargetId }) if (sshState?.status !== 'connected') { try { - const connected = await window.api.ssh.connect({ targetId: repo.connectionId }) + const connected = await window.api.ssh.connect({ targetId: sshTargetId }) if (connected?.status !== 'connected') { throw new Error('SSH target is unavailable.') } @@ -134,25 +185,6 @@ export function useAutomationDispatchEvents(): void { } } - if ( - automation.workspaceMode === 'existing' && - automationWorktree && - automation.runContext?.repoId && - automationWorktree.repoId !== automation.runContext.repoId - ) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', - 'The target workspace is on a different host than this automation run target.' - ) - }) - return - } - if (automation.workspaceMode === 'existing' && !automationWorktree) { await markDispatchResult({ runId: run.id, diff --git a/src/renderer/src/lib/adopt-agent-background-session-tab.ts b/src/renderer/src/lib/adopt-agent-background-session-tab.ts new file mode 100644 index 000000000..ec6041376 --- /dev/null +++ b/src/renderer/src/lib/adopt-agent-background-session-tab.ts @@ -0,0 +1,113 @@ +import { useAppStore } from '@/store' +import { makePaneKey, type PaneKey } from '../../../shared/stable-pane-id' +import type { AgentType } from '../../../shared/agent-status-types' +import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' +import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' + +type Store = ReturnType +type RegisterArgs = Parameters + +/** Reserves env-stable tab and pane identities before spawning the PTY. */ +export function reserveAgentBackgroundSessionIdentity(args: { + store: Store + agentType: AgentType + worktreeId: string + launchConfig: RegisterArgs[1] + env: Record | undefined +}): { + reservedTabId: string + leafId: string + paneKey: PaneKey + launchToken: string + launchRegistration: NonNullable + paneEnv: Record +} { + const reservedTabId = createBrowserUuid() + const leafId = createBrowserUuid() + const paneKey = makePaneKey(reservedTabId, leafId) + const launchToken = createBrowserUuid() + const launchRegistration = { + agentType: args.agentType, + launchToken, + tabId: reservedTabId, + leafId + } + args.store.registerAgentLaunchConfig(paneKey, args.launchConfig, launchRegistration) + return { + reservedTabId, + leafId, + paneKey, + launchToken, + launchRegistration, + paneEnv: { + ...args.env, + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: reservedTabId, + ORCA_WORKTREE_ID: args.worktreeId, + ORCA_AGENT_LAUNCH_TOKEN: launchToken + } + } +} + +/** Publishes a hidden run tab already bound to its live PTY (#2989). */ +export async function adoptAgentBackgroundSessionTab(args: { + store: Store + worktreeId: string + reservedTabId: string + ptyId: string + paneKey: PaneKey + launchConfig: RegisterArgs[1] + launchRegistration: NonNullable + runtimeTarget: RuntimeClientTarget + runtimeTerminalHandle: string | null + onRetire: () => void + title?: string +}): Promise<{ + tab: ReturnType + paneKey: PaneKey + terminalOwnership: ReturnType +} | null> { + const { store, reservedTabId, ptyId, launchRegistration } = args + // The worktree can disappear while its PTY spawn is pending. + if ( + await retireUnownedTerminal({ + owner: { worktreeId: args.worktreeId }, + ptyId, + runtimeTarget: args.runtimeTarget, + runtimeTerminalHandle: args.runtimeTerminalHandle, + onRetire: args.onRetire + }) + ) { + return null + } + // Re-keying would desynchronize env-baked routing identities; fail closed. + if (isTerminalTabPresent(useAppStore.getState(), reservedTabId)) { + store.clearAgentLaunchConfig(args.paneKey) + args.onRetire() + await retireProvider({ + ptyId, + runtimeTarget: args.runtimeTarget, + runtimeTerminalHandle: args.runtimeTerminalHandle + }) + return null + } + const tab = store.createTab(args.worktreeId, undefined, undefined, { + id: reservedTabId, + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + const paneKey = args.paneKey + store.registerAgentLaunchConfig(paneKey, args.launchConfig, launchRegistration) + const terminalOwnership = bindAutomationTerminal( + tab, + paneKey, + ptyId, + args.runtimeTarget.kind, + args.title + ) + return { tab, paneKey, terminalOwnership } +} diff --git a/src/renderer/src/lib/agent-background-session-launch-host.test.ts b/src/renderer/src/lib/agent-background-session-launch-host.test.ts new file mode 100644 index 000000000..86fd8577f --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-launch-host.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { resolveAgentBackgroundLaunchHost } from './agent-background-session-launch-host' + +function makeFolderHostState(args: { + connectionId: string | null + folderPath: string + repos?: { + id: string + connectionId: string | null + path: string + projectGroupId: string + }[] +}) { + return { + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath: args.folderPath, + connectionId: args.connectionId + } + ], + projectGroups: [ + { + id: 'group-1', + parentGroupId: null, + connectionId: args.connectionId + } + ], + repos: args.repos ?? [] + } +} + +describe('resolveAgentBackgroundLaunchHost', () => { + it('keeps an authoritative local folder owner local', () => { + const host = resolveAgentBackgroundLaunchHost({ + store: makeFolderHostState({ connectionId: null, folderPath: '/project' }) as never, + worktreeId: 'folder:folder-1', + worktreePath: '/project', + repo: null + }) + + expect(host).toMatchObject({ + connectionId: null, + isRemote: false, + expectedConnectionId: null + }) + }) + + it('fails closed when folder ownership is ambiguous', () => { + const store = makeFolderHostState({ + connectionId: 'ssh-1', + folderPath: '/project', + repos: [ + { + id: 'repo-local', + connectionId: null, + path: '/project/repo', + projectGroupId: 'group-1' + } + ] + }) + + expect(() => + resolveAgentBackgroundLaunchHost({ + store: store as never, + worktreeId: 'folder:folder-1', + worktreePath: '/project', + repo: null + }) + ).toThrow('unavailable or ambiguous') + }) + + it('uses Linux startup quoting for a local WSL folder', () => { + const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project' + const host = resolveAgentBackgroundLaunchHost({ + store: makeFolderHostState({ connectionId: null, folderPath }) as never, + worktreeId: 'folder:folder-1', + worktreePath: folderPath, + repo: null + }) + + expect(host.platform).toBe('linux') + }) +}) diff --git a/src/renderer/src/lib/agent-background-session-launch-host.ts b/src/renderer/src/lib/agent-background-session-launch-host.ts new file mode 100644 index 000000000..7919f9f3a --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-launch-host.ts @@ -0,0 +1,71 @@ +import type { useAppStore } from '@/store' +import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { isWslUncPath } from '../../../shared/wsl-paths' + +type LaunchStore = ReturnType +type LaunchRepo = LaunchStore['repos'][number] + +export type AgentBackgroundLaunchHost = { + /** SSH connection to spawn on, or null for a local launch. */ + connectionId: string | null + /** Platform whose shell quoting and CLI naming the startup plan must target. */ + platform: NodeJS.Platform + isRemote: boolean + /** Accepted status connection; undefined preserves unknown-owner behavior. */ + expectedConnectionId: string | null | undefined +} + +function resolveFolderWorkspaceConnectionIdForLaunch( + store: LaunchStore, + worktreeId: string +): string | null | undefined { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type !== 'folder') { + return undefined + } + return getFolderWorkspaceConnectionId(store, parsed.folderWorkspaceId) +} + +/** Resolves folder launch ownership from workspace scope when no repo row exists. */ +export function resolveAgentBackgroundLaunchHost(args: { + store: LaunchStore + worktreeId: string + worktreePath: string | undefined + repo: LaunchRepo | null | undefined +}): AgentBackgroundLaunchHost { + const { store, worktreeId, worktreePath, repo } = args + if (repo) { + return { + connectionId: repo.connectionId ?? null, + platform: getAgentLaunchPlatformForRepo( + repo, + repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) + ), + isRemote: repoIsRemote(repo), + expectedConnectionId: repo.connectionId ?? null + } + } + const folderWorkspaceConnectionId = resolveFolderWorkspaceConnectionIdForLaunch(store, worktreeId) + const isFolderWorkspace = parseWorkspaceKey(worktreeId)?.type === 'folder' + if (isFolderWorkspace && folderWorkspaceConnectionId === undefined) { + throw new Error('The target folder workspace host is unavailable or ambiguous.') + } + return { + connectionId: folderWorkspaceConnectionId ?? null, + platform: folderWorkspaceConnectionId + ? isWindowsAbsolutePathLike(worktreePath ?? '') + ? 'win32' + : 'linux' + : isWslUncPath(worktreePath ?? '') + ? 'linux' + : CLIENT_PLATFORM, + isRemote: Boolean(folderWorkspaceConnectionId), + expectedConnectionId: isFolderWorkspace ? (folderWorkspaceConnectionId ?? null) : undefined + } +} diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index b338493c4..85def0ffa 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -24,7 +24,14 @@ export type AgentBackgroundSessionTestState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } }[] - repos: { id: string; connectionId: string | null; path: string }[] + repos: { id: string; connectionId: string | null; path: string; projectGroupId?: string | null }[] + folderWorkspaces: { + id: string + projectGroupId: string + folderPath: string + connectionId?: string | null + }[] + projectGroups: { id: string; parentGroupId?: string | null; connectionId?: string | null }[] worktreesByRepo: Record< string, { id: string; repoId: string; projectId: string; path: string; displayName: string }[] @@ -35,6 +42,7 @@ export type AgentBackgroundSessionTestState = { sshConnectionStates: Map transientClearedAgentStatusConnectionIds: Record allWorktrees: () => { id: string; repoId: string; path: string }[] + getKnownWorktreeById: (worktreeId: string) => { id: string; path: string } | undefined createTab: TestMock setTabCustomTitle: TestMock updateTabPtyId: TestMock @@ -87,6 +95,8 @@ export function createAgentBackgroundSessionTestState(mocks: { } ] }, + folderWorkspaces: [] as AgentBackgroundSessionTestState['folderWorkspaces'], + projectGroups: [] as AgentBackgroundSessionTestState['projectGroups'], tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] }, terminalLayoutsByTabId: {} as Record< string, @@ -96,6 +106,8 @@ export function createAgentBackgroundSessionTestState(mocks: { sshConnectionStates: new Map(), transientClearedAgentStatusConnectionIds: {} as Record, allWorktrees: () => state.worktreesByRepo['repo-1'], + getKnownWorktreeById: (worktreeId: string) => + state.worktreesByRepo['repo-1']?.find((worktree) => worktree.id === worktreeId), createTab: mocks.createTab, setTabCustomTitle: mocks.setTabCustomTitle, updateTabPtyId: mocks.updateTabPtyId, @@ -120,6 +132,8 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi } state.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + state.folderWorkspaces = [] + state.projectGroups = [] state.worktreesByRepo = { 'repo-1': [ { @@ -136,6 +150,9 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi state.ptyIdsByTabId = {} state.sshConnectionStates = new Map() state.transientClearedAgentStatusConnectionIds = {} + // Why: restored here so a test that stubs folder-workspace lookup cannot leak it forward. + state.getKnownWorktreeById = (worktreeId: string) => + state.worktreesByRepo['repo-1']?.find((worktree) => worktree.id === worktreeId) } export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTestState): void { @@ -146,6 +163,13 @@ export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTes } } +/** The tab id reserved before the spawn; the run tab adopts it once the PTY is live. */ +export function expectReservedAgentBackgroundTabId(spawn: TestMock): string { + const tabId = spawn.mock.calls[0]?.[0]?.tabId + expect(tabId).toMatch(AGENT_BACKGROUND_SESSION_UUID_RE) + return tabId +} + export function expectStableAgentBackgroundPaneSpawn(spawn: TestMock): string { const spawnArgs = spawn.mock.calls[0]?.[0] const paneKey = spawnArgs?.env?.ORCA_PANE_KEY @@ -153,7 +177,7 @@ export function expectStableAgentBackgroundPaneSpawn(spawn: TestMock): string { expect(typeof paneKey).toBe('string') expect(typeof leafId).toBe('string') expect(leafId).toMatch(AGENT_BACKGROUND_SESSION_UUID_RE) - expect(paneKey).toBe(`tab-1:${leafId}`) + expect(paneKey).toBe(`${expectReservedAgentBackgroundTabId(spawn)}:${leafId}`) return paneKey } @@ -208,8 +232,9 @@ export function resetAgentBackgroundSessionTestHarness(args: { (args.runtimeCall as unknown as (value: unknown) => unknown)(request) ) resetAgentBackgroundSessionTestState(args.state) - args.createTab.mockImplementation(() => { - const tab = { id: 'tab-1', title: 'Terminal 1' } + // Why: production reserves the tab id before the spawn; honoring options.id mirrors createTab's adoption contract. + args.createTab.mockImplementation((_worktreeId, _groupId, _shellOverride, options) => { + const tab = { id: options?.id ?? 'tab-1', title: 'Terminal 1' } args.state.tabsByWorktree['wt-1'].push(tab) return tab }) diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts new file mode 100644 index 000000000..6ae4071ba --- /dev/null +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -0,0 +1,511 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE, + createAgentBackgroundSessionTestState, + resetAgentBackgroundSessionTestHarness, + useRemoteAgentBackgroundRuntime +} from '@/lib/agent-background-session-test-state' + +const mockSpawn = vi.fn() +const mockKill = vi.fn() +const mockWrite = vi.fn() +const mockRuntimeEnvironmentCall = vi.fn() +const mockRuntimeEnvironmentTransportCall = vi.fn() +const mockRuntimeEnvironmentSubscribe = vi.fn() +const mockCreateTab = vi.fn() +const mockSetTabCustomTitle = vi.fn() +const mockUpdateTabPtyId = vi.fn() +const mockCloseTab = vi.fn() +const mockSetTabLayout = vi.fn() +const mockRegisterAgentLaunchConfig = vi.fn() +const mockRegisterEagerPtyBuffer = vi.fn() +const mockSubscribeToPtyData = vi.fn() +const mockSubscribeToPtyExit = vi.fn() +const mockPasteDraftWhenAgentReady = vi.fn() +const mockMarkTrusted = vi.fn() +const mockDispatchEvent = vi.fn() +const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>() +const state = createAgentBackgroundSessionTestState({ + createTab: mockCreateTab, + setTabCustomTitle: mockSetTabCustomTitle, + updateTabPtyId: mockUpdateTabPtyId, + closeTab: mockCloseTab, + setTabLayout: mockSetTabLayout, + registerAgentLaunchConfig: mockRegisterAgentLaunchConfig +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state, + subscribe: vi.fn(() => () => {}) + } +})) + +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) + +vi.mock('@/lib/agent-launch-platform', () => ({ + getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, + subscribeToPtyExit: mockSubscribeToPtyExit +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: mockSubscribeToPtyData +})) + +describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', () => { + beforeEach(() => { + resetAgentBackgroundSessionTestHarness({ + state, + createTab: mockCreateTab, + closeTab: mockCloseTab, + getLaunchPlatform: mockGetAgentLaunchPlatformForRepo, + runtimeCall: mockRuntimeEnvironmentCall, + runtimeTransportCall: mockRuntimeEnvironmentTransportCall, + runtimeSubscribe: mockRuntimeEnvironmentSubscribe, + subscribeToData: mockSubscribeToPtyData, + subscribeToExit: mockSubscribeToPtyExit, + setTabLayout: mockSetTabLayout, + updateTabPtyId: mockUpdateTabPtyId, + dispatchEvent: mockDispatchEvent, + kill: mockKill, + markTrusted: mockMarkTrusted, + spawn: mockSpawn, + write: mockWrite + }) + }) + + it('closes a runtime terminal when its worktree disappears before creation resolves', async () => { + useRemoteAgentBackgroundRuntime(state) + let resolveCreate!: (result: { + ok: true + result: { terminal: { handle: string; worktreeId: string; title: null } } + }) => void + const createResult = new Promise<{ + ok: true + result: { terminal: { handle: string; worktreeId: string; title: null } } + }>((resolve) => { + resolveCreate = resolve + }) + mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.createAgentSession') { + return createResult + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const launch = launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run remotely' + }) + await vi.waitFor(() => + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.createAgentSession' }) + ) + ) + state.worktreesByRepo['repo-1'] = [] + resolveCreate({ + ok: true, + result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } } + }) + + await expect(launch).resolves.toBeNull() + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.close', + params: { terminal: 'terminal-after-close' } + }) + ) + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled() + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) + + it('forwards Hermes startup queries through SSH command transport', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'remote automation prompt' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), + connectionId: 'ssh-1', + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' }) + }) + ) + }) + + it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe( + "claude '--dangerously-skip-permissions' 'run the automation'" + ) + expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined() + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "claude '--dangerously-skip-permissions' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'", + startupCommandDelivery: 'shell-ready' + }) + ) + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + expect(mockWrite).not.toHaveBeenCalled() + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('falls back when an SSH shell produces no observable startup data', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + vi.advanceTimersByTime(1_550) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + state.settings = { + agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" + }) + ) + expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + expect(mockWrite).not.toHaveBeenCalled() + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not rearm SSH background startup delivery after exit cleanup', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void + exitSidecar(0) + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('creates background sessions on the active runtime environment', async () => { + useRemoteAgentBackgroundRuntime(state) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const result = await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).not.toHaveBeenCalled() + const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params + const leafId = params?.placement?.leafId + const tabId = params?.placement?.tabId + expect(leafId).toMatch(UUID_RE) + expect(tabId).toMatch(UUID_RE) + // Why: background launches have no explicit recipe override, so remote host settings win. + expect(params).not.toHaveProperty('agentArgs') + expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith( + `${tabId}:${leafId}`, + { + agentCommand: "claude '--dangerously-skip-permissions'", + agentArgs: '--dangerously-skip-permissions', + agentEnv: {} + }, + { + agentType: 'claude', + launchToken: expect.stringMatching(UUID_RE), + tabId, + leafId + } + ) + expect(mockSetTabLayout).toHaveBeenCalledWith( + tabId, + expect.objectContaining({ + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' } + }) + ) + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.createAgentSession', + params: expect.objectContaining({ + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), + worktree: 'id:wt-1', + agent: 'claude', + prompt: 'run the automation', + promptDelivery: 'auto-submit', + placement: { tabId, leafId }, + presentation: 'background' + }), + timeoutMs: 15_000 + }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'remote:env-1@@terminal-1') + expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + expect(result).toMatchObject({ + tabId, + paneKey: `${tabId}:${leafId}`, + ptyId: 'remote:env-1@@terminal-1', + terminalOwnership: null + }) + }) + + it('preserves the legacy background spawn on an old remote host', async () => { + useRemoteAgentBackgroundRuntime(state) + mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { + if (request.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + }) + } + return Promise.resolve({ + id: 'create', + ok: true, + result: { terminal: { handle: 'legacy-terminal-1' } } + }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run remotely' + }) + ).resolves.toMatchObject({ ptyId: 'remote:env-1@@legacy-terminal-1' }) + + expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.create', + params: expect.objectContaining({ + worktree: 'id:wt-1', + command: "claude '--dangerously-skip-permissions' 'run remotely'", + launchAgent: 'claude', + presentation: 'background' + }) + }) + ) + }) + + it('closes a created runtime terminal when its data subscription fails', async () => { + useRemoteAgentBackgroundRuntime(state) + mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).rejects.toThrow('subscription failed') + + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-1' }, + timeoutMs: undefined + }) + const tabId = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params?.placement?.tabId + expect(tabId).toMatch(UUID_RE) + expect(state.clearTabPtyId).toHaveBeenCalledWith(tabId, 'remote:env-1@@terminal-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^${tabId}:`)) + ) + expect(mockCloseTab).toHaveBeenCalledWith(tabId, { + recordInteraction: false, + reason: 'cleanup' + }) + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) + + it('spawns an SSH folder-workspace automation on the owning host, not locally', async () => { + // Folder workspaces have no repo row, so launch ownership comes from their scope. + state.repos = [ + { id: 'repo-1', connectionId: 'ssh-1', path: '/srv/proj/api', projectGroupId: 'grp-1' } + ] + state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: 'ssh-1' }] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/srv/proj', connectionId: 'ssh-1' } + ] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/srv/proj' } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'folder:fw-1', + prompt: 'run the automation' + }) + + expect(mockMarkTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/srv/proj', + connectionId: 'ssh-1' + }) + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'ssh-1', cwd: '/srv/proj' }) + ) + }) + + it('keeps a local folder workspace on the local host', async () => { + state.repos = [ + { id: 'repo-1', connectionId: null, path: '/home/me/proj/api', projectGroupId: 'grp-1' } + ] + state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: null }] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/home/me/proj', connectionId: null } + ] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/home/me/proj' } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'folder:fw-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: null, cwd: '/home/me/proj' }) + ) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index a82996c97..78fd3a91f 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -4,9 +4,9 @@ import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import { AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE, createAgentBackgroundSessionTestState, + expectReservedAgentBackgroundTabId, expectStableAgentBackgroundPaneSpawn, - resetAgentBackgroundSessionTestHarness, - useRemoteAgentBackgroundRuntime + resetAgentBackgroundSessionTestHarness } from '@/lib/agent-background-session-test-state' const mockSpawn = vi.fn() @@ -36,10 +36,11 @@ const state = createAgentBackgroundSessionTestState({ setTabLayout: mockSetTabLayout, registerAgentLaunchConfig: mockRegisterAgentLaunchConfig }) +let currentStoreState = state vi.mock('@/store', () => ({ useAppStore: { - getState: () => state, + getState: () => currentStoreState, subscribe: vi.fn(() => () => {}) } })) @@ -68,6 +69,7 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { + currentStoreState = state resetAgentBackgroundSessionTestHarness({ state, createTab: mockCreateTab, @@ -88,7 +90,7 @@ describe('launchAgentBackgroundSession', () => { }) }) - it('spawns a PTY immediately and adopts it in an inactive tab', async () => { + it('spawns a PTY first and creates the inactive tab already bound to it', async () => { const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const result = await launchAgentBackgroundSession({ @@ -98,14 +100,21 @@ describe('launchAgentBackgroundSession', () => { title: 'Nightly audit' }) + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + // A store-visible PTY-less run tab fresh-spawns a shell (#2989). + expect(mockSpawn.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateTab.mock.invocationCallOrder[0] ?? 0 + ) expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + id: tabId, + initialPtyId: 'pty-1', activate: false, recordInteraction: false }) expect(mockDispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } + detail: { worktreeId: 'wt-1', tabIds: [tabId] } }) ) expect(mockUpdateTabPtyId.mock.invocationCallOrder[0]).toBeLessThan( @@ -116,18 +125,18 @@ describe('launchAgentBackgroundSession', () => { cwd: '/repo/worktree', command: "claude '--dangerously-skip-permissions' 'run the automation'", env: expect.objectContaining({ - ORCA_TAB_ID: 'tab-1', + ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: 'wt-1' }), connectionId: null, worktreeId: 'wt-1', - tabId: 'tab-1' + tabId }) ) const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) - const leafId = paneKey.slice('tab-1:'.length) + const leafId = paneKey.slice(`${tabId}:`.length) expect(mockSetTabLayout).toHaveBeenCalledWith( - 'tab-1', + tabId, expect.objectContaining({ root: { type: 'leaf', leafId }, activeLeafId: leafId, @@ -147,17 +156,17 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn.mock.calls[0]?.[0].launchToken).toBe( mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN ) - expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit', { + expect(mockSetTabCustomTitle).toHaveBeenCalledWith(tabId, 'Nightly audit', { recordInteraction: false }) - expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') + expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'pty-1') expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function)) expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function)) expect(mockSubscribeToPtyExit).toHaveBeenCalledWith('pty-1', expect.any(Function)) - expect(result).toMatchObject({ tabId: 'tab-1', paneKey, ptyId: 'pty-1' }) + expect(result).toMatchObject({ tabId, paneKey, ptyId: 'pty-1' }) }) - it('does not mount the tab while the explicit PTY spawn is unresolved', async () => { + it('does not create or mount the tab while the explicit PTY spawn is unresolved', async () => { let resolveSpawn!: (result: { id: string }) => void mockSpawn.mockReturnValueOnce( new Promise<{ id: string }>((resolve) => { @@ -173,18 +182,24 @@ describe('launchAgentBackgroundSession', () => { }) await Promise.resolve() - expect(mockCreateTab).toHaveBeenCalled() + // Publishing a PTY-less tab here reproduces #2989. + expect(mockCreateTab).not.toHaveBeenCalled() expect(mockDispatchEvent).not.toHaveBeenCalled() resolveSpawn({ id: 'pty-slow' }) await expect(launch).resolves.toMatchObject({ ptyId: 'pty-slow' }) - expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-slow') + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + expect(mockCreateTab.mock.calls[0]?.[3]).toMatchObject({ + id: tabId, + initialPtyId: 'pty-slow' + }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'pty-slow') expect(mockDispatchEvent).toHaveBeenCalledWith( - expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } }) + expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: [tabId] } }) ) }) - it('kills a local PTY when its tab closes before spawn resolves', async () => { + it('kills a local PTY when its worktree disappears before spawn resolves', async () => { let resolveSpawn!: (result: { id: string }) => void mockSpawn.mockReturnValueOnce( new Promise<{ id: string }>((resolve) => { @@ -198,64 +213,66 @@ describe('launchAgentBackgroundSession', () => { worktreeId: 'wt-1', prompt: 'run slowly' }) - await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) - state.tabsByWorktree['wt-1'] = [] + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledOnce()) + state.worktreesByRepo['repo-1'] = [] resolveSpawn({ id: 'pty-after-close' }) await expect(launch).resolves.toBeNull() expect(mockKill).toHaveBeenCalledWith('pty-after-close') + expect(mockCreateTab).not.toHaveBeenCalled() expect(mockUpdateTabPtyId).not.toHaveBeenCalled() expect(mockSubscribeToPtyData).not.toHaveBeenCalled() expect(mockDispatchEvent).not.toHaveBeenCalled() }) - it('closes a runtime terminal when its tab closes before creation resolves', async () => { - useRemoteAgentBackgroundRuntime(state) - let resolveCreate!: (result: { - ok: true - result: { terminal: { handle: string; worktreeId: string; title: null } } - }) => void - const createResult = new Promise<{ - ok: true - result: { terminal: { handle: string; worktreeId: string; title: null } } - }>((resolve) => { - resolveCreate = resolve - }) - mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { - if (args.method === 'terminal.createAgentSession') { - return createResult - } - return Promise.resolve({ ok: true, result: {} }) - }) + it('launches into a folder workspace that is absent from worktreesByRepo throughout', async () => { + // Folder workspaces never appear in worktreesByRepo. + state.worktreesByRepo['repo-1'] = [] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/tmp/folder-workspace' } + ] + state.projectGroups = [{ id: 'grp-1', connectionId: null }] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' + ? { id: 'folder:fw-1', path: '/tmp/folder-workspace' } + : undefined const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const launch = launchAgentBackgroundSession({ agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run remotely' - }) - await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) - await vi.waitFor(() => - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ method: 'terminal.createAgentSession' }) - ) - ) - state.tabsByWorktree['wt-1'] = [] - resolveCreate({ - ok: true, - result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } } + worktreeId: 'folder:fw-1', + prompt: 'run in a folder workspace' }) - await expect(launch).resolves.toBeNull() - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + await expect(launch).resolves.toMatchObject({ ptyId: 'pty-1' }) + expect(mockKill).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledOnce() + }) + + it('launches a local WSL folder through wsl.exe', async () => { + const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project' + state.worktreesByRepo['repo-1'] = [] + state.folderWorkspaces = [ + { id: 'fw-wsl', projectGroupId: 'grp-wsl', folderPath, connectionId: null } + ] + state.projectGroups = [{ id: 'grp-wsl', connectionId: null }] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-wsl' ? { id: worktreeId, path: folderPath } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'folder:fw-wsl', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ - method: 'terminal.close', - params: { terminal: 'terminal-after-close' } + cwd: folderPath, + shellOverride: 'wsl.exe', + command: "claude '--dangerously-skip-permissions' 'run the automation'" }) ) - expect(mockUpdateTabPtyId).not.toHaveBeenCalled() - expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled() - expect(mockDispatchEvent).not.toHaveBeenCalled() }) it('records effective launch config returned by local PTY spawn', async () => { @@ -274,11 +291,12 @@ describe('launchAgentBackgroundSession', () => { }) const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) - const leafId = paneKey.slice('tab-1:'.length) + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + const leafId = paneKey.slice(`${tabId}:`.length) expect(mockRegisterAgentLaunchConfig).toHaveBeenLastCalledWith(paneKey, effectiveLaunchConfig, { agentType: 'claude', launchToken: mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN, - tabId: 'tab-1', + tabId, leafId }) }) @@ -317,7 +335,7 @@ describe('launchAgentBackgroundSession', () => { command: "claude '--dangerously-skip-permissions' 'don'\\''t use powershell quoting'", connectionId: null, worktreeId: 'wt-1', - tabId: 'tab-1' + tabId: expect.stringMatching(UUID_RE) }) ) }) @@ -440,13 +458,16 @@ describe('launchAgentBackgroundSession', () => { const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void sidecar(0) - expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') - expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/)) + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + expect(state.clearTabPtyId).toHaveBeenCalledWith(tabId, 'pty-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^${tabId}:`)) + ) expect(onExit).toHaveBeenCalledWith('pty-1', 0) expect(unsubscribe).toHaveBeenCalled() }) - it('removes the inactive tab if PTY spawn fails', async () => { + it('leaves no tab behind if PTY spawn fails', async () => { mockSpawn.mockRejectedValueOnce(new Error('spawn failed')) const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -458,11 +479,63 @@ describe('launchAgentBackgroundSession', () => { }) ).rejects.toThrow('spawn failed') - expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { + // Why: the tab is only created once a PTY is live, so a failed spawn has nothing to close. + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockCloseTab).not.toHaveBeenCalled() + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^${expectReservedAgentBackgroundTabId(mockSpawn)}:`)) + ) + expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + }) + + it('closes the adopted tab if binding fails after the PTY is live', async () => { + mockSubscribeToPtyData.mockImplementationOnce(() => { + throw new Error('subscribe failed') + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).rejects.toThrow('subscribe failed') + + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + expect(mockCloseTab).toHaveBeenCalledWith(tabId, { recordInteraction: false, reason: 'cleanup' }) - expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + expect(mockKill).toHaveBeenCalledWith('pty-1') + }) + + it('retires the launch instead of adopting a colliding tab id', async () => { + mockSpawn.mockImplementationOnce((args: { tabId: string }) => { + currentStoreState = { + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + 'wt-1': [{ id: args.tabId, title: 'Squatter' }] + } + } + return Promise.resolve({ id: 'pty-1' }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).resolves.toBeNull() + + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockKill).toHaveBeenCalledWith('pty-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expectStableAgentBackgroundPaneSpawn(mockSpawn) + ) }) it('submits prompts for stdin-after-start agents in background mode', async () => { @@ -479,7 +552,7 @@ describe('launchAgentBackgroundSession', () => { ) expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( expect.objectContaining({ - tabId: 'tab-1', + tabId: expectReservedAgentBackgroundTabId(mockSpawn), content: 'run the automation', agent: 'aider', submit: true @@ -523,299 +596,4 @@ describe('launchAgentBackgroundSession', () => { }) ) }) - - it('forwards Hermes startup queries through SSH command transport', async () => { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'hermes', - worktreeId: 'wt-1', - prompt: 'remote automation prompt' - }) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.objectContaining({ - command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), - connectionId: 'ssh-1', - env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' }) - }) - ) - }) - - it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe( - "claude '--dangerously-skip-permissions' 'run the automation'" - ) - expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined() - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "claude '--dangerously-skip-permissions' 'run the automation'\r" - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'", - startupCommandDelivery: 'shell-ready' - }) - ) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - state.settings = { - agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, - activeRuntimeEnvironmentId: null, - terminalMainSideEffectAuthority: undefined - } - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" - }) - ) - expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" - ) - } finally { - vi.useRealTimers() - } - }) - - it('does not rearm SSH background startup delivery after exit cleanup', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void - exitSidecar(0) - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('creates background sessions on the active runtime environment', async () => { - useRemoteAgentBackgroundRuntime(state) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - const result = await launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - - expect(mockSpawn).not.toHaveBeenCalled() - const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params - const leafId = params?.placement?.leafId - expect(leafId).toMatch(UUID_RE) - // Why: background launches have no explicit recipe override, so remote host settings win. - expect(params).not.toHaveProperty('agentArgs') - expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith( - `tab-1:${leafId}`, - { - agentCommand: "claude '--dangerously-skip-permissions'", - agentArgs: '--dangerously-skip-permissions', - agentEnv: {} - }, - { - agentType: 'claude', - launchToken: expect.stringMatching(UUID_RE), - tabId: 'tab-1', - leafId - } - ) - expect(mockSetTabLayout).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' } - }) - ) - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'terminal.createAgentSession', - params: expect.objectContaining({ - clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), - worktree: 'id:wt-1', - agent: 'claude', - prompt: 'run the automation', - promptDelivery: 'auto-submit', - placement: { tabId: 'tab-1', leafId }, - presentation: 'background' - }), - timeoutMs: 15_000 - }) - expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') - expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled() - expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'terminal.multiplex', - params: {} - }), - expect.any(Object) - ) - expect(result).toMatchObject({ - tabId: 'tab-1', - paneKey: `tab-1:${leafId}`, - ptyId: 'remote:env-1@@terminal-1', - terminalOwnership: null - }) - }) - - it('preserves the legacy background spawn on an old remote host', async () => { - useRemoteAgentBackgroundRuntime(state) - mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { - if (request.method === 'status.get') { - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId: 'old-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 2, - capabilities: [] - } - }) - } - return Promise.resolve({ - id: 'create', - ok: true, - result: { terminal: { handle: 'legacy-terminal-1' } } - }) - }) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await expect( - launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run remotely' - }) - ).resolves.toMatchObject({ ptyId: 'remote:env-1@@legacy-terminal-1' }) - - expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'terminal.create', - params: expect.objectContaining({ - worktree: 'id:wt-1', - command: "claude '--dangerously-skip-permissions' 'run remotely'", - launchAgent: 'claude', - presentation: 'background' - }) - }) - ) - }) - - it('closes a created runtime terminal when its data subscription fails', async () => { - useRemoteAgentBackgroundRuntime(state) - mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await expect( - launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - ).rejects.toThrow('subscription failed') - - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'terminal.close', - params: { terminal: 'terminal-1' }, - timeoutMs: undefined - }) - expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') - expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/)) - expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { - recordInteraction: false, - reason: 'cleanup' - }) - expect(mockDispatchEvent).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 9ab4a5e68..f65204d65 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -4,18 +4,15 @@ import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult } from '@/lib/agent-background-session-contract' -import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { scheduleAgentBackgroundDraft } from '@/lib/agent-background-draft-delivery' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { resolveAgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host' import { makePaneKey } from '../../../shared/stable-pane-id' import { registerEagerPtyBuffer, @@ -25,9 +22,7 @@ import { import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' -import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' -import { createBrowserUuid } from '@/lib/browser-uuid' +import { retireProvider } from '@/lib/retire-unowned-background-terminal' import { createRuntimeAgentBackgroundTerminal } from '@/lib/runtime-agent-background-create' import { subscribeToRuntimeTerminalData, @@ -38,42 +33,48 @@ import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startu import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' -import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' +import type { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' +import { + adoptAgentBackgroundSessionTab, + reserveAgentBackgroundSessionIdentity +} from '@/lib/adopt-agent-background-session-tab' import { createBackgroundAgentStatusConsumer } from '@/lib/background-agent-status-consumer' +import { isWslUncPath } from '../../../shared/wsl-paths' export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs ): Promise { const { agent, worktreeId, prompt, launchSource, title, onData, onExit, onAgentStatus } = args const store = useAppStore.getState() - const worktree = store.allWorktrees().find((entry) => entry.id === worktreeId) + // Folder workspaces exist only in getKnownWorktreeById (#2989). + const worktree = store.getKnownWorktreeById(worktreeId) const repo = worktree ? store.repos.find((entry) => entry.id === worktree.repoId) : null if (!worktree) { throw new Error('The target workspace is no longer available.') } + const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) + const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) + // Folder launch ownership cannot be derived from a repo row (#2989). + const launchHost = resolveAgentBackgroundLaunchHost({ + store, + worktreeId, + worktreePath: worktree.path, + repo + }) const preflight = TUI_AGENT_CONFIG[agent].preflightTrust if (preflight && worktree.path && window.api.agentTrust?.markTrusted) { try { await window.api.agentTrust.markTrusted({ preset: preflight, - workspacePath: worktree.path + workspacePath: worktree.path, + ...(launchHost.connectionId ? { connectionId: launchHost.connectionId } : {}) }) } catch { - // Best-effort: continue with launch. The user can still accept the trust menu. + // Best-effort: the user can still accept the trust prompt. } } - const cmdOverrides = store.settings?.agentCmdOverrides ?? {} - const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) - const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) - const launchPlatform = repo - ? getAgentLaunchPlatformForRepo( - repo, - repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) - ) - : CLIENT_PLATFORM - // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - // `orca-ide` rename must not be applied for remote launches. - const isRemote = repo ? repoIsRemote(repo) : false + const { platform: launchPlatform, isRemote } = launchHost const startupShell = resolveLocalWindowsAgentStartupShell({ platform: launchPlatform, isRemote, @@ -99,37 +100,17 @@ export async function launchAgentBackgroundSession( return null } - // Why: automation runs should start without revealing the workspace. - // Spawn the PTY immediately, then attach an inactive tab to the live session. - const tab = store.createTab(worktreeId, undefined, undefined, { - activate: false, - recordInteraction: false - }) - // Why: agent hook callbacks are keyed by pane, and background automation - // tabs never mount a TerminalPane to inject this env for us. createBrowserUuid - // (not crypto.randomUUID) because the latter is undefined in non-secure - // browser contexts — the LAN web client served over plain HTTP. - const leafId = createBrowserUuid() - const paneKey = makePaneKey(tab.id, leafId) - const launchToken = createBrowserUuid() - const launchRegistration = { - agentType: agent, - launchToken, - tabId: tab.id, - leafId - } - store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, launchRegistration) - // Why: `title` labels the tab/worktree entry. Pane titles render as an - // in-terminal title row, so background sessions must not persist it there. - store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId)) - const paneEnv = { - ...startupPlan.env, - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: tab.id, - ORCA_WORKTREE_ID: worktreeId, - ORCA_AGENT_LAUNCH_TOKEN: launchToken - } - const sshConnectionId = repo?.connectionId ?? null + // A hidden run tab must never be store-visible without its PTY (#2989). + const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } = + reserveAgentBackgroundSessionIdentity({ + store, + agentType: agent, + worktreeId, + launchConfig: startupPlan.launchConfig, + env: startupPlan.env + }) + let paneKey = makePaneKey(reservedTabId, leafId) + const sshConnectionId = launchHost.connectionId const sshStartupDelivery = createSshBackgroundStartupDelivery({ command: sshConnectionId ? startupPlan.launchCommand : null, waitForShellReady: @@ -147,6 +128,7 @@ export async function launchAgentBackgroundSession( let ptyId = '', runtimeTerminalHandle: string | null = null let returnedLaunchConfig: typeof startupPlan.launchConfig | undefined + let tab: ReturnType | null = null let exitHandled = false, eagerPtyBuffer: EagerPtyHandle | null = null let terminalOwnership: ReturnType = null @@ -160,7 +142,9 @@ export async function launchAgentBackgroundSession( unsubscribeExit() unsubscribeData() sshStartupDelivery.clear() - useAppStore.getState().clearTabPtyId(tab.id, exitPtyId) + if (tab) { + useAppStore.getState().clearTabPtyId(tab.id, exitPtyId) + } useAppStore.getState().clearAgentLaunchConfig(paneKey) onExit?.(exitPtyId, code) } @@ -174,7 +158,7 @@ export async function launchAgentBackgroundSession( paneKey, launchToken, mainOwnsAgentStatusWrites, - expectedConnectionId: repo ? (repo.connectionId ?? null) : undefined, + expectedConnectionId: launchHost.expectedConnectionId, runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null, getPtyId: () => ptyId, onAgentStatus @@ -192,7 +176,7 @@ export async function launchAgentBackgroundSession( const created = await createRuntimeAgentBackgroundTerminal({ environmentId: runtimeTarget.environmentId, worktreeId, - tabId: tab.id, + tabId: reservedTabId, leafId, agent, ...(hasPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}), @@ -216,6 +200,7 @@ export async function launchAgentBackgroundSession( rows: 40, cwd: worktree.path, command: startupPlan.launchCommand, + ...(!sshConnectionId && isWslUncPath(worktree.path) ? { shellOverride: 'wsl.exe' } : {}), ...(!startupPlan.startupCommandDelivery ? {} : { startupCommandDelivery: startupPlan.startupCommandDelivery }), @@ -225,7 +210,7 @@ export async function launchAgentBackgroundSession( launchAgent: agent, connectionId: sshConnectionId, worktreeId, - tabId: tab.id, + tabId: reservedTabId, leafId, telemetry: { agent_kind: tuiAgentToAgentKind(agent), @@ -236,25 +221,29 @@ export async function launchAgentBackgroundSession( ptyId = result.id returnedLaunchConfig = result.launchConfig } - if ( - await retireUnownedTerminal({ - tabId: tab.id, - ptyId, - runtimeTarget, - runtimeTerminalHandle, - onRetire: () => { - exitHandled = true - sshStartupDelivery.clear() - store.clearAgentLaunchConfig(paneKey) - } - }) - ) { + const adopted = await adoptAgentBackgroundSessionTab({ + store, + worktreeId, + reservedTabId, + ptyId, + paneKey, + launchConfig: returnedLaunchConfig ?? startupPlan.launchConfig, + launchRegistration, + runtimeTarget, + runtimeTerminalHandle, + onRetire: () => { + exitHandled = true + sshStartupDelivery.clear() + store.clearAgentLaunchConfig(paneKey) + }, + ...(title ? { title } : {}) + }) + if (!adopted) { return null } - if (returnedLaunchConfig) { - store.registerAgentLaunchConfig(paneKey, returnedLaunchConfig, launchRegistration) - } - terminalOwnership = bindAutomationTerminal(tab, paneKey, ptyId, runtimeTarget.kind, title) + tab = adopted.tab + paneKey = adopted.paneKey + terminalOwnership = adopted.terminalOwnership if (agent === 'command-code' && hasPrompt && !isFollowupPath) { // Why: Command Code does not expose a prompt-start hook; seed working for // hidden prompt launches so sidebar/activity surfaces do not stay idle. @@ -297,6 +286,7 @@ export async function launchAgentBackgroundSession( // alive regardless of whether the tab is hidden or mounted. unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) } + sshStartupDelivery.armFallback(ptyId) // Why: bind the explicit PTY and ownership before mount; an earlier mount // can double-spawn, while later tracking can miss user takeover. @@ -312,19 +302,23 @@ export async function launchAgentBackgroundSession( // A failure between them must not strand an invisible runtime terminal. exitHandled = true terminalOwnership?.release() + const createdTab = tab runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) - runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId)) + if (createdTab) { + runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(createdTab.id, ptyId)) + } runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey)) if (ptyId) { await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle }) } - // Why: a launch-failure cleanup close is not a user close — keep it out of - // the Cmd+Shift+T reopen stack. - runBestEffortAgentBackgroundCleanups(() => - store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' }) - ) + if (createdTab) { + // Cleanup closes must not enter the reopen stack. + runBestEffortAgentBackgroundCleanups(() => + store.closeTab(createdTab.id, { recordInteraction: false, reason: 'cleanup' }) + ) + } throw error } } diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 85a025984..4c4c1603d 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -183,7 +183,7 @@ async function createBackgroundTab(args: { } if ( await retireUnownedTerminal({ - tabId: tab.id, + owner: { tabId: tab.id }, ptyId, runtimeTarget: { kind: 'local' } }) @@ -215,7 +215,7 @@ async function addSetupSplit(args: { }) if ( await retireUnownedTerminal({ - tabId: args.tab.tabId, + owner: { tabId: args.tab.tabId }, ptyId: setupPtyId, runtimeTarget: { kind: 'local' } }) diff --git a/src/renderer/src/lib/retire-unowned-background-terminal.ts b/src/renderer/src/lib/retire-unowned-background-terminal.ts index 7a7f12812..40a0b0afb 100644 --- a/src/renderer/src/lib/retire-unowned-background-terminal.ts +++ b/src/renderer/src/lib/retire-unowned-background-terminal.ts @@ -3,17 +3,24 @@ import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc- import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' export async function retireUnownedTerminal(args: { - tabId: string + /** Present tab id, or `{ worktreeId }` for a launch whose tab is created after the spawn. */ + owner: { tabId: string } | { worktreeId: string } ptyId: string runtimeTarget: RuntimeClientTarget runtimeTerminalHandle?: string | null onRetire?: () => void }): Promise { - if (isTerminalTabPresent(useAppStore.getState(), args.tabId)) { + const state = useAppStore.getState() + const owner = args.owner + const isOwned = + 'tabId' in owner + ? isTerminalTabPresent(state, owner.tabId) + : // Folder workspaces exist only in getKnownWorktreeById. + state.getKnownWorktreeById(owner.worktreeId) !== undefined + if (isOwned) { return false } - // Why: close can win while provider creation is in flight, before the - // returned handle is bindable to store state or visible to tab retirement. + // Close can win before the provider is bindable to store state. args.onRetire?.() await retireProvider(args) return true diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts index eb178fd59..f117ca447 100644 --- a/src/renderer/src/lib/ssh-background-startup-delivery.ts +++ b/src/renderer/src/lib/ssh-background-startup-delivery.ts @@ -14,6 +14,7 @@ type SshBackgroundStartupDeliveryOptions = { export type SshBackgroundStartupDelivery = { handleData(data: string): string + armFallback(ptyId: string): void schedule(ptyId: string): void clear(): void } @@ -51,22 +52,28 @@ export function createSshBackgroundStartupDelivery( } } + const armFallback = (ptyId: string): void => { + lastPtyId = ptyId + if (!pendingCommand || fallbackTimer !== null) { + return + } + fallbackTimer = setTimeout(() => { + fallbackTimer = null + startupShellReady = true + schedule(ptyId) + }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) + } + const schedule = (ptyId: string): void => { lastPtyId = ptyId if (!pendingCommand) { return } if (!startupShellReady) { - if (fallbackTimer === null) { - // Why: hidden SSH sessions can use shells that cannot emit Orca's - // marker. Prefer readiness, but never drop the startup command forever. - fallbackTimer = setTimeout(() => { - fallbackTimer = null - markShellReady() - }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) - } + armFallback(ptyId) return } + clearFallbackTimer() clearInjectTimer() injectTimer = setTimeout(() => { injectTimer = null @@ -102,6 +109,7 @@ export function createSshBackgroundStartupDelivery( } return scanned.output }, + armFallback, schedule, clear() { clearInjectTimer()