diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index 236aa175691..f6cd0bde235 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -252,6 +252,11 @@ } } }, + "59a45eb98b67": { + "capabilities": { + "$rpc": "null" + } + }, "73bcd4456e97": { "name": "status.get#1", "ordinal": 1, @@ -427,6 +432,14 @@ } } }, + "ee20a1dc39e7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "null" + } + }, "f793cb0dfb40": { "capabilities": { "agentLaunch": false, @@ -459,9 +472,9 @@ "sender": ["0ccee4def5e0"], "payloads": ["d08f74d65ee6"], "settlements": { - "probe": "041e5e32563c" + "probe": "ee20a1dc39e7" }, - "state": "f793cb0dfb40", + "state": "59a45eb98b67", "effects": [] } }, @@ -471,9 +484,9 @@ "sender": ["73bcd4456e97"], "payloads": ["d08f74d65ee6"], "settlements": { - "probe": "041e5e32563c" + "probe": "ee20a1dc39e7" }, - "state": "f793cb0dfb40", + "state": "59a45eb98b67", "effects": [] } }, @@ -519,9 +532,9 @@ "sender": ["16a71e555cb2"], "payloads": ["d08f74d65ee6"], "settlements": { - "probe": "041e5e32563c" + "probe": "ee20a1dc39e7" }, - "state": "f793cb0dfb40", + "state": "59a45eb98b67", "effects": [] } }, @@ -531,9 +544,9 @@ "sender": ["82f760e2c99f"], "payloads": ["d08f74d65ee6"], "settlements": { - "probe": "041e5e32563c" + "probe": "ee20a1dc39e7" }, - "state": "f793cb0dfb40", + "state": "59a45eb98b67", "effects": [] } }, @@ -543,9 +556,9 @@ "sender": ["e90520ab92af"], "payloads": ["d08f74d65ee6"], "settlements": { - "probe": "041e5e32563c" + "probe": "ee20a1dc39e7" }, - "state": "f793cb0dfb40", + "state": "59a45eb98b67", "effects": [] } }, @@ -555,9 +568,9 @@ "sender": ["2b8c44a85130"], "payloads": ["d08f74d65ee6"], "settlements": { - "probe": "041e5e32563c" + "probe": "ee20a1dc39e7" }, - "state": "f793cb0dfb40", + "state": "59a45eb98b67", "effects": [] } }, @@ -567,9 +580,9 @@ "sender": ["b799ba7b0c33"], "payloads": ["d08f74d65ee6"], "settlements": { - "probe": "041e5e32563c" + "probe": "ee20a1dc39e7" }, - "state": "f793cb0dfb40", + "state": "59a45eb98b67", "effects": [] } } diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 10a0cab636e..698a7a97139 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -84,8 +84,11 @@ function sourceInputs(renderer: ReactTestRenderer) { ) } -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the modal only sends requests. -const requestsOnly = (sendRequest: unknown) => ({ sendRequest }) as unknown as RpcClient +function requestsOnly(sendRequest: RpcClient['sendRequest']): RpcClient { + const client = new FakeSession('connected') + client.sendRequest.mockImplementation(sendRequest) + return client +} function runTargetPicker(renderer: ReactTestRenderer) { return renderer.root @@ -122,7 +125,7 @@ describe('NewWorktreeModal project targets', () => { } return new Promise(() => {}) }) - const client = { sendRequest } as unknown as RpcClient + const client = requestsOnly(sendRequest) await act(async () => { renderer = create( @@ -161,8 +164,8 @@ describe('NewWorktreeModal project targets', () => { upstream: { owner: 'stablyai', repo: 'orca' } } ] - const client = { - sendRequest: vi.fn().mockImplementation((method: string) => { + const client = requestsOnly( + vi.fn().mockImplementation((method: string) => { if (method === 'repo.list') { return Promise.resolve({ ok: true, result: { repos: listedRepos } }) } @@ -171,7 +174,7 @@ describe('NewWorktreeModal project targets', () => { } return new Promise(() => {}) }) - } as unknown as RpcClient + ) await act(async () => { renderer = create( @@ -248,7 +251,7 @@ describe('NewWorktreeModal project targets', () => { } return Promise.resolve({ ok: true, result: {} }) }) - const client = { sendRequest } as unknown as RpcClient + const client = requestsOnly(sendRequest) await act(async () => { renderer = create( @@ -304,7 +307,7 @@ describe('NewWorktreeModal project targets', () => { } return Promise.resolve({ ok: true, result: {} }) }) - const client = { sendRequest } as unknown as RpcClient + const client = requestsOnly(sendRequest) await act(async () => { renderer = create( @@ -556,16 +559,16 @@ describe('NewWorktreeModal project targets', () => { resolveOldList = resolve }) const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' } - const oldClient = { - sendRequest: vi.fn().mockImplementation((method: string) => { + const oldClient = requestsOnly( + vi.fn().mockImplementation((method: string) => { if (method === 'repo.list') { return oldList } return new Promise(() => {}) }) - } as unknown as RpcClient - const freshClient = { - sendRequest: vi.fn().mockImplementation((method: string) => { + ) + const freshClient = requestsOnly( + vi.fn().mockImplementation((method: string) => { if (method === 'repo.list') { return Promise.resolve({ ok: true, result: { repos: [freshRepo] } }) } @@ -574,7 +577,7 @@ describe('NewWorktreeModal project targets', () => { } return new Promise(() => {}) }) - } as unknown as RpcClient + ) const modalProps = { visible: true, hostId: 'host-1', @@ -605,7 +608,7 @@ describe('NewWorktreeModal project targets', () => { it('remounts before the reopened session renders, so no stale instance sees visible', async () => { const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {})) - const client = { sendRequest } as unknown as RpcClient + const client = requestsOnly(sendRequest) const modalProps = { client, hostId: 'host-1', @@ -630,8 +633,8 @@ describe('NewWorktreeModal project targets', () => { // remounted the modal mid-edit and silently threw away the picked source. it('keeps the picked source when a reconnect swaps the client for the same host', async () => { const makeClient = () => - ({ - sendRequest: vi.fn().mockImplementation((method: string) => { + requestsOnly( + vi.fn().mockImplementation((method: string) => { if (method === 'repo.list') { return Promise.resolve({ ok: true, result: { repos } }) } @@ -640,7 +643,7 @@ describe('NewWorktreeModal project targets', () => { } return new Promise(() => {}) }) - }) as unknown as RpcClient + ) const modalProps = { visible: true, hostId: 'host-1', @@ -681,9 +684,7 @@ describe('NewWorktreeModal project targets', () => { it('keeps the form when a host switch renders but never commits', async () => { setCachedRepos('host-2', repos) - const client = { - sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) - } as unknown as RpcClient + const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {}))) const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} } let frozen = false const tree = (hostId: string) => @@ -708,9 +709,7 @@ describe('NewWorktreeModal project targets', () => { }) it('keeps the form when a close renders but never commits', async () => { - const client = { - sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) - } as unknown as RpcClient + const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {}))) const modalProps = { client, hostId: 'host-1', @@ -744,9 +743,7 @@ describe('NewWorktreeModal project targets', () => { it('starts a fresh form session when the modal switches hosts', async () => { setCachedRepos('host-2', repos) - const client = { - sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) - } as unknown as RpcClient + const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {}))) const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} } await act(async () => { @@ -762,9 +759,7 @@ describe('NewWorktreeModal project targets', () => { }) it('starts with fresh form state after closing and reopening', async () => { - const client = { - sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) - } as unknown as RpcClient + const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {}))) const modalProps = { client, hostId: 'host-1', diff --git a/mobile/src/tasks/worktree-create-capability-recovery.test.ts b/mobile/src/tasks/worktree-create-capability-recovery.test.ts new file mode 100644 index 00000000000..0fc96bb200c --- /dev/null +++ b/mobile/src/tasks/worktree-create-capability-recovery.test.ts @@ -0,0 +1,315 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import { useNewWorktreeRuntimeCapabilities } from './worktree-create-capability' + +function connection() { + let generation = 1 + const client = new FakeSession('connected') + client.getLastConnectedAt = () => generation + client.sendRequest.mockResolvedValue({ + id: 'status', + ok: true, + result: { + capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'], + hostPlatform: 'linux' + }, + _meta: { runtimeId: 'test' } + }) + return { + client, + sendRequest: client.sendRequest, + reconnect() { + generation += 1 + client.publishState('connected') + } + } +} + +describe('capability probe recovery', () => { + let renderer: ReactTestRenderer | undefined + let current: ReturnType | undefined + function Harness({ client, enabled }: { client: RpcClient; enabled: boolean }) { + current = useNewWorktreeRuntimeCapabilities(client, enabled) + return null + } + async function render(client: RpcClient, enabled = true) { + await act(async () => { + const element = createElement(Harness, { client, enabled }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() + }) + + it('retries a transient failure when the create sheet reopens on the same client', async () => { + const host = connection() + host.sendRequest.mockRejectedValueOnce(new Error('offline')) + await render(host.client) + expect(current?.tasksSupported).toBe(false) + await render(host.client, false) + await render(host.client) + expect(current?.tasksSupported).toBe(true) + await expect(current?.getWorktreeCreateCutoverSupport()).resolves.toBeTruthy() + }) + + it('recovers Tasks without requiring a sheet reopen', async () => { + vi.useFakeTimers() + const host = connection() + host.sendRequest.mockRejectedValueOnce(new Error('timeout')) + await render(host.client) + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(current?.tasksSupported).toBe(true) + }) + + it('does not publish a retired client probe after switching to a disabled host', async () => { + const first = connection() + const second = connection() + let resolve: ((value: Awaited>) => void) | undefined + first.sendRequest.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }) + ) + await render(first.client) + await render(second.client, false) + await act(async () => + resolve?.({ + id: 'late', + ok: true, + result: { capabilities: ['mobile.tasks.v1'] }, + _meta: { runtimeId: 'retired' } + }) + ) + expect(current?.tasksSupported).toBe(false) + expect(current?.hostPlatform).toBeNull() + expect(second.sendRequest).not.toHaveBeenCalled() + }) + + it('reprobes after reconnect while keeping confirmed old hosts conservative', async () => { + const host = connection() + host.sendRequest.mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'old' } + }) + await render(host.client) + await expect(current?.getWorktreeCreateCutoverSupport()).resolves.toBe(false) + expect(host.sendRequest).toHaveBeenCalledTimes(1) + await act(async () => host.reconnect()) + expect(current?.tasksSupported).toBe(true) + expect(host.sendRequest).toHaveBeenCalledTimes(2) + }) + it.each(['before', 'after'])( + 'ignores a prior connection reply resolved %s the current reply', + async (order) => { + const host = connection() + let resolveOld: ((value: Awaited>) => void) | undefined + let resolveNew: ((value: Awaited>) => void) | undefined + host.sendRequest.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve + }) + ) + host.sendRequest.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveNew = resolve + }) + ) + await render(host.client) + await act(async () => host.reconnect()) + const oldReply: Awaited> = { + id: 'old', + ok: true, + result: { capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'] }, + _meta: { runtimeId: 'old' } + } + const newReply: Awaited> = { + id: 'new', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'new' } + } + await act(async () => { + if (order === 'before') { + resolveOld?.(oldReply) + } + resolveNew?.(newReply) + }) + expect(current?.tasksSupported).toBe(false) + if (order === 'after') { + await act(async () => resolveOld?.(oldReply)) + } + expect(current?.tasksSupported).toBe(false) + await expect(current?.getWorktreeCreateCutoverSupport()).resolves.toBe(false) + expect(host.sendRequest).toHaveBeenCalledTimes(2) + } + ) + + it('shares one pending status barrier between create callers and the sheet', async () => { + const host = connection() + let resolve: ((value: Awaited>) => void) | undefined + host.sendRequest.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }) + ) + await render(host.client) + const first = current?.getWorktreeCreateCutoverSupport() + const second = current?.getWorktreeCreateCutoverSupport() + const launch = current?.getAgentLaunchSupport() + expect(host.sendRequest).toHaveBeenCalledTimes(1) + await act(async () => + resolve?.({ + id: 'shared', + ok: true, + result: { capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'] }, + _meta: { runtimeId: 'test' } + }) + ) + await expect(first).resolves.toBeTruthy() + await expect(second).resolves.toBeTruthy() + await expect(launch).resolves.toBe(false) + expect(current?.tasksSupported).toBe(true) + }) + + it('cancels backoff while disabled and cannot rearm after unmount', async () => { + vi.useFakeTimers() + const host = connection() + host.sendRequest.mockRejectedValue(new Error('offline')) + await render(host.client) + await render(host.client, false) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(host.sendRequest).toHaveBeenCalledTimes(1) + await render(host.client) + expect(host.sendRequest).toHaveBeenCalledTimes(2) + act(() => renderer?.unmount()) + renderer = undefined + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(host.sendRequest).toHaveBeenCalledTimes(2) + }) + it.each([true, false])( + 'hides prior capabilities after reconnect with the sheet initially enabled=%s', + async (enabled) => { + const host = connection() + await render(host.client) + expect(current?.tasksSupported).toBe(true) + expect(current?.hostPlatform).toBe('linux') + await render(host.client, enabled) + let reject: ((reason: Error) => void) | undefined + host.sendRequest.mockImplementationOnce( + () => + new Promise((_resolve, fail) => { + reject = fail + }) + ) + await act(async () => host.reconnect()) + await render(host.client) + expect(current?.tasksSupported).toBe(false) + expect(current?.hostPlatform).toBeNull() + await act(async () => reject?.(new Error('replacement offline'))) + expect(current?.tasksSupported).toBe(false) + } + ) + it('bounds failed probes over ten minutes and stops all retries on close', async () => { + vi.useFakeTimers() + const host = connection() + host.sendRequest.mockRejectedValue(new Error('offline')) + await render(host.client) + await act(async () => { + await vi.advanceTimersByTimeAsync(600_000) + }) + expect(host.sendRequest).toHaveBeenCalledTimes(24) + expect(vi.getTimerCount()).toBe(1) + await render(host.client, false) + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + await vi.advanceTimersByTimeAsync(600_000) + }) + expect(host.sendRequest).toHaveBeenCalledTimes(24) + }) + + it('does not poll a confirmed unsupported host across renders or sheet reopens', async () => { + vi.useFakeTimers() + const host = connection() + host.sendRequest.mockResolvedValue({ + id: 'old', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'old' } + }) + await render(host.client) + for (let reopen = 0; reopen < 20; reopen += 1) { + await render(host.client, false) + await render(host.client) + } + await act(async () => { + await vi.advanceTimersByTimeAsync(600_000) + }) + expect(host.sendRequest).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['old-first', 'new-first'])( + 'leaves no retry or RPC after overlapping reconnect failures settle %s and unmount', + async (order) => { + vi.useFakeTimers() + const host = connection() + let failOld: ((reason: Error) => void) | undefined + let failNew: ((reason: Error) => void) | undefined + host.sendRequest.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failOld = reject + }) + ) + host.sendRequest.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failNew = reject + }) + ) + await render(host.client) + await act(async () => host.reconnect()) + await act(async () => { + if (order === 'old-first') { + failOld?.(new Error('old offline')) + } + failNew?.(new Error('new offline')) + }) + if (order === 'new-first') { + await act(async () => failOld?.(new Error('old offline'))) + } + expect(vi.getTimerCount()).toBe(1) + act(() => renderer?.unmount()) + renderer = undefined + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(host.sendRequest).toHaveBeenCalledTimes(2) + host.reconnect() + expect(host.sendRequest).toHaveBeenCalledTimes(2) + } + ) +}) diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index 78fa42be9f8..562c2032b0b 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -238,11 +238,6 @@ describe('readNewWorktreeRuntimeCapabilities', () => { }) it('fails closed when capability detection is unavailable', async () => { - await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({ - tasksSupported: false, - worktreeCreateIdempotency: false, - agentLaunch: false, - hostPlatform: null - }) + await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toBeNull() }) }) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index ed354522b30..f4e36eca008 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -27,25 +27,18 @@ export type NewWorktreeRuntimeCapabilities = { hostPlatform: NodeJS.Platform | null } -const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = { - tasksSupported: false, - worktreeCreateIdempotency: false, - agentLaunch: false, - hostPlatform: null -} - // 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 { +): Promise { for (let migrationRetry = 0; ; migrationRetry += 1) { try { const status = worktreeCreateCapabilityRead.interpret( await worktreeCreateCapabilityRead.request(client) ) if (!status.accepted) { - return UNSUPPORTED_CAPABILITIES + return null } const result = status.value const capabilities = result.capabilities ?? [] @@ -63,7 +56,9 @@ export async function readNewWorktreeRuntimeCapabilities( typeof advertisedIdempotency === 'object' && !Array.isArray(advertisedIdempotency) ? resolveWorktreeCreateIdempotencySupport( - (advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs + 'dedupeTtlMs' in advertisedIdempotency + ? advertisedIdempotency.dedupeTtlMs + : undefined ) : { dedupeTtlMs: 0 } : false, @@ -71,7 +66,7 @@ export async function readNewWorktreeRuntimeCapabilities( } } catch (error) { if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) { - return UNSUPPORTED_CAPABILITIES + return null } } } @@ -86,49 +81,125 @@ export function useNewWorktreeRuntimeCapabilities( getWorktreeCreateCutoverSupport: () => Promise getAgentLaunchSupport: () => Promise } { - const [tasksSupported, setTasksSupported] = useState(false) - const [hostPlatform, setHostPlatform] = useState(null) + const [capabilityState, setCapabilityState] = useState<{ + client: RpcClient + generation: number | undefined + connectedAt: number | null | undefined + capabilities: NewWorktreeRuntimeCapabilities + } | null>(null) const capabilityProbeRef = useRef<{ client: RpcClient | null - promise: Promise + generation: number | undefined + connectedAt: number | null | undefined + promise: Promise } | null>(null) - const getCapabilities = useCallback((): Promise => { - 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 = { + const getCapabilities = useCallback(async (): Promise => { + const generation = client?.getGeneration?.() + const connectedAt = client?.getLastConnectedAt() + let probe = capabilityProbeRef.current + if ( + !probe || + probe.client !== client || + probe.generation !== generation || + probe.connectedAt !== connectedAt + ) { + // A queued create can arrive before passive effects; share its status barrier. + probe = { client, - promise: client - ? readNewWorktreeRuntimeCapabilities(client) - : Promise.resolve(UNSUPPORTED_CAPABILITIES) + generation, + connectedAt, + promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null) } + capabilityProbeRef.current = probe } - return capabilityProbeRef.current.promise + const capabilities = await probe.promise + if ( + capabilityProbeRef.current !== probe || + client?.getGeneration?.() !== generation || + client?.getLastConnectedAt() !== connectedAt + ) { + return null + } + if (!capabilities && capabilityProbeRef.current === probe) { + // A failed request says nothing about what the host supports. + capabilityProbeRef.current = null + } + return capabilities }, [client]) + useEffect(() => { + if (capabilityProbeRef.current?.client !== client) { + capabilityProbeRef.current = null + } + }, [client]) + + // oxlint-disable-next-line react-doctor/effect-needs-cleanup -- Cleanup cancels the timer and fences async rearming. useEffect(() => { if (!enabled || !client) { return } let stale = false - void getCapabilities().then((capabilities) => { - if (!stale) { - setTasksSupported(capabilities.tasksSupported) - setHostPlatform(capabilities.hostPlatform) + let refreshVersion = 0 + let retry: ReturnType | undefined + let retryDelay = 1_000 + const refresh = async () => { + if (stale) { + return + } + const version = ++refreshVersion + clearTimeout(retry) + const capabilities = await getCapabilities() + if (stale || version !== refreshVersion) { + return + } + if (capabilities) { + setCapabilityState({ + client, + generation: client.getGeneration?.(), + connectedAt: client.getLastConnectedAt(), + capabilities + }) + retryDelay = 1_000 + } + if (!capabilityProbeRef.current && client.getState() === 'connected') { + retry = setTimeout(() => void refresh(), retryDelay) + retryDelay = Math.min(retryDelay * 2, 30_000) + } + } + const unsubscribe = client.onStateChange((state) => { + if (state === 'connected') { + setCapabilityState(null) + capabilityProbeRef.current = null + void refresh() } }) + void refresh() return () => { stale = true + clearTimeout(retry) + unsubscribe() } - }, [client, enabled, getCapabilities, setTasksSupported]) + }, [client, enabled, getCapabilities]) const getWorktreeCreateCutoverSupport = useCallback( - () => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency), + () => + getCapabilities().then((capabilities) => capabilities?.worktreeCreateIdempotency ?? false), [getCapabilities] ) const getAgentLaunchSupport = useCallback( - () => getCapabilities().then((capabilities) => capabilities.agentLaunch), + () => getCapabilities().then((capabilities) => capabilities?.agentLaunch ?? false), [getCapabilities] ) - return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } + const capabilities = + capabilityState?.client === client && + capabilityState?.generation === client?.getGeneration?.() && + capabilityState?.connectedAt === client?.getLastConnectedAt() + ? capabilityState?.capabilities + : null + return { + tasksSupported: capabilities?.tasksSupported ?? false, + hostPlatform: capabilities?.hostPlatform ?? null, + getWorktreeCreateCutoverSupport, + getAgentLaunchSupport + } } diff --git a/mobile/src/transport/host-status-capability-ignorability.test.ts b/mobile/src/transport/host-status-capability-ignorability.test.ts index 35762e8699c..a1dcdb561fc 100644 --- a/mobile/src/transport/host-status-capability-ignorability.test.ts +++ b/mobile/src/transport/host-status-capability-ignorability.test.ts @@ -97,7 +97,7 @@ type ClientVisibleOutcome = { probedCapabilities: readonly string[] | null answeredProbe: boolean quickCommandsSupported: boolean - worktreeCreateSupport: NewWorktreeRuntimeCapabilities + worktreeCreateSupport: NewWorktreeRuntimeCapabilities | null } /** Every released path that reads status.get, plus the states derived from what it published. */ @@ -160,8 +160,8 @@ describe('mobileWeb.bundle.v1 on a released client', () => { expect(advertised.gateStatus).not.toBeNull() expect(advertised.probedCapabilities).toContain(MOBILE_WEB_BUNDLE_CAPABILITY) expect(advertised.quickCommandsSupported).toBe(true) - expect(advertised.worktreeCreateSupport.tasksSupported).toBe(true) - expect(advertised.worktreeCreateSupport.worktreeCreateIdempotency).not.toBe(false) + expect(advertised.worktreeCreateSupport?.tasksSupported).toBe(true) + expect(advertised.worktreeCreateSupport?.worktreeCreateIdempotency).not.toBe(false) expect(oldDesktop.gates.hostCapabilities).not.toContain(MOBILE_WEB_BUNDLE_CAPABILITY) expect(asIfNeverAdvertised(advertised)).toEqual(oldDesktop)