fix(mobile): retry session capability probe so tab-row actions survive relay cutover (#9794)

* fix(mobile): retry session capability probe so tab-row actions survive relay cutover

The session screen learned host capabilities (quick commands, browser
screencast, agent history, query-reply input) from a single status.get
fired when the screen connected. Over relay, a relay-to-direct transport
cutover rejects every in-flight request while connState stays
'connected', and a request timeout does the same — so one transient
failure latched the capability flags false (or left them null on an
ok:false reply) and the quick-commands tab-row button stayed hidden
until the screen was remounted.

Replace the one-shot probe with startRuntimeCapabilityProbe: retry
promptly after a cutover (the replacement transport is already
authenticated) and with capped exponential backoff on other failures,
until a probe lands or the effect is cleaned up. Also export the
cutover-error predicate from stable-logical-rpc-client and reuse it in
worktree-create-capability instead of a local copy.

* fix(mobile): reset runtime gates before capability reprobe
This commit is contained in:
Brennan Benson 2026-07-21 17:55:02 -07:00 committed by GitHub
parent 1fef1e1ddd
commit 6e6b7d8195
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 241 additions and 41 deletions

View File

@ -46,6 +46,7 @@ import {
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { startRuntimeCapabilityProbe } from '../../../../src/transport/runtime-capability-probe'
import {
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
@ -250,7 +251,6 @@ import type {
MobileSessionTabType,
RenderableDiffLine,
RuntimeRepoSummary,
RuntimeStatusResult,
SessionTabsResult,
Terminal,
TerminalCreateResult,
@ -2317,40 +2317,23 @@ export default function SessionScreen() {
}
// Why: a client swap can keep the route connected while moving to an older
// host; clear the prior capability before exposing host-specific actions.
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
setShowQuickCommands(false)
let stale = false
void client
.sendRequest('status.get')
.then((response) => {
if (stale || !response.ok) {
return
}
const status = (response as RpcSuccess).result as RuntimeStatusResult
setBrowserScreencastSupported(
status.capabilities?.includes('browser.screencast.v1') === true
)
setAgentSessionHistorySupported(
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
)
setQuickCommandsSupported(supportsMobileQuickCommands(status.capabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current =
status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true
})
.catch(() => {
if (!stale) {
setBrowserScreencastSupported(false)
setAgentSessionHistorySupported(false)
setQuickCommandsSupported(false)
setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
}
})
return () => {
stale = true
}
hostQueryReplyInputSupportedRef.current = false
// Why: the probe retries — a relay→direct cutover or request timeout rejects
// status.get without changing connState, which used to latch these hidden.
return startRuntimeCapabilityProbe(client, (capabilities) => {
setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1'))
setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
setQuickCommandsSupported(supportsMobileQuickCommands(capabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current = capabilities.includes(
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
)
})
}, [client, connState])
// Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity.

View File

@ -48,6 +48,27 @@ describe('mobile session startup', () => {
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
})
it('fails runtime capability gates closed before probing a replacement client', () => {
const capabilityEffect = sliceBetween(
'const hostQueryReplyInputSupportedRef = useRef(false)',
'// Why: read deviceToken from host record'
)
const probeStart = capabilityEffect.indexOf('startRuntimeCapabilityProbe(client,')
expect(probeStart).toBeGreaterThanOrEqual(0)
for (const reset of [
'setBrowserScreencastSupported(null)',
'setAgentSessionHistorySupported(null)',
'setQuickCommandsSupported(null)',
'setShowQuickCommands(false)',
'hostQueryReplyInputSupportedRef.current = false'
]) {
const resetIndex = capabilityEffect.lastIndexOf(reset)
expect(resetIndex).toBeGreaterThanOrEqual(0)
expect(resetIndex).toBeLessThan(probeStart)
}
})
it('activates an already-selected pending terminal tab after hydration', () => {
expect(source).toContain(
'const pendingTerminalActivationAttemptRef = useRef<string | null>(null)'

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
@ -95,10 +95,3 @@ export function useNewWorktreeRuntimeCapabilities(
)
return { tasksSupported, getWorktreeCreateCutoverSupport }
}
function isLogicalClientCutoverError(error: unknown): boolean {
return (
error instanceof LogicalClientCutoverError ||
(error instanceof Error && error.message === 'RPC interrupted by connection migration')
)
}

View File

@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startRuntimeCapabilityProbe } from './runtime-capability-probe'
import { LogicalClientCutoverError } from './stable-logical-rpc-client'
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
type ProbeOutcome = RpcResponse | Error
function makeClient(outcomes: ProbeOutcome[]): { client: RpcClient; calls: () => number } {
let calls = 0
const client = {
sendRequest: () => {
const outcome = outcomes[Math.min(calls, outcomes.length - 1)]
calls += 1
return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome)
}
} as unknown as RpcClient
return { client, calls: () => calls }
}
const ok = (capabilities: string[]): RpcResponse => ({
ok: true,
id: '1',
result: { capabilities },
_meta: { runtimeId: 'r1' }
})
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('startRuntimeCapabilityProbe', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('delivers capabilities on first success', async () => {
const { client, calls } = makeClient([ok(['a.v1'])])
const seen: (readonly string[])[] = []
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
expect(seen).toEqual([['a.v1']])
expect(calls()).toBe(1)
cancel()
})
it('retries promptly after a logical-client cutover rejection', async () => {
const { client, calls } = makeClient([new LogicalClientCutoverError(), ok(['a.v1'])])
const seen: (readonly string[])[] = []
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
expect(seen).toEqual([])
await vi.advanceTimersByTimeAsync(250)
expect(seen).toEqual([['a.v1']])
expect(calls()).toBe(2)
cancel()
})
it('backs off on other failures and eventually recovers', async () => {
const { client, calls } = makeClient([
new Error('Request timed out: status.get'),
new Error('Request timed out: status.get'),
ok(['a.v1'])
])
const seen: (readonly string[])[] = []
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(1_000)
expect(seen).toEqual([])
await vi.advanceTimersByTimeAsync(2_000)
expect(seen).toEqual([['a.v1']])
expect(calls()).toBe(3)
cancel()
})
it('retries an ok:false response instead of settling', async () => {
const failure: RpcResponse = {
ok: false,
id: '1',
error: { code: 'internal', message: 'nope' },
_meta: { runtimeId: 'r1' }
}
const { client } = makeClient([failure, ok(['a.v1'])])
const seen: (readonly string[])[] = []
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
expect(seen).toEqual([])
await vi.advanceTimersByTimeAsync(1_000)
expect(seen).toEqual([['a.v1']])
cancel()
})
it('caps the failure backoff', async () => {
const outcomes: ProbeOutcome[] = Array.from({ length: 10 }, () => new Error('timeout'))
outcomes.push(ok(['a.v1']))
const { client, calls } = makeClient(outcomes)
const seen: (readonly string[])[] = []
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
// Why: 1s+2s+4s+8s then 15s cap; ten failures fit well inside 8 capped waits.
await vi.advanceTimersByTimeAsync(15_000 * 10)
expect(seen).toEqual([['a.v1']])
expect(calls()).toBe(11)
cancel()
})
it('stops retrying and dropping results once cancelled', async () => {
const { client, calls } = makeClient([new Error('timeout'), ok(['a.v1'])])
const seen: (readonly string[])[] = []
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
cancel()
await vi.advanceTimersByTimeAsync(60_000)
expect(seen).toEqual([])
expect(calls()).toBe(1)
})
it('ignores a success that resolves after cancellation', async () => {
let resolveRequest: ((response: RpcResponse) => void) | null = null
const client = {
sendRequest: () =>
new Promise<RpcResponse>((resolve) => {
resolveRequest = resolve
})
} as unknown as RpcClient
const seen: (readonly string[])[] = []
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
cancel()
resolveRequest?.(ok(['a.v1']))
await flushMicrotasks()
expect(seen).toEqual([])
})
})

View File

@ -0,0 +1,58 @@
import type { RpcClient } from './rpc-client'
import type { RpcSuccess } from './types'
import { isLogicalClientCutoverError } from './stable-logical-rpc-client'
// Why: a relay→direct cutover or request timeout can reject an in-flight
// status.get without ever changing connState, so a one-shot probe would latch
// capability-gated UI hidden until the screen remounts; retry until one lands.
const CUTOVER_RETRY_DELAY_MS = 250
const FAILURE_RETRY_BASE_DELAY_MS = 1_000
const FAILURE_RETRY_MAX_DELAY_MS = 15_000
export function startRuntimeCapabilityProbe(
client: RpcClient,
onCapabilities: (capabilities: readonly string[]) => void
): () => void {
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
let failureRetries = 0
function attempt(): void {
void client.sendRequest('status.get').then(
(response) => {
if (cancelled) {
return
}
if (!response.ok) {
scheduleRetry(false)
return
}
const status = (response as RpcSuccess).result as { capabilities?: string[] }
onCapabilities(status.capabilities ?? [])
},
(error: unknown) => {
if (cancelled) {
return
}
scheduleRetry(isLogicalClientCutoverError(error))
}
)
}
function scheduleRetry(cutover: boolean): void {
// Why: cutover means the replacement transport is already authenticated —
// re-ask promptly; other failures back off so a wedged host isn't hammered.
const delay = cutover
? CUTOVER_RETRY_DELAY_MS
: Math.min(FAILURE_RETRY_BASE_DELAY_MS * 2 ** failureRetries++, FAILURE_RETRY_MAX_DELAY_MS)
retryTimer = setTimeout(attempt, delay)
}
attempt()
return () => {
cancelled = true
if (retryTimer) {
clearTimeout(retryTimer)
}
}
}

View File

@ -9,6 +9,14 @@ export class LogicalClientCutoverError extends Error {
}
}
// Why: instanceof can miss across bundle copies, so also match by message.
export function isLogicalClientCutoverError(error: unknown): boolean {
return (
error instanceof LogicalClientCutoverError ||
(error instanceof Error && error.message === 'RPC interrupted by connection migration')
)
}
type SubscriptionRecord = {
method: string
params: unknown