mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(mobile): await worktree replay capability
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user