From e1066973f2fce1d10b2179c0f25aaa076f335f80 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 24 Oct 2026 23:00:47 -0700 Subject: [PATCH] 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> --- src/main/github/pr-head-tracking-ref.test.ts | 2 +- src/main/github/pr-head-tracking-ref.ts | 5 +- ...ktrees-create-metadata-persistence.test.ts | 4 +- .../ipc/worktrees-ssh-pr-head-fetch.test.ts | 2 +- .../ipc/worktrees-wsl-runtime-routing.test.ts | 3 +- .../gitlab-and-pr-bases-part-02.spec.ts | 4 +- .../runtime/runtime-gitlab-worktree-base.ts | 6 +- .../composer-state/composer-submit-model.ts | 4 +- .../composer-submit-orchestration.ts | 5 +- .../quick-creation-execution.ts | 15 +- .../composer-state/quick-submit-action.ts | 48 +++++- src/renderer/src/i18n/locales/en.json | 5 + src/renderer/src/i18n/locales/es.json | 5 + src/renderer/src/i18n/locales/fr.json | 5 + src/renderer/src/i18n/locales/ja.json | 5 + src/renderer/src/i18n/locales/ko.json | 5 + src/renderer/src/i18n/locales/pt.json | 5 + src/renderer/src/i18n/locales/zh.json | 5 + .../src/lib/composer-create-handoff.test.ts | 156 ++++++++++++++++++ .../src/lib/composer-create-handoff.ts | 139 ++++++++++++++++ 20 files changed, 412 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/lib/composer-create-handoff.test.ts create mode 100644 src/renderer/src/lib/composer-create-handoff.ts diff --git a/src/main/github/pr-head-tracking-ref.test.ts b/src/main/github/pr-head-tracking-ref.test.ts index fa9e7a9b53e..066c0ce5d6a 100644 --- a/src/main/github/pr-head-tracking-ref.test.ts +++ b/src/main/github/pr-head-tracking-ref.test.ts @@ -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 } ) }) diff --git a/src/main/github/pr-head-tracking-ref.ts b/src/main/github/pr-head-tracking-ref.ts index 31e4dce5ab2..f50b094c1eb 100644 --- a/src/main/github/pr-head-tracking-ref.ts +++ b/src/main/github/pr-head-tracking-ref.ts @@ -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 + } ) } diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 51ee27e23b7..953d8ed545f 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -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'], diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index 4beec403874..1289bc7a83c 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -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( [ diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index eaa7788a623..bd7a7753394 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -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'], diff --git a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts index 9767ea2aa4d..a2f15ad86b4 100644 --- a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts @@ -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}`], diff --git a/src/main/runtime/runtime-gitlab-worktree-base.ts b/src/main/runtime/runtime-gitlab-worktree-base.ts index 239b9a623be..f58dd1cc1c4 100644 --- a/src/main/runtime/runtime-gitlab-worktree-base.ts +++ b/src/main/runtime/runtime-gitlab-worktree-base.ts @@ -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 => { - 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 => fetchCompareBaseRefWithLocalFallback({ diff --git a/src/renderer/src/hooks/composer-state/composer-submit-model.ts b/src/renderer/src/hooks/composer-state/composer-submit-model.ts index c683a320fab..977887a7372 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-model.ts @@ -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 prepareQuickSubmit: ( resolution: PendingSmartGitHubSubmitResolution, diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index 2bb1003e744..86a33910db9 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -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, diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 1e370e4c9d0..9ee8db0f138 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -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 => { 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() diff --git a/src/renderer/src/hooks/composer-state/quick-submit-action.ts b/src/renderer/src/hooks/composer-state/quick-submit-action.ts index b1600f85158..fb1b93d9a9e 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-action.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-action.ts @@ -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 ] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5c99f7b95ac..2e574d4183a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 14044e4f8db..0a376ef3d86 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index eb2a60c3ede..29c46603606 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6cd33a3e0fb..04532c7da56 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1147,6 +1147,11 @@ "openInChat": "この {{value0}} の会話はチャットで開かれています。開くにはセッション履歴を更新してください。", "ownershipUnknown": "Orca はこの {{value0}} の会話の所有者を確認できませんでした。セッション履歴を更新して、再試行してください。" }, + "composerCreateHandoff": { + "reopen": "再度開く", + "preparationTimedOut": "ワークツリーの準備がタイムアウトしました。", + "needsInput": "追加の入力が必要なため、ワークツリーは作成されませんでした。" + }, "pendingWorktreeCreation": { "checkingOutFiles": "ファイルをチェックアウト中… {{percent}}%", "provisioningVm": "VMをプロビジョニング中…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 21d53b907a3..a3b8d8a4ff0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1147,6 +1147,11 @@ "openInChat": "이 {{value0}} 대화는 채팅에서 열려 있습니다. 열려면 세션 기록을 새로고침하세요.", "ownershipUnknown": "Orca가 이 {{value0}} 대화의 소유자를 확인할 수 없습니다. 세션 기록을 새로고침한 후 다시 시도하세요." }, + "composerCreateHandoff": { + "reopen": "다시 열기", + "preparationTimedOut": "워크트리 준비 시간이 초과되었습니다.", + "needsInput": "추가 입력이 필요하여 워크트리가 생성되지 않았습니다." + }, "pendingWorktreeCreation": { "checkingOutFiles": "파일 체크아웃 중… {{percent}}%", "provisioningVm": "VM 프로비저닝 중…", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 99c46da1182..c267a78a8e7 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4dda7c17a68..7c4b0a83923 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1147,6 +1147,11 @@ "openInChat": "此 {{value0}} 会话已在聊天中打开。请刷新会话历史以打开它。", "ownershipUnknown": "Orca 无法确认此 {{value0}} 会话的归属。请刷新会话历史并重试。" }, + "composerCreateHandoff": { + "reopen": "重新打开", + "preparationTimedOut": "工作树准备超时。", + "needsInput": "需要更多输入,因此未创建工作树。" + }, "pendingWorktreeCreation": { "checkingOutFiles": "正在检出文件… {{percent}}%", "provisioningVm": "正在预配虚拟机…", diff --git a/src/renderer/src/lib/composer-create-handoff.test.ts b/src/renderer/src/lib/composer-create-handoff.test.ts new file mode 100644 index 00000000000..dca3761027d --- /dev/null +++ b/src/renderer/src/lib/composer-create-handoff.test.ts @@ -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 = {} + 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 { + 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() + }) +}) diff --git a/src/renderer/src/lib/composer-create-handoff.ts b/src/renderer/src/lib/composer-create-handoff.ts new file mode 100644 index 00000000000..7b28f395d43 --- /dev/null +++ b/src/renderer/src/lib/composer-create-handoff.ts @@ -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 | 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() + } + } +}