fix(mobile): reconcile workspace creates across Relay migration

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-17 17:24:53 -04:00
co-authored by Orca
parent 2cb5d4e149
commit 6181599894
15 changed files with 515 additions and 79 deletions
+4 -1
View File
@@ -89,6 +89,7 @@ import {
workspaceSshStatusLabel
} from '../../../src/tasks/workspace-ssh-gate'
import { WORKTREE_CREATE_TIMEOUT_MS } from '../../../src/tasks/workspace-create-timeout'
import { sendMigrationSafeWorktreeCreate } from '../../../src/tasks/worktree-create-migration'
import {
isSetupHookTrusted,
normalizeSetupHookTrust,
@@ -5610,7 +5611,9 @@ export default function MobileTasksScreen() {
sparseCheckout: sparseCheckoutOverride
})
}
const response = await client.sendRequest('worktree.create', params, {
const response = await sendMigrationSafeWorktreeCreate({
client,
params,
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
if (!isSuccess(response)) {
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createBlankWorkspace } from './blank-workspace-create'
vi.mock('expo-crypto', () => ({
randomUUID: () => '00000000-0000-4000-8000-000000000001'
}))
type Call = { method: string; params: unknown }
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
@@ -45,7 +49,8 @@ describe('createBlankWorkspace', () => {
repo: 'id:repo-1',
startupCommand: undefined,
setupDecision: 'inherit',
name: 'octopus'
name: 'octopus',
clientMutationId: '00000000-0000-4000-8000-000000000001'
}
})
const params = calls[0]?.params as Record<string, unknown>
@@ -1,8 +1,12 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
vi.mock('expo-crypto', () => ({
randomUUID: () => '00000000-0000-4000-8000-000000000001'
}))
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest'
import * as ExpoCrypto from 'expo-crypto'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { createWorktreeWithNameRetry } from './worktree-create-retry'
import {
sendMigrationSafeWorktreeCreate,
WORKTREE_CREATE_MIGRATION_UNCERTAIN_ERROR
} from './worktree-create-migration'
vi.mock('expo-crypto', () => ({
randomUUID: vi.fn(() => '00000000-0000-4000-8000-000000000001')
}))
const success = (worktreeId: string): RpcResponse => ({
id: 'response',
ok: true,
result: { worktree: { id: worktreeId } },
_meta: { runtimeId: 'runtime' }
})
const failure = (message: string): RpcResponse => ({
id: 'response',
ok: false,
error: { code: 'invalid_argument', message },
_meta: { runtimeId: 'runtime' }
})
function clientWithSend(sendRequest: RpcClient['sendRequest']): RpcClient {
return { sendRequest } as RpcClient
}
describe('migration-safe worktree creation', () => {
it('replays once with the identical candidate and mutation ID after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success('wt-1'))
const response = await sendMigrationSafeWorktreeCreate({
client: clientWithSend(sendRequest),
params: { repo: 'repo-1', name: 'migration-safe' },
timeoutMs: 100,
clientMutationId: '1cd77fbf-d9d0-4723-9a65-a9064d93f582'
})
expect(response).toEqual(success('wt-1'))
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest.mock.calls[0]?.[1]).toEqual(sendRequest.mock.calls[1]?.[1])
expect(sendRequest.mock.calls[1]?.[1]).toEqual({
repo: 'repo-1',
name: 'migration-safe',
clientMutationId: '1cd77fbf-d9d0-4723-9a65-a9064d93f582'
})
})
it('turns an old-desktop collision after cutover into actionable uncertainty', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(failure('Branch "migration-safe" already exists.'))
await expect(
sendMigrationSafeWorktreeCreate({
client: clientWithSend(sendRequest),
params: { repo: 'repo-1', name: 'migration-safe' },
timeoutMs: 100,
clientMutationId: '9ea97e27-4f66-4ca3-ae8a-e11c1f10990b'
})
).rejects.toThrow(WORKTREE_CREATE_MIGRATION_UNCERTAIN_ERROR)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
it('does not suffix after a second interrupted migration', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValue(new LogicalClientCutoverError())
await expect(
createWorktreeWithNameRetry({
client: clientWithSend(sendRequest),
baseName: 'migration-safe',
buildParams: (name) => ({ repo: 'repo-1', name })
})
).rejects.toThrow(WORKTREE_CREATE_MIGRATION_UNCERTAIN_ERROR)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest.mock.calls.map((call) => (call[1] as { name: string }).name)).toEqual([
'migration-safe',
'migration-safe'
])
})
it('still suffixes an ordinary non-migration collision with a fresh mutation ID', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValueOnce(failure('Branch "octopus" already exists.'))
.mockResolvedValueOnce(success('wt-2'))
const mutationIds = [
'4203a4ad-f6df-4260-b40d-5e4d59c5e254',
'8d292184-d3c3-42a3-9fd5-294fe4e2d15f'
]
vi.mocked(ExpoCrypto.randomUUID).mockImplementation(() => mutationIds.shift()!)
await expect(
createWorktreeWithNameRetry({
client: clientWithSend(sendRequest),
baseName: 'octopus',
buildParams: (name) => ({ repo: 'repo-1', name })
})
).resolves.toEqual({ worktreeId: 'wt-2', name: 'octopus-2' })
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
{
repo: 'repo-1',
name: 'octopus',
clientMutationId: '4203a4ad-f6df-4260-b40d-5e4d59c5e254'
},
{
repo: 'repo-1',
name: 'octopus-2',
clientMutationId: '8d292184-d3c3-42a3-9fd5-294fe4e2d15f'
}
])
})
})
@@ -0,0 +1,51 @@
import * as ExpoCrypto from 'expo-crypto'
import { isRetryableWorktreeCreateConflict } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
export const WORKTREE_CREATE_MIGRATION_UNCERTAIN_ERROR =
'Connection changed while this workspace was being created. It may already exist—refresh the workspace list before trying again.'
class WorktreeCreateMigrationUncertainError extends Error {
constructor() {
super(WORKTREE_CREATE_MIGRATION_UNCERTAIN_ERROR)
}
}
export async function sendMigrationSafeWorktreeCreate(args: {
client: RpcClient
params: Record<string, unknown>
timeoutMs: number
clientMutationId?: string
}): Promise<RpcResponse> {
const requestParams = {
...args.params,
clientMutationId: args.clientMutationId ?? ExpoCrypto.randomUUID()
}
const send = (): Promise<RpcResponse> =>
args.client.sendRequest('worktree.create', requestParams, { timeoutMs: args.timeoutMs })
try {
return await send()
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
throw error
}
}
try {
const response = await send()
// Why: old desktops ignore the operation ID. A same-name collision after
// cutover can mean the first request committed, so suffixing could duplicate it.
if (!response.ok && isRetryableWorktreeCreateConflict(response.error.message)) {
throw new WorktreeCreateMigrationUncertainError()
}
return response
} catch (error) {
if (error instanceof WorktreeCreateMigrationUncertainError) {
throw error
}
throw new WorktreeCreateMigrationUncertainError()
}
}
+4 -1
View File
@@ -6,6 +6,7 @@ import {
isRetryableWorktreeCreateConflict
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
import { sendMigrationSafeWorktreeCreate } from './worktree-create-migration'
// Why: server-side collision checks (branch already exists locally / on a remote
// / already has PR #N) can fire even after a pre-flight basename dedupe —
@@ -30,7 +31,9 @@ export async function createWorktreeWithNameRetry(args: {
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), {
const response = await sendMigrationSafeWorktreeCreate({
client,
params: buildParams(candidateName),
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
if (response.ok) {
@@ -90,6 +90,25 @@ attachWebglAddon(true);`).runInNewContext(context)
}
}
function shouldUseWebglRenderer(navigator: {
userAgent: string
platform: string
maxTouchPoints: number
}): boolean {
const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()')
const functionEnd = terminalHtmlSource.indexOf(
' // Why: iOS WebKit does not reliably resolve',
functionStart
)
expect(functionStart).toBeGreaterThanOrEqual(0)
expect(functionEnd).toBeGreaterThan(functionStart)
const context = { navigator, useWebgl: false }
new Script(
`${terminalHtmlSource.slice(functionStart, functionEnd)}\nuseWebgl = shouldUseWebglRenderer();`
).runInNewContext(context)
return context.useWebgl
}
describe('terminal WebView bundled engine', () => {
it('keeps the assembled terminal HTML free of external engine URLs', () => {
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
@@ -210,6 +229,39 @@ describe('terminal WebView bundled engine', () => {
expect(timers).toHaveLength(0)
})
it.each([
{
name: 'iPhone',
navigator: {
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)',
platform: 'iPhone',
maxTouchPoints: 5
},
expected: false
},
{
name: 'touch MacIntel iPadOS',
navigator: {
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0)',
platform: 'MacIntel',
maxTouchPoints: 5
},
expected: false
},
{
name: 'Android',
navigator: {
userAgent: 'Mozilla/5.0 (Linux; Android 16)',
platform: 'Linux armv8l',
maxTouchPoints: 5
},
expected: true
}
])('selects the expected renderer for $name', ({ navigator, expected }) => {
expect(shouldUseWebglRenderer(navigator)).toBe(expected)
expect(terminalHtmlSource).toContain('if (shouldUseWebglRenderer()) attachWebglAddon(true);')
})
it('falls back to a refreshed DOM renderer when the delayed WebGL retry fails', () => {
const { addons, term, timers } = createWebglRecoveryHarness(true)
+6 -1
View File
@@ -262,6 +262,11 @@ window.onerror = function(msg) {
if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true;
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
}
function shouldUseWebglRenderer() {
// Why: iOS WebKit can leave transient blank glyph-atlas cells that repaint
// later even though xterm's buffer is correct; the DOM renderer avoids that atlas.
return !isIOSWebView();
}
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace';
@@ -740,7 +745,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
var nextTerm = term;
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (shouldUseWebglRenderer()) attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
if (typeof replayData === 'string' && replayData.length > 0) {
enqueueWrite(replayData);
@@ -25,7 +25,7 @@ export const TERMINAL_WEBGL_RECOVERY_JS = `
try { addon.dispose(); } catch (e) {}
refreshTerminalSurface();
if (!allowRecovery) return;
// Why: one delayed retry handles transient iOS context loss without
// Why: one delayed retry handles transient context loss without
// entering a GPU crash loop; a second loss stays on the DOM renderer.
cancelWebglContextRecovery();
var recoveryTerm = term;
@@ -53,7 +53,7 @@ export const TERMINAL_WEBGL_RECOVERY_JS = `
document.addEventListener('visibilitychange', function() {
if (document.visibilityState !== 'visible') return;
// Why: iOS may restore the xterm model while discarding GPU pixels/theme
// Why: WebKit may restore the xterm model while discarding surface/theme
// paint state, so visibility must rebuild the atlas and repaint every row.
applyTerminalTheme(terminalThemeInput);
try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import { runIdempotentWorktreeCreate } from './worktree-create-idempotency'
describe('worktree.create idempotency', () => {
it('shares concurrent and settled-success replays for one runtime', async () => {
const runtime = {}
let resolveCreate!: (value: { worktree: { id: string } }) => void
const create = vi.fn(
() =>
new Promise<{ worktree: { id: string } }>((resolve) => {
resolveCreate = resolve
})
)
const args = {
runtime,
clientMutationId: '12bf2c0f-49d9-46c9-9005-c231715c53ad',
params: { repo: 'repo-1', name: 'migration-safe' },
create
}
const first = runIdempotentWorktreeCreate(args)
const concurrent = runIdempotentWorktreeCreate(args)
await Promise.resolve()
expect(create).toHaveBeenCalledTimes(1)
const created = { worktree: { id: 'wt-1' } }
resolveCreate(created)
await expect(Promise.all([first, concurrent])).resolves.toEqual([created, created])
await expect(runIdempotentWorktreeCreate(args)).resolves.toBe(created)
expect(create).toHaveBeenCalledTimes(1)
})
it('rejects reuse of a mutation ID with different parameters', async () => {
const runtime = {}
const clientMutationId = 'fdb51f1b-39dc-4fb4-8e36-8017b3ec1639'
await runIdempotentWorktreeCreate({
runtime,
clientMutationId,
params: { repo: 'repo-1', name: 'first' },
create: async () => ({ worktree: { id: 'wt-1' } })
})
await expect(
runIdempotentWorktreeCreate({
runtime,
clientMutationId,
params: { repo: 'repo-1', name: 'second' },
create: async () => ({ worktree: { id: 'wt-2' } })
})
).rejects.toThrow('cannot be reused with different')
})
it('drops failures so the same operation can retry', async () => {
const runtime = {}
const create = vi
.fn<() => Promise<{ worktree: { id: string } }>>()
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValueOnce({ worktree: { id: 'wt-retried' } })
const args = {
runtime,
clientMutationId: '6cb64ce3-eaac-496c-a5e6-2c395c12252a',
params: { repo: 'repo-1', name: 'retryable' },
create
}
await expect(runIdempotentWorktreeCreate(args)).rejects.toThrow('temporary failure')
await expect(runIdempotentWorktreeCreate(args)).resolves.toEqual({
worktree: { id: 'wt-retried' }
})
expect(create).toHaveBeenCalledTimes(2)
})
it('does not share mutation IDs across runtimes', async () => {
const create = vi.fn(async () => ({ worktree: { id: 'wt-1' } }))
const request = {
clientMutationId: '0fab3dbf-16b8-4588-a1e5-81e931415e4c',
params: { repo: 'repo-1', name: 'isolated' },
create
}
await runIdempotentWorktreeCreate({ ...request, runtime: {} })
await runIdempotentWorktreeCreate({ ...request, runtime: {} })
expect(create).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,56 @@
import { isDeepStrictEqual } from 'node:util'
import { InvalidArgumentError } from '../core'
const WORKTREE_CREATE_RESULT_TTL_MS = 5 * 60_000
type WorktreeCreateEntry = {
params: unknown
result: Promise<unknown>
}
const worktreeCreatesByRuntime = new WeakMap<object, Map<string, WorktreeCreateEntry>>()
export async function runIdempotentWorktreeCreate<TResult>(args: {
runtime: object
clientMutationId: string | undefined
params: unknown
create: () => Promise<TResult>
}): Promise<TResult> {
if (!args.clientMutationId) {
return args.create()
}
const clientMutationId = args.clientMutationId
let entries = worktreeCreatesByRuntime.get(args.runtime)
if (!entries) {
entries = new Map()
worktreeCreatesByRuntime.set(args.runtime, entries)
}
const existing = entries.get(clientMutationId)
if (existing) {
if (!isDeepStrictEqual(existing.params, args.params)) {
throw new InvalidArgumentError(
'clientMutationId cannot be reused with different worktree.create parameters'
)
}
return existing.result as Promise<TResult>
}
// Why: publish the promise before creation begins so concurrent migration replays
// share one mutation; retain success briefly for a response lost during cutover.
const entry: WorktreeCreateEntry = {
params: args.params,
result: Promise.resolve().then(args.create)
}
entries.set(clientMutationId, entry)
const drop = (): void => {
if (entries?.get(clientMutationId) === entry) {
entries.delete(clientMutationId)
}
}
void entry.result.then(() => {
setTimeout(drop, WORKTREE_CREATE_RESULT_TTL_MS).unref?.()
}, drop)
return entry.result as Promise<TResult>
}
@@ -2,6 +2,18 @@ import { describe, expect, it } from 'vitest'
import { WorktreeCreate } from './worktree-schemas'
describe('worktree RPC schemas', () => {
it('accepts a UUID mutation ID and rejects malformed IDs', () => {
expect(
WorktreeCreate.safeParse({
repo: 'repo-1',
clientMutationId: '7350e466-9f8e-427f-8d42-2c70c8d9f801'
}).success
).toBe(true)
expect(WorktreeCreate.safeParse({ repo: 'repo-1', clientMutationId: 'reused' }).success).toBe(
false
)
})
it('rejects invalid startup agent values', () => {
const parsed = WorktreeCreate.safeParse({
repo: 'repo-1',
@@ -65,6 +65,7 @@ export const WorktreeCreate = z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
clientMutationId: z.string().uuid().optional(),
name: OptionalString,
baseBranch: OptionalString,
compareBaseRef: OptionalString,
@@ -154,6 +154,27 @@ describe('worktree RPC methods', () => {
})
})
it('returns the original workspace when a create mutation is replayed', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-created' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const params = {
repo: 'repo-1',
name: 'migration-safe',
clientMutationId: 'c39750ef-6c87-4bb4-a152-e22ac7eb7a7d'
}
const first = await dispatcher.dispatch(makeRequest('worktree.create', params))
const replay = await dispatcher.dispatch(makeRequest('worktree.create', params))
expect(first).toMatchObject({ ok: true, result: { worktree: { id: 'wt-created' } } })
expect(replay).toMatchObject({ ok: true, result: { worktree: { id: 'wt-created' } } })
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
})
it('mints automation provenance from a valid dispatch request on worktree creation', async () => {
const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1')
const runtime = {
+84 -71
View File
@@ -19,6 +19,7 @@ import {
WorktreeSet,
WorktreeSortOrder
} from './worktree-schemas'
import { runIdempotentWorktreeCreate } from './worktree-create-idempotency'
export const WORKTREE_METHODS: RpcMethod[] = [
defineMethod({
@@ -70,79 +71,91 @@ 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({
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 }
: {})
handler: async (params, { runtime }) =>
runIdempotentWorktreeCreate({
runtime,
clientMutationId: params.clientMutationId,
params,
create: async () => {
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({
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
}
: 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
}
})
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',