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:
parent
974447175f
commit
1fd0f731fc
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 } = {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue