Files
orca/mobile/src/tasks/worktree-create-capability.ts
Brennan Benson 6e6b7d8195 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
2026-07-21 17:55:02 -07:00

98 lines
3.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
// replay an ambiguous create unless the host advertises idempotency support.
// Mirrors WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY in the shared protocol.
export const MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY = 'worktree.create-idempotency.v1'
const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
idempotentWorktreeCreateSupported: boolean
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
idempotentWorktreeCreateSupported: false
}
// Why: status.get is safe to replay and must settle before create, independently
// of slower provider probes, so ambiguous cutover retries are gated correctly.
export async function readNewWorktreeRuntimeCapabilities(
client: RpcClient
): Promise<NewWorktreeRuntimeCapabilities> {
for (let migrationRetry = 0; ; migrationRetry += 1) {
try {
const response = await client.sendRequest('status.get')
if (!response.ok) {
return UNSUPPORTED_CAPABILITIES
}
const capabilities =
((response as RpcSuccess).result as { capabilities?: string[] }).capabilities ?? []
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
idempotentWorktreeCreateSupported: capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
)
}
} catch (error) {
if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) {
return UNSUPPORTED_CAPABILITIES
}
}
}
}
export function useNewWorktreeRuntimeCapabilities(
client: RpcClient | null,
enabled: boolean
): {
tasksSupported: boolean
getWorktreeCreateCutoverSupport: () => Promise<boolean>
} {
const [tasksSupported, setTasksSupported] = useState(false)
const capabilityProbeRef = useRef<{
client: RpcClient | null
promise: Promise<NewWorktreeRuntimeCapabilities>
} | null>(null)
const getCapabilities = useCallback((): Promise<NewWorktreeRuntimeCapabilities> => {
if (!capabilityProbeRef.current || capabilityProbeRef.current.client !== client) {
// Why: a queued tap can reach Create before passive effects run; lazily
// starting one shared probe keeps that path from failing open.
capabilityProbeRef.current = {
client,
promise: client
? readNewWorktreeRuntimeCapabilities(client)
: Promise.resolve(UNSUPPORTED_CAPABILITIES)
}
}
return capabilityProbeRef.current.promise
}, [client])
useEffect(() => {
if (!enabled || !client) {
return
}
let stale = false
void getCapabilities().then((capabilities) => {
if (!stale) {
setTasksSupported(capabilities.tasksSupported)
}
})
return () => {
stale = true
}
}, [client, enabled, getCapabilities, setTasksSupported])
const getWorktreeCreateCutoverSupport = useCallback(
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
[getCapabilities]
)
return { tasksSupported, getWorktreeCreateCutoverSupport }
}