diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 76b0055d1..dee14cdd3 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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. diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index 58454c1d9..bad6bd330 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -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(null)' diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 20500c10a..d9b9f75c0 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -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') - ) -} diff --git a/mobile/src/transport/runtime-capability-probe.test.ts b/mobile/src/transport/runtime-capability-probe.test.ts new file mode 100644 index 000000000..cffc97f30 --- /dev/null +++ b/mobile/src/transport/runtime-capability-probe.test.ts @@ -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 { + 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((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([]) + }) +}) diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts new file mode 100644 index 000000000..b0f4ad115 --- /dev/null +++ b/mobile/src/transport/runtime-capability-probe.ts @@ -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 | 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) + } + } +} diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 0931a6636..901263768 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -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