fix(mobile): survive connection-migration cutovers during worktree create (#9234)
* 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
This commit is contained in:
parent
b259300032
commit
e719ef1a57
|
|
@ -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<string | null>(null)
|
||||
const [note, setNote] = useState('')
|
||||
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities(
|
||||
client,
|
||||
visible
|
||||
)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
|
||||
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ export async function createBlankWorkspace(args: {
|
|||
createdWithAgentId: TuiAgent | undefined
|
||||
comment: string | undefined
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
return createWorktreeWithNameRetry({
|
||||
client: args.client,
|
||||
baseName: args.baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
buildParams: (name) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${args.repoId}`,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ const baseArgs = {
|
|||
setupDecision: 'inherit' as const,
|
||||
agent,
|
||||
workspaceName: undefined,
|
||||
note: undefined
|
||||
note: undefined,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
}
|
||||
|
||||
describe('createWorkspaceFromComposerSource', () => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
|
|||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromComposerSource(
|
||||
|
|
@ -92,6 +93,7 @@ async function createWorkItemWorkspace(args: {
|
|||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
|
|
@ -219,6 +225,7 @@ async function createNewBranchWorkspace(args: {
|
|||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<NewWorktreeRuntimeCapabilities> {
|
||||
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<boolean>
|
||||
} {
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const capabilityProbeRef = useRef<{
|
||||
client: RpcClient | null
|
||||
promise: Promise<NewWorktreeRuntimeCapabilities>
|
||||
} | null>(null)
|
||||
const getCapabilities = useCallback((): Promise<NewWorktreeRuntimeCapabilities> => {
|
||||
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')
|
||||
)
|
||||
}
|
||||
|
|
@ -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<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
|
||||
}
|
||||
|
||||
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<boolean>((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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown>
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
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<string, unknown>
|
||||
maxAttempts?: number
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
export async function createWorktreeWithNameRetry(
|
||||
args: CreateWorktreeWithNameRetryArgs
|
||||
): Promise<WorktreeCreateResult> {
|
||||
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<string, unknown>,
|
||||
supportsIdempotentCutoverRetry: boolean
|
||||
): Promise<RpcResponse> {
|
||||
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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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({
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -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<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 })
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 & {})
|
||||
|
|
|
|||
Loading…
Reference in New Issue