From e719ef1a57f0767db8a4644970240eacf43ed062 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:56:43 -0700 Subject: [PATCH] fix(mobile): survive connection-migration cutovers during worktree create (#9234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): survive connection-migration cutovers during worktree create A worktree.create in flight when the mobile transport migrates (relay/direct hand-off on shoddy cellular, relay lease rotation, relay recovery) rejects with "RPC interrupted by connection migration" even though the host completed it — leaving the Create Workspace modal stuck while the worktree exists on desktop. A naive retry hits a name collision and spawns a duplicate. Mirror the existing mobile terminal-create idempotency: worktree.create now accepts an optional clientMutationId that the host dedupes (in-flight + brief post-success TTL), and mobile mints one key per candidate name and re-issues the create on a cutover so the retry reconciles instead of duplicating. * fix(mobile): gate worktree cutover replay by capability * fix(mobile): await worktree replay capability --- mobile/src/components/NewWorktreeModal.tsx | 22 +-- .../src/tasks/blank-workspace-create.test.ts | 20 +- mobile/src/tasks/blank-workspace-create.ts | 2 + .../src/tasks/source-workspace-create.test.ts | 3 +- mobile/src/tasks/source-workspace-create.ts | 8 + .../tasks/worktree-create-capability.test.ts | 57 ++++++ .../src/tasks/worktree-create-capability.ts | 104 +++++++++++ .../src/tasks/worktree-create-retry.test.ts | 172 ++++++++++++++++++ mobile/src/tasks/worktree-create-retry.ts | 87 ++++++++- src/main/runtime/orca-runtime.test.ts | 76 ++++++++ src/main/runtime/orca-runtime.ts | 40 ++++ ...ime-request-connection.integration.test.ts | 7 + .../runtime/rpc/methods/worktree-schemas.ts | 3 + src/main/runtime/rpc/methods/worktree.test.ts | 27 +++ src/main/runtime/rpc/methods/worktree.ts | 148 +++++++-------- src/shared/protocol-version.ts | 7 +- 16 files changed, 682 insertions(+), 101 deletions(-) create mode 100644 mobile/src/tasks/worktree-create-capability.test.ts create mode 100644 mobile/src/tasks/worktree-create-capability.ts create mode 100644 mobile/src/tasks/worktree-create-retry.test.ts diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 09a0914d4..61c9b2e71 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -50,7 +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 { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability' import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' import { filterAvailableTaskProviders, @@ -210,7 +210,10 @@ function NewWorktreeModalContent({ const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) const [note, setNote] = useState('') const [availableProviders, setAvailableProviders] = useState([]) - const [tasksSupported, setTasksSupported] = useState(false) + const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities( + client, + visible + ) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -373,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') ]) @@ -407,16 +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)) const glabInstalled = (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab ?.installed === true @@ -698,7 +694,8 @@ function NewWorktreeModalContent({ }, workspaceName: trimmedName || undefined, note: trimmedNote, - nameIsAutoManaged: composer.isNameAutoManaged + nameIsAutoManaged: composer.isNameAutoManaged, + supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport() }) : await createBlankWorkspace({ client, @@ -707,7 +704,8 @@ function NewWorktreeModalContent({ startupCommand: command, createdWithAgentId, comment: trimmedNote, - setupDecision + setupDecision, + supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport() }) if ('error' in result) { setError(result.error) diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index 1d35d29f7..ba0fc4f10 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -34,7 +34,8 @@ describe('createBlankWorkspace', () => { startupCommand: undefined, createdWithAgentId: undefined, comment: undefined, - setupDecision: 'inherit' + setupDecision: 'inherit', + supportsIdempotentCutoverRetry: true }) expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) @@ -45,7 +46,10 @@ describe('createBlankWorkspace', () => { repo: 'id:repo-1', startupCommand: undefined, setupDecision: 'inherit', - name: 'octopus' + name: 'octopus', + // Idempotency key so a create interrupted by a connection migration can be + // safely retried without the host spawning a duplicate worktree. + clientMutationId: expect.any(String) } }) const params = calls[0]?.params as Record @@ -64,7 +68,8 @@ describe('createBlankWorkspace', () => { startupCommand: 'claude', createdWithAgentId: 'claude', comment: 'spike', - setupDecision: 'run' + setupDecision: 'run', + supportsIdempotentCutoverRetry: true }) expect(calls[0]?.params).toMatchObject({ @@ -93,7 +98,8 @@ describe('createBlankWorkspace', () => { startupCommand: undefined, createdWithAgentId: undefined, comment: undefined, - setupDecision: 'inherit' + setupDecision: 'inherit', + supportsIdempotentCutoverRetry: true }) expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) @@ -118,7 +124,8 @@ describe('createBlankWorkspace', () => { startupCommand: undefined, createdWithAgentId: undefined, comment: undefined, - setupDecision: 'inherit' + setupDecision: 'inherit', + supportsIdempotentCutoverRetry: true }) expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) @@ -136,7 +143,8 @@ describe('createBlankWorkspace', () => { startupCommand: undefined, createdWithAgentId: undefined, comment: undefined, - setupDecision: 'skip' + setupDecision: 'skip', + supportsIdempotentCutoverRetry: true }) expect(result).toEqual({ error: 'SSH connection is not available' }) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index 014af5581..09a2a1b61 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -14,10 +14,12 @@ export async function createBlankWorkspace(args: { createdWithAgentId: TuiAgent | undefined comment: string | undefined setupDecision: WorkspaceCreateSetupDecision + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { return createWorktreeWithNameRetry({ client: args.client, baseName: args.baseName, + supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, buildParams: (name) => { const params: Record = { repo: `id:${args.repoId}`, diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts index d23e0c663..592375a77 100644 --- a/mobile/src/tasks/source-workspace-create.test.ts +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -30,7 +30,8 @@ const baseArgs = { setupDecision: 'inherit' as const, agent, workspaceName: undefined, - note: undefined + note: undefined, + supportsIdempotentCutoverRetry: true } describe('createWorkspaceFromComposerSource', () => { diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index d16cc1741..8daeac0e0 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -30,6 +30,7 @@ export type CreateWorkspaceFromComposerArgs = { workspaceName: string | undefined note: string | undefined nameIsAutoManaged?: boolean + supportsIdempotentCutoverRetry: boolean | Promise } export async function createWorkspaceFromComposerSource( @@ -92,6 +93,7 @@ async function createWorkItemWorkspace(args: { workspaceName: string | undefined note: string | undefined nameIsAutoManaged?: boolean + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args const item = selection.item @@ -136,6 +138,7 @@ async function createWorkItemWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName, + supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, buildParams: (name) => ({ ...params, name }) }) } @@ -148,6 +151,7 @@ async function createBranchWorkspace(args: { agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice @@ -173,6 +177,7 @@ async function createBranchWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName, + supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, maxAttempts: 1, buildParams: (name) => applyCommon({ @@ -195,6 +200,7 @@ async function createBranchWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName, + supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, buildParams: (candidate) => { const params: Record = { repo: `id:${targetRepoId}`, @@ -219,6 +225,7 @@ async function createNewBranchWorkspace(args: { agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined + supportsIdempotentCutoverRetry: boolean | Promise }): Promise { const { client, selection, targetRepoId, setupDecision, agent, note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice @@ -230,6 +237,7 @@ async function createNewBranchWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName: selection.branchName, + supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, buildParams: (candidate) => { const params: Record = { repo: `id:${targetRepoId}`, 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 000000000..cd12cdbef --- /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 new file mode 100644 index 000000000..20500c10a --- /dev/null +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -0,0 +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 new file mode 100644 index 000000000..a03be2ad2 --- /dev/null +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { createWorktreeWithNameRetry } from './worktree-create-retry' + +type Attempt = { method: string; params: Record } + +// A client whose per-call outcome is scripted: return an id, a server error +// message, or throw (transport-level rejection, e.g. a connection-migration +// cutover). Records every call so tests can assert on the clientMutationId. +function scriptedClient( + outcomes: Array<{ id: string } | { errorMessage: string } | { throws: unknown }>, + attempts: Attempt[] +): RpcClient { + let call = 0 + return { + sendRequest: async (method: string, params?: unknown) => { + attempts.push({ method, params: (params ?? {}) as Record }) + const outcome = outcomes[Math.min(call, outcomes.length - 1)]! + call += 1 + if ('throws' in outcome) { + throw outcome.throws + } + if ('errorMessage' in outcome) { + return { + id: '1', + ok: false, + error: { code: 'x', message: outcome.errorMessage }, + _meta: { runtimeId: 'r' } + } + } + return { + id: '1', + ok: true, + result: { worktree: { id: outcome.id } }, + _meta: { runtimeId: 'r' } + } + } + } as unknown as RpcClient +} + +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) + const result = await createWorktreeWithNameRetry({ + client, + baseName: 'otter', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-1' + }) + expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' }) + expect(attempts).toHaveLength(1) + expect(attempts[0]!.params).toMatchObject({ name: 'otter', clientMutationId: 'key-1' }) + }) + + it('retries a connection-migration cutover with the SAME key, then succeeds', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ throws: new LogicalClientCutoverError() }, { id: 'wt-2' }], + attempts + ) + const result = await createWorktreeWithNameRetry({ + client, + baseName: 'seal', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-mig' + }) + expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' }) + expect(attempts).toHaveLength(2) + // Idempotency: both the interrupted send and the retry carry one key so the + // host dedupes instead of creating a duplicate worktree. + expect(attempts[0]!.params.clientMutationId).toBe('key-mig') + expect(attempts[1]!.params.clientMutationId).toBe('key-mig') + expect(attempts[1]!.params.name).toBe('seal') + }) + + it('gives up after the cutover retry budget and rethrows', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient([{ throws: new LogicalClientCutoverError() }], attempts) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'crab', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-x' + }) + ).rejects.toBeInstanceOf(LogicalClientCutoverError) + // Initial attempt + 5 retries. + expect(attempts).toHaveLength(6) + }) + + it('does not treat an ordinary transport error as a cutover', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient([{ throws: new Error('Request timed out') }], attempts) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'eel', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-t' + }) + ).rejects.toThrow('Request timed out') + expect(attempts).toHaveLength(1) + }) + + it('mints a fresh key per candidate when a name collision bumps the suffix', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ errorMessage: 'already exists locally' }, { id: 'wt-3' }], + attempts + ) + let n = 0 + const result = await createWorktreeWithNameRetry({ + client, + baseName: 'topic', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => `key-${(n += 1)}` + }) + expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' }) + expect(attempts).toHaveLength(2) + // A collision is a genuinely different create, so it gets a distinct key. + expect(attempts[0]!.params.clientMutationId).toBe('key-1') + expect(attempts[1]!.params.clientMutationId).toBe('key-2') + expect(attempts[1]!.params.name).toBe('topic-2') + }) + + it('does not replay an ambiguous cutover when the host lacks idempotency support', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient([{ throws: new LogicalClientCutoverError() }], attempts) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'ray', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: false, + mintMutationId: () => 'must-not-be-used' + }) + ).rejects.toBeInstanceOf(LogicalClientCutoverError) + expect(attempts).toHaveLength(1) + expect(attempts[0]!.params.clientMutationId).toBeUndefined() + }) +}) diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 4eb749f5c..ed59cc6dd 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -1,5 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import type { RpcResponse, RpcSuccess } from '../transport/types' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, getClientWorktreeCreateCandidate, @@ -14,25 +15,53 @@ import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' // loop in src/renderer/src/store/slices/worktrees.ts. export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } +// Why: a create in flight when the mobile transport migrates (relay/direct +// hand-off on shoddy cellular, relay lease rotation) rejects with a cutover error +// even though the host may have completed it. The shared clientMutationId makes a +// retry idempotent, so re-issue on the fresh session a bounded number of times +// instead of surfacing "RPC interrupted by connection migration" with the +// worktree silently created. +const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5 + +export type CreateWorktreeWithNameRetryArgs = { + client: RpcClient + baseName: string + buildParams: (name: string) => Record + supportsIdempotentCutoverRetry: boolean | Promise + maxAttempts?: number + // Injected in tests; production mints a fresh idempotency key per candidate. + mintMutationId?: () => string +} + // Creates a worktree, retrying with a numeric suffix on a name-collision error. // buildParams receives the candidate name so callers can assemble source-specific // params (linked issue/PR, base branch, etc.) around it. Callers that can't clear // a collision by re-suffixing (e.g. reusing a fixed existing branch) pass // maxAttempts: 1 to fail fast instead of burning the full retry budget. -export async function createWorktreeWithNameRetry(args: { - client: RpcClient - baseName: string - buildParams: (name: string) => Record - maxAttempts?: number -}): Promise { +export async function createWorktreeWithNameRetry( + args: CreateWorktreeWithNameRetryArgs +): Promise { 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 for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const candidateName = getClientWorktreeCreateCandidate(baseName, attempt) - const response = await client.sendRequest('worktree.create', buildParams(candidateName), { - timeoutMs: WORKTREE_CREATE_TIMEOUT_MS - }) + const candidateParams = buildParams(candidateName) + // Why: older hosts strip unknown fields, so only stamp and replay when the + // host advertises idempotency. One key per candidate makes cutover retries + // safe while a name-collision bump remains a genuinely new create. + const params = supportsIdempotentCutoverRetry + ? { ...candidateParams, clientMutationId: mintMutationId() } + : candidateParams + const response = await sendWorktreeCreateResilient( + client, + params, + supportsIdempotentCutoverRetry + ) if (response.ok) { const result = (response as RpcSuccess).result as { worktree: { id: string } } return { worktreeId: result.worktree.id, name: candidateName } @@ -44,3 +73,41 @@ export async function createWorktreeWithNameRetry(args: { } return { error: lastError ?? 'Failed to create workspace' } } + +// Sends worktree.create, re-issuing on a connection-migration cutover only. The +// shared clientMutationId in `params` keeps the retry idempotent host-side. +async function sendWorktreeCreateResilient( + client: RpcClient, + params: Record, + supportsIdempotentCutoverRetry: boolean +): Promise { + for (let migrationRetry = 0; ; migrationRetry += 1) { + try { + return await client.sendRequest('worktree.create', params, { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + } catch (error) { + if ( + !supportsIdempotentCutoverRetry || + !isLogicalClientCutoverError(error) || + migrationRetry >= WORKTREE_CREATE_CUTOVER_MAX_RETRIES + ) { + throw error + } + // Why: LogicalClientCutoverError is raised only after migrateTo installs an + // authenticated replacement, so retry immediately instead of adding UI lag. + } + } +} + +function isLogicalClientCutoverError(error: unknown): boolean { + return ( + error instanceof LogicalClientCutoverError || + (error instanceof Error && error.message === 'RPC interrupted by connection migration') + ) +} + +function defaultWorktreeCreateMutationId(): string { + const randomPart = Math.random().toString(36).slice(2, 10) + return `worktree-create:${Date.now().toString(36)}:${randomPart}` +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index afb3b7477..e5021aebb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1418,6 +1418,81 @@ computeWorktreePathMock.mockImplementation( ) ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath) +describe('OrcaRuntimeService.dedupeWorktreeCreate', () => { + it('coalesces concurrent creates that share a clientMutationId', async () => { + const runtime = new OrcaRuntimeService(store) + let calls = 0 + const factory = (): Promise<{ worktree: { id: string } }> => { + calls += 1 + return Promise.resolve({ worktree: { id: 'wt' } }) + } + const [a, b] = await Promise.all([ + runtime.dedupeWorktreeCreate('id:r', 'key-1', factory), + runtime.dedupeWorktreeCreate('id:r', 'key-1', factory) + ]) + expect(calls).toBe(1) + expect(a).toBe(b) + }) + + it('reuses a settled success for a retry whose response was lost in a cutover', async () => { + const runtime = new OrcaRuntimeService(store) + let calls = 0 + const factory = (): Promise<{ worktree: { id: string } }> => { + calls += 1 + return Promise.resolve({ worktree: { id: `wt-${calls}` } }) + } + const first = await runtime.dedupeWorktreeCreate('id:r', 'key-1', factory) + const retried = await runtime.dedupeWorktreeCreate('id:r', 'key-1', factory) + expect(calls).toBe(1) + expect(retried).toEqual(first) + }) + + it('expires settled successes after the reconnect window', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + let calls = 0 + const factory = (): Promise<{ n: number }> => Promise.resolve({ n: (calls += 1) }) + await runtime.dedupeWorktreeCreate('id:r', 'key-1', factory) + await vi.advanceTimersByTimeAsync(59_999) + await runtime.dedupeWorktreeCreate('id:r', 'key-1', factory) + expect(calls).toBe(1) + + await vi.advanceTimersByTimeAsync(1) + await runtime.dedupeWorktreeCreate('id:r', 'key-1', factory) + expect(calls).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('drops a failed create so a genuine retry starts fresh', async () => { + const runtime = new OrcaRuntimeService(store) + let calls = 0 + const factory = (): Promise => { + calls += 1 + return Promise.reject(new Error(`boom-${calls}`)) + } + await expect(runtime.dedupeWorktreeCreate('id:r', 'key-1', factory)).rejects.toThrow('boom-1') + await expect(runtime.dedupeWorktreeCreate('id:r', 'key-1', factory)).rejects.toThrow('boom-2') + expect(calls).toBe(2) + }) + + it('never dedupes across repos or when no clientMutationId is supplied', async () => { + const runtime = new OrcaRuntimeService(store) + let calls = 0 + const factory = (): Promise<{ n: number }> => { + calls += 1 + return Promise.resolve({ n: calls }) + } + await runtime.dedupeWorktreeCreate('id:a', 'key-1', factory) + await runtime.dedupeWorktreeCreate('id:b', 'key-1', factory) + await runtime.dedupeWorktreeCreate('id:a', undefined, factory) + await runtime.dedupeWorktreeCreate('id:a', undefined, factory) + expect(calls).toBe(4) + }) +}) + describe('OrcaRuntimeService', () => { it('projects runtime-backed settings to paired clients', () => { const runtime = new OrcaRuntimeService({ @@ -1530,6 +1605,7 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(status.capabilities).toContain('workspace-ports.v1') expect(status.capabilities).toContain('mobile.tasks.v1') + expect(status.capabilities).toContain('worktree.create-idempotency.v1') expect(status.capabilities).toContain('project-host-setup.v1') expect(status.capabilities).toContain('linear.issue-attribute-filter.v1') expect(status.capabilities).not.toContain('browser.screencast.v1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d919c077b..c9fcca82e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1291,6 +1291,10 @@ function getAgentLaunchPlatformForRepo( // Why: long enough for a phone to reconnect and retry a create whose response // was lost, short enough that an intentional later re-resume forks fresh. const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 +// Why: same idempotency window for worktree.create — a phone whose create was +// interrupted by a connection migration retries with the same clientMutationId +// and reuses the just-created worktree instead of spawning a duplicate. +const WORKTREE_CREATE_RESULT_TTL_MS = 60_000 const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150 const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 const BRACKETED_PASTE_BEGIN = '\x1b[200~' @@ -2199,6 +2203,10 @@ export class OrcaRuntimeService { string, Promise >() + // Why: idempotency map for worktree.create — a create interrupted by a mobile + // connection migration is retried with the same clientMutationId and returns + // the in-flight (or just-finished) operation instead of a duplicate worktree. + private worktreeCreateByMutationId = new Map>() // Why: a mobile create waits for the renderer to publish the new tab's surface // via graph-sync, but a throttled/hidden renderer can park that past the surface // timeout and the create would then destroy the live PTY (#7587). This lets the @@ -18804,6 +18812,38 @@ export class OrcaRuntimeService { }) } + // Why: dedupes a worktree.create whose response was lost when a mobile + // connection migration (relay/direct hand-off on shoddy cellular) rejected the + // in-flight request. A retry with the same clientMutationId returns the + // in-flight or just-finished create instead of a duplicate worktree; failures + // drop immediately so a genuine retry starts fresh, and successes linger + // briefly so a retry whose response was lost in the cutover still reconciles. + dedupeWorktreeCreate( + repoSelector: string, + clientMutationId: string | undefined, + run: () => Promise + ): Promise { + if (!clientMutationId) { + return run() + } + const key = `${repoSelector}\0${clientMutationId}` + const inflight = this.worktreeCreateByMutationId.get(key) + if (inflight) { + return inflight as Promise + } + const created = run() + this.worktreeCreateByMutationId.set(key, created) + const drop = (): void => { + if (this.worktreeCreateByMutationId.get(key) === created) { + this.worktreeCreateByMutationId.delete(key) + } + } + void created.then(() => { + setTimeout(drop, WORKTREE_CREATE_RESULT_TTL_MS).unref?.() + }, drop) + return created + } + async createMobileSessionTerminal( worktreeSelector: string, opts: { diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 2715883a4..41b579b07 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -19,6 +19,11 @@ import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol- const REMOTE_RUNTIME_TEST_TIMEOUT_MS = 15_000 const REMOTE_RUNTIME_REQUEST_TIMEOUT_MS = 5_000 +// worktree.create routes through the runtime's clientMutationId idempotency +// wrapper; these stubs run the create straight through (no dedupe). +const passthroughDedupe = (_repo: string, _id: string | undefined, run: () => Promise) => + run() + describe('remote runtime request connection integration', () => { it( 'fetches repos through the real E2EE WebSocket runtime', @@ -145,6 +150,7 @@ describe('remote runtime request connection integration', () => { source: 'git', worktrees }), + dedupeWorktreeCreate: passthroughDedupe, createManagedWorktree: ({ name }: { name?: string }) => { const worktree = { id: `repo-1::${name || 'created'}`, @@ -370,6 +376,7 @@ describe('remote runtime request connection integration', () => { source: 'git', worktrees }), + dedupeWorktreeCreate: passthroughDedupe, createManagedWorktree: ({ name }: { name?: string }) => { const worktree = { id: `repo-1::${name || 'created'}`, diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 96f281357..e271432e5 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -143,6 +143,9 @@ export const WorktreeCreate = z .unknown() .transform((value) => (isTuiAgent(value) ? value : undefined)) .optional(), + // Why: mobile retries a create interrupted by a connection migration with the + // same key so the host dedupes instead of spawning a duplicate worktree. + clientMutationId: z.string().min(1).max(128).optional(), automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional() }) .superRefine((params, ctx) => { diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 8bc68319d..ef96f0e16 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -19,10 +19,16 @@ function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } +// worktree.create routes through the runtime's clientMutationId idempotency +// wrapper; these unit mocks run the create straight through (no dedupe). +const passthroughDedupe = (_repo: string, _id: string | undefined, run: () => Promise) => + run() + describe('worktree RPC methods', () => { it('routes mobile session-only activation without notifying desktop clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, activateManagedWorktree: vi .fn() .mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true }) @@ -46,6 +52,7 @@ describe('worktree RPC methods', () => { it('forwards the mobile clientKind to the runtime on session-only activation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, activateManagedWorktree: vi .fn() .mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true }) @@ -70,6 +77,7 @@ describe('worktree RPC methods', () => { it('routes dirty-file force to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, removeManagedWorktree: vi.fn().mockResolvedValue({}) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -89,6 +97,7 @@ describe('worktree RPC methods', () => { it('routes create options to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService @@ -158,6 +167,7 @@ describe('worktree RPC methods', () => { const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1') const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), showAutomation: vi.fn(() => ({ id: 'automation-1', @@ -246,6 +256,7 @@ describe('worktree RPC methods', () => { } const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(runtimeLocalRepo), showAutomation: vi.fn(() => ({ id: 'automation-runtime', @@ -303,6 +314,7 @@ describe('worktree RPC methods', () => { const dispatchToken = createAutomationDispatchToken('automation-edited', 'run-edited') const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), showAutomation: vi.fn(() => ({ id: 'automation-edited', @@ -366,6 +378,7 @@ describe('worktree RPC methods', () => { const dispatchToken = createAutomationDispatchToken('automation-retry', 'run-retry') const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), showAutomation: vi.fn(() => ({ id: 'automation-retry', @@ -429,6 +442,7 @@ describe('worktree RPC methods', () => { it('rejects forged automation provenance requests on worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), showAutomation: vi.fn(() => ({ id: 'automation-1', @@ -471,6 +485,7 @@ describe('worktree RPC methods', () => { it('forwards startup command and env to runtime worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' }, @@ -524,6 +539,7 @@ describe('worktree RPC methods', () => { it('drops invalid startup launch config env at the runtime RPC boundary', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService @@ -558,6 +574,7 @@ describe('worktree RPC methods', () => { it('forwards task startup drafts to runtime worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService @@ -588,6 +605,7 @@ describe('worktree RPC methods', () => { it('routes create-base prefetches to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, prefetchManagedWorktreeCreateBase: vi.fn().mockResolvedValue(undefined) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -609,6 +627,7 @@ describe('worktree RPC methods', () => { it('maps unknown telemetry sources to the runtime default instead of rejecting create', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService @@ -635,6 +654,7 @@ describe('worktree RPC methods', () => { it('rejects worktree.create when both parent and no-parent are supplied', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, createManagedWorktree: vi.fn() } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -656,6 +676,7 @@ describe('worktree RPC methods', () => { it('passes explicit repo selectors to PR base resolution and preserves start-point fields', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, resolveManagedPrBase: vi.fn().mockResolvedValue({ baseBranch: 'abc123', headSha: 'abc123', @@ -694,6 +715,7 @@ describe('worktree RPC methods', () => { it('passes explicit repo selectors to MR base resolution', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-head' }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -719,6 +741,7 @@ describe('worktree RPC methods', () => { it('forwards Linear metadata through worktree.set', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, updateManagedWorktreeMeta: vi.fn().mockResolvedValue({ id: 'wt-1' }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -746,6 +769,7 @@ describe('worktree RPC methods', () => { it('forwards push target clears through worktree.set', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, updateManagedWorktreeMeta: vi.fn().mockResolvedValue({ id: 'wt-1' }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -771,6 +795,7 @@ describe('worktree RPC methods', () => { it('rejects worktree.set when both parent and no-parent are supplied', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, updateManagedWorktreeMeta: vi.fn() } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -802,6 +827,7 @@ describe('worktree RPC methods', () => { } const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, listWorktreeLineage: vi.fn().mockResolvedValue(lineage), listWorkspaceLineage: vi.fn().mockResolvedValue({}) } as unknown as OrcaRuntimeService @@ -817,6 +843,7 @@ describe('worktree RPC methods', () => { it('persists smart sort order on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, persistManagedWorktreeSortOrder: vi.fn().mockReturnValue({ updated: 2 }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 8132a96e3..4d3382f63 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -70,79 +70,85 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.create', params: WorktreeCreate, - handler: async (params, { runtime }) => { - const repo = await runtime.showRepo(params.repo) - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: params.repo, - repo, - request: params.automationProvenanceRequest - }) - // Why: provenance tokens are reserved before creation so retries can recover, - // but failed create attempts must release the reservation for a safe retry. - try { - const result = await runtime.createManagedWorktree({ + handler: async (params, { runtime }) => + // Why: a mobile create interrupted by a connection migration is retried with + // the same clientMutationId; dedupe so the host returns the in-flight/created + // worktree instead of spawning a duplicate. No key (desktop/CLI) runs plainly. + runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + const repo = await runtime.showRepo(params.repo) + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, repoSelector: params.repo, - name: params.name ?? '', - baseBranch: params.baseBranch, - compareBaseRef: params.compareBaseRef, - branchNameOverride: params.branchNameOverride, - linkedIssue: params.linkedIssue, - linkedPR: params.linkedPR, - linkedLinearIssue: params.linkedLinearIssue, - linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, - linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, - linkedGitLabMR: params.linkedGitLabMR, - linkedGitLabIssue: params.linkedGitLabIssue, - linkedBitbucketPR: params.linkedBitbucketPR, - linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, - linkedGiteaPR: params.linkedGiteaPR, - comment: params.comment, - displayName: params.displayName, - telemetrySource: params.telemetrySource, - workspaceStatus: params.workspaceStatus, - manualOrder: params.manualOrder, - sparseCheckout: params.sparseCheckout, - pushTarget: params.pushTarget, - runHooks: params.runHooks === true, - activate: params.activate === true, - setupDecision: params.setupDecision, - createdWithAgent: params.createdWithAgent ?? params.startupAgent, - automationProvenance, - startup: params.startupCommand - ? { - command: params.startupCommand, - ...(params.startupEnv ? { env: params.startupEnv } : {}), - ...(params.startupLaunchConfig ? { launchConfig: params.startupLaunchConfig } : {}), - ...(params.startupCommandDelivery - ? { startupCommandDelivery: params.startupCommandDelivery } - : {}) - } - : undefined, - ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), - ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), - startupDraft: params.startupDraft, - lineage: { - parentWorkspace: params.parentWorkspace, - envParentWorkspace: params.envParentWorkspace, - parentWorktree: params.parentWorktree, - ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), - noParent: params.noParent === true, - callerTerminalHandle: params.callerTerminalHandle, - orchestrationContext: params.orchestrationContext - } + repo, + request: params.automationProvenanceRequest }) - finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) - // Why: agent callers need a stable dispatch target without traversing - // terminal-list layout duplicates after creating the worktree. - return params.startupAgent && result.startupTerminal?.handle - ? { ...result, agentTerminalHandle: result.startupTerminal.handle } - : result - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) - throw error - } - } + // Why: provenance tokens are reserved before creation so retries can recover, + // but failed create attempts must release the reservation for a safe retry. + try { + const result = await runtime.createManagedWorktree({ + repoSelector: params.repo, + name: params.name ?? '', + baseBranch: params.baseBranch, + compareBaseRef: params.compareBaseRef, + branchNameOverride: params.branchNameOverride, + linkedIssue: params.linkedIssue, + linkedPR: params.linkedPR, + linkedLinearIssue: params.linkedLinearIssue, + linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, + linkedGitLabMR: params.linkedGitLabMR, + linkedGitLabIssue: params.linkedGitLabIssue, + linkedBitbucketPR: params.linkedBitbucketPR, + linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, + linkedGiteaPR: params.linkedGiteaPR, + comment: params.comment, + displayName: params.displayName, + telemetrySource: params.telemetrySource, + workspaceStatus: params.workspaceStatus, + manualOrder: params.manualOrder, + sparseCheckout: params.sparseCheckout, + pushTarget: params.pushTarget, + runHooks: params.runHooks === true, + activate: params.activate === true, + setupDecision: params.setupDecision, + createdWithAgent: params.createdWithAgent ?? params.startupAgent, + automationProvenance, + startup: params.startupCommand + ? { + command: params.startupCommand, + ...(params.startupEnv ? { env: params.startupEnv } : {}), + ...(params.startupLaunchConfig + ? { launchConfig: params.startupLaunchConfig } + : {}), + ...(params.startupCommandDelivery + ? { startupCommandDelivery: params.startupCommandDelivery } + : {}) + } + : undefined, + ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), + ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), + startupDraft: params.startupDraft, + lineage: { + parentWorkspace: params.parentWorkspace, + envParentWorkspace: params.envParentWorkspace, + parentWorktree: params.parentWorktree, + ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), + noParent: params.noParent === true, + callerTerminalHandle: params.callerTerminalHandle, + orchestrationContext: params.orchestrationContext + } + }) + finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) + // Why: agent callers need a stable dispatch target without traversing + // terminal-list layout duplicates after creating the worktree. + return params.startupAgent && result.startupTerminal?.handle + ? { ...result, agentTerminalHandle: result.startupTerminal.handle } + : result + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) + throw error + } + }) }), defineMethod({ name: 'worktree.prefetchCreateBase', diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index f6242c93e..a1fc0ae81 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -44,6 +44,10 @@ export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate // floor-taking input. Mobile must not forward replies unless advertised. export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY = 'terminal.query-reply-input.v1' as const +// Why: older hosts strip worktree.create's clientMutationId, so mobile must only +// replay ambiguous cutovers when the host advertises idempotent create support. +export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = + 'worktree.create-idempotency.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -60,7 +64,8 @@ export const RUNTIME_CAPABILITIES = [ FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, AI_VAULT_RUNTIME_CAPABILITY, - TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY, + WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})