mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -1147,6 +1147,11 @@
|
||||
"openInChat": "この {{value0}} の会話はチャットで開かれています。開くにはセッション履歴を更新してください。",
|
||||
"ownershipUnknown": "Orca はこの {{value0}} の会話の所有者を確認できませんでした。セッション履歴を更新して、再試行してください。"
|
||||
},
|
||||
"composerCreateHandoff": {
|
||||
"reopen": "再度開く",
|
||||
"preparationTimedOut": "ワークツリーの準備がタイムアウトしました。",
|
||||
"needsInput": "追加の入力が必要なため、ワークツリーは作成されませんでした。"
|
||||
},
|
||||
"pendingWorktreeCreation": {
|
||||
"checkingOutFiles": "ファイルをチェックアウト中… {{percent}}%",
|
||||
"provisioningVm": "VMをプロビジョニング中…",
|
||||
|
||||
@@ -1147,6 +1147,11 @@
|
||||
"openInChat": "이 {{value0}} 대화는 채팅에서 열려 있습니다. 열려면 세션 기록을 새로고침하세요.",
|
||||
"ownershipUnknown": "Orca가 이 {{value0}} 대화의 소유자를 확인할 수 없습니다. 세션 기록을 새로고침한 후 다시 시도하세요."
|
||||
},
|
||||
"composerCreateHandoff": {
|
||||
"reopen": "다시 열기",
|
||||
"preparationTimedOut": "워크트리 준비 시간이 초과되었습니다.",
|
||||
"needsInput": "추가 입력이 필요하여 워크트리가 생성되지 않았습니다."
|
||||
},
|
||||
"pendingWorktreeCreation": {
|
||||
"checkingOutFiles": "파일 체크아웃 중… {{percent}}%",
|
||||
"provisioningVm": "VM 프로비저닝 중…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user