mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
fix(workspaces): settle a worktree create by outcome so a post-create throw still shows the workspace
A freshly created workspace could open with no tabs: executeWorktreeCreation's try/catch ends when createWorktree resolves, so a throw in the tail (activation, terminal seeding, after-wake seed, structured launch) escaped past the only code that removes the pending entry. All three callers invoked it as a bare void, so the rejection was unobserved, shouldShowWorktreeCreationSurface stayed true, and the creation panel covered a workbench that was mounted and fine behind it. The semantic boundary is createWorktree resolving. Before it, a failure is a create failure: the three call sites now share startWorktreeCreation, whose .catch() stamps status 'error' and toasts. After it, the worktree exists on disk, so the tail reports a settlement value instead of relying on nothing throwing. runWorktreePostCreateSteps returns 'complete' | 'cancelled' | 'awaiting-visibility' under one guard and never throws; executeWorktreeCreation owns settlement, and the two non-completing outcomes keep behaving exactly as before.
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { preflightAgentTrust } from '@/lib/agent-trust-preflight'
|
||||
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import {
|
||||
attachEphemeralVmRuntimeToWorkspace,
|
||||
cleanupEphemeralVmRuntimeForFailedCreate,
|
||||
@@ -13,16 +11,14 @@ import {
|
||||
formatWorkspaceCreateError,
|
||||
getWorkspaceCreateErrorToastMessage
|
||||
} from '@/lib/workspace-create-error-format'
|
||||
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
|
||||
import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup'
|
||||
import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session'
|
||||
import { runWorktreePostCreateSteps } from '@/lib/worktree-creation-post-create-steps'
|
||||
import { completeWorktreeCreation } from '@/lib/worktree-creation-completion'
|
||||
import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery'
|
||||
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
|
||||
|
||||
// Why: activePendingCreationId can outlive the terminal route when the user
|
||||
// switches app views; only the terminal route renders the creation panel.
|
||||
@@ -164,84 +160,40 @@ export async function executeWorktreeCreation(
|
||||
(completionState.activeView === 'terminal' &&
|
||||
completionState.activePendingCreationId === null))
|
||||
|
||||
let activation: ActivateAndRevealResult | false = false
|
||||
let primaryTabId: string | null
|
||||
if (shouldActivateOnCompletion && !structuredLaunch) {
|
||||
activation = activateAndRevealWorktree(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}),
|
||||
...(result.setup ? { setup: result.setup } : {}),
|
||||
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
|
||||
...(startupOpt ? { startup: startupOpt } : {}),
|
||||
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}),
|
||||
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
|
||||
})
|
||||
primaryTabId = activation === false ? null : activation.primaryTabId
|
||||
} else {
|
||||
// Keep chat creation on its pending surface until the session is ready.
|
||||
const hasExplicitTerminalWork = Boolean(
|
||||
startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs
|
||||
)
|
||||
primaryTabId =
|
||||
preparedRequest.agent !== null && !hasExplicitTerminalWork
|
||||
? null
|
||||
: ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
worktree.id,
|
||||
startupOpt,
|
||||
result.setup,
|
||||
preparedRequest.issueCommand,
|
||||
result.defaultTabs,
|
||||
{
|
||||
activateCreatedTabs: false,
|
||||
...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}),
|
||||
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
|
||||
}
|
||||
)
|
||||
if (!structuredLaunch && !backendSpawned) {
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, {
|
||||
startup: startupOpt,
|
||||
agent: preparedRequest.agent,
|
||||
activate: false
|
||||
})
|
||||
}
|
||||
// Why: past this point the worktree exists and its row is in the store, so every
|
||||
// remaining step is best-effort — settlement is by returned outcome, not by whether a
|
||||
// step threw, because an escaped throw would leave the creation surface covering the
|
||||
// finished workspace.
|
||||
const outcome = await runWorktreePostCreateSteps({
|
||||
creationId,
|
||||
request: preparedRequest,
|
||||
result,
|
||||
worktreeId: worktree.id,
|
||||
structuredLaunch,
|
||||
backendSpawned,
|
||||
shouldActivateOnCompletion,
|
||||
startupOpt,
|
||||
fallbackStartupOpt
|
||||
})
|
||||
// The cancel path already removed the pending entry.
|
||||
if (outcome.kind === 'cancelled') {
|
||||
return
|
||||
}
|
||||
|
||||
let structuredLaunchAccepted = structuredLaunch
|
||||
const { agentLaunchRoute } = preparedRequest
|
||||
if (
|
||||
agentLaunchRoute === 'structured-native-chat' &&
|
||||
isAgentSessionHandleProvider(preparedRequest.agent)
|
||||
) {
|
||||
const structuredSession = await launchStructuredWorktreeSession({
|
||||
creationId,
|
||||
request: preparedRequest,
|
||||
agentLaunchRoute,
|
||||
worktreeId: worktree.id,
|
||||
shouldActivateOnCompletion,
|
||||
fallbackStartupOpt,
|
||||
activation,
|
||||
primaryTabId
|
||||
})
|
||||
structuredLaunchAccepted = structuredSession.accepted
|
||||
activation = structuredSession.activation
|
||||
primaryTabId = structuredSession.primaryTabId
|
||||
if (structuredSession.cancelled) {
|
||||
return
|
||||
}
|
||||
if (structuredSession.visibilityUnknown) {
|
||||
markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id)
|
||||
return
|
||||
}
|
||||
// Why: this deliberately keeps the entry in an error state so the panel can offer a
|
||||
// retry; completing here would destroy the only handle on the unconfirmed session.
|
||||
if (outcome.kind === 'awaiting-visibility') {
|
||||
markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Narrowed to 'complete': a new outcome variant fails to typecheck here until it is
|
||||
// given its own settlement above, rather than silently settling nothing.
|
||||
await completeWorktreeCreation({
|
||||
creationId,
|
||||
request: preparedRequest,
|
||||
worktreeId: worktree.id,
|
||||
structuredLaunchAccepted,
|
||||
activation,
|
||||
primaryTabId,
|
||||
structuredLaunchAccepted: outcome.structuredLaunchAccepted,
|
||||
activation: outcome.activation,
|
||||
primaryTabId: outcome.primaryTabId,
|
||||
startupTerminalTabId: result.startupTerminal?.tabId,
|
||||
backendSpawned,
|
||||
focusOnCompletion: shouldActivateOnCompletion
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface'
|
||||
|
||||
// Guards the settlement contract of executeWorktreeCreation's post-create tail. Callers fire
|
||||
// and forget, and completeWorktreeCreation is the only thing that removes the pending entry,
|
||||
// so a throw once createWorktree has resolved must still complete — otherwise the creation
|
||||
// surface stays mounted over a finished workspace and the user sees no tabs. The tail settles
|
||||
// on a returned outcome, so the two non-completing outcomes (a cancelled create, an
|
||||
// unconfirmed structured launch) must survive unchanged: both deliberately keep or drop the
|
||||
// entry themselves, and a blanket `finally { completeWorktreeCreation() }` would destroy the
|
||||
// retry affordance the unconfirmed case depends on. Also covers the caller-side .catch()
|
||||
// backstop for failures BEFORE createWorktree resolves, which really are create failures.
|
||||
|
||||
type TestActiveView = 'terminal' | 'tasks'
|
||||
|
||||
const store = {
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null as string | null,
|
||||
experimentalNativeChat: undefined as boolean | undefined,
|
||||
openAgentTabsInChatByDefault: undefined as boolean | undefined
|
||||
},
|
||||
activeView: 'terminal' as TestActiveView,
|
||||
activePendingCreationId: 'creation-1' as string | null,
|
||||
repos: [] as { id: string; connectionId: string | null }[],
|
||||
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
|
||||
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
|
||||
store.pendingWorktreeCreations[entry.creationId] = entry
|
||||
store.activePendingCreationId = entry.creationId
|
||||
}),
|
||||
updatePendingWorktreeCreation: vi.fn(
|
||||
(creationId: string, patch: Partial<PendingWorktreeCreation>) => {
|
||||
const entry = store.pendingWorktreeCreations[creationId]
|
||||
if (entry) {
|
||||
store.pendingWorktreeCreations[creationId] = { ...entry, ...patch }
|
||||
}
|
||||
}
|
||||
),
|
||||
// Mirrors pending-worktree-creation.ts: drop the entry and the active pointer.
|
||||
removePendingWorktreeCreation: vi.fn((creationId: string) => {
|
||||
delete store.pendingWorktreeCreations[creationId]
|
||||
if (store.activePendingCreationId === creationId) {
|
||||
store.activePendingCreationId = null
|
||||
}
|
||||
}),
|
||||
setActivePendingWorktreeCreation: vi.fn((creationId: string | null) => {
|
||||
store.activePendingCreationId = creationId
|
||||
}),
|
||||
setActiveView: vi.fn((view: TestActiveView) => {
|
||||
store.activeView = view
|
||||
}),
|
||||
setSidebarOpen: vi.fn(),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
createWorktree: vi.fn(),
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>,
|
||||
unifiedTabsByWorktree: {}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => store
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-creation-structured-session', () => ({
|
||||
launchStructuredWorktreeSession: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
|
||||
queueWorkspaceActivationTerminalFocus: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({
|
||||
ensureAgentStartupInTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-creation-agent-seeds', () => ({
|
||||
seedAgentTabStateAfterWorktreeCreate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
|
||||
prepareEphemeralVmWorkspaceTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({
|
||||
prepareRequestForCreate: vi.fn(
|
||||
async (_creationId: string, request: WorktreeCreationRequest) => request
|
||||
),
|
||||
attachEphemeralVmRuntimeToWorkspace: vi.fn(async () => undefined),
|
||||
cleanupEphemeralVmRuntimeForFailedCreate: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
|
||||
import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session'
|
||||
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
|
||||
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
|
||||
import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation'
|
||||
import { executeWorktreeCreation } from './worktree-creation-flow-execute'
|
||||
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
|
||||
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
} as WorktreeCreationRequest
|
||||
}
|
||||
|
||||
function makeStructuredRequest(): WorktreeCreationRequest {
|
||||
return makeRequest({ agent: 'codex', agentLaunchRoute: 'structured-native-chat' })
|
||||
}
|
||||
|
||||
function seedPendingCreation(request: WorktreeCreationRequest): void {
|
||||
store.pendingWorktreeCreations = {
|
||||
'creation-1': {
|
||||
creationId: 'creation-1',
|
||||
phase: 'fetching',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request
|
||||
}
|
||||
}
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
}
|
||||
|
||||
function isCreationSurfaceShown(): boolean {
|
||||
return shouldShowWorktreeCreationSurface({
|
||||
activeView: 'terminal',
|
||||
activePendingCreationId: store.activePendingCreationId,
|
||||
hasActivePendingCreation:
|
||||
store.activePendingCreationId !== null &&
|
||||
store.pendingWorktreeCreations[store.activePendingCreationId] !== undefined
|
||||
})
|
||||
}
|
||||
|
||||
function expectSettledAndRevealed(): void {
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined()
|
||||
expect(store.activePendingCreationId).toBeNull()
|
||||
// The workbench is mounted behind the creation panel; dropping the entry is what
|
||||
// lets its tab chrome render.
|
||||
expect(isCreationSurfaceShown()).toBe(false)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// resetAllMocks: implementations from prior tests (the injected throws) must not leak.
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
store.activeView = 'terminal'
|
||||
store.repos = [{ id: 'repo-1', connectionId: null }]
|
||||
store.tabsByWorktree = {}
|
||||
store.pendingWorktreeCreations = {}
|
||||
store.activePendingCreationId = null
|
||||
store.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1' }
|
||||
})
|
||||
vi.mocked(launchStructuredWorktreeSession).mockResolvedValue({
|
||||
accepted: true,
|
||||
cancelled: false,
|
||||
visibilityUnknown: false,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
})
|
||||
|
||||
describe('a throw in the post-create tail still completes the creation', () => {
|
||||
it('activating branch: a throw in activateAndRevealWorktree completes and reveals the workspace', async () => {
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(activateAndRevealWorktree).mockImplementation(() => {
|
||||
throw new Error('activation exploded')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expectSettledAndRevealed()
|
||||
})
|
||||
|
||||
it('background branch: a throw in the initial terminal seed completes and reveals the workspace', async () => {
|
||||
store.activeView = 'tasks'
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockImplementation(() => {
|
||||
throw new Error('seeding exploded')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expectSettledAndRevealed()
|
||||
})
|
||||
|
||||
it('background branch: a throw in after-wake seeding completes and reveals the workspace', async () => {
|
||||
// User left the terminal view mid-create, so the non-activating branch runs.
|
||||
store.activeView = 'tasks'
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => {
|
||||
throw new Error('after-wake exploded')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalled()
|
||||
expectSettledAndRevealed()
|
||||
})
|
||||
|
||||
it('structured branch: a throw in the structured launch completes and reveals the workspace', async () => {
|
||||
const request = makeStructuredRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(launchStructuredWorktreeSession).mockRejectedValue(new Error('launch exploded'))
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expectSettledAndRevealed()
|
||||
})
|
||||
|
||||
// Why: settlement reports what already landed, so a later failure does not discard the tab
|
||||
// the earlier step seeded — agent startup would otherwise be delivered to no pane.
|
||||
it('keeps the tab a completed step already seeded when a later step throws', async () => {
|
||||
store.activeView = 'tasks'
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => {
|
||||
throw new Error('after-wake exploded')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(seedAgentTabStateAfterWorktreeCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ primaryTabId: 'tab-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('control: with no throw the same flow completes and reveals the workspace', async () => {
|
||||
store.activeView = 'tasks'
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expectSettledAndRevealed()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the two non-completing outcomes survive the throw guard', () => {
|
||||
// A blanket `finally { completeWorktreeCreation() }` would re-seed tabs and steal focus for
|
||||
// a workspace the user is already tearing down.
|
||||
it('cancelled: settles nothing, because the cancel path already removed the entry', async () => {
|
||||
const request = makeStructuredRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(launchStructuredWorktreeSession).mockImplementation(async () => {
|
||||
store.removePendingWorktreeCreation('creation-1')
|
||||
return {
|
||||
accepted: true,
|
||||
cancelled: true,
|
||||
visibilityUnknown: false,
|
||||
activation: false as const,
|
||||
primaryTabId: null
|
||||
}
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(seedAgentTabStateAfterWorktreeCreate).not.toHaveBeenCalled()
|
||||
expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A blanket finally would drop the entry and with it structuredLaunchRecoveryWorktreeId,
|
||||
// which is the only handle the panel's retry has on the unconfirmed session.
|
||||
it('awaiting-visibility: keeps the entry on an error surface so retry stays reachable', async () => {
|
||||
const request = makeStructuredRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(launchStructuredWorktreeSession).mockResolvedValue({
|
||||
accepted: true,
|
||||
cancelled: false,
|
||||
visibilityUnknown: true,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
status: 'error',
|
||||
error: 'Could not confirm whether Codex chat opened. Retry to check again.',
|
||||
structuredLaunchRecoveryWorktreeId: 'wt-1'
|
||||
})
|
||||
expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
// The surface legitimately persists here: it is the retry affordance, not a strand.
|
||||
expect(isCreationSurfaceShown()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a failure before createWorktree resolves is still reported as a create failure', () => {
|
||||
it('becomes a visible inline error while the panel is showing it', async () => {
|
||||
// Pre-create preparation runs before the in-function try/catch.
|
||||
vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded'))
|
||||
|
||||
const creationId = runBackgroundWorktreeCreation(makeRequest())
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.pendingWorktreeCreations[creationId]).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'prepare exploded'
|
||||
})
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'worktree create: unhandled failure',
|
||||
creationId,
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('is announced with a toast once the user has left the panel', async () => {
|
||||
store.activeView = 'tasks'
|
||||
vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded'))
|
||||
|
||||
const creationId = runBackgroundWorktreeCreation(makeRequest())
|
||||
// The pending surface is revealed synchronously; move away before the rejected
|
||||
// preparation reaches the fire-and-forget backstop.
|
||||
store.activeView = 'tasks'
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('prepare exploded')
|
||||
})
|
||||
expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ status: 'error' })
|
||||
})
|
||||
})
|
||||
@@ -137,9 +137,12 @@ function makePendingCreation(request: WorktreeCreationRequest): PendingWorktreeC
|
||||
}
|
||||
}
|
||||
|
||||
// Why a macrotask, not a counted pair of microtasks: the await count in
|
||||
// executeWorktreeCreation grows over time (VM preflight, post-create settlement), and a fixed
|
||||
// count silently starves the assertions that follow this helper. A timer boundary drains
|
||||
// whatever depth the flow has.
|
||||
async function flushAsyncWorktreeCreation(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('runBackgroundWorktreeCreation', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
findPendingLinkedWorkItemCreationId,
|
||||
@@ -11,11 +12,34 @@ import {
|
||||
getWorktreeCreationIndeterminate
|
||||
} from '@/lib/worktree-creation-flow-startup'
|
||||
import { retryStructuredWorktreeLaunch } from '@/lib/worktree-creation-structured-recovery'
|
||||
import {
|
||||
formatWorkspaceCreateError,
|
||||
getWorkspaceCreateErrorToastMessage
|
||||
} from '@/lib/workspace-create-error-format'
|
||||
|
||||
type ContinueBackgroundWorktreeCreationOptions = {
|
||||
revealCreationSurface?: boolean
|
||||
}
|
||||
|
||||
// Why: nothing awaits these creations, so an escaped rejection would otherwise
|
||||
// strand the pending entry — and the creation surface — with no error shown.
|
||||
function startWorktreeCreation(creationId: string, request: WorktreeCreationRequest): void {
|
||||
executeWorktreeCreation(creationId, request).catch((error: unknown) => {
|
||||
console.error('worktree create: unhandled failure', creationId, error)
|
||||
const store = useAppStore.getState()
|
||||
if (!store.pendingWorktreeCreations[creationId]) {
|
||||
return
|
||||
}
|
||||
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
|
||||
store.updatePendingWorktreeCreation(creationId, { status: 'error', error: message })
|
||||
// Why: the panel renders this error inline while its surface is visible;
|
||||
// only announce it separately after the user has navigated away.
|
||||
if (!(store.activeView === 'terminal' && store.activePendingCreationId === creationId)) {
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function revealPendingCreation(
|
||||
creationId: string,
|
||||
request: WorktreeCreationRequest,
|
||||
@@ -62,7 +86,7 @@ export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest):
|
||||
// client over plain HTTP). createBrowserUuid falls back to getRandomValues.
|
||||
const creationId = createBrowserUuid()
|
||||
revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request))
|
||||
void executeWorktreeCreation(creationId, request)
|
||||
startWorktreeCreation(creationId, request)
|
||||
return creationId
|
||||
}
|
||||
|
||||
@@ -101,7 +125,7 @@ export function continueBackgroundWorktreeCreation(
|
||||
store.setActiveView('terminal')
|
||||
store.setSidebarOpen(true)
|
||||
}
|
||||
void executeWorktreeCreation(creationId, request)
|
||||
startWorktreeCreation(creationId, request)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -133,5 +157,5 @@ export function retryBackgroundWorktreeCreation(creationId: string): void {
|
||||
)
|
||||
return
|
||||
}
|
||||
void executeWorktreeCreation(creationId, entry.request)
|
||||
startWorktreeCreation(creationId, entry.request)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
|
||||
import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session'
|
||||
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload'
|
||||
|
||||
/** How the created workspace settled. Every variant names one settlement the caller owes. */
|
||||
export type WorktreePostCreateOutcome =
|
||||
| {
|
||||
kind: 'complete'
|
||||
activation: ActivateAndRevealResult | false
|
||||
primaryTabId: string | null
|
||||
structuredLaunchAccepted: boolean
|
||||
}
|
||||
/** Abandoned mid-flight: the cancel path already removed the pending entry. */
|
||||
| { kind: 'cancelled' }
|
||||
/** Structured chat may or may not have opened; the entry must stay for the retry affordance. */
|
||||
| { kind: 'awaiting-visibility' }
|
||||
|
||||
export type WorktreePostCreateStepsArgs = {
|
||||
creationId: string
|
||||
request: WorktreeCreationRequest
|
||||
result: CreateWorktreeResult
|
||||
worktreeId: string
|
||||
structuredLaunch: boolean
|
||||
backendSpawned: boolean
|
||||
shouldActivateOnCompletion: boolean
|
||||
startupOpt: WorktreeStartupPayload | undefined
|
||||
fallbackStartupOpt: WorktreeStartupPayload | undefined
|
||||
}
|
||||
|
||||
/** Carries what already landed out of a step that throws, so settlement keeps it. */
|
||||
type PostCreateProgress = {
|
||||
activation: ActivateAndRevealResult | false
|
||||
primaryTabId: string | null
|
||||
structuredLaunchAccepted: boolean
|
||||
}
|
||||
|
||||
function openCreatedWorkspaceSurface(
|
||||
args: WorktreePostCreateStepsArgs,
|
||||
progress: PostCreateProgress
|
||||
): void {
|
||||
const { request, result } = args
|
||||
if (args.shouldActivateOnCompletion && !args.structuredLaunch) {
|
||||
const activation = activateAndRevealWorktree(args.worktreeId, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
...(request.agent !== null ? { agent: request.agent } : {}),
|
||||
...(result.setup ? { setup: result.setup } : {}),
|
||||
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
|
||||
...(args.startupOpt ? { startup: args.startupOpt } : {}),
|
||||
...(request.issueCommand ? { issueCommand: request.issueCommand } : {}),
|
||||
...(args.backendSpawned ? { backendStartupTerminalSpawned: true } : {})
|
||||
})
|
||||
progress.activation = activation
|
||||
progress.primaryTabId = activation === false ? null : activation.primaryTabId
|
||||
return
|
||||
}
|
||||
// Keep chat creation on its pending surface until the session is ready.
|
||||
const hasExplicitTerminalWork = Boolean(
|
||||
args.startupOpt || result.setup || request.issueCommand || result.defaultTabs
|
||||
)
|
||||
progress.primaryTabId =
|
||||
request.agent !== null && !hasExplicitTerminalWork
|
||||
? null
|
||||
: ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
args.worktreeId,
|
||||
args.startupOpt,
|
||||
result.setup,
|
||||
request.issueCommand,
|
||||
result.defaultTabs,
|
||||
{
|
||||
activateCreatedTabs: false,
|
||||
...(request.agent !== null ? { callerProvidesSurface: true } : {}),
|
||||
...(args.backendSpawned ? { backendStartupTerminalSpawned: true } : {})
|
||||
}
|
||||
)
|
||||
if (!args.structuredLaunch && !args.backendSpawned) {
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(args.worktreeId, {
|
||||
startup: args.startupOpt,
|
||||
agent: request.agent,
|
||||
activate: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function settleCreatedWorkspace(
|
||||
args: WorktreePostCreateStepsArgs,
|
||||
progress: PostCreateProgress
|
||||
): Promise<WorktreePostCreateOutcome> {
|
||||
openCreatedWorkspaceSurface(args, progress)
|
||||
const { agentLaunchRoute } = args.request
|
||||
if (
|
||||
agentLaunchRoute !== 'structured-native-chat' ||
|
||||
!isAgentSessionHandleProvider(args.request.agent)
|
||||
) {
|
||||
return { kind: 'complete', ...progress }
|
||||
}
|
||||
const structuredSession = await launchStructuredWorktreeSession({
|
||||
creationId: args.creationId,
|
||||
request: args.request,
|
||||
agentLaunchRoute,
|
||||
worktreeId: args.worktreeId,
|
||||
shouldActivateOnCompletion: args.shouldActivateOnCompletion,
|
||||
fallbackStartupOpt: args.fallbackStartupOpt,
|
||||
activation: progress.activation,
|
||||
primaryTabId: progress.primaryTabId
|
||||
})
|
||||
progress.structuredLaunchAccepted = structuredSession.accepted
|
||||
progress.activation = structuredSession.activation
|
||||
progress.primaryTabId = structuredSession.primaryTabId
|
||||
if (structuredSession.cancelled) {
|
||||
return { kind: 'cancelled' }
|
||||
}
|
||||
if (structuredSession.visibilityUnknown) {
|
||||
return { kind: 'awaiting-visibility' }
|
||||
}
|
||||
return { kind: 'complete', ...progress }
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every step that follows a resolved `createWorktree` and reports how the workspace
|
||||
* settled. Never throws: the worktree already exists on disk and its row is already in the
|
||||
* store, so a failed follow-up step still has to settle the creation and show the workspace
|
||||
* rather than strand the creation surface over it. Steps added here inherit that guarantee.
|
||||
*/
|
||||
export async function runWorktreePostCreateSteps(
|
||||
args: WorktreePostCreateStepsArgs
|
||||
): Promise<WorktreePostCreateOutcome> {
|
||||
const progress: PostCreateProgress = {
|
||||
activation: false,
|
||||
primaryTabId: null,
|
||||
structuredLaunchAccepted: args.structuredLaunch
|
||||
}
|
||||
try {
|
||||
return await settleCreatedWorkspace(args, progress)
|
||||
} catch (error) {
|
||||
console.error('worktree create: post-create step failed', args.worktreeId, error)
|
||||
return { kind: 'complete', ...progress }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user