mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
fix(agents): keep the global Windows runtime for local detection inside SSH workspaces
The no-project fallback for the configured Windows runtime default bailed out whenever any repo or worktree was active, so an SSH workspace (or a stale active repo id) sent local agent detection to the raw Windows host. Refresh in the status bar then overwrote detectedAgentIds with the host's empty list and the usage meters vanished. Only a local project can own the runtime; defer to it, otherwise apply the global default under one shared cache key. Fixes #18837
This commit is contained in:
@@ -68,13 +68,39 @@ describe('useActiveProjectSkillRuntime', () => {
|
||||
expect(result.current.discoveryTarget).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not adopt the global default once a project is active', () => {
|
||||
it('lets an active local project override the global default', () => {
|
||||
setGlobalWslDefault('Ubuntu')
|
||||
useAppStore.setState({ activeRepoId: 'repo-1' })
|
||||
useAppStore.setState({
|
||||
activeRepoId: 'repo-1',
|
||||
repos: [{ id: 'repo-1', path: 'C:\\repo', displayName: 'r', badgeColor: 'b', addedAt: 1 }],
|
||||
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] as never
|
||||
})
|
||||
const { result } = renderHook(() => useActiveProjectSkillRuntime())
|
||||
|
||||
expect(result.current.agentRuntime).toBeUndefined()
|
||||
useAppStore.setState({ activeRepoId: null })
|
||||
expect(result.current.agentRuntime).toEqual({ runtime: 'host', label: 'Windows' })
|
||||
useAppStore.setState({ activeRepoId: null, repos: [], projects: [] })
|
||||
})
|
||||
|
||||
it('keeps the global WSL default while an SSH project is active', () => {
|
||||
setGlobalWslDefault('Ubuntu')
|
||||
useAppStore.setState({
|
||||
activeRepoId: 'repo-ssh',
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/home/alice/repo',
|
||||
displayName: 'r',
|
||||
badgeColor: 'b',
|
||||
addedAt: 1,
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
}
|
||||
]
|
||||
})
|
||||
const { result } = renderHook(() => useActiveProjectSkillRuntime())
|
||||
|
||||
expect(result.current.agentRuntime).toMatchObject({ runtime: 'wsl' })
|
||||
useAppStore.setState({ activeRepoId: null, repos: [] })
|
||||
})
|
||||
|
||||
it('does not inject the local WSL runtime or shell into a remote environment', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
getGlobalWindowsExecutionRuntimeContext,
|
||||
getLocalAgentPreflightContext,
|
||||
getLocalPreflightContext,
|
||||
getLocalProjectExecutionRuntimeContext,
|
||||
@@ -375,18 +376,54 @@ describe('local preflight context', () => {
|
||||
expect(localPreflightContextKey(context)).toBe('local-project:wsl:Ubuntu')
|
||||
})
|
||||
|
||||
it('does not use the global runtime default for active SSH projects', () => {
|
||||
it('keeps the global WSL runtime for local agent checks inside an active SSH project', () => {
|
||||
const state = {
|
||||
...makeState({
|
||||
repoPath: '/home/alice/repo',
|
||||
repo: { connectionId: 'builder', executionHostId: 'ssh:builder' }
|
||||
worktreePath: '/home/alice/repo',
|
||||
repo: { connectionId: 'builder', executionHostId: 'ssh:builder' },
|
||||
worktree: { hostId: 'ssh:builder' }
|
||||
}),
|
||||
settings: {
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
} as unknown as AppState
|
||||
|
||||
expect(getLocalAgentPreflightContext(state, 'win32')).toBeUndefined()
|
||||
// Why: the SSH host owns remote execution, but the local CLIs this probe
|
||||
// looks for still live in the configured Windows runtime, not the raw host.
|
||||
expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe(
|
||||
'local-project:wsl:Ubuntu'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the global WSL runtime when the active repo id has no repo row', () => {
|
||||
const state = {
|
||||
...makeState({ repoPath: undefined }),
|
||||
activeRepoId: 'ghost',
|
||||
activeWorktreeId: null,
|
||||
settings: {
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
} as unknown as AppState
|
||||
|
||||
expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe(
|
||||
'local-project:wsl:Ubuntu'
|
||||
)
|
||||
})
|
||||
|
||||
it('lets a local project own the runtime instead of the global default', () => {
|
||||
const state = {
|
||||
...makeState({ repoPath: 'C:\\Users\\alice\\repo' }),
|
||||
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }],
|
||||
settings: {
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
} as unknown as AppState
|
||||
|
||||
expect(getGlobalWindowsExecutionRuntimeContext(state, undefined, 'win32')).toBeUndefined()
|
||||
expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe(
|
||||
'repo-1:windows-host'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the project override over legacy agent location for local agent checks', () => {
|
||||
|
||||
@@ -41,6 +41,9 @@ type LocalProjectRuntimeState = Pick<
|
||||
// or `[]` fallback would miss the cache on every read.
|
||||
const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
|
||||
const EMPTY_REPOS: AppState['repos'] = []
|
||||
// Why: the global Windows default is one runtime regardless of which remote
|
||||
// workspace is active, so every non-owned caller shares this cache key.
|
||||
const GLOBAL_LOCAL_PROJECT_ID = 'local-project'
|
||||
|
||||
type LocalProjectRuntimeWslContext = {
|
||||
wslAvailable?: boolean
|
||||
@@ -91,7 +94,7 @@ export function getLocalProjectExecutionRuntimeContext(
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolves the Windows default only when no project can own the runtime. */
|
||||
/** Resolves the Windows default only when no local project can own the runtime. */
|
||||
export function getGlobalWindowsExecutionRuntimeContext(
|
||||
state: LocalProjectRuntimeState,
|
||||
worktreeId?: string | null,
|
||||
@@ -100,16 +103,22 @@ export function getGlobalWindowsExecutionRuntimeContext(
|
||||
): ProjectExecutionRuntimeResolution | undefined {
|
||||
if (
|
||||
appPlatform !== 'win32' ||
|
||||
worktreeId ||
|
||||
state.activeRepoId ||
|
||||
state.activeWorktreeId ||
|
||||
worktreeId === FLOATING_TERMINAL_WORKTREE_ID ||
|
||||
!state.settings?.localWindowsRuntimeDefault
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
// Why: an SSH/runtime workspace (or a stale active id) cannot own the local
|
||||
// Windows runtime, and the local CLIs still live where the default says.
|
||||
// Only a local project owner may displace the global default.
|
||||
const worktree = getLocalWorktree(state, worktreeId)
|
||||
const repo = getLocalRuntimeRepoForWorktree(state, worktree)
|
||||
if (isLocalRuntimeRepo(repo) && isLocalRuntimeWorktree(worktree)) {
|
||||
return undefined
|
||||
}
|
||||
return resolveProjectExecutionRuntime({
|
||||
appPlatform: 'win32',
|
||||
projectId: getLocalPreflightProjectId(state, worktreeId),
|
||||
projectId: GLOBAL_LOCAL_PROJECT_ID,
|
||||
projectRuntimePreference: { kind: 'inherit-global' },
|
||||
globalWindowsRuntimeDefault: state.settings.localWindowsRuntimeDefault,
|
||||
...wslContext
|
||||
@@ -323,6 +332,9 @@ function getLocalPreflightProjectId(
|
||||
): string {
|
||||
const activeWorktree = getLocalWorktree(state, worktreeId)
|
||||
return (
|
||||
activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project'
|
||||
activeWorktree?.projectId ??
|
||||
activeWorktree?.repoId ??
|
||||
state.activeRepoId ??
|
||||
GLOBAL_LOCAL_PROJECT_ID
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { createDetectedAgentsSlice } from './detected-agents'
|
||||
|
||||
const detectAgents = vi.fn()
|
||||
const refreshAgents = vi.fn()
|
||||
|
||||
globalThis.window = {
|
||||
api: {
|
||||
preflight: { detectAgents, refreshAgents },
|
||||
platform: { get: () => ({ platform: 'win32' }) }
|
||||
} as unknown as Window['api']
|
||||
} as Window & typeof globalThis
|
||||
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/home/alice/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
} satisfies Repo
|
||||
|
||||
const sshWorktree = {
|
||||
id: 'repo-ssh::main',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/alice/repo',
|
||||
hostId: 'ssh:builder'
|
||||
} as Worktree
|
||||
|
||||
function createTestStore() {
|
||||
const store = create<AppState>()(
|
||||
(...args) => createDetectedAgentsSlice(...args) as unknown as AppState
|
||||
)
|
||||
store.setState({
|
||||
settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } },
|
||||
repos: [sshRepo],
|
||||
projects: [],
|
||||
worktreesByRepo: { 'repo-ssh': [sshWorktree] },
|
||||
activeRepoId: 'repo-ssh',
|
||||
activeWorktreeId: 'repo-ssh::main'
|
||||
} as Partial<AppState>)
|
||||
return store
|
||||
}
|
||||
|
||||
describe('local detected agents inside an SSH workspace', () => {
|
||||
beforeEach(() => {
|
||||
// Why: mirrors the reported machine -- CLIs live in WSL, the Windows host has none.
|
||||
const agentsFor = (context: { projectRuntime?: { runtime?: { kind?: string } } }) =>
|
||||
context?.projectRuntime?.runtime?.kind === 'wsl' ? ['claude', 'codex'] : []
|
||||
detectAgents.mockReset().mockImplementation(async (context) => agentsFor(context))
|
||||
refreshAgents.mockReset().mockImplementation(async (context) => ({
|
||||
agents: agentsFor(context),
|
||||
addedPathSegments: [],
|
||||
shellHydrationOk: true,
|
||||
pathSource: 'shell_hydrate',
|
||||
pathFailureReason: 'none'
|
||||
}))
|
||||
})
|
||||
|
||||
it('refreshes in the global WSL runtime instead of the raw Windows host', async () => {
|
||||
const store = createTestStore()
|
||||
|
||||
await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude', 'codex'])
|
||||
await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['claude', 'codex'])
|
||||
|
||||
const detectContext = detectAgents.mock.calls[0]?.[0]
|
||||
expect(detectContext).toMatchObject({
|
||||
projectRuntime: { runtime: { kind: 'wsl', distro: 'Ubuntu', reason: 'global-default' } }
|
||||
})
|
||||
// Why: Refresh must re-detect in the runtime the visible list came from (#18837).
|
||||
expect(refreshAgents).toHaveBeenCalledWith(detectContext)
|
||||
expect(store.getState().detectedAgentIds).toEqual(['claude', 'codex'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user