fix(worktrees): never leave the create-worktree dialog stuck on a stalled preflight (#33400)

* fix(worktrees): never leave the create-worktree dialog stuck on a stalled preflight

Bound the same-repo PR branch fetch with the review-head fetch timeout, and
hand a slow composer submit off to a staged sidebar row so the dialog closes.

* fix(worktrees): harden create-worktree handoff from adversarial review

Dedupe handed-off submits against linked creations resolved during preflight,
bound the staged sidebar row with a deadline, offer Reopen on handed-off
failures, and bound the GitLab MR branch fetch.

* test(worktrees): expect the bounded PR/MR branch fetch timeout

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-24 23:00:47 -07:00
committed by GitHub
co-authored by OrcaWin
parent 10005e5d15
commit e1066973f2
20 changed files with 412 additions and 16 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ describe('fetchPrHeadTrackingRef', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['fetch', 'origin', '+refs/heads/feature/x:refs/remotes/origin/feature/x'],
{ cwd: '/repo' }
{ cwd: '/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
})
+4 -1
View File
@@ -30,7 +30,10 @@ export async function fetchPrHeadTrackingRef(
}
await gitExecFileAsync(
['fetch', remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`],
options.localGitExecOptions ?? { cwd: repo.path }
{
...(options.localGitExecOptions ?? { cwd: repo.path }),
timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS
}
)
}
@@ -578,7 +578,7 @@ describe('registerWorktreeHandlers', () => {
'pr-prateek-orca',
'+refs/heads/prateek/fix-sidebar-agents-toggle*:refs/remotes/pr-prateek-orca/prateek/fix-sidebar-agents-toggle*'
],
{ cwd: '/workspace/repo' }
{ cwd: '/workspace/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
[
@@ -761,7 +761,7 @@ describe('registerWorktreeHandlers', () => {
'origin',
'+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature'
],
{ cwd: '/workspace/repo' }
{ cwd: '/workspace/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', 'origin/feature/add-feature'],
@@ -172,7 +172,7 @@ describe('registerWorktreeHandlers', () => {
'origin',
'+refs/heads/feat/onboarding-model-choice-782:refs/remotes/origin/feat/onboarding-model-choice-782'
],
{ cwd: '/workspace/repo' }
{ cwd: '/workspace/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
[
@@ -1,3 +1,4 @@
import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
setPlatform,
@@ -437,7 +438,7 @@ describe('registerWorktreeHandlers', () => {
'origin',
'+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature'
],
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', 'origin/feature/add-feature'],
@@ -161,7 +161,7 @@ describe('OrcaRuntimeService', () => {
'origin',
'+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature'
],
{ cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' }
{ cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
expect(gitSpy).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature/add-feature'], {
cwd: TEST_REPO_PATH,
@@ -266,7 +266,7 @@ describe('OrcaRuntimeService', () => {
)
expect(gitSpy).toHaveBeenCalledWith(
['fetch', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: TEST_REPO_PATH }
{ cwd: TEST_REPO_PATH, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
expect(gitSpy).toHaveBeenCalledWith(
['rev-parse', '--verify', `refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/42^{commit}`],
@@ -4,6 +4,7 @@ import { isFolderRepo } from '../../shared/repo-kind'
import { requireRepoLocalGitRoute } from '../repo-execution-host'
import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch'
import { gitExecFileAsync } from '../git/runner'
import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref'
import { getProjectRefForRemote, getWorkItemByProjectRef } from '../gitlab/client'
import { getGlabKnownHosts } from '../gitlab/gl-utils'
import {
@@ -107,7 +108,10 @@ export async function resolveRuntimeGitLabWorktreeBase(
}
const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined
const fetchRemoteTrackingRef = async (branch: string, ref: string): Promise<void> => {
await gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`])
await gitExecFileAsync(['fetch', remote, `+refs/heads/${branch}:${ref}`], {
...localGitExecOptions,
timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS
})
}
const fetchCompareBaseRef = (): Promise<boolean> =>
fetchCompareBaseRefWithLocalFallback({
@@ -5,6 +5,7 @@ import type { SetupDecision } from '../../../../shared/worktree/create-types'
import type { WorkspaceIntentName } from '../../../../shared/workspace-name'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import type { ComposerCreateHandoff } from '@/lib/composer-create-handoff'
import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions'
type SmartCreateNames = {
@@ -48,7 +49,8 @@ export type ComposerSubmitModel = {
workspaceNameSeed: string,
workspaceRunContext: WorktreeCreationRequest['workspaceRunContext'],
repoId: string,
options?: QuickSubmitOptions
options?: QuickSubmitOptions,
handoff?: ComposerCreateHandoff
) => Promise<void>
prepareQuickSubmit: (
resolution: PendingSmartGitHubSubmitResolution,
@@ -127,6 +127,7 @@ export function useComposerSubmitOrchestration(
isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget,
isSubmissionCancelled: target.composerTargetStore.isSubmissionCancelled,
linkedPR: target.workspaceIdentityState.linkedPR,
linkedWorkItem: target.sourceContextState.linkedWorkItem,
name: target.sourceContextState.name,
onCreated: target.composerTargetStore.onCreated,
parsedLinkedIssueNumber: target.derivedComposerState.parsedLinkedIssueNumber,
@@ -143,7 +144,9 @@ export function useComposerSubmitOrchestration(
showProjectRequiredError: source.branchStartPointActions.showProjectRequiredError,
sourceIntentBlocksCreate: target.workspaceIdentityState.sourceIntentBlocksCreate,
sparseError: target.derivedComposerState.sparseError,
submitFolderTarget: folderSubmitOrchestration.submitFolderTarget
submitFolderTarget: folderSubmitOrchestration.submitFolderTarget,
taskSourceContext: target.sourceContextState.taskSourceContext,
telemetrySource: target.composerTargetStore.telemetrySource
})
return {
folderSubmitOrchestration,
@@ -38,6 +38,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { useAppStore } from '@/store'
import { settleComposerSubmit } from '@/lib/composer-submit-cancellation'
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import type { ComposerCreateHandoff } from '@/lib/composer-create-handoff'
import { translate } from '@/i18n/i18n'
import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context'
import { buildQuickComposerStartup } from './quick-startup-plan'
@@ -84,7 +85,8 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
workspaceNameSeed: string,
workspaceRunContext: WorktreeCreationRequest['workspaceRunContext'],
repoId: string,
options?: QuickSubmitOptions
options?: QuickSubmitOptions,
handoff?: ComposerCreateHandoff
): Promise<void> => {
const prepared = await prepareQuickSubmit(
smartGitHubResolution,
@@ -251,7 +253,16 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
clearNewWorkspaceDraft()
}
runBackgroundWorktreeCreation(request)
if (handoff) {
handoff.start(request)
} else {
runBackgroundWorktreeCreation(request)
}
// The composer already closed at handoff; closing again could dismiss a newer modal.
if (handoff?.isHandedOff()) {
return
}
if (createMultiple) {
resetForNextCreate()
@@ -11,6 +11,7 @@ import {
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import { toast } from 'sonner'
import { scheduleComposerCreateHandoff } from '@/lib/composer-create-handoff'
import type { ComposerModel } from './composer-model'
@@ -22,6 +23,7 @@ type QuickSubmitActionInput = Pick<
| 'isProjectGroupTarget'
| 'isSubmissionCancelled'
| 'linkedPR'
| 'linkedWorkItem'
| 'name'
| 'onCreated'
| 'parsedLinkedIssueNumber'
@@ -38,6 +40,8 @@ type QuickSubmitActionInput = Pick<
| 'sourceIntentBlocksCreate'
| 'sparseError'
| 'submitFolderTarget'
| 'taskSourceContext'
| 'telemetrySource'
>
export function useQuickSubmitAction(input: QuickSubmitActionInput) {
@@ -48,6 +52,7 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) {
isProjectGroupTarget,
isSubmissionCancelled,
linkedPR,
linkedWorkItem,
name,
onCreated,
parsedLinkedIssueNumber,
@@ -63,7 +68,9 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) {
showProjectRequiredError,
sourceIntentBlocksCreate,
sparseError,
submitFolderTarget
submitFolderTarget,
taskSourceContext,
telemetrySource
} = input
const submitQuick = useCallback(
@@ -131,6 +138,33 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) {
setCreateError(null)
setCreating(true)
const handoff = scheduleComposerCreateHandoff({
stagingRequest: {
repoId,
workspaceRunContext,
name: workspaceNameSeed,
...(parsedLinkedIssueNumber != null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
...(effectiveLinkedPR != null ? { linkedPR: effectiveLinkedPR } : {}),
setupDecision: setupDecision ?? 'inherit',
agent: requestedAgent,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null
},
// Why: closing (even under "Create more") keeps a later dismiss from cancelling this submit.
onHandOff: () => onCreated?.(),
isCancelled: isSubmissionCancelled,
reopenComposer: () =>
useAppStore.getState().openModal('new-workspace-composer', {
prefilledName: name,
initialRepoId: repoId,
linkedWorkItem,
taskSourceContext,
telemetrySource
})
})
try {
const smartGitHubSettlement = await settleComposerSubmit(
resolvePendingSmartGitHubSubmit(),
@@ -145,16 +179,21 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) {
workspaceNameSeed,
workspaceRunContext,
repoId,
options
options,
handoff
)
} catch (error) {
if (isSubmissionCancelled()) {
return
}
const formattedError = formatWorkspaceCreateError(error)
if (handoff.fail(getWorkspaceCreateErrorToastMessage(formattedError))) {
return
}
setCreateError(formattedError)
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
} finally {
handoff.dispose()
setCreating(false)
}
},
@@ -165,6 +204,7 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) {
isProjectGroupTarget,
isSubmissionCancelled,
linkedPR,
linkedWorkItem,
name,
onCreated,
parsedLinkedIssueNumber,
@@ -180,7 +220,9 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) {
showProjectRequiredError,
sourceIntentBlocksCreate,
sparseError,
submitFolderTarget
submitFolderTarget,
taskSourceContext,
telemetrySource
]
)
+5
View File
@@ -1402,6 +1402,11 @@
"openInChat": "This {{value0}} conversation is open in a chat. Refresh Session History to open it.",
"ownershipUnknown": "Orca could not confirm who owns this {{value0}} conversation. Refresh Session History and try again."
},
"composerCreateHandoff": {
"reopen": "Reopen",
"preparationTimedOut": "Worktree preparation timed out.",
"needsInput": "Worktree was not created because it needs more input."
},
"pendingWorktreeCreation": {
"checkingOutFiles": "Checking out files… {{percent}}%",
"provisioningVm": "Provisioning VM…",
+5
View File
@@ -1147,6 +1147,11 @@
"openInChat": "Esta conversación de {{value0}} está abierta en un chat. Actualiza el historial de sesiones para abrirla.",
"ownershipUnknown": "Orca no pudo confirmar quién es el propietario de esta conversación de {{value0}}. Actualiza el historial de sesiones e inténtalo de nuevo."
},
"composerCreateHandoff": {
"reopen": "Reabrir",
"preparationTimedOut": "Se agotó el tiempo de preparación del worktree.",
"needsInput": "No se creó el worktree porque necesita más información."
},
"pendingWorktreeCreation": {
"checkingOutFiles": "Extrayendo archivos… {{percent}}%",
"provisioningVm": "Provisionando VM…",
+5
View File
@@ -1287,6 +1287,11 @@
"openInChat": "Cette conversation {{value0}} est ouverte dans un chat. Actualisez l'historique des sessions pour l'ouvrir.",
"ownershipUnknown": "Orca n'a pas pu confirmer à qui appartient cette conversation {{value0}}. Actualisez l'historique des sessions et réessayez."
},
"composerCreateHandoff": {
"reopen": "Rouvrir",
"preparationTimedOut": "La préparation du worktree a expiré.",
"needsInput": "Le worktree n'a pas été créé, car des informations supplémentaires sont requises."
},
"pendingWorktreeCreation": {
"checkingOutFiles": "Extraction des fichiers… {{percent}}%",
"provisioningVm": "Provisionnement de la VM…",
+5
View File
@@ -1147,6 +1147,11 @@
"openInChat": "この {{value0}} の会話はチャットで開かれています。開くにはセッション履歴を更新してください。",
"ownershipUnknown": "Orca はこの {{value0}} の会話の所有者を確認できませんでした。セッション履歴を更新して、再試行してください。"
},
"composerCreateHandoff": {
"reopen": "再度開く",
"preparationTimedOut": "ワークツリーの準備がタイムアウトしました。",
"needsInput": "追加の入力が必要なため、ワークツリーは作成されませんでした。"
},
"pendingWorktreeCreation": {
"checkingOutFiles": "ファイルをチェックアウト中… {{percent}}%",
"provisioningVm": "VMをプロビジョニング中…",
+5
View File
@@ -1147,6 +1147,11 @@
"openInChat": "이 {{value0}} 대화는 채팅에서 열려 있습니다. 열려면 세션 기록을 새로고침하세요.",
"ownershipUnknown": "Orca가 이 {{value0}} 대화의 소유자를 확인할 수 없습니다. 세션 기록을 새로고침한 후 다시 시도하세요."
},
"composerCreateHandoff": {
"reopen": "다시 열기",
"preparationTimedOut": "워크트리 준비 시간이 초과되었습니다.",
"needsInput": "추가 입력이 필요하여 워크트리가 생성되지 않았습니다."
},
"pendingWorktreeCreation": {
"checkingOutFiles": "파일 체크아웃 중… {{percent}}%",
"provisioningVm": "VM 프로비저닝 중…",
+5
View File
@@ -1910,6 +1910,11 @@
"openInChat": "Esta conversa de {{value0}} está aberta em um chat. Atualize o Histórico de sessões para abri-la.",
"ownershipUnknown": "O Orca não conseguiu confirmar quem é o proprietário desta conversa de {{value0}}. Atualize o Histórico de sessões e tente novamente."
},
"composerCreateHandoff": {
"reopen": "Reabrir",
"preparationTimedOut": "A preparação do worktree expirou.",
"needsInput": "O worktree não foi criado porque precisa de mais informações."
},
"pendingWorktreeCreation": {
"checkingOutFiles": "Obtendo arquivos… {{percent}}%",
"provisioningVm": "Provisionando VM…",
+5
View File
@@ -1147,6 +1147,11 @@
"openInChat": "此 {{value0}} 会话已在聊天中打开。请刷新会话历史以打开它。",
"ownershipUnknown": "Orca 无法确认此 {{value0}} 会话的归属。请刷新会话历史并重试。"
},
"composerCreateHandoff": {
"reopen": "重新打开",
"preparationTimedOut": "工作树准备超时。",
"needsInput": "需要更多输入,因此未创建工作树。"
},
"pendingWorktreeCreation": {
"checkingOutFiles": "正在检出文件… {{percent}}%",
"provisioningVm": "正在预配虚拟机…",
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
const flow = vi.hoisted(() => ({
begin: vi.fn(() => 'staged-1'),
continue: vi.fn(() => true),
run: vi.fn(() => 'run-1')
}))
const store = vi.hoisted(() => {
const pendingWorktreeCreations: Record<string, { creationId: string; request: unknown }> = {}
return {
pendingWorktreeCreations,
removePendingWorktreeCreation: vi.fn(),
setActivePendingWorktreeCreation: vi.fn(),
setActiveView: vi.fn(),
setSidebarOpen: vi.fn()
}
})
const toastError = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({ toast: { error: toastError } }))
vi.mock('@/lib/worktree-creation-flow', () => ({
beginBackgroundWorktreePreparation: flow.begin,
continueBackgroundWorktreeCreation: flow.continue,
runBackgroundWorktreeCreation: flow.run
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
import {
COMPOSER_CREATE_PREPARE_DEADLINE_MS,
scheduleComposerCreateHandoff
} from './composer-create-handoff'
function makeRequest(name: string, linkedPR?: number): WorktreeCreationRequest {
return {
repoId: 'repo-1',
name,
...(linkedPR != null ? { linkedPR } : {}),
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null
}
}
const stagingRequest = makeRequest('staged')
const finalRequest = makeRequest('final')
function schedule(isCancelled: () => boolean = () => false) {
const onHandOff = vi.fn()
const reopenComposer = vi.fn()
const handoff = scheduleComposerCreateHandoff({
stagingRequest,
onHandOff,
reopenComposer,
isCancelled
})
return { handoff, onHandOff, reopenComposer }
}
function handOff(): ReturnType<typeof schedule> {
const scheduled = schedule()
vi.advanceTimersToNextTimer()
store.pendingWorktreeCreations['staged-1'] = { creationId: 'staged-1', request: stagingRequest }
return scheduled
}
describe('scheduleComposerCreateHandoff', () => {
beforeEach(() => {
vi.useFakeTimers()
for (const id of Object.keys(store.pendingWorktreeCreations)) {
delete store.pendingWorktreeCreations[id]
}
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('starts directly without staging or closing when preflight is fast', () => {
const { handoff, onHandOff } = schedule()
handoff.start(finalRequest)
vi.runAllTimers()
expect(flow.run).toHaveBeenCalledWith(finalRequest)
expect(flow.begin).not.toHaveBeenCalled()
expect(onHandOff).not.toHaveBeenCalled()
expect(handoff.fail('boom')).toBe(false)
})
it('closes the composer onto a staged row when preflight stalls, then continues it', () => {
const { handoff, onHandOff } = handOff()
expect(flow.begin).toHaveBeenCalledWith(stagingRequest)
expect(onHandOff).toHaveBeenCalledTimes(1)
handoff.start(finalRequest)
handoff.dispose()
expect(flow.continue).toHaveBeenCalledWith('staged-1', finalRequest)
expect(toastError).not.toHaveBeenCalled()
})
it('focuses an existing creation for the same PR instead of duplicating it', () => {
store.pendingWorktreeCreations['other'] = {
creationId: 'other',
request: makeRequest('other', 42)
}
const { handoff } = handOff()
handoff.start(makeRequest('final', 42))
expect(flow.continue).not.toHaveBeenCalled()
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('staged-1')
expect(store.setActivePendingWorktreeCreation).toHaveBeenCalledWith('other')
})
it('reports a handed-off failure once with a way back to the composer', () => {
const { handoff, reopenComposer } = handOff()
expect(handoff.fail('boom')).toBe(true)
handoff.dispose()
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('staged-1')
expect(toastError).toHaveBeenCalledTimes(1)
toastError.mock.calls[0][1].action.onClick()
expect(reopenComposer).toHaveBeenCalled()
})
it('drops a staged row that never starts by the deadline and ignores the late request', () => {
const { handoff } = handOff()
vi.advanceTimersByTime(COMPOSER_CREATE_PREPARE_DEADLINE_MS)
delete store.pendingWorktreeCreations['staged-1']
handoff.start(finalRequest)
expect(toastError).toHaveBeenCalledTimes(1)
expect(flow.continue).not.toHaveBeenCalled()
expect(flow.run).not.toHaveBeenCalled()
})
it('stays quiet when the user cancelled the staged row', () => {
const { handoff } = handOff()
delete store.pendingWorktreeCreations['staged-1']
handoff.start(finalRequest)
handoff.dispose()
expect(flow.continue).not.toHaveBeenCalled()
expect(toastError).not.toHaveBeenCalled()
})
it('does not stage or close again after the user dismissed the composer', () => {
const { onHandOff } = schedule(() => true)
vi.runAllTimers()
expect(flow.begin).not.toHaveBeenCalled()
expect(onHandOff).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,139 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
findPendingLinkedWorkItemCreationId,
type WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import {
beginBackgroundWorktreePreparation,
continueBackgroundWorktreeCreation,
runBackgroundWorktreeCreation
} from '@/lib/worktree-creation-flow'
// Why: composer preflight (PR fetch, hook checks, settings writes) can stall on
// the network; past this delay the sidebar row owns the submit so the modal closes.
export const COMPOSER_CREATE_HANDOFF_DELAY_MS = 1_500
// Why: the staged row must not become the new place a submit hangs forever.
export const COMPOSER_CREATE_PREPARE_DEADLINE_MS = 5 * 60_000
export type ComposerCreateHandoff = {
/** Starts creation, continuing the staged sidebar row when one exists. */
start: (request: WorktreeCreationRequest) => void
/** True once a staged sidebar row owns the submit. */
isHandedOff: () => boolean
/** Reports a handed-off failure; returns false when the composer still owns it. */
fail: (message: string) => boolean
/** Ends the handoff window, reporting a handed-off submit that never started. */
dispose: () => void
}
export function scheduleComposerCreateHandoff({
stagingRequest,
onHandOff,
isCancelled,
reopenComposer,
delayMs = COMPOSER_CREATE_HANDOFF_DELAY_MS,
deadlineMs = COMPOSER_CREATE_PREPARE_DEADLINE_MS
}: {
/** Display-only placeholder; never executed, so Retry cannot run it. */
stagingRequest: WorktreeCreationRequest
onHandOff: () => void
isCancelled: () => boolean
/** Restores the user's input once the closed composer can no longer show it. */
reopenComposer: () => void
delayMs?: number
deadlineMs?: number
}): ComposerCreateHandoff {
let stagedCreationId: string | null = null
let finished = false
let deadline: ReturnType<typeof setTimeout> | undefined
const finish = (): void => {
finished = true
clearTimeout(timer)
clearTimeout(deadline)
}
const fail = (message: string): boolean => {
if (stagedCreationId === null) {
return false
}
if (finished) {
return true
}
finish()
const store = useAppStore.getState()
// A missing row means the user cancelled it; stay quiet.
if (!store.pendingWorktreeCreations[stagedCreationId]) {
return true
}
store.removePendingWorktreeCreation(stagedCreationId)
toast.error(message, {
action: {
label: translate('auto.lib.composerCreateHandoff.reopen', 'Reopen'),
onClick: reopenComposer
}
})
return true
}
const timer = setTimeout(() => {
if (finished || isCancelled()) {
return
}
stagedCreationId = beginBackgroundWorktreePreparation(stagingRequest)
onHandOff()
deadline = setTimeout(
() =>
fail(
translate(
'auto.lib.composerCreateHandoff.preparationTimedOut',
'Worktree preparation timed out.'
)
),
deadlineMs
)
}, delayMs)
return {
start: (request) => {
if (finished) {
return
}
finish()
if (stagedCreationId === null) {
runBackgroundWorktreeCreation(request)
return
}
const store = useAppStore.getState()
if (!store.pendingWorktreeCreations[stagedCreationId]) {
return
}
// Why: the staged row could not carry links resolved during preflight, so dedupe now.
const others = Object.fromEntries(
Object.entries(store.pendingWorktreeCreations).filter(([id]) => id !== stagedCreationId)
)
const existingCreationId = findPendingLinkedWorkItemCreationId(others, request)
if (existingCreationId) {
store.removePendingWorktreeCreation(stagedCreationId)
store.setActivePendingWorktreeCreation(existingCreationId)
store.setActiveView('terminal')
store.setSidebarOpen(true)
return
}
continueBackgroundWorktreeCreation(stagedCreationId, request)
},
isHandedOff: () => stagedCreationId !== null,
fail,
dispose: () => {
fail(
translate(
'auto.lib.composerCreateHandoff.needsInput',
'Worktree was not created because it needs more input.'
)
)
finish()
}
}
}