fix(agents): an old Windows or unknown-platform host asks to update instead of answering its default list (#27164)

* fix(agents): an old Windows or unknown-platform host asks to update instead of answering its default list

A host without preflight.workspace-scoped.v1 answers a workspace's agent
request with its own default list. On a Windows host that list misses a WSL
workspace's agents, and a host that does not report its platform may be
Windows. Only a known non-Windows host's default is still used; the others
record an empty list plus an update notice, shown in the quick-launch menu.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(agents): decide the old-host fallback from a fresh status

Reading the platform from the shared capability cache could see a probe
another caller had just started (no status yet) and flag a Linux host. A
fresh status is race-free and also catches an in-place upgrade.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(agents): name the served host status for its role

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
OrcaWin
2026-10-09 23:21:39 -07:00
committed by GitHub
co-authored by Claude m4air
parent abff7ab86a
commit ebeafbd205
10 changed files with 251 additions and 34 deletions
@@ -117,7 +117,7 @@ function QuickLaunchAgentMenuItemsInner({
// instead of the remote server's. Use the same ssh/runtime/local owner
// resolution as the rest of the tab bar.
const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId)
const { detectedIds } = useDetectedAgents(agentDetectionTarget)
const { detectedIds, needsServerUpdate } = useDetectedAgents(agentDetectionTarget)
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const disabledAgents = useAppStore(
(s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS
@@ -230,12 +230,20 @@ function QuickLaunchAgentMenuItemsInner({
disabled
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 text-muted-foreground"
>
{detectedIds && detectedIds.length > 0
? translate('auto.components.tab.bar.QuickLaunchButton.8dea9b5cdf', 'No enabled agents')
: translate(
'auto.components.tab.bar.QuickLaunchButton.e518f544b1',
'No agents detected'
)}
{needsServerUpdate
? translate(
'auto.components.tab.bar.QuickLaunchButton.needsServerUpdate',
'Update Orca on this server to list this workspace’s agents'
)
: detectedIds && detectedIds.length > 0
? translate(
'auto.components.tab.bar.QuickLaunchButton.8dea9b5cdf',
'No enabled agents'
)
: translate(
'auto.components.tab.bar.QuickLaunchButton.e518f544b1',
'No agents detected'
)}
</DropdownMenuItem>
) : null}
{agents.map((agent) => {
+8 -1
View File
@@ -10,6 +10,8 @@ export type UseDetectedAgentsResult = {
/** True when the first probe for this mounted remote target finished without a result. */
detectionFailed: boolean
isRefreshing: boolean
/** True when the runtime host is too old to list this workspace's agents. */
needsServerUpdate: boolean
/** Forces a re-detect on the target host (`preflight.refreshAgents` for
* local/runtime targets, a fresh probe for SSH) and updates every
* subscribed surface in the same tick. Idempotent while in flight:
@@ -124,6 +126,11 @@ export function useDetectedAgents(
? (s.isRefreshingLocalAgentsByContext[localContextKey] ?? false)
: s.isRefreshingAgents
})
const needsServerUpdate = useAppStore((s) =>
targetKind === 'runtime' && recordKey
? s.runtimeAgentDetectionNeedsServerUpdate[recordKey] === true
: false
)
const detectionFailed =
detectedIds === null &&
!isLoading &&
@@ -198,5 +205,5 @@ export function useDetectedAgents(
localContextKey
])
return { detectedIds, isLoading, detectionFailed, isRefreshing, refresh }
return { detectedIds, isLoading, detectionFailed, isRefreshing, needsServerUpdate, refresh }
}
+2 -1
View File
@@ -3640,7 +3640,8 @@
"ec2adf093e": "Launch {{value0}} in a new terminal",
"465e432ef1": "Could not build launch command for {{value0}}.",
"e518f544b1": "No agents detected",
"8dea9b5cdf": "No enabled agents"
"8dea9b5cdf": "No enabled agents",
"needsServerUpdate": "Update Orca on this server to list this workspace’s agents"
},
"RecentTabSwitcher": {
"329638ff6f": "Switch Tab",
+2 -1
View File
@@ -3426,7 +3426,8 @@
"ec2adf093e": "Iniciar {{value0}} en un nuevo terminal",
"465e432ef1": "No se pudo generar el comando de inicio para {{value0}}.",
"e518f544b1": "No se detectaron agentes",
"8dea9b5cdf": "No hay agentes habilitados"
"8dea9b5cdf": "No hay agentes habilitados",
"needsServerUpdate": "Actualiza Orca en este servidor para ver los agentes de este espacio de trabajo"
},
"RecentTabSwitcher": {
"329638ff6f": "Cambiar pestaña",
+2 -1
View File
@@ -3531,7 +3531,8 @@
"ec2adf093e": "Lancer {{value0}} dans un nouveau terminal",
"465e432ef1": "Impossible de construire la commande de lancement pour {{value0}}.",
"e518f544b1": "Aucun agent détecté",
"8dea9b5cdf": "Aucun agent activé"
"8dea9b5cdf": "Aucun agent activé",
"needsServerUpdate": "Mettez à jour Orca sur ce serveur pour lister les agents de cet espace de travail"
},
"RecentTabSwitcher": {
"329638ff6f": "Changer d'onglet",
+2 -1
View File
@@ -3426,7 +3426,8 @@
"ec2adf093e": "新規ターミナルで {{value0}} を起動",
"465e432ef1": "{{value0}} の起動コマンドを作成できませんでした。",
"e518f544b1": "Agent が検出されませんでした",
"8dea9b5cdf": "有効な Agent がありません"
"8dea9b5cdf": "有効な Agent がありません",
"needsServerUpdate": "このワークスペースの Agent を表示するには、このサーバーの Orca を更新してください"
},
"RecentTabSwitcher": {
"329638ff6f": "タブの切り替え",
+2 -1
View File
@@ -3426,7 +3426,8 @@
"ec2adf093e": "새 terminal에서 {{value0}} 실행",
"465e432ef1": "{{value0}}에 대한 실행 명령을 작성할 수 없습니다.",
"e518f544b1": "감지된 agents가 없습니다.",
"8dea9b5cdf": "활성화된 agents가 없습니다."
"8dea9b5cdf": "활성화된 agents가 없습니다.",
"needsServerUpdate": "이 워크스페이스의 에이전트를 보려면 이 서버의 Orca를 업데이트하세요"
},
"RecentTabSwitcher": {
"329638ff6f": "탭 전환",
+2 -1
View File
@@ -3426,7 +3426,8 @@
"ec2adf093e": "在新终端中启动 {{value0}}",
"465e432ef1": "无法为 {{value0}} 构建启动命令。",
"e518f544b1": "未检测到智能体",
"8dea9b5cdf": "没有启用智能体"
"8dea9b5cdf": "没有启用智能体",
"needsServerUpdate": "请更新此服务器上的 Orca 以列出此工作区的智能体"
},
"RecentTabSwitcher": {
"329638ff6f": "切换选项卡",
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import { getRuntimeAgentInventoryKey } from './runtime-agent-inventory-key'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY,
RUNTIME_PROTOCOL_VERSION
} from '../../../../shared/protocol-version'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
const runtimeEnvironmentCall = vi.fn()
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeEnvironmentCall } } })
type ServedHostStatus = { hostPlatform?: NodeJS.Platform; scoped: boolean }
function serveHost(host: ServedHostStatus): void {
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
const result =
method === 'status.get'
? {
runtimeId: 'remote-runtime',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
capabilities: host.scoped ? [PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY] : [],
...(host.hostPlatform ? { hostPlatform: host.hostPlatform } : {})
}
: method === 'preflight.refreshAgents'
? { agents: ['codex'] }
: ['codex']
return Promise.resolve({ id: method, ok: true, result, _meta: { runtimeId: 'remote-runtime' } })
})
}
function agentCalls(): unknown[] {
return runtimeEnvironmentCall.mock.calls
.map(([request]) => request)
.filter((request: { method: string }) => request.method !== 'status.get')
}
const KEY = getRuntimeAgentInventoryKey('env-1', 'wt-1')
describe('workspace agent detection on a host without workspace-scoped detection', () => {
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
runtimeEnvironmentCall.mockReset()
})
it.each([
['a Windows host', { hostPlatform: 'win32' as const, scoped: false }],
['a host that does not report its platform', { scoped: false }]
])('asks to update %s instead of answering its default list', async (_label, host) => {
serveHost(host)
const store = createTestStore()
await expect(store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual([])
expect(agentCalls()).toEqual([])
expect(store.getState().runtimeDetectedAgentIds[KEY]).toEqual([])
expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBe(true)
expect(store.getState().isDetectingRuntimeAgents[KEY]).toBe(false)
})
it('replaces a cached list with the update notice on refresh', async () => {
serveHost({ hostPlatform: 'win32', scoped: false })
const store = createTestStore()
store.setState({ runtimeDetectedAgentIds: { [KEY]: ['claude'] } })
await expect(store.getState().refreshRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual(
[]
)
expect(agentCalls()).toEqual([])
expect(store.getState().runtimeDetectedAgentIds[KEY]).toEqual([])
expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBe(true)
expect(store.getState().isRefreshingRuntimeAgents[KEY]).toBe(false)
})
it('still answers with the default list of a known non-Windows host', async () => {
serveHost({ hostPlatform: 'linux', scoped: false })
const store = createTestStore()
await expect(store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual([
'codex'
])
expect(agentCalls()).toEqual([
expect.objectContaining({ method: 'preflight.detectAgents', params: undefined })
])
expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBeUndefined()
})
it('answers concurrent workspaces of a known non-Windows host with its default list', async () => {
serveHost({ hostPlatform: 'linux', scoped: false })
const store = createTestStore()
const lists = await Promise.all([
store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1'),
store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-2')
])
expect(lists).toEqual([['codex'], ['codex']])
expect(store.getState().runtimeAgentDetectionNeedsServerUpdate).toEqual({})
})
it('still answers the host default list of an old Windows host', async () => {
serveHost({ hostPlatform: 'win32', scoped: false })
const store = createTestStore()
await expect(store.getState().ensureRuntimeDetectedAgents('env-1')).resolves.toEqual(['codex'])
expect(store.getState().runtimeAgentDetectionNeedsServerUpdate).toEqual({})
})
it('clears the notice once the host is updated', async () => {
serveHost({ hostPlatform: 'win32', scoped: false })
const store = createTestStore()
await store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')
serveHost({ hostPlatform: 'win32', scoped: true })
await expect(store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')).resolves.toEqual([
'codex'
])
expect(agentCalls()).toEqual([
expect.objectContaining({ method: 'preflight.detectAgents', params: { worktreeId: 'wt-1' } })
])
expect(store.getState().runtimeDetectedAgentIds[KEY]).toEqual(['codex'])
expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBeUndefined()
})
it('drops the notice with its environment', async () => {
serveHost({ hostPlatform: 'win32', scoped: false })
const store = createTestStore()
await store.getState().ensureRuntimeDetectedAgents('env-1', 'wt-1')
store.getState().clearRuntimeDetectedAgents('env-1')
expect(store.getState().runtimeAgentDetectionNeedsServerUpdate).toEqual({})
})
})
@@ -3,6 +3,7 @@ import type { AppState } from '../types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import {
callRuntimeRpc,
getRuntimeEnvironmentStatus,
RuntimeRpcCallError,
runtimeEnvironmentSupportsCapability
} from '@/runtime/runtime-rpc-client'
@@ -21,6 +22,8 @@ export type RuntimeDetectedAgentsSlice = {
runtimeDetectedAgentIds: Record<string, TuiAgent[] | null>
isDetectingRuntimeAgents: Record<string, boolean>
isRefreshingRuntimeAgents: Record<string, boolean>
/** Keys whose host is too old to list that workspace's agents; their list is empty. */
runtimeAgentDetectionNeedsServerUpdate: Record<string, boolean>
ensureRuntimeDetectedAgents: (
environmentId: string,
worktreeId?: string | null
@@ -48,10 +51,17 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean {
return error instanceof RuntimeRpcCallError && error.code === 'method_not_found'
}
export class RuntimeAgentDetectionNeedsServerUpdateError extends Error {
constructor() {
super("Update Orca on this server to list this workspace's agents.")
this.name = 'RuntimeAgentDetectionNeedsServerUpdateError'
}
}
/**
* Sends `preflight.detectAgents` / `refreshAgents` scoped to a workspace. A host without the
* workspace-scoped capability would discard the params and answer with its own default, so it is
* asked for exactly that. The host default goes out synchronously, as it always has.
* workspace-scoped capability can only answer its own default, which is the workspace's list only
* on a known non-Windows host. The host default goes out synchronously, as it always has.
*/
export function callRuntimeAgentDetection<T>(
environmentId: string,
@@ -61,10 +71,22 @@ export function callRuntimeAgentDetection<T>(
if (!worktreeId) {
return call(undefined)
}
return runtimeEnvironmentSupportsCapability(
environmentId,
PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY
).then((supported) => call(supported ? { worktreeId } : undefined))
const capability = PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY
return runtimeEnvironmentSupportsCapability(environmentId, capability).then(async (supported) => {
if (supported) {
return call({ worktreeId })
}
// Why: a fresh status both reports the platform and catches an in-place upgrade.
const status = await getRuntimeEnvironmentStatus(environmentId)
if (status.capabilities?.includes(capability)) {
return call({ worktreeId })
}
// Why: an old Windows host's default omits a WSL workspace's agents; an absent platform may be one.
if (!status.hostPlatform || status.hostPlatform === 'win32') {
throw new RuntimeAgentDetectionNeedsServerUpdateError()
}
return call(undefined)
})
}
export function _getRuntimeDetectPromiseCountForTest(): number {
@@ -104,18 +126,41 @@ export const createRuntimeDetectedAgentsSlice: StateCreator<
const runtimeDetectedAgentIds = withoutKeys(s.runtimeDetectedAgentIds, dropKey)
const isDetectingRuntimeAgents = withoutKeys(s.isDetectingRuntimeAgents, dropKey)
const isRefreshingRuntimeAgents = withoutKeys(s.isRefreshingRuntimeAgents, dropKey)
const runtimeAgentDetectionNeedsServerUpdate = withoutKeys(
s.runtimeAgentDetectionNeedsServerUpdate,
dropKey
)
return runtimeDetectedAgentIds === s.runtimeDetectedAgentIds &&
isDetectingRuntimeAgents === s.isDetectingRuntimeAgents &&
isRefreshingRuntimeAgents === s.isRefreshingRuntimeAgents
isRefreshingRuntimeAgents === s.isRefreshingRuntimeAgents &&
runtimeAgentDetectionNeedsServerUpdate === s.runtimeAgentDetectionNeedsServerUpdate
? s
: { runtimeDetectedAgentIds, isDetectingRuntimeAgents, isRefreshingRuntimeAgents }
: {
runtimeDetectedAgentIds,
isDetectingRuntimeAgents,
isRefreshingRuntimeAgents,
runtimeAgentDetectionNeedsServerUpdate
}
})
}
// Commits a settled detection; an old host commits an empty list, never its default.
const commitDetection = (key: string, agents: TuiAgent[], needsServerUpdate: boolean): void => {
set((s) => ({
runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: agents },
isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false },
isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [key]: false },
runtimeAgentDetectionNeedsServerUpdate: needsServerUpdate
? { ...s.runtimeAgentDetectionNeedsServerUpdate, [key]: true }
: withoutKeys(s.runtimeAgentDetectionNeedsServerUpdate, (k) => k === key)
}))
}
return {
runtimeDetectedAgentIds: {},
isDetectingRuntimeAgents: {},
isRefreshingRuntimeAgents: {},
runtimeAgentDetectionNeedsServerUpdate: {},
ensureRuntimeDetectedAgents: (environmentId: string, worktreeId?: string | null) => {
const key = getRuntimeAgentInventoryKey(environmentId, worktreeId)
@@ -148,18 +193,20 @@ export const createRuntimeDetectedAgentsSlice: StateCreator<
// while the detect was in flight — otherwise it re-adds a stale entry
// that retainRuntimeDetectedAgents just pruned.
if (runtimeDetectPromises.get(key) === pending) {
set((s) => ({
runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: typed },
isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false }
}))
commitDetection(key, typed, false)
}
return typed
})
.catch((): TuiAgent[] => {
.catch((error: unknown): TuiAgent[] => {
// Why: a remote runtime may be disconnected or version-incompatible.
// Keep the menu retryable instead of pinning a failed probe forever.
// Same in-flight guard as the .then() above.
if (runtimeDetectPromises.get(key) === pending) {
if (runtimeDetectPromises.get(key) !== pending) {
return []
}
if (error instanceof RuntimeAgentDetectionNeedsServerUpdateError) {
commitDetection(key, [], true)
} else {
set((s) => ({
isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false }
}))
@@ -207,15 +254,18 @@ export const createRuntimeDetectedAgentsSlice: StateCreator<
// Why: same guard as ensureRuntimeDetectedAgents — if the environment
// was retained out mid-refresh, don't re-add a pruned entry.
if (runtimeRefreshPromises.get(key) === pending) {
set((s) => ({
runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: typed },
isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false },
isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [key]: false }
}))
commitDetection(key, typed, false)
}
return typed
})
.catch(() => {
.catch((error: unknown) => {
if (
error instanceof RuntimeAgentDetectionNeedsServerUpdateError &&
runtimeRefreshPromises.get(key) === pending
) {
commitDetection(key, [], true)
return []
}
// Why: a disconnected runtime must keep Refresh retryable and must not
// wipe the last known agent list.
if (runtimeRefreshPromises.get(key) === pending) {