fix(automations): bind agent terminal output before publishing, and launch SSH folder workspaces on their own host (#10818)

* fix(terminal): bind the agent PTY before the run tab is ever published

Why: launchAgentBackgroundSession created the hidden run tab synchronously and
only then awaited the agent spawn, so the store briefly held a tab with
ptyId: null. Terminal.tsx re-renders on that write, and for an already-visited
worktree the tab can neither cold-park nor defer — a TerminalPane mounts, finds
no adopt candidate, and starts a fresh default shell. When the agent PTY
finally resolves it is rebound in state but the mounted pane still holds the
shell, so the user sees a bare prompt and the agent PTY is orphaned (#2989).

Reserve the tab id before the spawn and create the tab already bound to the
live PTY, with no await in between.

* fix(terminal): resolve folder workspaces, and fail closed on a tab-id collision

allWorktrees() reads only worktreesByRepo, so every folder workspace looked
absent and its automation died at resolution. getKnownWorktreeById covers both.
On a reserved-id collision, re-keying the tab could never work: ORCA_TAB_ID and
ORCA_PANE_KEY are already baked into the spawned process, so routing and hook
identity would permanently disagree. Retire the launch instead.

Co-authored-by: Orca <help@stably.ai>

* test(automations): split the background-session suite so it stays under the 800-line cap

Co-authored-by: Orca <help@stably.ai>

* fix(automations): route folder-workspace agent launches to their owning SSH host

Review of the bind-before-publish fix surfaced a second defect on the path it
newly makes reachable. A folder workspace has no repo row — its synthetic
repoId is `folder-workspace:<groupId>` — so `repos.find(...)` returns null and
every repo-derived launch input silently degraded to a local default:
connectionId null, platform CLIENT_PLATFORM, isRemote false. The automation
then spawned on the user's machine with a cwd that only exists on the SSH host.

Before this branch that path threw before reaching the spawn, so the bug was
latent; making folder-workspace automations work is what exposes it.

Host resolution now goes through resolveAgentBackgroundLaunchHost, which falls
back to the workspace scope (the same getFolderWorkspaceConnectionId that
ordinary terminal creation uses) when there is no repo. Ambiguous scopes —
mixed local/remote children — still resolve to a local launch rather than
guessing a host.

Extracted to its own module rather than inlined: the added branch pushed
launch-agent-background-session.ts over the 300-line oxlint cap, and a
max-lines disable is forbidden.

Tests: an SSH folder workspace must spawn with its connectionId and remote cwd;
a local one must stay local. Reverting the fallback fails the first and leaves
the rest green.

* fix(automations): close folder dispatch and adoption races

Read live state before adopting a reserved tab so a collision that lands during the spawn is retired instead of re-keyed. Route persisted folder-workspace dispatch through its ambiguity-aware owner, including SSH auth, remote trust, quiet-shell fallback, and WSL shell selection.

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-27 17:03:58 -07:00 committed by GitHub
parent 974447175f
commit 1fd0f731fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1329 additions and 489 deletions

View File

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

View File

@ -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<string, TestWorktree[]>,
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 } = {}

View File

@ -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<string>()
@ -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,

View File

@ -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<typeof useAppStore.getState>
type RegisterArgs = Parameters<Store['registerAgentLaunchConfig']>
/** 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<string, string> | undefined
}): {
reservedTabId: string
leafId: string
paneKey: PaneKey
launchToken: string
launchRegistration: NonNullable<RegisterArgs[2]>
paneEnv: Record<string, string>
} {
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<RegisterArgs[2]>
runtimeTarget: RuntimeClientTarget
runtimeTerminalHandle: string | null
onRetire: () => void
title?: string
}): Promise<{
tab: ReturnType<Store['createTab']>
paneKey: PaneKey
terminalOwnership: ReturnType<typeof bindAutomationTerminal>
} | 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 }
}

View File

@ -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')
})
})

View File

@ -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<typeof useAppStore.getState>
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
}
}

View File

@ -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<string, { status: string }>
transientClearedAgentStatusConnectionIds: Record<string, true>
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<string, { status: string }>(),
transientClearedAgentStatusConnectionIds: {} as Record<string, true>,
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
})

View File

@ -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' })
)
})
})

View File

@ -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()
})
})

View File

@ -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<LaunchAgentBackgroundSessionResult | null> {
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<typeof store.createTab> | null = null
let exitHandled = false,
eagerPtyBuffer: EagerPtyHandle | null = null
let terminalOwnership: ReturnType<typeof bindAutomationTerminal> = 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
}
}

View File

@ -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' }
})

View File

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

View File

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