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.
This commit is contained in:
Neil
2026-07-26 18:02:13 -07:00
parent 34af4a8141
commit 233a03f347
4 changed files with 158 additions and 15 deletions
@@ -0,0 +1,84 @@
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'
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
/**
* Connection the agent-status consumer should accept writes from. `undefined`
* means "unknown ownership, accept anything" — the pre-existing behavior for
* workspaces with no resolvable owner.
*/
expectedConnectionId: string | null | undefined
}
/**
* SSH connection owning a folder-workspace selector, or null otherwise. Returns
* null when ownership is ambiguous (mixed local/remote children) so an unclear
* scope fails to a local launch rather than to the wrong host.
*/
function resolveFolderWorkspaceConnectionIdForLaunch(
store: LaunchStore,
worktreeId: string
): string | null {
const parsed = parseWorkspaceKey(worktreeId)
if (parsed?.type !== 'folder') {
return null
}
return getFolderWorkspaceConnectionId(store, parsed.folderWorkspaceId) ?? null
}
/**
* Resolve which host an automation's agent session runs on.
*
* Why this is not just `repo.connectionId`: a folder workspace has no repo row —
* its synthetic repoId is `folder-workspace:<groupId>` — so repo-derived routing
* finds nothing and falls back to a LOCAL spawn using a path that exists only on
* the SSH host. Ownership has to come from the workspace scope, matching how
* ordinary terminal creation resolves it (#2989).
*/
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)
return {
connectionId: folderWorkspaceConnectionId,
// A remote folder workspace runs under the host's own shell; only a
// Windows-style path marks a win32 host (same rule as the repo path above).
platform: folderWorkspaceConnectionId
? isWindowsAbsolutePathLike(worktreePath ?? '')
? 'win32'
: 'linux'
: CLIENT_PLATFORM,
isRemote: Boolean(folderWorkspaceConnectionId),
// A resolved folder-workspace connection is as authoritative as a repo's.
expectedConnectionId: folderWorkspaceConnectionId ?? 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 }[]
@@ -88,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,
@@ -123,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': [
{
@@ -434,4 +434,54 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery',
})
expect(mockDispatchEvent).not.toHaveBeenCalled()
})
it('spawns an SSH folder-workspace automation on the owning host, not locally', async () => {
// A folder workspace has no repo row — its synthetic repoId is
// `folder-workspace:<groupId>` — so repo-derived routing resolves no
// connection and spawns LOCALLY with a path that only exists on the remote
// host. Ownership must come from the workspace scope instead (#2989).
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: 'claude',
worktreeId: 'folder:fw-1',
prompt: 'run the automation'
})
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,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,
@@ -69,15 +66,16 @@ export async function launchAgentBackgroundSession(
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
// `orca-ide` rename must not be applied for remote launches. Folder workspaces
// have no repo row, so host resolution cannot go through `repo` alone (#2989).
const launchHost = resolveAgentBackgroundLaunchHost({
store,
worktreeId,
worktreePath: worktree.path,
repo
})
const { platform: launchPlatform, isRemote } = launchHost
const startupShell = resolveLocalWindowsAgentStartupShell({
platform: launchPlatform,
isRemote,
@@ -115,7 +113,7 @@ export async function launchAgentBackgroundSession(
env: startupPlan.env
})
let paneKey = makePaneKey(reservedTabId, leafId)
const sshConnectionId = repo?.connectionId ?? null
const sshConnectionId = launchHost.connectionId
const sshStartupDelivery = createSshBackgroundStartupDelivery({
command: sshConnectionId ? startupPlan.launchCommand : null,
waitForShellReady:
@@ -163,7 +161,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