From fe5c374819e2c2e08ea563db5c7f7fc4d3dce7b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:36:57 -0700 Subject: [PATCH] fix(mobile): await worktree replay capability --- mobile/src/components/NewWorktreeModal.tsx | 25 ++--- mobile/src/tasks/blank-workspace-create.ts | 2 +- mobile/src/tasks/source-workspace-create.ts | 8 +- .../tasks/worktree-create-capability.test.ts | 57 ++++++++++ .../src/tasks/worktree-create-capability.ts | 100 ++++++++++++++++++ .../src/tasks/worktree-create-retry.test.ts | 24 +++++ mobile/src/tasks/worktree-create-retry.ts | 7 +- 7 files changed, 199 insertions(+), 24 deletions(-) create mode 100644 mobile/src/tasks/worktree-create-capability.test.ts diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index ebbfc168e0d..61c9b2e711b 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -50,8 +50,7 @@ import { } from '../worktree/new-workspace-dialog-repo-selection' import { createBlankWorkspace } from '../tasks/blank-workspace-create' import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' -import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability' -import { MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY } from '../tasks/worktree-create-capability' +import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability' import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' import { filterAvailableTaskProviders, @@ -211,8 +210,10 @@ function NewWorktreeModalContent({ const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) const [note, setNote] = useState('') const [availableProviders, setAvailableProviders] = useState([]) - const [tasksSupported, setTasksSupported] = useState(false) - const [idempotentWorktreeCreateSupported, setIdempotentWorktreeCreateSupported] = useState(false) + const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities( + client, + visible + ) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -375,7 +376,6 @@ function NewWorktreeModalContent({ // linear.status timeout, which rejects rather than resolving {ok:false}) // can't discard the already-resolved critical settings/ui results. const probes = Promise.allSettled([ - client.sendRequest('status.get'), client.sendRequest('preflight.check'), client.sendRequest('linear.status') ]) @@ -409,19 +409,10 @@ function NewWorktreeModalContent({ setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) } - const [statusRes, preflightRes, linearRes] = await probes + const [preflightRes, linearRes] = await probes if (stale) { return } - // Tasks is an additive RPC surface, so older paired desktops without the - // capability fall back to branch + blank sources only. - const statusResult = okResult(statusRes) - const capabilities = - (statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? [] - setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY)) - setIdempotentWorktreeCreateSupported( - capabilities.includes(MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY) - ) const glabInstalled = (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab ?.installed === true @@ -704,7 +695,7 @@ function NewWorktreeModalContent({ workspaceName: trimmedName || undefined, note: trimmedNote, nameIsAutoManaged: composer.isNameAutoManaged, - supportsIdempotentCutoverRetry: idempotentWorktreeCreateSupported + supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport() }) : await createBlankWorkspace({ client, @@ -714,7 +705,7 @@ function NewWorktreeModalContent({ createdWithAgentId, comment: trimmedNote, setupDecision, - supportsIdempotentCutoverRetry: idempotentWorktreeCreateSupported + supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport() }) if ('error' in result) { setError(result.error) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index be21be54378..09a2a1b611f 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -14,7 +14,7 @@ export async function createBlankWorkspace(args: { createdWithAgentId: TuiAgent | undefined comment: string | undefined setupDecision: WorkspaceCreateSetupDecision - supportsIdempotentCutoverRetry: boolean + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { return createWorktreeWithNameRetry({ client: args.client, diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index 2c6055dd3b2..8daeac0e04c 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -30,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = { workspaceName: string | undefined note: string | undefined nameIsAutoManaged?: boolean - supportsIdempotentCutoverRetry: boolean + supportsIdempotentCutoverRetry: boolean | Promise } export async function createWorkspaceFromComposerSource( @@ -93,7 +93,7 @@ async function createWorkItemWorkspace(args: { workspaceName: string | undefined note: string | undefined nameIsAutoManaged?: boolean - supportsIdempotentCutoverRetry: boolean + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args const item = selection.item @@ -151,7 +151,7 @@ async function createBranchWorkspace(args: { agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined - supportsIdempotentCutoverRetry: boolean + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice @@ -225,7 +225,7 @@ async function createNewBranchWorkspace(args: { agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined - supportsIdempotentCutoverRetry: boolean + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { const { client, selection, targetRepoId, setupDecision, agent, note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts new file mode 100644 index 00000000000..cd12cdbefa2 --- /dev/null +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability' + +function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient { + let call = 0 + return { + sendRequest: async () => { + const outcome = outcomes[Math.min(call, outcomes.length - 1)]! + call += 1 + if (outcome === 'cutover') { + throw new LogicalClientCutoverError() + } + if (outcome === 'error') { + throw new Error('offline') + } + return { + id: '1', + ok: true, + result: { capabilities: outcome }, + _meta: { runtimeId: 'r' } + } + } + } as unknown as RpcClient +} + +describe('readNewWorktreeRuntimeCapabilities', () => { + it('reads task and idempotent-create support from status.get', async () => { + await expect( + readNewWorktreeRuntimeCapabilities( + statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']]) + ) + ).resolves.toEqual({ + tasksSupported: true, + idempotentWorktreeCreateSupported: true + }) + }) + + it('retries the safe status probe after a connection cutover', async () => { + await expect( + readNewWorktreeRuntimeCapabilities( + statusClient(['cutover', ['worktree.create-idempotency.v1']]) + ) + ).resolves.toEqual({ + tasksSupported: false, + idempotentWorktreeCreateSupported: true + }) + }) + + it('fails closed when capability detection is unavailable', async () => { + await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({ + tasksSupported: false, + idempotentWorktreeCreateSupported: false + }) + }) +}) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 36321885c28..20500c10a7d 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -1,4 +1,104 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { LogicalClientCutoverError } 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 { + 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 +} { + const [tasksSupported, setTasksSupported] = useState(false) + const capabilityProbeRef = useRef<{ + client: RpcClient | null + 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 = { + 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 } +} + +function isLogicalClientCutoverError(error: unknown): boolean { + return ( + error instanceof LogicalClientCutoverError || + (error instanceof Error && error.message === 'RPC interrupted by connection migration') + ) +} diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index 5a7d39ffb1e..a03be2ad24b 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -40,6 +40,30 @@ function scriptedClient( } describe('createWorktreeWithNameRetry', () => { + it('waits for capability detection before sending a create', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient([{ id: 'wt-ready' }], attempts) + let resolveSupport!: (supported: boolean) => void + const support = new Promise((resolve) => { + resolveSupport = resolve + }) + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'puffin', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: support, + mintMutationId: () => 'key-ready' + }) + + await Promise.resolve() + expect(attempts).toHaveLength(0) + resolveSupport(true) + + await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' }) + expect(attempts).toHaveLength(1) + expect(attempts[0]!.params.clientMutationId).toBe('key-ready') + }) + it('stamps a clientMutationId on the create request', async () => { const attempts: Attempt[] = [] const client = scriptedClient([{ id: 'wt-1' }], attempts) diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index d487fc09490..ed59cc6dd01 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -27,7 +27,7 @@ export type CreateWorktreeWithNameRetryArgs = { client: RpcClient baseName: string buildParams: (name: string) => Record - supportsIdempotentCutoverRetry: boolean + supportsIdempotentCutoverRetry: boolean | Promise maxAttempts?: number // Injected in tests; production mints a fresh idempotency key per candidate. mintMutationId?: () => string @@ -41,7 +41,10 @@ export type CreateWorktreeWithNameRetryArgs = { export async function createWorktreeWithNameRetry( args: CreateWorktreeWithNameRetryArgs ): Promise { - const { client, baseName, buildParams, supportsIdempotentCutoverRetry } = args + const { client, baseName, buildParams } = args + // Why: creating before status.get settles would silently disable safe replay + // during the exact slow-network window this path is meant to recover from. + const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId let lastError: string | null = null