From dc6c8b90aba0b18b256247451c964a4d4da7d545 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:35:34 -0700 Subject: [PATCH] fix(mobile): survive connection-migration cutovers during worktree create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/tasks/blank-workspace-create.test.ts | 5 +- .../src/tasks/worktree-create-retry.test.ts | 134 ++++++++++++++++ mobile/src/tasks/worktree-create-retry.ts | 82 ++++++++-- src/main/runtime/orca-runtime.test.ts | 56 +++++++ 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 +++++++++--------- 9 files changed, 420 insertions(+), 82 deletions(-) create mode 100644 mobile/src/tasks/worktree-create-retry.test.ts diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index 1d35d29f758..1fe37dba473 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -45,7 +45,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 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 00000000000..6c5328cefda --- /dev/null +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -0,0 +1,134 @@ +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 +} + +const noSleep = async (): Promise => {} + +describe('createWorktreeWithNameRetry', () => { + 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 }), + mintMutationId: () => 'key-1', + sleep: noSleep + }) + 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 }), + mintMutationId: () => 'key-mig', + sleep: noSleep + }) + 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 }), + mintMutationId: () => 'key-x', + sleep: noSleep + }) + ).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 }), + mintMutationId: () => 'key-t', + sleep: noSleep + }) + ).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 }), + mintMutationId: () => `key-${(n += 1)}`, + sleep: noSleep + }) + 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') + }) +}) diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 4eb749f5c23..0d605a9a8f3 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,45 @@ 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 +const WORKTREE_CREATE_CUTOVER_RETRY_DELAY_MS = 300 + +export type CreateWorktreeWithNameRetryArgs = { + client: RpcClient + baseName: string + buildParams: (name: string) => Record + maxAttempts?: number + // Injected in tests; production mints a fresh idempotency key per candidate. + mintMutationId?: () => string + sleep?: (ms: number) => Promise +} + // 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 const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS + const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId + const sleep = args.sleep ?? defaultSleep 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 - }) + // Why: one idempotency key per candidate name. A cutover retry reuses the + // SAME key so the host dedupes (no duplicate worktree), while a name-collision + // bump is a genuinely new create that must mint a fresh key. + const params = { ...buildParams(candidateName), clientMutationId: mintMutationId() } + const response = await sendWorktreeCreateResilient(client, params, sleep) if (response.ok) { const result = (response as RpcSuccess).result as { worktree: { id: string } } return { worktreeId: result.worktree.id, name: candidateName } @@ -44,3 +65,44 @@ 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, + sleep: (ms: number) => Promise +): Promise { + for (let migrationRetry = 0; ; migrationRetry += 1) { + try { + return await client.sendRequest('worktree.create', params, { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + } catch (error) { + if ( + !isLogicalClientCutoverError(error) || + migrationRetry >= WORKTREE_CREATE_CUTOVER_MAX_RETRIES + ) { + throw error + } + // Give migrateTo a beat to settle on the authenticated replacement session. + await sleep(WORKTREE_CREATE_CUTOVER_RETRY_DELAY_MS) + } + } +} + +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}` +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 055d1c2ac48..4ecc79b1bc2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1387,6 +1387,62 @@ 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('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({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e1bce48a5a..a4c4e19376f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1287,6 +1287,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~' @@ -2182,6 +2186,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 @@ -18752,6 +18760,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 2715883a426..41b579b07a4 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 96f2813576e..e271432e5f3 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 8bc68319d2d..ef96f0e1688 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 8132a96e3f8..4d3382f635d 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',