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.
This commit is contained in:
Brennan Benson
2026-07-17 14:52:45 -07:00
parent f544820552
commit dc6c8b90ab
9 changed files with 420 additions and 82 deletions
@@ -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<string, unknown>
@@ -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<string, unknown> }
// 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<string, unknown> })
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<void> => {}
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')
})
})
+72 -10
View File
@@ -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<string, unknown>
maxAttempts?: number
// Injected in tests; production mints a fresh idempotency key per candidate.
mintMutationId?: () => string
sleep?: (ms: number) => Promise<void>
}
// 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<string, unknown>
maxAttempts?: number
}): Promise<WorktreeCreateResult> {
export async function createWorktreeWithNameRetry(
args: CreateWorktreeWithNameRetryArgs
): Promise<WorktreeCreateResult> {
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<string, unknown>,
sleep: (ms: number) => Promise<void>
): Promise<RpcResponse> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+56
View File
@@ -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<never> => {
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({
+40
View File
@@ -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<RuntimeMobileSessionCreateTerminalResult>
>()
// 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<string, Promise<unknown>>()
// 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<T>(
repoSelector: string,
clientMutationId: string | undefined,
run: () => Promise<T>
): Promise<T> {
if (!clientMutationId) {
return run()
}
const key = `${repoSelector}\0${clientMutationId}`
const inflight = this.worktreeCreateByMutationId.get(key)
if (inflight) {
return inflight as Promise<T>
}
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: {
@@ -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 = <T>(_repo: string, _id: string | undefined, run: () => Promise<T>) =>
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'}`,
@@ -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) => {
@@ -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 = <T>(_repo: string, _id: string | undefined, run: () => Promise<T>) =>
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 })
+77 -71
View File
@@ -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',