mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user