fix(mobile): agents for workspaces another runtime owns show why (#27196)

* fix(mobile): agents for workspaces another runtime owns show why

The paired host no longer lists its own agents for a workspace another Orca
server owns: preflight.detectAgents refuses with workspace_on_other_runtime,
and the phone shows 'Agents on Another Server - Pair that server directly'.
The phone also recognises an unambiguous runtime-owned repo row itself, so an
older host is never asked.

* fix(mobile): a shared repo id with a runtime-owned row defers to a refusing host

Rows on several hosts can share a repo id, and the first row's SSH connection
could name the paired host's own target. When any row is runtime-owned the phone
now asks a host that advertises preflight.other-runtime-refusal.v1 to decide by
worktree, and shows the other-server row on an older host.

* fix(mobile): keep the agent-loader change inside the web bundle budget

Move the refusal code beside its capability in protocol-version, read the
capability in the loader, and test the runtime: prefix directly: importing
execution-host into the loader split a web bundle chunk (129 > 128 assets).

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 02:09:10 -07:00
committed by GitHub
co-authored by m4air
parent b80c420054
commit 0ba67e1181
10 changed files with 286 additions and 43 deletions
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
import { WORKSPACE_ON_OTHER_RUNTIME } from '../../../src/shared/protocol-version'
import {
loadMobileNewTabAgentOptions,
MobileWorkspaceOnOtherRuntimeError
} from './mobile-new-tab-agent-loader'
function createClient(
handler: (method: string, params?: unknown) => Promise<unknown>
@@ -69,4 +73,94 @@ describe('mobile new-tab agent loading', () => {
'preflight.detectRemoteAgents'
])
})
it('#13752: names another runtime instead of listing the paired host for its workspace', async () => {
const client = createClient(async (method) => {
if (method === 'settings.get') {
return { ok: true, result: { settings: {} } }
}
if (method === 'repo.list') {
return {
ok: true,
result: {
repos: [{ id: 'repo-1', executionHostId: 'runtime:env-b', connectionId: 'b-ssh' }]
}
}
}
throw new Error(`unexpected request: ${method}`)
})
await expect(
loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' })
).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError)
// Neither the paired host nor its SSH targets are probed for a workspace it does not own.
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'repo.list',
'settings.get'
])
})
// Rows on two hosts share the id; the SSH row first, so its connection would name this host's target.
function sharedRepoIdClient(detectAgents: () => unknown) {
return createClient(async (method, params) => {
if (method === 'settings.get') {
return { ok: true, result: { settings: {} } }
}
if (method === 'repo.list') {
return {
ok: true,
result: {
repos: [
{ id: 'repo-1', executionHostId: 'ssh:a-target', connectionId: 'a-target' },
{ id: 'repo-1', executionHostId: 'runtime:env-b' }
]
}
}
}
if (method === 'preflight.detectAgents') {
expect(params).toEqual({ worktreeId: 'repo-1::/srv/worktree' })
return detectAgents()
}
throw new Error(`unexpected request: ${method}`)
})
}
it('#13752: lets a refusing host decide a shared repo id, and reads its refusal', async () => {
const refused = sharedRepoIdClient(() => ({
ok: false,
error: { code: 'runtime_error', message: WORKSPACE_ON_OTHER_RUNTIME }
}))
await expect(
loadMobileNewTabAgentOptions({
client: refused,
worktreeId: 'repo-1::/srv/worktree',
hostRefusesOtherRuntime: true
})
).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError)
const answered = sharedRepoIdClient(() => ({ ok: true, result: ['claude'] }))
await expect(
loadMobileNewTabAgentOptions({
client: answered,
worktreeId: 'repo-1::/srv/worktree',
hostRefusesOtherRuntime: true
})
).resolves.toEqual([{ agent: 'claude', label: 'Claude' }])
expect(answered.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'repo.list',
'settings.get',
'preflight.detectAgents'
])
})
it('#13752: an older host cannot decide a shared repo id, so nothing is probed', async () => {
const client = sharedRepoIdClient(() => ({ ok: true, result: ['codex'] }))
await expect(
loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' })
).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError)
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'repo.list',
'settings.get'
])
})
})
@@ -1,3 +1,7 @@
import {
PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY,
WORKSPACE_ON_OTHER_RUNTIME
} from '../../../src/shared/protocol-version'
import { newTabSettingsRead } from '../transport/settings-read-operations'
import {
type MobileRuntimeRepoSummary,
@@ -15,6 +19,17 @@ import {
type MobileNewTabAgentSettings
} from './mobile-new-tab-agent-options'
const WORKSPACE_ON_OTHER_RUNTIME_MESSAGE =
'This workspace runs on another Orca server. Pair that server directly to see its agents.'
/** The paired host does not own this workspace, so its agents are unverifiable from here. */
export class MobileWorkspaceOnOtherRuntimeError extends Error {
constructor() {
super(WORKSPACE_ON_OTHER_RUNTIME_MESSAGE)
this.name = 'MobileWorkspaceOnOtherRuntimeError'
}
}
/** What a launch in this workspace can choose from: the host's settings, the agents detected on
* the workspace's execution host, and the workspace's repo (absent for the floating workspace). */
export type MobileAgentLaunchContext = {
@@ -23,14 +38,26 @@ export type MobileAgentLaunchContext = {
repo: MobileRuntimeRepoSummary | null
}
export async function loadMobileAgentLaunchContext(args: {
export function hostRefusesOtherRuntimeWorkspace(
hostCapabilities: readonly string[] | null | undefined
): boolean {
return hostCapabilities?.includes(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY) === true
}
type MobileAgentLaunchContextArgs = {
client: RpcClient
worktreeId: string
}): Promise<MobileAgentLaunchContext> {
/** Whether the host refuses, rather than answers for, a workspace another runtime owns. */
hostRefusesOtherRuntime?: boolean
}
export async function loadMobileAgentLaunchContext(
args: MobileAgentLaunchContextArgs
): Promise<MobileAgentLaunchContext> {
const { client, worktreeId } = args
// Started before the settings read, not inside the array: the detection request goes on the wire
// first, and the recorded sender order is what says so.
const detectedAgentsRequest = loadDetectedAgents(client, worktreeId)
const detectedAgentsRequest = loadDetectedAgents(client, worktreeId, args.hostRefusesOtherRuntime)
const [settingsResponse, detectedAgents] = await Promise.all([
newTabSettingsRead.request(client),
detectedAgentsRequest
@@ -38,14 +65,13 @@ export async function loadMobileAgentLaunchContext(args: {
const readSettings = newTabSettingsRead.interpret(settingsResponse)
// Interpreted after the group, not inside it: whichever peer failed first must not decide the
// error the sheet shows, and main raised the detection refusal only once settings had settled.
const detected = detectedAgents.interpret(detectedAgents.reply)
const detected = interpretDetectedAgents(detectedAgents)
return { settings: readSettings(), detectedAgents: detected, repo: detectedAgents.repo }
}
export async function loadMobileNewTabAgentOptions(args: {
client: RpcClient
worktreeId: string
}): Promise<MobileNewTabAgentOption[]> {
export async function loadMobileNewTabAgentOptions(
args: MobileAgentLaunchContextArgs
): Promise<MobileNewTabAgentOption[]> {
const context = await loadMobileAgentLaunchContext(args)
return buildMobileNewTabAgentOptions(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
@@ -63,7 +89,8 @@ type DetectedAgentsReply = {
async function loadDetectedAgents(
client: RpcClient,
worktreeId: string
worktreeId: string,
hostRefusesOtherRuntime = false
): Promise<DetectedAgentsReply> {
// Why: the floating workspace runs on the paired host, so it has no repo connection to resolve.
if (isFloatingWorkspaceWorktreeId(worktreeId)) {
@@ -81,6 +108,22 @@ async function loadDetectedAgents(
if (!repo) {
throw new Error('worktree_repo_not_found')
}
// A prefix test, not parseExecutionHostId: importing execution-host here splits a web bundle chunk.
const runtimeOwned = repos
.filter((candidate) => candidate.id === repoId)
.map((candidate) => candidate.executionHostId?.startsWith('runtime:') === true)
if (runtimeOwned.includes(true)) {
// Why: rows on several hosts can share a repo id, and then only a host that refuses another
// runtime's workspace may decide; the first row's connection could name this host's SSH target.
if (!hostRefusesOtherRuntime || !runtimeOwned.includes(false)) {
throw new MobileWorkspaceOnOtherRuntimeError()
}
return {
reply: await preflightDetectAgentsRead.request(client, { worktreeId }),
interpret: preflightDetectAgentsRead.interpret,
repo
}
}
const connectionId = repo.connectionId?.trim() || null
return connectionId
? {
@@ -96,3 +139,15 @@ async function loadDetectedAgents(
repo
}
}
function interpretDetectedAgents(detected: DetectedAgentsReply): unknown[] {
try {
return detected.interpret(detected.reply)
} catch (error) {
// A host with the refusal answers it for a workspace another runtime owns.
if (error instanceof Error && error.message === WORKSPACE_ON_OTHER_RUNTIME) {
throw new MobileWorkspaceOnOtherRuntimeError()
}
throw error
}
}
@@ -32,7 +32,11 @@ export const sessionTerminalListRead = bindDeferredRpcOperation(
})
)
export type MobileRuntimeRepoSummary = { id: string; connectionId?: string | null }
export type MobileRuntimeRepoSummary = {
id: string
connectionId?: string | null
executionHostId?: string | null
}
const repoListReader = rpcResultVariant('runtime-repo-list', runtimeRepoListSchema)
@@ -144,7 +144,8 @@ export type DirtyMarkdownDraft = {
content: string
}
export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error'
/** `other-runtime`: another Orca server owns the workspace, so the paired host cannot list its agents. */
export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error' | 'other-runtime'
export type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
+9 -2
View File
@@ -6,7 +6,10 @@ import {
launchAgentInExistingWorkspace,
supportsMobileExistingAgentLaunch
} from './mobile-existing-agent-launch'
import { loadMobileAgentLaunchContext } from './mobile-new-tab-agent-loader'
import {
hostRefusesOtherRuntimeWorkspace,
loadMobileAgentLaunchContext
} from './mobile-new-tab-agent-loader'
import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-launch-agent'
// Launch path for the phone's AI buttons ("Fix checks with AI", "Resolve conflicts with AI", commit
@@ -38,7 +41,11 @@ export async function launchAgentWithPrompt(args: {
let resolved
try {
resolved = resolveMobileSourceControlLaunchAgent(
await loadMobileAgentLaunchContext({ client: args.client, worktreeId: args.worktreeId }),
await loadMobileAgentLaunchContext({
client: args.client,
worktreeId: args.worktreeId,
hostRefusesOtherRuntime: hostRefusesOtherRuntimeWorkspace(args.hostCapabilities)
}),
args.actionId
)
} catch (error) {
@@ -2,7 +2,11 @@ import { useEffect } from 'react'
import { AppState, type AppStateStatus } from 'react-native'
import { useClipboardReader } from '../platform/clipboard'
import { triggerSelection, triggerError } from '../platform/haptics'
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
import {
hostRefusesOtherRuntimeWorkspace,
loadMobileNewTabAgentOptions,
MobileWorkspaceOnOtherRuntimeError
} from './mobile-new-tab-agent-loader'
import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments'
import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from './use-mobile-terminal-paste'
@@ -13,6 +17,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
worktreeId,
client,
connState,
hostCapabilities,
activeHandle,
pendingDiffNotesDelivery,
showCreateTabDrawer,
@@ -120,6 +125,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
}
}, [clipboardContents, selectModeActive, setCanPaste])
const hostRefusesOtherRuntime = hostRefusesOtherRuntimeWorkspace(hostCapabilities)
useEffect(() => {
const shouldLoadAgentOptions = showCreateTabDrawer || pendingDiffNotesDelivery !== null
if (!shouldLoadAgentOptions) {
@@ -140,24 +146,34 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
void (async () => {
const options = await loadMobileNewTabAgentOptions({
client,
worktreeId
worktreeId,
hostRefusesOtherRuntime
})
if (stale) {
return
}
setCreateTabAgentOptions(options)
setCreateTabAgentLoadState('loaded')
})().catch(() => {
})().catch((error: unknown) => {
if (!stale) {
setCreateTabAgentOptions([])
setCreateTabAgentLoadState('error')
setCreateTabAgentLoadState(
error instanceof MobileWorkspaceOnOtherRuntimeError ? 'other-runtime' : 'error'
)
}
})
return () => {
stale = true
}
}, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId])
}, [
client,
connState,
hostRefusesOtherRuntime,
pendingDiffNotesDelivery,
showCreateTabDrawer,
worktreeId
])
return {
handlePaste,
flushPendingLiveInputBeforeAttachmentSend,
@@ -37,6 +37,13 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
sendDiffNotesToNewAgent,
handleCreateTerminal
} = scope
const otherRuntimeAgentRow = {
label: 'Agents on Another Server',
hint: 'Pair that server directly',
icon: Bot,
disabled: true,
onPress: () => {}
}
const createTabAgentActions =
createTabAgentLoadState === 'loading'
? [
@@ -66,17 +73,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
onPress: () => {}
}
]
: createTabAgentLoadState === 'error'
? [
{
label: 'Agent Presets Unavailable',
hint: 'Check the host connection',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
: createTabAgentLoadState === 'other-runtime'
? [otherRuntimeAgentRow]
: createTabAgentLoadState === 'error'
? [
{
label: 'Agent Presets Unavailable',
hint: 'Check the host connection',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
const sendDiffNotesAgentActions =
pendingDiffNotesDelivery === null
? []
@@ -118,17 +127,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
onPress: () => {}
}
]
: createTabAgentLoadState === 'error'
? [
{
label: 'Agent Presets Unavailable',
hint: 'Copy notes instead',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
: createTabAgentLoadState === 'other-runtime'
? [otherRuntimeAgentRow]
: createTabAgentLoadState === 'error'
? [
{
label: 'Agent Presets Unavailable',
hint: 'Copy notes instead',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
// Panel-icon taps route through the dock-vs-push decision (U1): dock-capable rows dock, constrained rows push.
const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => {
@@ -10,6 +10,11 @@ import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope'
import { splitWorktreeId } from '../../shared/worktree/id'
import {
PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY,
RUNTIME_CAPABILITIES,
WORKSPACE_ON_OTHER_RUNTIME
} from '../../shared/protocol-version'
import {
resolveWorkspaceAgentDetectionHost,
type WorkspaceAgentDetectionStore
@@ -245,6 +250,25 @@ describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => {
'worktree_execution_host_unresolved'
)
})
it('#13752: refuses a worktree or folder another runtime owns instead of probing here', () => {
const s = store({
repos: [
repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' }),
repo({
id: 'r2',
path: '/srv/r2',
executionHostId: 'runtime:env-b',
connectionId: 'b-ssh',
projectGroupId: 'g1'
})
],
projectGroups: [GROUP],
folderWorkspaces: [{ ...FOLDER, folderPath: '/srv', executionHostId: 'runtime:env-b' }]
})
expect(() => resolveFor(s, 'r1::/srv/r1')).toThrow(WORKSPACE_ON_OTHER_RUNTIME)
expect(() => resolveFor(s, 'r2::/srv/r2')).toThrow(WORKSPACE_ON_OTHER_RUNTIME)
expect(() => resolveFor(s, folderWorkspaceKey('f1'))).toThrow(WORKSPACE_ON_OTHER_RUNTIME)
})
})
describe('preflight.detectAgents resolves the workspace on the host', () => {
@@ -296,4 +320,20 @@ describe('preflight.detectAgents resolves the workspace on the host', () => {
})
expect(refreshMock).toHaveBeenCalledWith(wslContext('Ubuntu', 'p1', 'global-default'))
})
it('#13752: a paired client naming a workspace another runtime owns gets a refusal', async () => {
const dispatcher = dispatcherFor(
store({ repos: [repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' })] })
)
const response = await dispatcher.dispatch({
id: '1',
authToken: 't',
method: 'preflight.detectAgents',
params: { worktreeId: 'r1::/srv/r1' }
})
expect(detectInstalledMock).not.toHaveBeenCalled()
expect(response).toMatchObject({ ok: false, error: { message: WORKSPACE_ON_OTHER_RUNTIME } })
// Clients let a host decide a shared repo id only when it advertises this refusal.
expect(RUNTIME_CAPABILITIES).toContain(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY)
})
})
@@ -1,7 +1,9 @@
import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/protocol-version'
import {
getRepoExecutionHostId,
getSshTargetIdForExecutionHost,
LOCAL_EXECUTION_HOST_ID
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId
} from '../../shared/execution-host'
import {
findFolderWorkspaceCandidateRepos,
@@ -86,7 +88,10 @@ function resolveWorktree(
if (sshTargetId) {
return { kind: 'ssh', connectionId: sshTargetId }
}
// A worktree another runtime owns is not this host's to probe; keep the host default.
// Why refuse: another runtime's worktree is not this host's to probe, and its list would mislead.
if (parseExecutionHostId(routing.hostId)?.kind === 'runtime') {
throw new Error(WORKSPACE_ON_OTHER_RUNTIME)
}
return routing.hostId === LOCAL_EXECUTION_HOST_ID && routing.repo
? { kind: 'local', repo: routing.repo, path: worktree.path }
: null
@@ -108,6 +113,9 @@ function resolveFolderWorkspace(
if (host.kind === 'ambiguous') {
throw new Error('worktree_execution_host_unresolved')
}
if (host.kind === 'runtime') {
throw new Error(WORKSPACE_ON_OTHER_RUNTIME)
}
const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
const candidates = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId)
// Why one candidate: a folder spanning several repos has no single project runtime to pick.
+7
View File
@@ -324,6 +324,12 @@ export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY =
// must not read that answer as the workspace's (a WSL project on a Windows host differs).
export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY =
'preflight.workspace-scoped.v1' as const
// Why: an older host answers its own agents for a workspace another runtime owns; one with this
// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id.
export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY =
'preflight.other-runtime-refusal.v1' as const
/** What such a host answers, instead of probing itself, for another runtime's workspace. */
export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime'
// Hosts without this capability have no workspacePorts.scanHost/killHost; their scan and Stop act
// only on the endpoint itself, never on a workspace's SSH host.
export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY =
@@ -434,6 +440,7 @@ export const RUNTIME_CAPABILITIES = [
...AUTOMATION_RUNTIME_CAPABILITIES,
NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY,
PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY,
PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY,
WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY,
...AGENT_LAUNCH_RUNTIME_CAPABILITIES
] as const