fix(mobile): await worktree replay capability

This commit is contained in:
Brennan Benson
2026-07-17 15:36:57 -07:00
parent f77b62d0e0
commit fe5c374819
7 changed files with 199 additions and 24 deletions
+8 -17
View File
@@ -50,8 +50,7 @@ import {
} from '../worktree/new-workspace-dialog-repo-selection'
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability'
import { MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY } from '../tasks/worktree-create-capability'
import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import {
filterAvailableTaskProviders,
@@ -211,8 +210,10 @@ function NewWorktreeModalContent({
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [note, setNote] = useState('')
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
const [tasksSupported, setTasksSupported] = useState(false)
const [idempotentWorktreeCreateSupported, setIdempotentWorktreeCreateSupported] = useState(false)
const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities(
client,
visible
)
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
@@ -375,7 +376,6 @@ function NewWorktreeModalContent({
// linear.status timeout, which rejects rather than resolving {ok:false})
// can't discard the already-resolved critical settings/ui results.
const probes = Promise.allSettled([
client.sendRequest('status.get'),
client.sendRequest('preflight.check'),
client.sendRequest('linear.status')
])
@@ -409,19 +409,10 @@ function NewWorktreeModalContent({
setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {})
}
const [statusRes, preflightRes, linearRes] = await probes
const [preflightRes, linearRes] = await probes
if (stale) {
return
}
// Tasks is an additive RPC surface, so older paired desktops without the
// capability fall back to branch + blank sources only.
const statusResult = okResult(statusRes)
const capabilities =
(statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? []
setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY))
setIdempotentWorktreeCreateSupported(
capabilities.includes(MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY)
)
const glabInstalled =
(okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab
?.installed === true
@@ -704,7 +695,7 @@ function NewWorktreeModalContent({
workspaceName: trimmedName || undefined,
note: trimmedNote,
nameIsAutoManaged: composer.isNameAutoManaged,
supportsIdempotentCutoverRetry: idempotentWorktreeCreateSupported
supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport()
})
: await createBlankWorkspace({
client,
@@ -714,7 +705,7 @@ function NewWorktreeModalContent({
createdWithAgentId,
comment: trimmedNote,
setupDecision,
supportsIdempotentCutoverRetry: idempotentWorktreeCreateSupported
supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport()
})
if ('error' in result) {
setError(result.error)
+1 -1
View File
@@ -14,7 +14,7 @@ export async function createBlankWorkspace(args: {
createdWithAgentId: TuiAgent | undefined
comment: string | undefined
setupDecision: WorkspaceCreateSetupDecision
supportsIdempotentCutoverRetry: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
}): Promise<WorktreeCreateResult> {
return createWorktreeWithNameRetry({
client: args.client,
+4 -4
View File
@@ -30,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
supportsIdempotentCutoverRetry: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
}
export async function createWorkspaceFromComposerSource(
@@ -93,7 +93,7 @@ async function createWorkItemWorkspace(args: {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
supportsIdempotentCutoverRetry: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const item = selection.item
@@ -151,7 +151,7 @@ async function createBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
supportsIdempotentCutoverRetry: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -225,7 +225,7 @@ async function createNewBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
supportsIdempotentCutoverRetry: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -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
})
})
})
@@ -1,4 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
// replay an ambiguous create unless the host advertises idempotency support.
// Mirrors WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY in the shared protocol.
export const MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY = 'worktree.create-idempotency.v1'
const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
idempotentWorktreeCreateSupported: boolean
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
idempotentWorktreeCreateSupported: false
}
// Why: status.get is safe to replay and must settle before create, independently
// of slower provider probes, so ambiguous cutover retries are gated correctly.
export async function readNewWorktreeRuntimeCapabilities(
client: RpcClient
): Promise<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')
)
}
@@ -40,6 +40,30 @@ function scriptedClient(
}
describe('createWorktreeWithNameRetry', () => {
it('waits for capability detection before sending a create', async () => {
const attempts: Attempt[] = []
const client = scriptedClient([{ id: 'wt-ready' }], attempts)
let resolveSupport!: (supported: boolean) => void
const support = new Promise<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)
+5 -2
View File
@@ -27,7 +27,7 @@ export type CreateWorktreeWithNameRetryArgs = {
client: RpcClient
baseName: string
buildParams: (name: string) => Record<string, unknown>
supportsIdempotentCutoverRetry: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
maxAttempts?: number
// Injected in tests; production mints a fresh idempotency key per candidate.
mintMutationId?: () => string
@@ -41,7 +41,10 @@ export type CreateWorktreeWithNameRetryArgs = {
export async function createWorktreeWithNameRetry(
args: CreateWorktreeWithNameRetryArgs
): Promise<WorktreeCreateResult> {
const { client, baseName, buildParams, supportsIdempotentCutoverRetry } = args
const { client, baseName, buildParams } = args
// Why: creating before status.get settles would silently disable safe replay
// during the exact slow-network window this path is meant to recover from.
const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
let lastError: string | null = null