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:
parent
1fef1e1ddd
commit
6e6b7d8195
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue