fix(vm-recipes): keep a VM's checkout out of the sidebar until its create finishes (#40355)

A recipe VM create registers the VM's project before its environment
checks run, so the VM's primary checkout showed as a normal workspace
next to the pending row while the checks ran or were paused on a
failure. Hide that checkout in both sidebar list models until the
create adopts it (or makes its worktree). A cancelled or failed create
keeps it hidden while its VM is cleaned up; a VM that could not be
cleaned up is shown again so it can be removed.
This commit is contained in:
Neil
2026-11-09 05:36:44 -08:00
committed by GitHub
parent 0a7ce6e78f
commit f190139f03
10 changed files with 486 additions and 38 deletions
@@ -9,6 +9,11 @@ import {
} from '@/lib/sidebar-pending-layer'
import { useSidebarListSession } from '@/lib/sidebar-list-session'
import { readEndedCreations, subscribeEndedCreations } from '@/lib/sidebar-ended-creations'
import {
readHiddenVmCheckouts,
subscribeHiddenVmCheckouts,
withoutHiddenVmCheckouts
} from '@/lib/vm-create-hidden-checkouts'
import '../../../../../../shared/folder-workspaces/folder-workspace-execution-host'
import { getFolderWorkspaceHostId } from '../../../../../../shared/folder-workspaces/folder-workspace-host-id'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types'
@@ -73,6 +78,7 @@ export function useMainSidebarListModel(args: SidebarListModelArgs): SidebarList
const feed = useDesktopSidebarFeed()
const pending = useSyncExternalStore(subscribeSidebarPendingLayer, readSidebarPendingLayer)
const endedCreationIds = useSyncExternalStore(subscribeEndedCreations, readEndedCreations)
const hiddenVmCheckouts = useSyncExternalStore(subscribeHiddenVmCheckouts, readHiddenVmCheckouts)
const pendingCreationKeys = useAppStore(
useShallow((s) => selectPendingWorktreeCreationKeys(s.pendingWorktreeCreations))
)
@@ -84,10 +90,13 @@ export function useMainSidebarListModel(args: SidebarListModelArgs): SidebarList
const domains = useMemo(
() =>
withWindowCreations(
overlaySidebarPendingLayer(feed?.domains ?? EMPTY_SIDEBAR_FEED_DOMAINS, pending, {
repos,
lineageById: args.worktreeLineageById
}),
withoutHiddenVmCheckouts(
overlaySidebarPendingLayer(feed?.domains ?? EMPTY_SIDEBAR_FEED_DOMAINS, pending, {
repos,
lineageById: args.worktreeLineageById
}),
hiddenVmCheckouts
),
pendingCreationKeys,
endedCreationIds,
hiddenCreationKeys
@@ -98,6 +107,7 @@ export function useMainSidebarListModel(args: SidebarListModelArgs): SidebarList
pendingCreationKeys,
endedCreationIds,
hiddenCreationKeys,
hiddenVmCheckouts,
repos,
args.worktreeLineageById
]
@@ -1,5 +1,5 @@
import { selectSidebarLineageParentIdentities } from '@/store/slices/worktrees/metadata/cross-store-lineage-parents'
import { useMemo, useState } from 'react'
import { useMemo, useState, useSyncExternalStore } from 'react'
import { shallow } from 'zustand/shallow'
import type { AppState } from '@/store/types'
import { useAppStore } from '@/store'
@@ -24,6 +24,11 @@ import type { SidebarWorktreeFilters } from './use-filters'
import { useReusedArrayIdentity } from './use-reused-array-identity'
import { useSleepingSweepRetention } from './use-sleeping-sweep-retention'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
import {
readHiddenVmCheckouts,
subscribeHiddenVmCheckouts,
withoutHiddenVmWorktrees
} from '@/lib/vm-create-hidden-checkouts'
const EMPTY_WORKTREE_ID_SET: ReadonlySet<string> = new Set()
@@ -118,8 +123,9 @@ export function useVisibleSidebarWorktrees(args: {
worktreeIdsWithStructuredChat
})
const hiddenVmCheckouts = useSyncExternalStore(subscribeHiddenVmCheckouts, readHiddenVmCheckouts)
const recomputedVisibleWorktrees = useMemo(() => {
return computeVisibleWorktrees(worktreesByRepo, sortedIds, {
const visible = computeVisibleWorktrees(worktreesByRepo, sortedIds, {
filterRepoIds,
showSleepingWorkspaces,
tabsByWorktree,
@@ -148,7 +154,9 @@ export function useVisibleSidebarWorktrees(args: {
? [args.agentSendTargetWorktreeId]
: undefined
})
return withoutHiddenVmWorktrees(visible, hiddenVmCheckouts)
}, [
hiddenVmCheckouts,
args.agentSendTargetWorktreeId,
worktreeIdsWithLiveAgent,
sleepingSweepExemptWorktreeIds,
@@ -3,17 +3,19 @@ import type { ExecutionHostId } from '../../../shared/execution-host'
type FailedCreateCleanupActions = {
deleteProjectHostSetup: (setupId: string, hostId: ExecutionHostId) => Promise<unknown>
cleanupRuntime: (runtimeId: string) => Promise<unknown>
/** Resolves with the runtime record; a failed destroy resolves rather than rejects. */
cleanupRuntime: (runtimeId: string) => Promise<{ cleanupStatus?: string } | undefined>
reportSetupError: (error: unknown) => void
reportRuntimeError: (error: unknown) => void
}
/** Resolves false when any cleanup step failed and the VM or its project may remain. */
export async function cleanupFailedEphemeralVmWorkspace(
request: WorktreeCreationRequest,
actions: FailedCreateCleanupActions
): Promise<void> {
): Promise<boolean> {
if (!request.ephemeralVmRuntimeId) {
return
return true
}
if (
request.ephemeralVmCheckoutMode === 'provisioned-root' &&
@@ -26,16 +28,22 @@ export async function cleanupFailedEphemeralVmWorkspace(
)
if (deleted == null) {
actions.reportSetupError(new Error('Could not confirm project host rollback.'))
return
return false
}
} catch (error) {
actions.reportSetupError(error)
return
return false
}
}
try {
await actions.cleanupRuntime(request.ephemeralVmRuntimeId)
const runtime = await actions.cleanupRuntime(request.ephemeralVmRuntimeId)
if (runtime?.cleanupStatus === 'failed') {
actions.reportRuntimeError(new Error('The VM recipe could not destroy the VM.'))
return false
}
return true
} catch (error) {
actions.reportRuntimeError(error)
return false
}
}
@@ -7,6 +7,10 @@ import type { Repo } from '../../../shared/repo-types'
import { translate } from '@/i18n/i18n'
import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup'
import { gateVmCreateOnEnvironmentCheck } from '@/lib/ephemeral-vm-environment-check'
import {
hideVmCreateCheckout,
settleVmCreateCheckoutAfterCleanup
} from '@/lib/vm-create-hidden-checkouts'
import {
appendProvisioningLog,
appendProvisioningWarnings
@@ -60,7 +64,14 @@ async function prepareEphemeralVmRecipeRequest(
}
: {}),
provisionId: creationId,
setupExistingFolder: store.setupProjectExistingFolder
setupExistingFolder: async (args) => {
const setup = await store.setupProjectExistingFolder(args)
if (setup) {
// Why: until the create adopts it, the VM's primary checkout is not a workspace; the pending row stands for it.
hideVmCreateCheckout(creationId, { repoId: setup.repo.id, path: setup.repo.path })
}
return setup
}
})
} finally {
unsubscribeProvisionEvents?.()
@@ -119,7 +130,7 @@ async function prepareEphemeralVmRecipeRequest(
preparedTarget.setup.repo.path
)))
if (!proceed) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest, creationId)
return null
}
useAppStore.getState().updatePendingWorktreeCreation(creationId, {
@@ -182,10 +193,12 @@ function resolvePortableEphemeralVmProjectId(repo: Repo | undefined): string | n
return key.startsWith('github:') ? key : null
}
/** A VM cleaned up stays out of the sidebar; one that could not be is shown again so the user can remove it. */
export async function cleanupEphemeralVmRuntimeForFailedCreate(
request: WorktreeCreationRequest
request: WorktreeCreationRequest,
creationId: string
): Promise<void> {
await cleanupFailedEphemeralVmWorkspace(request, {
const cleanedUp = await cleanupFailedEphemeralVmWorkspace(request, {
deleteProjectHostSetup: (setupId, hostId) =>
useAppStore.getState().deleteProjectHostSetup({ setupId, hostId }),
cleanupRuntime: (runtimeId) => window.api.ephemeralVm.cleanup({ runtimeId }),
@@ -197,6 +210,7 @@ export async function cleanupEphemeralVmRuntimeForFailedCreate(
error
)
})
settleVmCreateCheckoutAfterCleanup(creationId, cleanedUp)
}
export function getProvisionedRootSparseCheckoutError(): string {
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_SIDEBAR_FEED_DOMAINS } from '../../../shared/sidebar-feed-client/sidebar-feed-snapshot'
import type { SidebarFeedDomains } from '../../../shared/sidebar-list/sidebar-feed-domains'
import {
hideVmCreateCheckout,
readHiddenVmCheckouts,
releaseVmCreateCheckout,
settleVmCreateCheckoutAfterCleanup,
subscribeHiddenVmCheckouts,
withoutHiddenVmCheckouts,
withoutHiddenVmWorktrees
} from './vm-create-hidden-checkouts'
type Row = { repoId: string; path: string; isMainWorktree?: boolean }
function domainsWith(workspaces: Row[], pairedServerRows: Row[]): SidebarFeedDomains {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the filter reads only repoId, path and isMainWorktree.
return {
...EMPTY_SIDEBAR_FEED_DOMAINS,
workspaces: new Map(workspaces.map((row, i) => [`w${i}`, row])),
pairedServerRows: new Map(pairedServerRows.map((row, i) => [`p${i}`, row]))
} as unknown as SidebarFeedDomains
}
function isListed(repoId: string, path = '/srv/repo'): boolean {
return (
withoutHiddenVmWorktrees([{ repoId, path, isMainWorktree: true }], readHiddenVmCheckouts())
.length === 1
)
}
afterEach(() => {
releaseVmCreateCheckout('c1')
releaseVmCreateCheckout('c2')
vi.useRealTimers()
})
describe('withoutHiddenVmCheckouts', () => {
it('drops only the VM primary checkout while its create is in progress', () => {
const vmPrimary = { repoId: 'vm-repo', path: '/srv/repo', isMainWorktree: true }
const vmLinked = { repoId: 'vm-repo', path: '/srv/repo-wt', isMainWorktree: false }
const sameRepoIdElsewhere = { repoId: 'vm-repo', path: '/other', isMainWorktree: true }
const local = { repoId: 'local', path: '/srv/repo', isMainWorktree: true }
const domains = domainsWith([local], [vmPrimary, vmLinked, sameRepoIdElsewhere])
hideVmCreateCheckout('c1', { repoId: 'vm-repo', path: '/srv/repo/' })
const filtered = withoutHiddenVmCheckouts(domains, readHiddenVmCheckouts())
expect([...filtered.pairedServerRows.values()]).toEqual([vmLinked, sameRepoIdElsewhere])
expect(filtered.workspaces).toBe(domains.workspaces)
})
it('keeps the same domains object when nothing is hidden or nothing matches', () => {
const domains = domainsWith([{ repoId: 'a', path: '/a' }], [])
expect(withoutHiddenVmCheckouts(domains, readHiddenVmCheckouts())).toBe(domains)
hideVmCreateCheckout('c1', { repoId: 'b', path: '/b' })
expect(withoutHiddenVmCheckouts(domains, readHiddenVmCheckouts())).toBe(domains)
})
})
describe('withoutHiddenVmWorktrees', () => {
it('drops the hidden VM primary checkout from the window list and keeps identity otherwise', () => {
const vmPrimary = { id: 'vm', repoId: 'vm-repo', path: '/srv/repo', isMainWorktree: true }
const other = { id: 'o', repoId: 'local', path: '/srv/repo', isMainWorktree: true }
const list = [vmPrimary, other]
expect(withoutHiddenVmWorktrees(list, readHiddenVmCheckouts())).toBe(list)
hideVmCreateCheckout('c1', { repoId: 'vm-repo', path: '/srv/repo' })
expect(withoutHiddenVmWorktrees(list, readHiddenVmCheckouts())).toEqual([other])
releaseVmCreateCheckout('c1')
expect(withoutHiddenVmWorktrees(list, readHiddenVmCheckouts())).toBe(list)
})
})
describe('hidden VM checkout lifecycle', () => {
it('notifies subscribers when a checkout is hidden and released', () => {
const listener = vi.fn()
const unsubscribe = subscribeHiddenVmCheckouts(listener)
hideVmCreateCheckout('c1', { repoId: 'vm', path: '/srv/repo' })
releaseVmCreateCheckout('c1')
releaseVmCreateCheckout('c1')
expect(listener).toHaveBeenCalledTimes(2)
unsubscribe()
})
it('shows a VM again when its cleanup failed, but not after it was cleaned up', () => {
vi.useFakeTimers()
hideVmCreateCheckout('c1', { repoId: 'failed-vm', path: '/srv/repo' })
settleVmCreateCheckoutAfterCleanup('c1', false)
expect(isListed('failed-vm')).toBe(true)
hideVmCreateCheckout('c2', { repoId: 'cleaned-vm', path: '/srv/repo' })
settleVmCreateCheckoutAfterCleanup('c2', true)
// A cancel racing the create cleans up twice; the second, failing pass must not un-hide it.
settleVmCreateCheckoutAfterCleanup('c2', false)
expect(isListed('cleaned-vm')).toBe(false)
vi.runAllTimers()
expect(isListed('cleaned-vm')).toBe(true)
})
it("keeps a retry's checkout hidden while the previous attempt's cleanup settles", () => {
vi.useFakeTimers()
hideVmCreateCheckout('c1', { repoId: 'vm', path: '/srv/repo' })
settleVmCreateCheckoutAfterCleanup('c1', true)
// Same host and path again: the retry's hide outlives the old attempt's.
hideVmCreateCheckout('c1', { repoId: 'vm', path: '/srv/repo' })
vi.runAllTimers()
expect(isListed('vm')).toBe(false)
releaseVmCreateCheckout('c1')
expect(isListed('vm')).toBe(true)
})
})
@@ -0,0 +1,152 @@
import type { SidebarFeedDomains } from '../../../shared/sidebar-list/sidebar-feed-domains'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
/** A VM's primary checkout, registered on its host before the create that owns it finishes. */
export type HiddenVmCheckout = { repoId: string; path: string }
type CheckoutRow = { repoId: string; path: string; isMainWorktree?: boolean }
/** What the sidebar filters by; `repoIds` lets most rows skip building a path key. */
export type HiddenVmCheckouts = {
readonly repoIds: ReadonlySet<string>
readonly keys: ReadonlySet<string>
}
// Upper bound for a cleaned-up VM's rows to leave the lists (its host is retired asynchronously).
const HIDDEN_AFTER_CLEANUP_MS = 60_000
const EMPTY: HiddenVmCheckouts = { repoIds: new Set(), keys: new Set() }
type Hide = HiddenVmCheckout & { key: string }
// Keyed per hide, not per creation: a retry re-provisions under the same creation id while the
// previous attempt's cleaned-up VM may still be listed.
let hides = new Map<number, Hide>()
const activeHideByCreation = new Map<string, number>()
const cleanedHides = new Set<number>()
let nextHideId = 1
let hidden: HiddenVmCheckouts = EMPTY
const listeners = new Set<() => void>()
function checkoutKey(repoId: string, path: string): string {
return `${repoId}\0${normalizeRuntimePathForComparison(path)}`
}
function publish(next: Map<number, Hide>): void {
hides = next
const values = [...next.values()]
hidden =
values.length === 0
? EMPTY
: {
repoIds: new Set(values.map((hide) => hide.repoId)),
keys: new Set(values.map((hide) => hide.key))
}
listeners.forEach((listener) => listener())
}
function removeHide(hideId: number): void {
cleanedHides.delete(hideId)
if (!hides.has(hideId)) {
return
}
const next = new Map(hides)
next.delete(hideId)
publish(next)
}
/**
* Why: a VM create registers the VM's project before its checks run and before the checkout is
* adopted or a worktree is made; the pending row stands for it until the create finishes.
*/
export function hideVmCreateCheckout(creationId: string, checkout: HiddenVmCheckout): void {
const hideId = nextHideId++
activeHideByCreation.set(creationId, hideId)
publish(
new Map(hides).set(hideId, { ...checkout, key: checkoutKey(checkout.repoId, checkout.path) })
)
}
/** The create finished: the checkout is now a workspace (or its project) the sidebar may list. */
export function releaseVmCreateCheckout(creationId: string): void {
const hideId = activeHideByCreation.get(creationId)
if (hideId === undefined) {
return
}
activeHideByCreation.delete(creationId)
removeHide(hideId)
}
/**
* A failed or cancelled create's VM cleanup finished. A VM that could not be cleaned up shows again
* so the user can remove it; a cleaned-up one stays hidden while its rows leave the lists. A later
* cleanup of the same create (a cancel racing the create) cannot un-hide a VM already cleaned up.
*/
export function settleVmCreateCheckoutAfterCleanup(creationId: string, cleanedUp: boolean): void {
const hideId = activeHideByCreation.get(creationId)
if (hideId === undefined || cleanedHides.has(hideId)) {
return
}
if (!cleanedUp) {
releaseVmCreateCheckout(creationId)
return
}
activeHideByCreation.delete(creationId)
cleanedHides.add(hideId)
setTimeout(() => removeHide(hideId), HIDDEN_AFTER_CLEANUP_MS)
}
export function subscribeHiddenVmCheckouts(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function readHiddenVmCheckouts(): HiddenVmCheckouts {
return hidden
}
function isHidden(checkouts: HiddenVmCheckouts, row: CheckoutRow): boolean {
return (
row.isMainWorktree !== false &&
checkouts.repoIds.has(row.repoId) &&
checkouts.keys.has(checkoutKey(row.repoId, row.path))
)
}
/** The window's sidebar list without the primary checkouts of VMs whose creates are in progress. */
export function withoutHiddenVmWorktrees<W extends CheckoutRow>(
worktrees: W[],
checkouts: HiddenVmCheckouts
): W[] {
if (checkouts.keys.size === 0) {
return worktrees
}
const kept = worktrees.filter((worktree) => !isHidden(checkouts, worktree))
return kept.length === worktrees.length ? worktrees : kept
}
function withoutHiddenRows<K, V extends CheckoutRow>(
rows: ReadonlyMap<K, V>,
checkouts: HiddenVmCheckouts
): ReadonlyMap<K, V> {
let kept: Map<K, V> | null = null
for (const [key, row] of rows) {
if (isHidden(checkouts, row)) {
kept ??= new Map(rows)
kept.delete(key)
}
}
return kept ?? rows
}
/** Main's rows without the primary checkouts of VMs whose creates are still in progress. */
export function withoutHiddenVmCheckouts(
domains: SidebarFeedDomains,
checkouts: HiddenVmCheckouts
): SidebarFeedDomains {
if (checkouts.keys.size === 0) {
return domains
}
const workspaces = withoutHiddenRows(domains.workspaces, checkouts)
const pairedServerRows = withoutHiddenRows(domains.pairedServerRows, checkouts)
return workspaces === domains.workspaces && pairedServerRows === domains.pairedServerRows
? domains
: { ...domains, workspaces, pairedServerRows }
}
@@ -9,6 +9,7 @@ import {
prepareRequestForCreate
} from '@/lib/ephemeral-vm-worktree-creation'
import { getProvisionedRootCreateOptions } from '@/lib/provisioned-root-create-options'
import { releaseVmCreateCheckout } from '@/lib/vm-create-hidden-checkouts'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
@@ -125,7 +126,7 @@ export async function executeWorktreeCreation(
return
}
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest, creationId)
}
if (recoverWorktreeLegacyPreparation(creationId, preparedRequest, error)) {
return
@@ -145,10 +146,12 @@ export async function executeWorktreeCreation(
// Why: cancellation can race a successful backend adoption; clean up again after it settles so an adopted workspace cannot outlive its destroyed VM.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest, creationId)
}
return
}
// The VM's checkout is now this workspace (or its project), so the sidebar may list it.
releaseVmCreateCheckout(creationId)
await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id)
const backendSpawned = result.startupTerminal?.spawned === true
@@ -3,6 +3,7 @@ import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-sli
import type { AppState } from '../../../types'
import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup'
import { endWindowCreation } from '@/lib/sidebar-ended-creations'
import { settleVmCreateCheckoutAfterCleanup } from '@/lib/vm-create-hidden-checkouts'
import { cancelVmEnvironmentCheck } from '@/lib/ephemeral-vm-environment-check-decision'
import {
pendingCreationChatDraftScopeKey,
@@ -137,7 +138,7 @@ export function createRemovePendingWorktreeCreation(
console.error('Failed to remove cancelled provisioned-root project setup:', error),
reportRuntimeError: (error) =>
console.error('Failed to clean up cancelled ephemeral VM runtime:', error)
})
}).then((cleanedUp) => settleVmCreateCheckoutAfterCleanup(creationId, cleanedUp))
}
}
@@ -8,6 +8,12 @@ import {
resetRemoteRuntimeMocks,
resetWorktreeSliceModuleMemory
} from '../worktrees-slice-test-harness'
import {
hideVmCreateCheckout,
readHiddenVmCheckouts,
releaseVmCreateCheckout,
withoutHiddenVmWorktrees
} from '@/lib/vm-create-hidden-checkouts'
const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn())
@@ -25,7 +31,18 @@ vi.mock('@/components/worktree-base-fallback-notice', () => ({
requestWorktreeBaseFallbackNotice
}))
beforeEach(resetWorktreeSliceModuleMemory)
beforeEach(() => {
resetWorktreeSliceModuleMemory()
releaseVmCreateCheckout('c1')
releaseVmCreateCheckout('c2')
})
function isCheckoutHidden(repoId: string): boolean {
return (
withoutHiddenVmWorktrees([{ repoId, path: '/workspace/repo' }], readHiddenVmCheckouts())
.length === 0
)
}
function makePendingCreation(
creationId: string,
@@ -197,6 +214,31 @@ describe('pending worktree creation state', () => {
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
})
it('keeps a cancelled VM checkout hidden once cleaned up, and shows it if the VM was not destroyed', async () => {
const store = createTestStore()
const request = { ...makePendingCreation('c1').request, ephemeralVmRuntimeId: 'runtime-1' }
store.getState().beginPendingWorktreeCreation(makePendingCreation('c1', { request }))
hideVmCreateCheckout('c1', { repoId: 'vm-repo', path: '/workspace/repo' })
store.getState().removePendingWorktreeCreation('c1')
await vi.waitFor(() => expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledTimes(1))
// Let the cleanup chain settle its hide before checking it was kept.
for (let i = 0; i < 10; i++) {
await Promise.resolve()
}
expect(isCheckoutHidden('vm-repo')).toBe(true)
// A recipe whose destroy fails resolves a record rather than rejecting.
mockApi.ephemeralVm.cleanup.mockResolvedValueOnce({ cleanupStatus: 'failed' })
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
store
.getState()
.beginPendingWorktreeCreation(makePendingCreation('c2', { request: { ...request } }))
hideVmCreateCheckout('c2', { repoId: 'vm-repo-2', path: '/workspace/repo' })
store.getState().removePendingWorktreeCreation('c2')
await vi.waitFor(() => expect(isCheckoutHidden('vm-repo-2')).toBe(false))
errorSpy.mockRestore()
})
it('setActiveWorktree dismisses the creation panel even when re-selecting the already-active worktree', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/a', repoId: 'repo1' })
+116 -18
View File
@@ -38,24 +38,8 @@ test('adopts a recipe-provisioned SSH root without creating a linked worktree',
await waitForSessionReady(orcaPage)
const sourceRepoId = await addRecipeRepo(orcaPage, sourceRepo)
await openSidebarWorkspaceComposer(orcaPage)
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
await dialog.getByRole('combobox', { name: 'Run on' }).click()
await orcaPage.getByRole('option', { name: /Per-Workspace Environment/ }).click()
await orcaPage
.getByRole('listbox', { name: 'Per-Workspace Environment' })
.getByText('Docker provisioned root', { exact: true })
.click()
const workspaceName = `provisioned-root-${Date.now()}`
await dialog.getByPlaceholder(/Type a name/i).fill(workspaceName)
await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click()
const trustDialog = orcaPage.getByRole('dialog', { name: /Run VM recipe/ })
await expect(trustDialog).toBeVisible()
await trustDialog.getByRole('button', { name: 'Run hooks' }).click()
await expect(dialog).toBeHidden({ timeout: 60_000 })
await startRecipeCreate(orcaPage, workspaceName)
await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({
timeout: 240_000
})
@@ -138,6 +122,113 @@ test('adopts a recipe-provisioned SSH root without creating a linked worktree',
}
})
test('hides the VM checkout while a create is paused on failed checks, and leaves nothing after cancel', async ({
orcaPage
}, testInfo) => {
// The first connect deploys the VM's managed server before the checks can run.
test.setTimeout(420_000)
let target: DockerSshHostTarget | null = null
const sourceRepo = mkdtempSync(path.join(tmpdir(), 'orca-provisioned-root-source-'))
try {
ensureDockerSshHostImage(process.cwd())
target = startDockerSshHostTarget(testInfo)
// The VM keeps a remote it cannot push to, so its environment checks fail and the create pauses.
seedRecipeRepo(sourceRepo, target, { reachableRemote: false })
await waitForSessionReady(orcaPage)
const sourceRepoId = await addRecipeRepo(orcaPage, sourceRepo)
const reposBefore = await orcaPage.evaluate(() =>
window.__store!.getState().repos.map((repo) => repo.id)
)
const workspaceName = `provisioned-root-${Date.now()}`
await startRecipeCreate(orcaPage, workspaceName)
const sidebar = orcaPage.getByRole('listbox', { name: 'Worktrees' })
const pendingRow = sidebar.getByRole('button', {
name: new RegExp(`${workspaceName} Environment checks failed`)
})
await expect(pendingRow).toBeVisible({ timeout: 240_000 })
// The VM's checkout is in the store by now; the sidebar shows only the pending row for it.
await expect
.poll(
() =>
orcaPage.evaluate(
({ sourceRepoId, workspaceName }) =>
Object.values(window.__store!.getState().worktreesByRepo)
.flat()
.some((wt) => wt.repoId !== sourceRepoId && wt.branch.endsWith(workspaceName)),
{ sourceRepoId, workspaceName }
),
{ timeout: 30_000 }
)
.toBe(true)
await expect(sidebar.getByRole('option', { name: new RegExp(workspaceName) })).toHaveCount(0)
await testInfo.attach('paused-vm-create-sidebar', {
body: await orcaPage.screenshot(),
contentType: 'image/png'
})
await sidebar.getByRole('button', { name: 'Cancel worktree creation' }).click()
await expect(pendingRow).toBeHidden()
const cancelled = target
await expect
.poll(
() => {
try {
execDockerSshHostTargetCommand(cancelled, 'true')
return false
} catch {
return true
}
},
{ timeout: 60_000, message: 'the cancelled VM was never destroyed' }
)
.toBe(true)
await expect
.poll(
() =>
orcaPage.evaluate(
(before) =>
window
.__store!.getState()
.repos.filter((repo) => !before.includes(repo.id))
.map((repo) => repo.id),
reposBefore
),
{ timeout: 30_000, message: 'the cancelled VM project was left behind' }
)
.toEqual([])
await expect(sidebar.getByRole('option', { name: new RegExp(workspaceName) })).toHaveCount(0)
await testInfo.attach('cancelled-vm-create-sidebar', {
body: await orcaPage.screenshot(),
contentType: 'image/png'
})
} finally {
cleanupDockerSshHostTarget(target)
rmSync(sourceRepo, { recursive: true, force: true })
}
})
async function startRecipeCreate(
page: Parameters<typeof waitForSessionReady>[0],
workspaceName: string
): Promise<void> {
await openSidebarWorkspaceComposer(page)
const dialog = page.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
await dialog.getByRole('combobox', { name: 'Run on' }).click()
await page.getByRole('option', { name: /Per-Workspace Environment/ }).click()
await page
.getByRole('listbox', { name: 'Per-Workspace Environment' })
.getByText('Docker provisioned root', { exact: true })
.click()
await dialog.getByPlaceholder(/Type a name/i).fill(workspaceName)
await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click()
const trustDialog = page.getByRole('dialog', { name: /Run VM recipe/ })
await expect(trustDialog).toBeVisible()
await trustDialog.getByRole('button', { name: 'Run hooks' }).click()
await expect(dialog).toBeHidden({ timeout: 60_000 })
}
async function addRecipeRepo(page: Parameters<typeof waitForSessionReady>[0], repoPath: string) {
return page.evaluate(async (pathValue) => {
const result = await window.api.repos.add({ path: pathValue })
@@ -152,7 +243,11 @@ async function addRecipeRepo(page: Parameters<typeof waitForSessionReady>[0], re
}, repoPath)
}
function seedRecipeRepo(repoPath: string, target: DockerSshHostTarget): string {
function seedRecipeRepo(
repoPath: string,
target: DockerSshHostTarget,
options: { reachableRemote: boolean } = { reachableRemote: true }
): string {
const createScript = path.join(repoPath, 'create.sh')
const destroyScript = path.join(repoPath, 'destroy.sh')
// The recipe's isolated HOME must still address the engine that owns the fixture container.
@@ -218,6 +313,9 @@ ${docker} rm -f ${quotePosixShell(target.containerName)} >/dev/null
)
// Why: VM creates run the environment checks first, and a VM that cannot fetch or push its
// remote pauses the create. Give the VM a remote it can reach so the create proceeds unattended.
if (!options.reachableRemote) {
return expectedRefHead
}
const vmOrigin = '/tmp/provisioned-root-origin.git'
execDockerSshHostTargetCommand(
target,