diff --git a/mobile/src/tasks/worktree-create-capability-recovery.test.ts b/mobile/src/tasks/worktree-create-capability-recovery.test.ts index 0fc96bb200c..9d88f433abb 100644 --- a/mobile/src/tasks/worktree-create-capability-recovery.test.ts +++ b/mobile/src/tasks/worktree-create-capability-recovery.test.ts @@ -3,6 +3,7 @@ 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 { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' import { useNewWorktreeRuntimeCapabilities } from './worktree-create-capability' function connection() { @@ -113,6 +114,34 @@ describe('capability probe recovery', () => { expect(current?.tasksSupported).toBe(true) expect(host.sendRequest).toHaveBeenCalledTimes(2) }) + it('keeps a pending create probe when the same host switches from LAN to relay', async () => { + const lan = new FakeSession('connected') + lan.getLastConnectedAt = () => 1 + let dropLan: ((reason: Error) => void) | undefined + lan.sendRequest.mockImplementation( + () => + new Promise((_resolve, reject) => { + dropLan = reject + }) + ) + const relay = connection() + relay.sendRequest.mockResolvedValue({ + id: 'relay', + ok: true, + result: { capabilities: ['agent.launch.v2', 'worktree.create-idempotency.v1'] }, + _meta: { runtimeId: 'test' } + }) + const logical = createStableLogicalRpcClient(lan, 'lan') + await render(logical) + const launch = current?.getAgentLaunchSupport() + const idempotency = current?.getWorktreeCreateCutoverSupport() + await act(async () => { + await logical.migrateTo(relay.client, 'relay') + dropLan?.(new Error('socket closed')) + }) + await expect(launch).resolves.toEqual({ replay: false }) + await expect(idempotency).resolves.toBeTruthy() + }) it.each(['before', 'after'])( 'ignores a prior connection reply resolved %s the current reply', async (order) => { diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index f6414a3cd1d..2e6d207edc4 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -89,37 +89,43 @@ export function useNewWorktreeRuntimeCapabilities( promise: Promise } | null>(null) 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 - ) { - // Queued creates can precede passive effects; share the same status barrier. - probe = { - client, - generation, - connectedAt, - promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null) + for (;;) { + const generation = client?.getGeneration?.() + const connectedAt = client?.getLastConnectedAt() + let probe = capabilityProbeRef.current + if ( + !probe || + probe.client !== client || + probe.generation !== generation || + probe.connectedAt !== connectedAt + ) { + // Queued creates can precede passive effects; share the same status barrier. + probe = { + client, + generation, + connectedAt, + promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null) + } + capabilityProbeRef.current = probe + } + const capabilities = await probe.promise + const current = capabilityProbeRef.current + if ( + current === probe && + client?.getGeneration?.() === generation && + client?.getLastConnectedAt() === connectedAt + ) { + if (!capabilities) { + // Probe failure does not establish missing host support. + capabilityProbeRef.current = null + } + return capabilities + } + // A reconnect or path switch keeps the host; wait for the current connection's answer. + if (current?.client !== client) { + return null } - capabilityProbeRef.current = probe } - const capabilities = await probe.promise - if ( - capabilityProbeRef.current !== probe || - client?.getGeneration?.() !== generation || - client?.getLastConnectedAt() !== connectedAt - ) { - return null - } - if (!capabilities && capabilityProbeRef.current === probe) { - // Probe failure does not establish missing host support. - capabilityProbeRef.current = null - } - return capabilities }, [client]) useEffect(() => {