Files
orca/mobile/src/tasks/worktree-create-capability.ts
T
Jinjing bc55fe3cb0 Support explicit remote branch reuse across workspaces (#34295)
* Support explicit remote branch reuse across workspaces

- Preserve selected branch identity, tracking, and busy-branch handling
- Add explicit branch-off intent with v2 runtime capability gating

* Style existing workspace action in branch reuse UI

* Preserve explicit branch reuse with versioned host support

- Gate strict reuse on a new request capability while retaining legacy behavior
- Warn when a reused remote branch cannot be configured as the push upstream

* Clarify selected branch checkout options

* Offer existing or new branch choices for worktrees

* Preserve new-branch intent when reusing a branch name

* Adapt branch composer tests and catalog after rebase

* Refresh mobile RPC recordings for branch intent capabilities

* Remove retired Russian localization entries

* Remove retired German and Turkish localization entries

* Add branch selection translations for existing workspaces

* Localize mobile worktree branch selection
2026-10-28 20:48:16 -07:00

228 lines
8.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { readAgentLaunchSupport, type AgentLaunchSupport } from './agent-launch-request'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { worktreeCreateCapabilityRead } from './mobile-workspace-create-operations'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
import {
WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY,
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
} from '../../../src/shared/worktree-runtime-capabilities'
import {
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
resolveWorktreeCreateIdempotencySupport,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
// Older hosts strip clientMutationId; never replay ambiguous creates without advertised idempotency.
export const MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY = 'worktree.create-idempotency.v1'
const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
agentLaunch: AgentLaunchSupport | false
branchIntentV2: boolean
branchIntentV2Request: boolean
hostPlatform: NodeJS.Platform | null
}
// Settle the replay-safe status probe before create, independently of slower provider probes.
export async function readNewWorktreeRuntimeCapabilities(
client: RpcClient
): Promise<NewWorktreeRuntimeCapabilities | null> {
for (let migrationRetry = 0; ; migrationRetry += 1) {
try {
const status = worktreeCreateCapabilityRead.interpret(
await worktreeCreateCapabilityRead.request(client)
)
if (!status.accepted) {
return null
}
const result = status.value
const capabilities = result.capabilities ?? []
const supportsIdempotency = capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
)
const advertisedIdempotency = result.worktreeCreateIdempotency
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
agentLaunch: readAgentLaunchSupport(capabilities),
branchIntentV2: capabilities.includes(WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY),
branchIntentV2Request: capabilities.includes(
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
),
worktreeCreateIdempotency: supportsIdempotency
? advertisedIdempotency === undefined
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
: advertisedIdempotency !== null &&
typeof advertisedIdempotency === 'object' &&
!Array.isArray(advertisedIdempotency)
? resolveWorktreeCreateIdempotencySupport(
'dedupeTtlMs' in advertisedIdempotency
? advertisedIdempotency.dedupeTtlMs
: undefined
)
: { dedupeTtlMs: 0 }
: false,
hostPlatform: readMobileRuntimeHostPlatform(result)
}
} catch (error) {
if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) {
return null
}
}
}
}
export function useNewWorktreeRuntimeCapabilities(
client: RpcClient | null,
enabled: boolean
): {
tasksSupported: boolean
hostPlatform: NodeJS.Platform | null
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
getAgentLaunchSupport: () => Promise<AgentLaunchSupport | false>
getBranchIntentSupport: (intent: 'open-existing-v2' | 'create-new') => Promise<boolean>
} {
const [capabilityState, setCapabilityState] = useState<{
client: RpcClient
generation: number | undefined
connectedAt: number | null | undefined
capabilities: NewWorktreeRuntimeCapabilities
} | null>(null)
const capabilityProbeRef = useRef<{
client: RpcClient | null
generation: number | undefined
connectedAt: number | null | undefined
promise: Promise<NewWorktreeRuntimeCapabilities | null>
} | null>(null)
const getCapabilities = useCallback(async (): Promise<NewWorktreeRuntimeCapabilities | null> => {
for (;;) {
const generation = client?.getGeneration?.()
const connectedAt = client?.getLastConnectedAt()
let probe = capabilityProbeRef.current
if (
!probe ||
probe.client !== client ||
probe.generation !== generation ||
probe.connectedAt !== connectedAt
) {
// Queued creates can precede passive effects; share the same status barrier.
probe = {
client,
generation,
connectedAt,
promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null)
}
capabilityProbeRef.current = probe
}
const capabilities = await probe.promise
const current = capabilityProbeRef.current
if (
current === probe &&
client?.getGeneration?.() === generation &&
client?.getLastConnectedAt() === connectedAt
) {
if (!capabilities) {
// Probe failure does not establish missing host support.
capabilityProbeRef.current = null
}
return capabilities
}
// A reconnect or path switch keeps the host; wait for the current connection's answer.
if (current?.client !== client) {
return null
}
}
}, [client])
useEffect(() => {
if (capabilityProbeRef.current?.client !== client) {
capabilityProbeRef.current = null
}
}, [client])
// oxlint-disable-next-line react-doctor/effect-needs-cleanup -- Cleanup cancels the timer and fences async rearming.
useEffect(() => {
if (!enabled || !client) {
return
}
let stale = false
let refreshVersion = 0
let retry: ReturnType<typeof setTimeout> | undefined
let retryDelay = 1_000
const refresh = async () => {
if (stale) {
return
}
const version = ++refreshVersion
clearTimeout(retry)
const capabilities = await getCapabilities()
if (stale || version !== refreshVersion) {
return
}
if (capabilities) {
setCapabilityState({
client,
generation: client.getGeneration?.(),
connectedAt: client.getLastConnectedAt(),
capabilities
})
retryDelay = 1_000
}
if (!capabilityProbeRef.current && client.getState() === 'connected') {
retry = setTimeout(() => void refresh(), retryDelay)
retryDelay = Math.min(retryDelay * 2, 30_000)
}
}
const unsubscribe = client.onStateChange((state) => {
if (state === 'connected') {
setCapabilityState(null)
capabilityProbeRef.current = null
void refresh()
}
})
void refresh()
return () => {
stale = true
clearTimeout(retry)
unsubscribe()
}
}, [client, enabled, getCapabilities])
const getWorktreeCreateCutoverSupport = useCallback(
() =>
getCapabilities().then((capabilities) => capabilities?.worktreeCreateIdempotency ?? false),
[getCapabilities]
)
const getAgentLaunchSupport = useCallback(
() => getCapabilities().then((capabilities) => capabilities?.agentLaunch ?? false),
[getCapabilities]
)
const getBranchIntentSupport = useCallback(
(intent: 'open-existing-v2' | 'create-new') =>
getCapabilities().then((capabilities) =>
intent === 'open-existing-v2'
? capabilities?.branchIntentV2Request === true
: capabilities?.branchIntentV2 === true
),
[getCapabilities]
)
const capabilities =
capabilityState?.client === client &&
capabilityState?.generation === client?.getGeneration?.() &&
capabilityState?.connectedAt === client?.getLastConnectedAt()
? capabilityState?.capabilities
: null
return {
tasksSupported: capabilities?.tasksSupported ?? false,
hostPlatform: capabilities?.hostPlatform ?? null,
getWorktreeCreateCutoverSupport,
getAgentLaunchSupport,
getBranchIntentSupport
}
}