Restore workspace deletion on server hosts (#41858)

* Fix worktree deletion on server hosts

* Finish workspace removal cancellation and final saves

* Wait for WSL workspace deletion to finish

* Capture CLI deletion errors in the server integration test

* test: canonicalize removal fixture and expose serve stop phases

* test: align serve fixture versions and report workspace paths

* test: isolate removal lifetimes and resolve native fixture paths

* Type-check cancellation assertions on the shared removal mock
This commit is contained in:
Brennan Benson
2026-11-13 20:50:00 -08:00
committed by GitHub
parent 8ee811174f
commit 869f6bbef7
45 changed files with 2728 additions and 350 deletions
@@ -1,3 +1,14 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { removeWorktree } from '../../worktrees/removal/worktree-removal'
import { prepareWorktreeRemovalsForStore } from '../../../workspaces/removal/worktree-removal-records-load'
import {
startBackgroundWorktreeRemoval,
_resetPendingWorktreeRemovalsForTests
} from '../../../workspaces/removal/worktree-background-removal'
import { worktreeRemovalLifetime } from '../../../workspaces/removal/worktree-removal-lifetime'
import { readWorktreeRemovalRecords } from '../../../workspaces/removal/worktree-removal-records'
import { setImmediate as nextTurn } from 'node:timers/promises'
import { Worker } from 'node:worker_threads'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -142,6 +153,59 @@ describe.skipIf(process.platform !== 'darwin')('macOS worker capture cancellatio
await vi.waitFor(() => expect(terminated).toHaveBeenCalledOnce())
})
it('keeps removal ownership and durable intent until an aborted worker child is actually terminated', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-removal-worker-close-'))
const gate = new SharedArrayBuffer(4)
await createHost(gate)
await prepareWorktreeRemovalsForStore({
getProfileStorageDirectory: () => directory,
getRepos: () => [{ id: 'repo' }]
})
const childStarted = Promise.withResolvers<void>()
let closing: Promise<void> | undefined
try {
const result = startBackgroundWorktreeRemoval({
removal: {
worktreeId: 'repo::feature',
repoId: 'repo',
repoPath: directory,
worktree: { path: join(directory, 'missing-checkout'), branch: 'feature', head: 'abc' },
force: false,
deleteBranch: false
},
publish: () => {},
run: (signal) =>
removeWorktree(directory, join(directory, 'missing-checkout'), false, {
knownRemovedWorktree: { branch: 'feature', head: 'abc' },
deleteBranch: false,
checkoutDeleteSignal: signal,
observeChildTermination: () => {
childStarted.resolve()
}
})
})
void result.catch(() => {})
await childStarted.promise
let closed = false
closing = worktreeRemovalLifetime.close().then(() => {
closed = true
})
await nextTurn()
expect(closed).toBe(false)
expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1)
Atomics.store(new Int32Array(gate), 0, 1)
Atomics.notify(new Int32Array(gate), 0)
await closing
expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1)
} finally {
Atomics.store(new Int32Array(gate), 0, 1)
Atomics.notify(new Int32Array(gate), 0)
await closing
_resetPendingWorktreeRemovalsForTests()
await rm(directory, { recursive: true, force: true })
}
})
it('does not infer child termination from worker loss before a spawn acknowledgement', async () => {
const gate = new SharedArrayBuffer(4)
await createHost(gate)
@@ -25,7 +25,10 @@ vi.mock('./worktree-list-reader', () => ({
import {
_resetWorktreeScanCacheForTests,
listWorktreeGraph,
listWorktrees
listWorktrees,
listWorktreesSharedStrict,
listWorktreeGraphSharedStrict,
bumpWorktreeScanGeneration
} from './worktree-scan-cache'
import { __resetSparseCheckoutStateCacheForTests } from '../sparse-checkout/worktree-sparse-checkout-cache'
@@ -67,3 +70,64 @@ describe('graph and annotated worktree scans', () => {
expect((await annotatedScan)[0]?.isSparse).toBe(true)
})
})
it('shares only matching live cancellation scopes and preserves generation freshness', async () => {
_resetWorktreeScanCacheForTests()
readWorktreeListMock.mockReset()
__resetSparseCheckoutStateCacheForTests()
const pending = Promise.withResolvers<GitWorktreeInfo[]>()
readWorktreeListMock.mockReturnValue(pending.promise)
detectSparseCheckoutMock.mockResolvedValue(false)
const first = new AbortController()
const other = new AbortController()
const reads = [
listWorktreesSharedStrict('/repo', { signal: first.signal }),
listWorktreesSharedStrict('/repo', { signal: first.signal }),
listWorktreesSharedStrict('/repo', { signal: other.signal }),
listWorktreesSharedStrict('/repo')
]
expect(readWorktreeListMock).toHaveBeenCalledTimes(3)
bumpWorktreeScanGeneration('/repo')
reads.push(listWorktreesSharedStrict('/repo', { signal: first.signal }))
expect(readWorktreeListMock).toHaveBeenCalledTimes(4)
first.abort()
await expect(listWorktreesSharedStrict('/repo', { signal: first.signal })).rejects.toThrow()
expect(readWorktreeListMock).toHaveBeenCalledTimes(4)
pending.resolve([])
const outcomes = await Promise.allSettled(reads)
expect(outcomes.map((outcome) => outcome.status)).toEqual([
'rejected',
'rejected',
'fulfilled',
'fulfilled',
'rejected'
])
})
it('keeps strict all-row graphs separate, preserves errors and refreshes in the same cancellation scope', async () => {
_resetWorktreeScanCacheForTests()
readTranslatedWorktreeGraphMock.mockReset()
const pending = Promise.withResolvers<GitWorktreeInfo[]>()
readTranslatedWorktreeGraphMock.mockReturnValue(pending.promise)
const signal = new AbortController().signal
const scans = [
listWorktreeGraphSharedStrict('/repo', { signal }),
listWorktreeGraphSharedStrict('/repo', { signal })
]
expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledOnce()
scans.push(listWorktreeGraph('/repo', { signal }))
expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(2)
bumpWorktreeScanGeneration('/repo')
scans.push(listWorktreeGraphSharedStrict('/repo', { signal }))
expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(3)
const nested = {
...ROW,
path: '/repo/checkout/.orca-worktree-preparation',
branch: 'refs/heads/orca-create-preparation/test'
}
pending.resolve([ROW, nested])
expect((await Promise.all(scans))[0]).toEqual([ROW, nested])
const failure = new Error('inventory unavailable')
readTranslatedWorktreeGraphMock.mockRejectedValueOnce(failure)
await expect(listWorktreeGraphSharedStrict('/repo', { signal })).rejects.toBe(failure)
})
@@ -1,3 +1,5 @@
import { readTranslatedWorktreeGraph } from './worktree-list-reader'
import { abortSignalReason } from '../../../../shared/abort-signal-reason'
import type { GitWorktreeInfo } from '../../../../shared/worktree/types'
import {
annotateSparseCheckoutStatus,
@@ -10,9 +12,12 @@ import { WORKTREE_LIST_TIMEOUT_MS } from '../worktree-operation-options'
import { resolveGitAdmissionTier } from '../../command-runner/admission/git-operation-executor'
import { withRepositoryGitCause } from '../../repository-git-cause'
let signalIdentities = new WeakMap<AbortSignal, number>()
let nextSignalIdentity = 0
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
type WorktreeScanKind = 'graph' | 'lenient' | 'strict' | 'strict-true-empty'
type WorktreeScanKind = 'graph' | 'strict-graph' | 'lenient' | 'strict' | 'strict-true-empty'
const worktreeScanGenerations = new Map<string, number>()
@@ -62,6 +67,8 @@ export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; genera
}
export function _resetWorktreeScanCacheForTests(): void {
signalIdentities = new WeakMap()
nextSignalIdentity = 0
inFlightWorktreeScans.clear()
worktreeScanGenerations.clear()
}
@@ -73,18 +80,18 @@ function shareWorktreeScan(
kind: WorktreeScanKind,
run: (repoPath: string, options: GitWorktreeExecOptions) => Promise<GitWorktreeInfo[]>
): Promise<GitWorktreeInfo[]> {
if (options.signal?.aborted) {
return Promise.reject(abortSignalReason(options.signal))
}
let signalIdentity = 0
if (options.signal) {
return withRepositoryGitCause(
repoPath,
options,
{ family: 'catalog', reason: 'worktree-scan', cache: 'bypass-signal' },
() => run(repoPath, options)
)
signalIdentity = signalIdentities.get(options.signal) ?? ++nextSignalIdentity
signalIdentities.set(options.signal, signalIdentity)
}
const generation = worktreeScanGenerations.get(repoPath) ?? 0
const timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
// Separate deadlines, failure contracts and admission tiers so joiners cannot inherit weaker scans.
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${options.includeCreatePreparations === true}\0${generation}\0${kind}\0${resolveGitAdmissionTier(options.admissionTier)}`
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${options.includeCreatePreparations === true}\0${generation}\0${kind}\0${resolveGitAdmissionTier(options.admissionTier)}\0${signalIdentity}`
const inFlight = inFlightWorktreeScans.get(key)
if (inFlight) {
return inFlight
@@ -113,7 +120,7 @@ async function runAnnotatedWorktreeScan(
return annotateSparseCheckoutStatus(repoPath, worktrees, options)
}
/** Git failures return []; matching callers coalesce, while AbortSignal callers stay isolated. */
/** Git failures return []; matching callers coalesce, while different cancellation scopes stay isolated. */
export function listWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
@@ -121,7 +128,7 @@ export function listWorktrees(
return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan)
}
/** Skip sparse probes while retaining generation fencing and AbortSignal isolation. */
/** Skip sparse probes while retaining generation fencing and cancellation-scope isolation. */
export function listWorktreeGraph(
repoPath: string,
options: GitWorktreeExecOptions = {}
@@ -149,3 +156,11 @@ export function listWorktreesSharedStrictAllowingTrueEmpty(
listWorktreesStrictAllowingTrueEmptyUnshared
)
}
/** Strict authority includes every registration, without preparation filtering or sparse probes. */
export function listWorktreeGraphSharedStrict(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
return shareWorktreeScan(repoPath, options, 'strict-graph', readTranslatedWorktreeGraph)
}
@@ -56,6 +56,7 @@ import {
} from './remove-worktree-test-harness'
import { removeWorktree } from './worktree-removal'
import * as nativeRemoval from './windows-worktree-removal'
import { __getSparseCheckoutStateCacheSizeForTests } from '../sparse-checkout/worktree-sparse-checkout-cache'
const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
@@ -85,6 +86,35 @@ describe('removeWorktree', () => {
})
})
it('keeps an aborted removal pending until the mutation child termination is confirmed', async () => {
const termination = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const abort = new Error('checkout delete stopped')
gitExecFileAsyncMock.mockImplementation(async (args, options) => {
if (args.includes('remove')) {
expect(options.spawnOnWorkerThread).toBe(true)
options.observeChildTermination(termination.promise, true)
started.resolve()
throw abort
}
return { stdout: '', stderr: '' }
})
let settled = false
const result = removeWorktree('/repo', '/repo/feature', false, {
knownRemovedWorktree: { branch: 'feature', head: 'abc' },
deleteBranch: false
})
const outcome = result.catch((error: unknown) => {
settled = true
return error
})
await started.promise
await Promise.resolve()
expect(settled).toBe(false)
termination.resolve()
expect(await outcome).toBe(abort)
})
it('preserves the branch when requested for a pre-existing local branch checkout', async () => {
mockGitCommands({
'git worktree list --porcelain': {
@@ -250,3 +280,45 @@ locked active agent session
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
})
})
it('finishes native Git registration and branch cleanup when checkout stop follows physical success', async () => {
platformSpy.mockReturnValue('win32')
const directory = await mkdtemp(join(tmpdir(), 'orca-native-finalization-'))
const checkout = join(directory, 'feature')
await mkdir(checkout)
const controller = new AbortController()
const physical = vi.spyOn(nativeRemoval, 'removeWindowsCheckout').mockImplementation(async () => {
await rm(checkout, { recursive: true })
controller.abort(new Error('host stopping after checkout completion'))
})
let registered = true
gitExecFileAsyncMock.mockImplementation(async (args: string[], options) => {
if (options.signal?.aborted) {
throw options.signal.reason
}
if (args.includes('list')) {
return {
stdout: registered ? `worktree ${checkout}\nHEAD abc\nbranch refs/heads/feature\n` : '',
stderr: ''
}
}
if (args.includes('remove')) {
registered = false
}
return { stdout: '', stderr: '' }
})
try {
await expect(
removeWorktree(directory, checkout, false, {
knownRemovedWorktree: { branch: 'feature', head: 'abc' },
checkoutDeleteSignal: controller.signal
})
).resolves.toEqual({})
expect(existsSync(checkout)).toBe(false)
expect(registered).toBe(false)
expect(getGitCalls()).toContain('git branch -d -- feature')
} finally {
physical.mockRestore()
await rm(directory, { recursive: true, force: true })
}
})
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
throwIfSignalAborted,
waitForPromiseWithSignal
} from '../../../../shared/abort-signal-reason'
import type { GitWorktreeInfo } from '../../../../shared/worktree/types'
const { graph, stat, names, removeChild, unlink, rmdir, clean } = vi.hoisted(() => ({
graph: vi.fn(),
stat: vi.fn(),
names: vi.fn(),
removeChild: vi.fn(),
unlink: vi.fn(),
rmdir: vi.fn(),
clean: vi.fn()
}))
vi.mock('../catalog/worktree-list-reader', async (original) => ({
...(await original<Record<string, unknown>>()),
readTranslatedWorktreeGraph: graph
}))
vi.mock('../../../filesystem/asar-transparent-fs', () => ({
lstat: stat,
readdir: names,
rm: removeChild,
unlink,
rmdir
}))
vi.mock('node:fs/promises', async (original) => ({
...(await original<Record<string, unknown>>()),
readFile: async () => 'gitdir: C:/repo/.git/worktrees/feature'
}))
vi.mock('../../../workspaces/removal/worktree-removal-safety', async (original) => ({
...(await original<Record<string, unknown>>()),
canSafelyRemoveOrphanedWorktreeDirectory: async () => true
}))
vi.mock('./worktree-removal-preflight', () => ({ assertWorktreeCleanForRemoval: clean }))
import { authorizeWindowsCheckout, removeWindowsCheckout } from './windows-worktree-removal'
import { _resetWorktreeScanCacheForTests } from '../catalog/worktree-scan-cache'
const originalPlatform = process.platform
const root = 'C:/checkout/feature'
const row: GitWorktreeInfo = {
path: root,
branch: 'refs/heads/feature',
head: 'abc',
isBare: false,
isMainWorktree: false
}
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
_resetWorktreeScanCacheForTests()
graph.mockResolvedValue([row])
stat.mockImplementation(async (path: string) => {
if (String(path).endsWith('locked')) {
throw missing
}
return { isDirectory: () => true, isSymbolicLink: () => false }
})
names.mockResolvedValue(['held', '.git'])
removeChild.mockResolvedValue(undefined)
unlink.mockResolvedValue(undefined)
rmdir.mockResolvedValue(undefined)
clean.mockResolvedValue(undefined)
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
vi.resetAllMocks()
_resetWorktreeScanCacheForTests()
})
it.each([10, 100])(
'shares one native eligibility graph for %i equivalent overlapping authorizations',
async (population) => {
const reading = Promise.withResolvers<GitWorktreeInfo[]>()
graph.mockReturnValueOnce(reading.promise)
const signal = new AbortController().signal
const checks = Array.from({ length: population }, () =>
authorizeWindowsCheckout('C:/repo', root, true, { checkoutDeleteSignal: signal })
)
expect(graph).toHaveBeenCalledOnce()
reading.resolve([row])
expect(await Promise.all(checks)).toEqual(Array.from({ length: population }, () => true))
expect(graph.mock.calls[0]?.[1].signal).toBe(signal)
}
)
it('refuses nested preparation registrations and strict inventory errors', async () => {
graph.mockResolvedValueOnce([row, { ...row, path: `${root}/.orca-worktree-preparation` }])
await expect(authorizeWindowsCheckout('C:/repo', root, true, {})).rejects.toThrow(
'another registered worktree'
)
const failure = new Error('inventory failed')
graph.mockRejectedValueOnce(failure)
await expect(authorizeWindowsCheckout('C:/repo', root, true, {})).rejects.toBe(failure)
expect(removeChild).not.toHaveBeenCalled()
})
it.each(['status', 'recursive'] as const)(
'cancels the later %s read with the original checkout stop reason',
async (stage) => {
const controller = new AbortController()
const held = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
if (stage === 'status') {
clean.mockImplementation(async (_root, _force, options) => {
entered.resolve()
await waitForPromiseWithSignal(held.promise, options.signal)
})
} else {
graph
.mockImplementationOnce(async () => [row])
.mockImplementationOnce(async (_repo, options) => {
entered.resolve()
await waitForPromiseWithSignal(held.promise, options.signal)
return [row]
})
}
const reason = new Error('owner stopped')
const pending = authorizeWindowsCheckout('C:/repo', root, false, {
checkoutDeleteSignal: controller.signal
})
const rejected = expect(pending).rejects.toBe(reason)
await entered.promise
controller.abort(reason)
await rejected
expect(unlink).not.toHaveBeenCalled()
}
)
it('joins held children before cancelling the real repeated delete-time authorization', async () => {
const controller = new AbortController()
const sibling = Promise.withResolvers<void>()
const childrenStarted = Promise.withResolvers<void>()
const repeated = Promise.withResolvers<void>()
let settled = false
removeChild.mockImplementation(async () => {
childrenStarted.resolve()
await sibling.promise
})
// Initial clean authorization and its recursive proof, then the post-child recheck.
graph
.mockImplementationOnce(async () => [row])
.mockImplementationOnce(async () => [row])
.mockImplementationOnce(async (_repo, options) => {
repeated.resolve()
await waitForPromiseWithSignal(new Promise<never>(() => {}), options.signal)
throwIfSignalAborted(options.signal)
return [row]
})
const pending = removeWindowsCheckout('C:/repo', root, false, {
checkoutDeleteSignal: controller.signal
})
const outcome = pending
.finally(() => {
settled = true
})
.catch((error: unknown) => error)
await childrenStarted.promise
expect(settled).toBe(false)
expect(unlink).not.toHaveBeenCalled()
sibling.resolve()
await repeated.promise
const reason = new Error('owner stopped during repeated proof')
controller.abort(reason)
expect(await outcome).toBe(reason)
expect(unlink).not.toHaveBeenCalled()
expect(rmdir).not.toHaveBeenCalled()
})
it('honors distinct read and checkout signals without replacing either caller option', async () => {
const read = new AbortController()
const checkout = new AbortController()
const entered = Promise.withResolvers<void>()
graph.mockImplementation(async (_repo, options) => {
entered.resolve()
return waitForPromiseWithSignal(new Promise<GitWorktreeInfo[]>(() => {}), options.signal)
})
const options = { signal: read.signal, checkoutDeleteSignal: checkout.signal }
const reason = new Error('checkout closing')
const rejected = expect(authorizeWindowsCheckout('C:/repo', root, true, options)).rejects.toBe(
reason
)
await entered.promise
checkout.abort(reason)
await rejected
expect(options.signal).toBe(read.signal)
expect(options.checkoutDeleteSignal).toBe(checkout.signal)
})
@@ -1,3 +1,4 @@
import { throwIfSignalAborted } from '../../../../shared/abort-signal-reason'
import { join, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { lstat, readdir } from '../../../filesystem/asar-transparent-fs'
@@ -15,7 +16,7 @@ import {
isDangerousWorktreeRemovalPath,
ORPHANED_WORKTREE_DIRECTORY_MESSAGE
} from '../../../workspaces/removal/worktree-removal-safety'
import { readTranslatedWorktreeGraph } from '../catalog/worktree-list-reader'
import { listWorktreeGraphSharedStrict } from '../catalog/worktree-scan-cache'
import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight'
import { getErrorCode, type RemoveWorktreeOptions } from '../worktree-operation-options'
@@ -83,7 +84,42 @@ export async function authorizeWindowsCheckout(
beforeTeardown = false,
checkClean = true
): Promise<boolean> {
const worktrees = await readTranslatedWorktreeGraph(repo, options)
const signal =
options.signal &&
options.checkoutDeleteSignal &&
options.signal !== options.checkoutDeleteSignal
? AbortSignal.any([options.signal, options.checkoutDeleteSignal])
: (options.checkoutDeleteSignal ?? options.signal)
options = { ...options, signal, checkoutDeleteSignal: signal }
const checkStopped = () => throwIfSignalAborted(signal)
checkStopped()
try {
return await authorizeWindowsCheckoutReads(
repo,
root,
force,
options,
beforeTeardown,
checkClean
)
} catch (error) {
checkStopped()
throw error
}
}
async function authorizeWindowsCheckoutReads(
repo: string,
root: string,
force: boolean,
options: RemoveWorktreeOptions & Parameters<typeof assertWorktreeCleanForRemoval>[2],
beforeTeardown: boolean,
checkClean: boolean
): Promise<boolean> {
const checkStopped = () => throwIfSignalAborted(options.signal)
checkStopped()
const worktrees = await listWorktreeGraphSharedStrict(repo, options)
checkStopped()
const current = findRegisteredDeletableWorktree(repo, root, worktrees, CLIENT_REMOVAL_HOME)
if (isDangerousWorktreeRemovalPath(root, repo, CLIENT_REMOVAL_HOME)) {
throw new Error(`Refusing to delete protected worktree path: ${root}`)
@@ -107,7 +143,9 @@ export async function authorizeWindowsCheckout(
let entry
try {
entry = await lstat(root)
checkStopped()
} catch (error) {
checkStopped()
if (getErrorCode(error) === 'ENOENT') {
return false
}
@@ -116,14 +154,22 @@ export async function authorizeWindowsCheckout(
if (!entry.isDirectory() || entry.isSymbolicLink()) {
throw new Error(`Refusing to delete unregistered worktree path: ${root}`)
}
if (await nativeWindowsMarkerMissing(root, options)) {
const markerMissing = await nativeWindowsMarkerMissing(root, options)
checkStopped()
if (markerMissing) {
// Reject only; an empty observation never authorizes recursive deletion after teardown.
if (beforeTeardown && (await readdir(root)).length > 0) {
throw unregisteredFolderRefusal(root)
if (beforeTeardown) {
const names = await readdir(root)
checkStopped()
if (names.length > 0) {
throw unregisteredFolderRefusal(root)
}
}
return false
}
if (!(await canSafelyRemoveOrphanedWorktreeDirectory(root, repo, CLIENT_REMOVAL_HOME))) {
const safe = await canSafelyRemoveOrphanedWorktreeDirectory(root, repo, CLIENT_REMOVAL_HOME)
checkStopped()
if (!safe) {
throw new Error(`Refusing to delete unregistered worktree path: ${root}`)
}
if (!current && !force) {
@@ -137,17 +183,22 @@ export async function authorizeWindowsCheckout(
.trim()
.replace(/^gitdir:\s*/i, '')
)
checkStopped()
try {
await lstat(join(admin, 'locked'))
checkStopped()
throw new Error('Cannot remove a locked worktree. Unlock it before deleting.')
} catch (error) {
checkStopped()
if (getErrorCode(error) !== 'ENOENT') {
throw error
}
}
if (checkClean && !force) {
await assertWorktreeCleanForRemoval(root, force, options, admin)
return authorizeWindowsCheckout(repo, root, force, options, beforeTeardown, false)
checkStopped()
return authorizeWindowsCheckoutReads(repo, root, force, options, beforeTeardown, false)
}
checkStopped()
return true
}
@@ -37,7 +37,17 @@ export async function removeWorktree(
// Reserve forceBranchDelete for failed-creation rollback so user deletes preserve unmerged commits.
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
options = removalWorkerOptions(options)
const writers: Promise<void>[] = []
const observe = options.observeChildTermination
options = removalWorkerOptions({
...options,
observeChildTermination: (terminated, writes) => {
observe?.(terminated, writes)
if (writes) {
writers.push(terminated)
}
}
})
try {
// Wait out ref packing before deleting branches; concurrent pruning can finish safely.
return await withRepoRefMaintenancePaused('worktree-remove', () =>
@@ -46,6 +56,7 @@ export async function removeWorktree(
)
)
} finally {
await Promise.all(writers)
invalidateWslLinkedWorktreeGitRouting(worktreePath)
invalidateSparseCheckoutState(repoPath, worktreePath)
bumpWorktreeScanGeneration(repoPath)
@@ -72,12 +83,15 @@ async function performRemoveWorktree(
// Avoid default deadlines during large deletes; preserve Windows long-path support used at creation.
const longPathArgs = windowsLongPathGitArgs(repoPath)
const execOptions = {
const finalizationOptions = {
...gitExecOptions(repoPath, options),
...(options.checkoutDeleteSignal ? { signal: options.checkoutDeleteSignal } : {}),
...gitEnvWithoutYesNoPrompt(),
admissionExempt: true as const
}
const execOptions = {
...finalizationOptions,
...(options.checkoutDeleteSignal ? { signal: options.checkoutDeleteSignal } : {})
}
const args = [...longPathArgs, 'worktree', 'remove']
if (force) {
args.push('--force')
@@ -102,7 +116,7 @@ async function performRemoveWorktree(
}
await gitExecFileAsync(
[...longPathArgs, 'worktree', 'remove', '--force', '--', worktreePath],
execOptions
finalizationOptions
)
}
} else {
@@ -143,12 +157,26 @@ export async function finishUnregisteredWorktreeRemoval(
assertCheckoutGone: () => Promise<void>,
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
options = removalWorkerOptions(options)
await assertCheckoutGone()
await pruneGitWorktrees(gitExecOptions(repoPath, options)).catch((error: unknown) =>
console.warn(`[git] worktree prune failed in ${repoPath}`, error)
)
return deleteBranchOfUnregisteredWorktree(repoPath, worktreePath, branch, options)
const writers: Promise<void>[] = []
const observe = options.observeChildTermination
options = removalWorkerOptions({
...options,
observeChildTermination: (terminated, writes) => {
observe?.(terminated, writes)
if (writes) {
writers.push(terminated)
}
}
})
try {
await assertCheckoutGone()
await pruneGitWorktrees(gitExecOptions(repoPath, options)).catch((error: unknown) =>
console.warn(`[git] worktree prune failed in ${repoPath}`, error)
)
return await deleteBranchOfUnregisteredWorktree(repoPath, worktreePath, branch, options)
} finally {
await Promise.all(writers)
}
}
/** Clean up an unregistered checkout's branch using normal preservation rules, without deleting files. */
@@ -158,7 +186,17 @@ export async function deleteBranchOfUnregisteredWorktree(
branch: { name: string; head: string } | null,
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
options = removalWorkerOptions(options)
const writers: Promise<void>[] = []
const observe = options.observeChildTermination
options = removalWorkerOptions({
...options,
observeChildTermination: (terminated, writes) => {
observe?.(terminated, writes)
if (writes) {
writers.push(terminated)
}
}
})
try {
if (!branch?.name || !(await localBranchExists(repoPath, branch.name, options))) {
return {}
@@ -167,6 +205,7 @@ export async function deleteBranchOfUnregisteredWorktree(
deleteBranchOfRemovedWorktree(repoPath, branch.name, branch.head, options)
)
} finally {
await Promise.all(writers)
invalidateSparseCheckoutState(repoPath, worktreePath)
bumpWorktreeScanGeneration(repoPath)
}
+251 -2
View File
@@ -1,5 +1,20 @@
import type * as WorktreeScanCacheModule from '../git/worktrees/catalog/worktree-scan-cache'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
_resetWorktreeRemovalLifetimeForTests,
worktreeRemovalLifetime
} from '../workspaces/removal/worktree-removal-lifetime'
import { prepareWorktreeRemovalsForStore } from '../workspaces/removal/worktree-removal-records-load'
import { readWorktreeRemovalRecords } from '../workspaces/removal/worktree-removal-records'
import {
waitForWorktreeRemovalAcceptance,
_resetPendingWorktreeRemovalsForTests
} from '../workspaces/removal/worktree-background-removal'
import { removeRuntimeRegisteredLocalWorktree } from '../runtime/workspaces/removal/runtime-registered-local-worktree-removal'
import type { RuntimeStore } from '../runtime/construction/runtime-store-contract'
import * as scopedRootsInvalidation from './worktrees/removal/registered-worktree-roots-scoped-invalidation'
import * as authorizedRootsCache from './worktrees/removal/registered-worktree-roots-cache'
import {
@@ -12,7 +27,11 @@ import {
killAllProcessesForWorktreeMock
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
import { mockKnownFeatureWorktree, mockSelectedWslProjectRuntime } from './worktrees-test-fixtures'
afterEach(() => {
_resetWorktreeRemovalLifetimeForTests()
})
vi.mock('../git/worktrees/catalog/worktree-scan-cache', async (importOriginal) => {
return {
@@ -222,3 +241,233 @@ describe('registerWorktreeHandlers', () => {
expect(removeWorktreeMock).not.toHaveBeenCalled()
})
})
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
it.each(['ipc', 'runtime'] as const)(
'%s refuses after a held preflight loses to committed shutdown, before stopping terminals',
async (entry) => {
const runtime = setupWorktreeHandlers()
const acquireWatcherRemoval = vi.fn(async (_path: string) => ({
finish: async (_removed: boolean) => {}
}))
runtime.acquireFileWatcherRemoval = acquireWatcherRemoval
const rows = mockKnownFeatureWorktree()
const entered = deferred()
const preflight = deferred()
assertWorktreeCleanForRemovalMock.mockImplementation(async () => {
entered.resolve()
await preflight.promise
})
const invoke = () =>
entry === 'ipc'
? Promise.resolve(
handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
)
: removeRuntimeRegisteredLocalWorktree({
repo: {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '',
addedAt: 0
},
target: {
id: 'repo-1::/workspace/feature-wt',
repoId: 'repo-1',
path: '/workspace/feature-wt'
},
registeredWorktree: rows[1],
removedPushTarget: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this path refuses before consuming store mutations.
store: store as unknown as RuntimeStore,
localOptions: {},
hasLocalOptions: false,
force: false,
runHooks: false,
allowFailedArchiveHook: false,
allowUnverifiedPtyStop: false,
deleteBranch: true,
acquireWatcherRemoval,
stopPtys: async () => {
await killAllProcessesForWorktreeMock()
},
closeWatchers: async () => {},
preserveBranchHead: (result) => result ?? {},
finishRemoval: () => {},
onRemoved: () => {},
publish: () => {}
})
const request = invoke()
const refused = expect(request).rejects.toThrow('Orca is stopping')
await entered.promise
await worktreeRemovalLifetime.close()
preflight.resolve()
await refused
expect(runtime.acquireFileWatcherRemoval).not.toHaveBeenCalled()
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
}
)
it.each(['ipc', 'runtime'] as const)(
'%s finishes actual admission when teardown owned preparation before shutdown',
async (entry) => {
const runtime = setupWorktreeHandlers()
const acquireWatcherRemoval = vi.fn(async (_path: string) => ({
finish: async (_removed: boolean) => {}
}))
runtime.acquireFileWatcherRemoval = acquireWatcherRemoval
const rows = mockKnownFeatureWorktree()
const directory = await mkdtemp(join(tmpdir(), 'orca-removal-boundary-'))
await prepareWorktreeRemovalsForStore({
getProfileStorageDirectory: () => directory,
getRepos: () => [{ id: 'repo-1' }]
})
const entered = deferred()
const stopped = deferred()
killAllProcessesForWorktreeMock.mockImplementation(async () => {
entered.resolve()
await stopped.promise
return { runtimeStopped: 1, providerStopped: 0, registryStopped: 0 }
})
try {
const request =
entry === 'ipc'
? Promise.resolve(
handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
)
: removeRuntimeRegisteredLocalWorktree({
repo: {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '',
addedAt: 0
},
target: {
id: 'repo-1::/workspace/feature-wt',
repoId: 'repo-1',
path: '/workspace/feature-wt'
},
registeredWorktree: rows[1],
removedPushTarget: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cleanup is aborted before store mutations; admission uses the real journal.
store: store as unknown as RuntimeStore,
localOptions: {},
hasLocalOptions: false,
force: false,
runHooks: false,
allowFailedArchiveHook: false,
allowUnverifiedPtyStop: false,
deleteBranch: true,
acquireWatcherRemoval,
stopPtys: async () => {
await killAllProcessesForWorktreeMock()
},
closeWatchers: async () => {},
preserveBranchHead: (result) => result ?? {},
finishRemoval: () => {},
onRemoved: () => {},
publish: () => {}
})
await entered.promise
const closing = worktreeRemovalLifetime.close()
stopped.resolve()
if (entry === 'ipc') {
void request.catch(() => {})
await waitForWorktreeRemovalAcceptance('repo-1::/workspace/feature-wt')
} else {
await expect(request).resolves.toMatchObject({ removing: true })
}
await closing
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(await readWorktreeRemovalRecords(directory)).toEqual([
expect.objectContaining({
worktreeId: 'repo-1::/workspace/feature-wt',
deleteBranch: true,
force: false
})
])
} finally {
_resetPendingWorktreeRemovalsForTests()
await rm(directory, { recursive: true, force: true })
}
}
)
it.each(['ipc', 'runtime'] as const)(
'%s synchronous WSL removal retains main shutdown behavior without a journal lease',
async (entry) => {
const runtime = setupWorktreeHandlers()
mockSelectedWslProjectRuntime()
const rows = mockKnownFeatureWorktree()
const finish = vi.fn(async () => {})
const gate = { finish: vi.fn(async () => {}) }
const acquireWatcherRemoval = vi.fn(async (_path: string) => gate)
runtime.acquireFileWatcherRemoval = acquireWatcherRemoval
const signal = worktreeRemovalLifetime.signal
removeWorktreeMock.mockImplementation(async (_repo, _path, _force, options) => {
expect(options).not.toHaveProperty('checkoutDeleteSignal', expect.anything())
expect(options).not.toHaveProperty('signal', expect.anything())
await worktreeRemovalLifetime.close()
expect(signal.aborted).toBe(true)
return {}
})
const result =
entry === 'ipc'
? Promise.resolve(
handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
deleteBranch: false
})
)
: removeRuntimeRegisteredLocalWorktree({
repo: {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '',
addedAt: 0
},
target: {
id: 'repo-1::/workspace/feature-wt',
repoId: 'repo-1',
path: '/workspace/feature-wt'
},
registeredWorktree: rows[1],
removedPushTarget: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the existing store fixture owns all metadata consumed by this real removal boundary.
store: store as unknown as RuntimeStore,
localOptions: { wslDistro: 'Ubuntu' },
hasLocalOptions: true,
force: false,
runHooks: false,
allowFailedArchiveHook: false,
allowUnverifiedPtyStop: false,
deleteBranch: false,
acquireWatcherRemoval,
stopPtys: async () => {},
closeWatchers: async () => {},
preserveBranchHead: (value) => value ?? {},
finishRemoval: finish,
onRemoved: () => {},
publish: () => {}
})
await expect(result).resolves.toMatchObject({})
if (entry === 'runtime') {
expect(finish).toHaveBeenCalledOnce()
} else {
expect(store.removeWorktreeMeta).toHaveBeenCalled()
}
expect(gate.finish).toHaveBeenCalledWith(true)
expect(removeWorktreeMock).toHaveBeenCalledOnce()
}
)
@@ -1,3 +1,4 @@
import { worktreeRemovalLifetime } from '../../../workspaces/removal/worktree-removal-lifetime'
import { assertRegisteredLocalWorktreePreflight } from './registered-local-worktree-preflight'
import type { Repo } from '../../../../shared/repo-types'
import {
@@ -37,11 +38,7 @@ import {
stopPtysForDestructiveWorktreeRemoval
} from './worktree-removal-ownership'
import { preservedBranchCleanupScopeKey } from '../../../../shared/git/branches/preserved-branch-cleanup'
import {
removesInBackground,
startBackgroundWorktreeRemoval,
waitForWorktreeRemovalAcceptance
} from '../../../workspaces/removal/worktree-background-removal'
import * as backgroundRemoval from '../../../workspaces/removal/worktree-background-removal'
import { worktreeHostIdentity } from '../../../persistence/worktree-meta-host-identity'
export async function removeRegisteredLocalWorktree(
@@ -56,7 +53,6 @@ export async function removeRegisteredLocalWorktree(
hasLocalWorktreeGitOptions: boolean,
deleteBranch: boolean
): Promise<RemoveWorktreeResult> {
const { runtime } = context
const refreshedWorktrees = hasLocalWorktreeGitOptions
? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions)
: await listGitWorktreesStrict(repo.path)
@@ -101,86 +97,92 @@ export async function removeRegisteredLocalWorktree(
markerMissing
)
const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () =>
runtime.acquireFileWatcherRemoval(canonicalWorktreePath)
const background = backgroundRemoval.removesInBackground(
canonicalWorktreePath,
localWorktreeGitOptions
)
let accepted = false
try {
// Fence watcher/spawn installs through linked-path cleanup, Git removal and recursive fallback; PTYs must release handles first.
await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => {
await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, {
allowUnverifiedStop: args.allowUnverifiedPtyStop
})
})
if (!markerMissing && linkedPaths.length > 0) {
await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths)
}
accepted = true
} finally {
if (!accepted) {
await removalGate.finish(false)
}
}
const cleanupPushTarget = () =>
cleanupUnusedWorktreePushTargetRemote(
repo.path,
args.worktreeId,
removedPushTarget,
context.store,
localWorktreeGitOptions
const prepare = async (): Promise<RemoveWorktreeResult> => {
const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () =>
context.runtime.acquireFileWatcherRemoval(canonicalWorktreePath)
)
// Read before the purge drops the row; listeners hear `removed` after it.
const removedIdentity = worktreeHostIdentity(context.store, args.worktreeId, removalHostId)
const finish = (checkoutDeleteSignal?: AbortSignal): Promise<RemoveWorktreeResult> =>
finishLocalWorktreeRemoval({
context,
args,
repo,
repoId,
canonicalWorktreePath,
removalHostId,
removedPushTarget,
localWorktreeGitOptions,
hasLocalWorktreeGitOptions,
deleteBranch,
refreshedRegisteredWorktree,
removalGate,
checkoutDeleteSignal,
cleanupPushTarget
})
if (!removesInBackground(canonicalWorktreePath, localWorktreeGitOptions)) {
const result = await finish()
runtime.publishWorktreeRemovalChange(repoId)
return result
}
// After acceptance, deletion must survive request cancellation; other views observe the host's removing marker.
void startBackgroundWorktreeRemoval({
removal: {
worktreeId: args.worktreeId,
repoId,
repoPath: repo.path,
worktree: refreshedRegisteredWorktree,
deleteBranch,
force: args.force ?? false
},
run: async (stopSignal) => {
const result = await finish(stopSignal)
context.options?.onWorktreeLifecycle?.({
kind: 'removed',
worktreeId: args.worktreeId,
path: canonicalWorktreePath,
...removedIdentity
let accepted = false
try {
// Fence watcher/spawn installs through linked-path cleanup, Git removal and recursive fallback; PTYs must release handles first.
await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => {
await stopPtysForDestructiveWorktreeRemoval(context.runtime, args.worktreeId, {
allowUnverifiedStop: args.allowUnverifiedPtyStop
})
})
if (!markerMissing && linkedPaths.length > 0) {
await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths)
}
accepted = true
} finally {
if (!accepted) {
await removalGate.finish(false)
}
}
const cleanupPushTarget = () =>
cleanupUnusedWorktreePushTargetRemote(
repo.path,
args.worktreeId,
removedPushTarget,
context.store,
localWorktreeGitOptions
)
// Read before the purge drops the row; listeners hear `removed` after it.
const removedIdentity = worktreeHostIdentity(context.store, args.worktreeId, removalHostId)
const finish = (checkoutDeleteSignal?: AbortSignal): Promise<RemoveWorktreeResult> =>
finishLocalWorktreeRemoval({
context,
args,
repo,
repoId,
canonicalWorktreePath,
removalHostId,
removedPushTarget,
localWorktreeGitOptions,
deleteBranch,
refreshedRegisteredWorktree,
removalGate,
checkoutDeleteSignal,
cleanupPushTarget
})
if (!background) {
const result = await finish()
context.runtime.publishWorktreeRemovalChange(repoId)
return result
},
publish: () => runtime.publishWorktreeRemovalChange(repoId),
admissionFailed: () => removalGate.finish(false),
cleanupPushTargetRemote: cleanupPushTarget
})
await waitForWorktreeRemovalAcceptance(args.worktreeId)
return { removing: true }
}
// After acceptance, deletion must survive request cancellation; other views observe the host's removing marker.
void backgroundRemoval.startBackgroundWorktreeRemoval({
removal: {
worktreeId: args.worktreeId,
repoId,
repoPath: repo.path,
worktree: refreshedRegisteredWorktree,
deleteBranch,
force: args.force ?? false
},
run: async (stopSignal) => {
const result = await finish(stopSignal)
context.options?.onWorktreeLifecycle?.({
kind: 'removed',
worktreeId: args.worktreeId,
path: canonicalWorktreePath,
...removedIdentity
})
return result
},
publish: () => context.runtime.publishWorktreeRemovalChange(repoId),
admissionFailed: () => removalGate.finish(false),
cleanupPushTargetRemote: cleanupPushTarget
})
await backgroundRemoval.waitForWorktreeRemovalAcceptance(args.worktreeId)
return { removing: true }
}
return background ? worktreeRemovalLifetime.withPreparation(prepare) : prepare()
}
async function finishLocalWorktreeRemoval({
@@ -192,7 +194,6 @@ async function finishLocalWorktreeRemoval({
removalHostId,
removedPushTarget,
localWorktreeGitOptions,
hasLocalWorktreeGitOptions,
deleteBranch,
refreshedRegisteredWorktree,
removalGate,
@@ -207,14 +208,12 @@ async function finishLocalWorktreeRemoval({
removalHostId: ExecutionHostId
removedPushTarget: GitPushTarget | undefined
localWorktreeGitOptions: LocalProjectWorktreeGitOptions
hasLocalWorktreeGitOptions: boolean
deleteBranch: boolean
refreshedRegisteredWorktree: GitWorktreeInfo
removalGate: { finish: (removed: boolean) => Promise<void> }
checkoutDeleteSignal?: AbortSignal
cleanupPushTarget: () => Promise<void>
}): Promise<RemoveWorktreeResult> {
const { store, runtime } = context
let removalResult: RemoveWorktreeResult | undefined
let removalCompleted = false
try {
@@ -223,7 +222,7 @@ async function finishLocalWorktreeRemoval({
...(!deleteBranch ? { deleteBranch } : {}),
// Reuse the authoritative listing instead of rescanning siblings during deletion.
knownRemovedWorktree: refreshedRegisteredWorktree,
...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}),
...localWorktreeGitOptions,
checkoutDeleteSignal
}
removalResult = preserveBranchHeadFallback(
@@ -245,7 +244,7 @@ async function finishLocalWorktreeRemoval({
localWorktreeGitOptions,
registeredWorktree: refreshedRegisteredWorktree,
deleteBranch,
closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath),
closeWatcher: (worktreePath) => context.runtime.closeFileWatchersForRemoval(worktreePath),
signal: checkoutDeleteSignal
})
if (recoveredRemovalResult) {
@@ -259,14 +258,15 @@ async function finishLocalWorktreeRemoval({
repo.path,
canonicalWorktreePath,
localWorktreeGitOptions,
(path) => runtime.closeFileWatchersForRemoval(path)
(path) => context.runtime.closeFileWatchersForRemoval(path),
checkoutDeleteSignal
)
// Failed removal may retain registration and branch locks; prune stale tracking.
await pruneGitWorktrees({ cwd: repo.path, ...localWorktreeGitOptions }).catch(() => {})
await cleanupPushTarget()
runtime.clearOptimisticReconcileToken(args.worktreeId)
context.runtime.clearOptimisticReconcileToken(args.worktreeId)
removeWorktreeMetadataAndTransientState(
{ store, runtime },
context,
args.worktreeId,
removalHostId,
args.snapshotPruneBatchId
@@ -276,7 +276,7 @@ async function finishLocalWorktreeRemoval({
hostId: removalHostId
})
preservedBranchCleanupByScope.delete(cleanupScopeKey)
invalidateAuthorizedRootsCacheForRepo(store, repoId)
invalidateAuthorizedRootsCacheForRepo(context.store, repoId)
removalCompleted = true
return {}
} else {
@@ -299,17 +299,17 @@ async function finishLocalWorktreeRemoval({
refreshedRegisteredWorktree.head,
removedPushTarget
)
runtime.clearOptimisticReconcileToken(args.worktreeId)
context.runtime.clearOptimisticReconcileToken(args.worktreeId)
await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => {
removeWorktreeMetadataAndTransientState(
{ store, runtime },
context,
args.worktreeId,
removalHostId,
args.snapshotPruneBatchId
)
})
await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => {
invalidateAuthorizedRootsCacheForRepo(store, repoId)
invalidateAuthorizedRootsCacheForRepo(context.store, repoId)
})
return removalResult ?? {}
}
@@ -1,3 +1,14 @@
import { OrcadProfileShutdown } from '../host-runtime/orcad-profile-shutdown'
import { Store } from '../../persistence/loading-store/store'
import {
closeTestStores,
createSqliteTestStore,
readPersistedStateJson
} from '../../persistence/persistence-test-harness'
import { readFile } from 'node:fs/promises'
import { holdRemovalMetadata } from '../../workspaces/removal/worktree-removal-lifetime.test-fixtures'
import { readWorktreeRemovalRecords } from '../../workspaces/removal/worktree-removal-records'
import { _resetPendingWorktreeRemovalsForTests } from '../../workspaces/removal/worktree-background-removal'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -8,10 +19,15 @@ vi.mock('../browser-providers/orcad-browser-startup', () => ({
}))
import { ORCAD_LOCK_FILE_NAME } from '../instance-lock/orcad-instance-lock'
import { worktreeRemovalLifetime } from '../../workspaces/removal/worktree-removal-lifetime'
import { startOrcadWithHost } from './orcad-lifecycle'
const roots: string[] = []
afterEach(() => {
afterEach(async () => {
await closeTestStores()
vi.restoreAllMocks()
vi.useRealTimers()
_resetPendingWorktreeRemovalsForTests()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
@@ -41,3 +57,51 @@ describe('startOrcadWithHost', () => {
expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true)
})
})
it('joins successful removal metadata and journal before profile ownership is released', async () => {
vi.useFakeTimers()
const root = dataRoot()
const profile = new OrcadProfileShutdown()
const dataFile = join(root, 'orca-data.json')
const store = createSqliteTestStore(Store, { dataFile })
profile.store = store
await profile.store.flushPendingOrThrowAsync({ fullCheckpoint: true })
store.getWorkspaceSession().activeTabId = 'before-stop'
const savedTab = () => JSON.parse(readPersistedStateJson(dataFile)).workspaceSession.activeTabId
let pending!: Awaited<ReturnType<typeof holdRemovalMetadata>>
let transportStopped!: () => void
const transport = new Promise<void>((resolve) => {
transportStopped = resolve
})
let checkpointing: Promise<void> | undefined
const handle = await startOrcadWithHost(
root,
async (registerCleanup) => {
registerCleanup(profile.finalize)
registerCleanup(() => worktreeRemovalLifetime.close())
pending = await holdRemovalMetadata(root, () => {
store.getWorkspaceSession().activeTabId = 'after-delete'
})
registerCleanup(async () => {
transportStopped()
})
return {}
},
() => {},
() => (checkpointing = profile.checkpoint())
)
const stopping = handle.stop()
await transport
expect(() => worktreeRemovalLifetime.acquirePreparation()).toThrow('Orca is stopping')
expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true)
expect(await readWorktreeRemovalRecords(root)).toHaveLength(1)
await checkpointing
expect(savedTab()).toBe('before-stop')
pending.release()
await stopping
await pending.result
expect(await readFile(join(root, 'metadata-finished'), 'utf8')).toBe('removed')
expect(await readWorktreeRemovalRecords(root)).toEqual([])
expect(savedTab()).toBe('after-delete')
expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(false)
})
@@ -1,3 +1,4 @@
import { worktreeRemovalLifetime } from '../../workspaces/removal/worktree-removal-lifetime'
import { setRuntimeBrowserCommandsFactory } from '../../runtime/browser/installation/runtime-browser-commands-factory'
import { markHostShuttingDown } from '../../startup/quit/host-shutdown-state'
import { setDesktopBrowserBridgeHostEnabled } from '../../runtime/desktop-browser-bridge/desktop-browser-bridge-registry'
@@ -76,6 +77,7 @@ export function installOrcadShutdownSignals(
}
stopping = true
markHostShuttingDown()
void worktreeRemovalLifetime.close().catch(() => {})
holdOrcadTerminalAdmissionUntilExit()
exitCode = stoppedExitCode
retractClean = onFailed
@@ -117,7 +119,8 @@ export async function stopOrcadStructuredAgentSessions(): Promise<void> {
export async function startOrcadWithLifecycle<T extends object>(
start: (registerRuntimeCleanup: (cleanup: BackendServiceCleanup) => void) => Promise<T>,
cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise<void>
cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise<void>,
checkpoint: () => Promise<void> = async () => {}
): Promise<T & { stop(): Promise<void> }> {
const runtime = new BackendServiceLifetime()
const cleanup = createIdempotentOrcadCleanup(async () => {
@@ -125,11 +128,23 @@ export async function startOrcadWithLifecycle<T extends object>(
recordAgentSessionRuntimeEnd('quit')
// Startup failures reach cleanup without shutdown admission while RPC may already be serving.
markHostShuttingDown()
void worktreeRemovalLifetime.close().catch(() => {})
let checkpointError: unknown
const checkpointing = Promise.resolve()
.then(checkpoint)
.catch((error: unknown) => {
checkpointError = error
})
let runtimeCleanupSucceeded = false
try {
await runtime.stop()
await checkpointing
if (checkpointError) {
throw checkpointError
}
runtimeCleanupSucceeded = true
} finally {
await checkpointing
await cleanupHost(runtimeCleanupSucceeded)
}
})
@@ -151,7 +166,8 @@ export async function startOrcadWithLifecycle<T extends object>(
export async function startOrcadWithHost<T extends object>(
userDataPath: string,
start: (registerCleanup: (cleanup: BackendServiceCleanup) => void) => Promise<T>,
runQuitHandlers: () => void | Promise<void>
runQuitHandlers: () => void | Promise<void>,
checkpoint: () => Promise<void> = async () => {}
): Promise<T & { stop(): Promise<void>; instance: OrcadManagedStopInstance }> {
const instanceLock = acquireOrcadInstanceLock(userDataPath, {
// Yield the data root to the process that took its lock; supervisors stand by on this code.
@@ -201,7 +217,8 @@ export async function startOrcadWithHost<T extends object>(
host.add(() => setDesktopBrowserBridgeHostEnabled(false))
host.add(() => browserProvider?.stop())
await host.stop()
}
},
checkpoint
)
}
@@ -16,12 +16,14 @@ import { installOrcadShutdownSignals, startOrcadWithHost } from './orcad-lifecyc
import { ORCAD_LOCK_HEARTBEAT_MS } from '../instance-lock/orcad-lock-clone-machine'
import { orcadRunFailed } from './orcad-unexpected-exit'
import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from '../../../shared/single-instance-exit-code'
import { _resetPendingWorktreeRemovalsForTests } from '../../workspaces/removal/worktree-background-removal'
const roots: string[] = []
afterEach(() => {
// Drops the shutdown deadline timer so it cannot call the real process.exit later.
vi.useRealTimers()
vi.restoreAllMocks()
_resetPendingWorktreeRemovalsForTests()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
+17 -31
View File
@@ -1,3 +1,6 @@
import { OrcadProfileShutdown } from './orcad-profile-shutdown'
import { prepareWorktreeRemovalsForStore } from '../../workspaces/removal/worktree-removal-records-load'
import { worktreeRemovalLifetime } from '../../workspaces/removal/worktree-removal-lifetime'
// Compose the Node-host runtime; paired desktop pages work even without a local browser engine.
import process from 'node:process'
import { getAppEnvironment } from '../../../shared/app-environment'
@@ -7,11 +10,9 @@ import { installOrcadHostAdapters, runOrcadQuitHandlers } from './orcad-host-ada
import { prepareOrcadSecretStoreAndMigrate } from '../credentials/orcad-secret-store-startup'
import { describeOrcadBindExposure, resolveOrcadBindHost } from '../../../shared/orcad-bind-address'
import {
flushOrcadProfileStoreForShutdown,
startOrcadWithHost,
stopOrcadStructuredAgentSessions
} from '../host-lifecycle/orcad-lifecycle'
import { resolveOrcadWebClientRoot } from '../serve-startup/orcad-command-arguments'
import type { BackendServiceCleanup } from '../../backend/service-composition'
import { bindOrcadStopTerminalAdmission } from '../host-lifecycle/managed-stop/orcad-stop-terminal-admission'
import type { OrcadHandle, OrcadOptions } from '../serve-startup/orcad-start-contract'
@@ -23,7 +24,6 @@ import {
} from '../../../shared/ai-vault/search/ai-vault-search-settings'
import { stampRuntimeSourceEnv } from '../../../shared/runtime-source-env'
import { adoptWslHostLoginEnvironmentLogged } from './orcad-wsl-host-login-environment'
import { orcadWsPortOptions } from '../serve-startup/orcad-ws-port-policy'
import { createOrcadStartupTiming } from '../diagnostics/orcad-startup-timing'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority'
@@ -42,13 +42,14 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
const userDataPath = resolveUserDataPath()
// Override inherited data roots so CLI-shared Codex homes resolve on this host.
process.env.ORCA_USER_DATA_PATH = userDataPath
const profileShutdown = new OrcadProfileShutdown()
const { readiness, instance, stop } = await startOrcadWithHost(
userDataPath,
(registerCleanup) =>
activateBackendServices({
supervised: process.env[ORCA_SERVE_SUPERVISED_ENV] === '1',
registerCleanup,
activate: (cleanup) => activateOrcadRuntime(options, cleanup)
activate: (cleanup) => activateOrcadRuntime(options, cleanup, profileShutdown)
}),
async () => {
try {
@@ -59,7 +60,8 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
closeOrcadObservability = () => Promise.resolve()
await closing
}
}
},
profileShutdown.checkpoint
)
const version = getAppEnvironment().getVersion()
return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop }
@@ -67,7 +69,8 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
async function activateOrcadRuntime(
options: OrcadOptions,
registerCleanup: (cleanup: BackendServiceCleanup) => void
registerCleanup: (cleanup: BackendServiceCleanup) => void,
profileShutdown: OrcadProfileShutdown
): Promise<Pick<OrcadHandle, 'readiness'>> {
const { time: timed, report: reportStartupTiming } = createOrcadStartupTiming()
// Overlap login-shell probing with imports and store startup.
@@ -83,7 +86,7 @@ async function activateOrcadRuntime(
// in-flight spawn settles; a failed one leaves only the disconnect to do.
registerCleanup(() => daemonStartup.catch(() => {}).then(() => stopOrcadDaemon()))
const { OrcaRuntimeService } = await import('../../runtime/orca-runtime')
const { OrcaRuntimeRpcServer } = await import('../../runtime/rpc/server/runtime-rpc')
const { createOrcadRuntimeRpcServer } = await import('./orcad-runtime-rpc')
const { registerHeadlessPtyRuntime } = await import('../../ipc/pty/register-headless-runtime')
const { getLocalPtyProvider } = await import('../../ipc/pty/provider/registry')
const { installOrcadObservability } = await import('../diagnostics/orcad-observability')
@@ -111,8 +114,6 @@ async function activateOrcadRuntime(
const { disposeWatcherProcessAndWait } =
await import('../../ipc/filesystem/watcher-process/parcel-watcher-process')
let paneGridsForShutdown: { flushPaneGrids(): Promise<void> | null } | null = null
let profileStoreForShutdown: Parameters<typeof flushOrcadProfileStoreForShutdown>[0] | undefined
let uninstallHookStatusRepublish = (): void => {}
let uninstallObservedStatusIdentity = (): void => {}
// Cleanups run in reverse: RPC, then recovery and watchers, then the final flush; the daemon
@@ -120,12 +121,7 @@ async function activateOrcadRuntime(
registerCleanup(() => agentHookServer.stop())
registerCleanup(() => uninstallHookStatusRepublish())
registerCleanup(() => uninstallObservedStatusIdentity())
registerCleanup(async () => {
// A SQLite-backed orcad has no JSON mirror to absorb a debounced write after SIGTERM.
if (profileStoreForShutdown) {
await flushOrcadProfileStoreForShutdown(profileStoreForShutdown, paneGridsForShutdown)
}
})
registerCleanup(profileShutdown.finalize)
registerCleanup(stopOrcadStructuredAgentSessions)
registerCleanup(() => disposeWatcherProcessAndWait())
const { DesktopPushService } = await import('../../runtime/push/desktop-push-service')
@@ -145,9 +141,11 @@ async function activateOrcadRuntime(
const { store: profileStore, authority: profileStateAuthority } = await timed('profile', () =>
createOrcadProfileStateStartup(runtimeUserDataPath)
)
profileShutdown.store = profileStore
registerCleanup(() => worktreeRemovalLifetime.close())
await timed('worktree-removals', () => prepareWorktreeRemovalsForStore(profileStore))
const observedPaneIdentities = new AgentStatusObservedPaneIdentities()
const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities)
profileStoreForShutdown = profileStore
uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) =>
observedStatusCapture.observe(enriched)
)
@@ -224,7 +222,7 @@ async function activateOrcadRuntime(
}
}
})
paneGridsForShutdown = runtime
profileShutdown.paneGrids = runtime
accountLaunch?.attachToRuntime(runtime)
const { wireGitHubIssueLinkTitleFill } =
@@ -309,25 +307,13 @@ async function activateOrcadRuntime(
const bindHost = resolveOrcadBindHost(options.bind)
bindOrcadStopTerminalAdmission(runtime.terminalUpdateAdmission)
const rpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: runtimeUserDataPath,
enableWebSocket: true,
// Pin operator exposure across restarts; pairing must not silently widen a loopback bind.
pinnedBindHost: bindHost,
serveNetworkSources: {
allowPublic: options.allowPublic === true,
refusePublic: options.refusePublic === true,
pairingAddress: options.pairingAddress ?? null
},
webClientRoot: resolveOrcadWebClientRoot(options.webClientRoot),
...orcadWsPortOptions(options.port)
})
const rpc = createOrcadRuntimeRpcServer(runtime, runtimeUserDataPath, options, bindHost)
pairedEnvironments.bindRpc(rpc)
runtime.setAgentSenderPeerKey(() => rpc.getE2EEPublicKey())
// Stop RPC before cleanup can tear down writers and runtime state.
registerCleanup(() => rpc.stop())
await timed('rpc', () => rpc.start())
worktreeRemovalLifetime.resume(() => runtime.finishInterruptedWorktreeRemovals())
// The daemon has no window to wait for; its CI-line watches start with the runtime.
runtime.startPullRequestWatches()
startOrcadSchedulers(runtime, profileStore, registerCleanup)
@@ -0,0 +1,24 @@
import type { Store } from '../../persistence/loading-store/store'
import { beginProfileShutdownCheckpoint } from '../../persistence/profile-shutdown-checkpoint'
import { flushOrcadProfileStoreForShutdown } from '../host-lifecycle/orcad-lifecycle'
/** Capture acquired profile resources even when activation fails before runtime construction. */
export class OrcadProfileShutdown {
store?: Pick<Store, 'flushPendingOrThrowAsync' | 'flushFinalOrThrowAsync' | 'freezeWritesAsync'>
paneGrids?: { flushPaneGrids(): Promise<void> | null }
private checkpointing?: Promise<void>
readonly checkpoint = (): Promise<void> =>
(this.checkpointing ??= beginProfileShutdownCheckpoint(
this.store,
this.paneGrids?.flushPaneGrids()
))
readonly finalize = async (): Promise<void> => {
await this.checkpointing?.catch(() => {})
if (this.store) {
await flushOrcadProfileStoreForShutdown(this.store, this.paneGrids)
}
}
}
@@ -0,0 +1,27 @@
import { OrcaRuntimeRpcServer } from '../../runtime/rpc/server/runtime-rpc'
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
import type { OrcadOptions } from '../serve-startup/orcad-start-contract'
import { resolveOrcadWebClientRoot } from '../serve-startup/orcad-command-arguments'
import { orcadWsPortOptions } from '../serve-startup/orcad-ws-port-policy'
export function createOrcadRuntimeRpcServer(
runtime: OrcaRuntimeService,
userDataPath: string,
options: OrcadOptions,
bindHost: string
): OrcaRuntimeRpcServer {
return new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
// Pin operator exposure across restarts; pairing must not silently widen a loopback bind.
pinnedBindHost: bindHost,
serveNetworkSources: {
allowPublic: options.allowPublic === true,
refusePublic: options.refusePublic === true,
pairingAddress: options.pairingAddress ?? null
},
webClientRoot: resolveOrcadWebClientRoot(options.webClientRoot),
...orcadWsPortOptions(options.port)
})
}
@@ -1,19 +1,33 @@
import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
writeFileSync
} from 'node:fs'
import { tmpdir, userInfo } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcess, spawnProcess } from '@orca/process-host'
import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../../../shared/orcad-artifacts'
import {
ORCAD_CLI_ENTRY_FILENAME,
ORCAD_NODE_RUNTIME_MARKER_FILENAME
} from '../../../../shared/orcad-artifacts'
import {
findBundledWebClientRoot,
WEB_CLIENT_INDEX_FILENAME
} from '../../../../shared/bundled-web-client-root'
import { orcadServeArgs } from '../../../../shared/orcad-serve-argv'
import { orcadManagedStopRequestPath } from '../../host-lifecycle/managed-stop/orcad-managed-stop-request'
import { readWorktreeRemovalRecords } from '../../../workspaces/removal/worktree-removal-records'
import { ORCAD_LOCK_FILE_NAME } from '../../instance-lock/orcad-instance-lock'
import { resolveBundledOrcadRuntime } from '../../runtime-launch/orcad-bundled-runtime'
import { skipForMissingInputs } from '../native-slots/orcad-node-slot-fixture'
import { buildDaemonSessionClient } from '../daemon-processes/orcad-daemon-session-client-fixture'
import type { ServeCleanupDiagnostics } from './orcad-serve-cleanup-diagnostics.test-fixture'
import { boundedText } from './orcad-serve-cleanup-redaction.test-fixture'
import {
killAndAwaitExit,
killProfileDaemons,
@@ -79,6 +93,7 @@ function serveEnv(userData: string): NodeJS.ProcessEnv {
),
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_DISABLE_MACOS_LOGIN_SHELL: '1',
ORCA_VERSION: '0.0.0-serve-parity-test',
ORCA_USER_DATA: userData
}
}
@@ -90,6 +105,99 @@ function profile(): string {
}
describe.skipIf(skip)('orca serve on orcad', () => {
// Real server startup, CLI commands and graceful shutdown each cross process boundaries.
it('deletes a linked worktree through the real CLI with a durable host journal', async () => {
const userData = realpathSync.native(profile())
const home = join(userData, 'isolated-home')
const repo = join(userData, 'repo')
const worktree = join(userData, 'feature')
for (const path of [home, repo]) {
mkdirSync(path, { recursive: true })
}
const env = {
...serveEnv(userData),
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, '.config'),
XDG_CACHE_HOME: join(home, '.cache'),
XDG_DATA_HOME: join(home, '.local', 'share'),
XDG_STATE_HOME: join(home, '.local', 'state'),
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEX_HOME: join(home, '.codex'),
ORCA_E2E_USER_DATA_DIR: userData,
ORCA_E2E_HOME_DIR: home,
ORCA_USER_DATA_PATH: userData
}
const git = async (args: string[]) => {
const result = await runProcess({ program: 'git', args, cwd: repo, env, timeoutMs: 30_000 })
expect(result.code, result.stderr).toBe(0)
return result.stdout
}
await git(['init', '-q'])
await git([
'-c',
'user.name=Removal Test',
'-c',
'user.email=removal@example.invalid',
'commit',
'--allow-empty',
'-qm',
'seed'
])
await git(['worktree', 'add', '-q', worktree, '-b', 'feature'])
const cli = async (args: string[]) => {
reportServePhase(userData, 'beforeCli', { args })
const result = await runProcess({
program: runtime!,
args: [join(slotDir, ORCAD_CLI_ENTRY_FILENAME), ...args, '--json'],
cwd: repo,
env,
timeoutMs: 60_000
})
reportServePhase(userData, 'afterCli', {
args,
code: result.code,
failure: result.code === 0 ? undefined : boundedText(result.stdout + result.stderr)
})
expect(
result.code,
JSON.stringify({ args, stdout: result.stdout, stderr: result.stderr })
).toBe(0)
const response = JSON.parse(result.stdout)
expect(response.ok).toBe(true)
return response.result
}
try {
await serveOnce(userData, ['--bind', '127.0.0.1', '--port', '0', '--json'], env)
await cli(['repo', 'add', '--path', repo])
if (process.platform === 'win32') {
const catalog = await cli(['worktree', 'ps'])
reportServePhase(userData, 'registeredPaths', {
expectedWorktree: worktree,
gitWorktrees: await git(['worktree', 'list', '--porcelain']),
catalog: catalog.worktrees.map(
(row: { worktreeId: string; path: string; branch: string }) => ({
id: row.worktreeId,
path: row.path,
branch: row.branch
})
)
})
}
await cli(['worktree', 'rm', '--worktree', `path:${worktree}`, '--force', '--delete-branch'])
expect(existsSync(worktree)).toBe(false)
expect(await git(['worktree', 'list', '--porcelain'])).not.toContain(
`worktree ${worktree.replaceAll('\\', '/')}`
)
expect(await git(['branch', '--list', 'feature'])).toBe('')
} finally {
await stopServeProfile(userData, null)
}
expect(await readWorktreeRemovalRecords(join(userData, 'profiles', 'local-default'))).toEqual(
[]
)
}, 180_000)
it('refuses with exit 78 while the desktop app holds the profile', async () => {
const userData = profile()
writeFileSync(
@@ -181,6 +289,33 @@ describe.skipIf(skip)('orca serve on orcad', () => {
})
const running = new Map<string, ReturnType<typeof spawnProcess>>()
const readinessByProfile = new Map<string, ServeReadiness>()
const launchDiagnostics = new Map<string, { listenerVersion: string; stderr: () => string }>()
function reportServePhase(
userData: string,
phase: string,
details: Record<string, unknown> = {}
): void {
if (process.platform !== 'win32' && process.env.ORCA_SERVE_CLEANUP_DIAGNOSTICS !== '1') {
return
}
const child = running.get(userData)
const launch = launchDiagnostics.get(userData)
console.error(
'[orcad-serve-stop]',
JSON.stringify({
phase,
userData,
pid: child?.pid,
exitCode: child?.exitCode,
signalCode: child?.signalCode,
listenerVersionFromLaunchEnv: launch?.listenerVersion,
...details,
stderrTail: boundedText(launch?.stderr() ?? '', 8_192)
})
)
}
async function serveDaemonPid(userData: string): Promise<number> {
const { readiness, stderr } = await serveOnce(userData, [
@@ -198,23 +333,30 @@ async function serveDaemonPid(userData: string): Promise<number> {
}
type ServeReadiness = {
runtimeId: string
pairing?: { webClientUrl?: unknown }
health?: { terminalDaemon?: { state?: string; pid: number } }
}
async function serveOnce(
userData: string,
args: string[]
args: string[],
env = serveEnv(userData)
): Promise<{ readiness: ServeReadiness; stderr: string }> {
const child = spawnProcess({
program: runtime!,
args: [join(slotDir, 'orcad.js'), ...args],
env: serveEnv(userData),
env,
timeoutMs: null
})
running.set(userData, child)
let stdout = ''
let stderr = ''
launchDiagnostics.set(userData, {
listenerVersion: env.ORCA_VERSION ?? '0.0.0-orcad',
stderr: () => stderr
})
reportServePhase(userData, 'spawned')
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
const line = await new Promise<string>((resolveLine, reject) => {
const timer = setTimeout(() => reject(new Error(`no readiness: ${stderr}`)), 120_000)
@@ -228,20 +370,69 @@ async function serveOnce(
})
})
const readiness: ServeReadiness = JSON.parse(line)
readinessByProfile.set(userData, readiness)
reportServePhase(userData, 'ready')
return { readiness, stderr }
}
/** SIGTERM preserves daemon terminals on POSIX; Windows uses its stop-request file. */
async function stopServe(userData: string): Promise<void> {
const child = running.get(userData)
running.delete(userData)
if (!child || child.exitCode !== null) {
running.delete(userData)
launchDiagnostics.delete(userData)
return
}
await recordProfileDaemons(userData)
const exited = new Promise((settle) => child.once('exit', settle))
child.kill('SIGTERM')
await exited
let phase = 'beforeDaemonRecording'
reportServePhase(userData, phase)
const diagnosticTimer = setTimeout(() => reportServePhase(userData, `waiting:${phase}`), 10_000)
diagnosticTimer.unref()
try {
await recordProfileDaemons(userData)
phase = 'afterDaemonRecording'
reportServePhase(userData, phase)
const exited = new Promise((settle) => child.once('exit', settle))
if (process.platform === 'win32') {
const lockPath = join(userData, ORCAD_LOCK_FILE_NAME)
const lock = JSON.parse(readFileSync(lockPath, 'utf8'))
const readiness = readinessByProfile.get(userData)
if (lock.pid !== child.pid || lock.startedAtMs === null || !readiness?.runtimeId) {
throw new Error('Cannot prove ownership of the serve process to stop')
}
const instance = { pid: lock.pid, startedAtMs: lock.startedAtMs, nonce: lock.nonce, lockPath }
const requestPath = orcadManagedStopRequestPath(instance)
phase = 'beforeStopPublication'
reportServePhase(userData, phase, {
lockVersion: lock.version,
startedAtMs: lock.startedAtMs,
requestPath
})
writeFileSync(
requestPath,
JSON.stringify({
schemaVersion: 1,
transactionId: randomUUID(),
version: lock.version,
runtimeId: readiness.runtimeId,
instance
})
)
} else {
child.kill('SIGTERM')
}
phase = 'awaitingExit'
reportServePhase(userData, phase)
await exited
reportServePhase(userData, 'exited')
running.delete(userData)
readinessByProfile.delete(userData)
launchDiagnostics.delete(userData)
} catch (error) {
reportServePhase(userData, `failed:${phase}`)
throw error
} finally {
clearTimeout(diagnosticTimer)
}
}
async function stopServeProfile(userData: string, shellPid: number | null): Promise<void> {
@@ -0,0 +1,15 @@
import type { Store } from './loading-store/store'
/** Save current state without closing the writer while removal still owns it. */
export function beginProfileShutdownCheckpoint(
store: Pick<Store, 'flushPendingOrThrowAsync'> | null | undefined,
paneGridFlush?: Promise<void> | null
): Promise<void> {
const checkpoint = Promise.resolve(paneGridFlush).then(async () => {
await store?.flushPendingOrThrowAsync({ drainToStableGeneration: false, fullCheckpoint: true })
})
void checkpoint.catch((error) =>
console.error('[persistence] Failed to checkpoint before shutdown:', error)
)
return checkpoint
}
@@ -430,10 +430,12 @@ describe('OrcaRuntimeService', () => {
expect(assertWorktreeCleanForRemoval).toHaveBeenCalledWith(registeredWorktree.path, false, {
wslDistro: 'Ubuntu'
})
expect(removeWorktree).toHaveBeenCalledWith(repo.path, registeredWorktree.path, false, {
knownRemovedWorktree: registeredWorktree,
wslDistro: 'Ubuntu'
})
expect(removeWorktree).toHaveBeenCalledWith(
repo.path,
registeredWorktree.path,
false,
expect.objectContaining({ knownRemovedWorktree: registeredWorktree, wslDistro: 'Ubuntu' })
)
})
it('surfaces selected-runtime list failures during runtime worktree removal', async () => {
@@ -459,7 +459,6 @@ describe('OrcaRuntimeService', () => {
'Worktree is no longer registered with Git but its directory remains.'
)
await expect(lstat(orphanPath)).resolves.toBeTruthy()
expect(removePathSpy).toHaveBeenCalledWith(orphanPath, {})
expect(removeWorktreeMeta).not.toHaveBeenCalled()
expect(gitSpy).not.toHaveBeenCalledWith(['worktree', 'prune'], expect.anything())
} finally {
@@ -105,6 +105,7 @@ type FinishOutcome =
async function finishAfterRestart(
options: {
repoGone?: boolean
repoRows?: Repo[]
branch?: string
head?: string
force?: boolean
@@ -140,6 +141,11 @@ async function finishAfterRestart(
const purged: string[] = []
const remember = vi.fn()
const host = removalHost(options.repoGone, options.stopPtys)
if (options.repoRows) {
const rows = options.repoRows
host.store.getRepos = () => rows
host.store.getRepo = (id) => rows.find((row) => row.id === id)
}
host.purge = ({ worktreeId }) => {
purged.push(worktreeId)
}
@@ -250,3 +256,32 @@ describe('finishing an interrupted worktree removal after a restart', () => {
})
installRealWorkerThreadChildProcesses()
it.each(['remote-first', 'local-first', 'missing-local'])(
'recovers only the local owner with a %s same-ID catalog',
async (order) => {
const remote: Repo = {
...repo,
path: join(scratchDir, 'nonlocal-repo'),
connectionId: 'ssh-fixture'
}
const repoRows =
order === 'remote-first'
? [remote, repo]
: order === 'local-first'
? [repo, remote]
: [remote]
const { outcome, purged, records } = await finishAfterRestart({ repoRows })
expect(outcome.status).toBe('removed')
expect(records).toEqual([])
if (order === 'missing-local') {
expect(purged).toEqual([])
expect(existsSync(worktreePath)).toBe(true)
expect(await isRegistered(worktreePath)).toBe(true)
} else {
expect(purged).toEqual([`repo-1::${worktreePath}`])
expect(existsSync(worktreePath)).toBe(false)
expect(await isRegistered(worktreePath)).toBe(false)
}
}
)
@@ -1,3 +1,7 @@
import {
throwIfSignalAborted,
waitForPromiseWithSignal
} from '../../../../shared/abort-signal-reason'
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { RuntimeStore } from '../../construction/runtime-store-contract'
@@ -5,9 +9,12 @@ import {
releaseStartupRemovalFence,
type BackgroundWorktreeRemovalJob
} from '../../../workspaces/removal/worktree-background-removal'
import { resolveWorktreeRemovalMetadata } from '../../../workspaces/removal/worktree-removal-repo-owner'
import {
resolveWorktreeRemovalMetadata,
resolveWorktreeRemovalRepoOwner
} from '../../../workspaces/removal/worktree-removal-repo-owner'
import type { RuntimePreservedBranchCleanup } from '../../repositories/runtime-preserved-branch-cleanup'
import { listWorktreesStrict } from '../../../git/worktrees/catalog/worktree-listing'
import { listWorktreesSharedStrict as listWorktreesStrict } from '../../../git/worktrees/catalog/worktree-scan-cache'
import { cleanupUnusedWorktreePushTargetRemote } from '../../../ipc/worktree-remote'
import {
authorizeWindowsCheckout,
@@ -103,7 +110,7 @@ export function interruptedLocalWorktreeRemovalJob(
},
publish: () => host.publish(record.repoId),
cleanupPushTargetRemote: async () => {
const repo = host.store.getRepo(record.repoId)
const repo = localRemovalRepo(host.store, record.repoId)
if (repo) {
await cleanupUnusedWorktreePushTargetRemote(
repo.path,
@@ -133,7 +140,7 @@ async function finishInterruptedLocalWorktreeRemoval(
args: InterruptedLocalWorktreeRemovalArgs
): Promise<RemoveWorktreeResult> {
const { record, store } = args
const repo = store.getRepo(record.repoId)
const repo = localRemovalRepo(store, record.repoId)
if (!repo) {
console.warn(`[worktrees] dropping removal of ${record.worktreePath}: its repo is gone`)
return {}
@@ -151,7 +158,11 @@ async function finishInterruptedLocalWorktreeRemoval(
deleteBranch: record.deleteBranch,
target: { id: record.worktreeId }
}
const worktrees = await listWorktreesStrict(repo.path, localOptions)
const worktrees = await listWorktreesStrict(repo.path, {
...localOptions,
signal: args.stopSignal
})
throwIfSignalAborted(args.stopSignal)
const registered = worktrees.some((worktree) =>
areWorktreePathsEqual(worktree.path, record.worktreePath)
)
@@ -181,7 +192,15 @@ async function finishInterruptedLocalWorktreeRemoval(
},
checkoutDeleteSignal: args.stopSignal
}
if (!native && deletable && (await isCheckoutMissingGitLink(deletable.path, localOptions))) {
if (
!native &&
deletable &&
(await waitForPromiseWithSignal(
isCheckoutMissingGitLink(deletable.path, localOptions),
args.stopSignal
))
) {
throwIfSignalAborted(args.stopSignal)
// If Git deleted .git first, it cannot remove/validate the checkout; prune registration only and keep all files for the checks below.
await removeStaleLocalWorktreeRegistration({
canonicalWorktreePath: deletable.path,
@@ -205,14 +224,15 @@ async function finishInterruptedLocalWorktreeRemoval(
true
)
}
throwIfSignalAborted(args.stopSignal)
const gate = await args.acquireWatcherRemoval(record.worktreePath)
if (args.stopPtys) {
try {
await args.stopPtys()
} catch (error) {
await gate.finish(false)
throw error
}
try {
throwIfSignalAborted(args.stopSignal)
await args.stopPtys?.()
throwIfSignalAborted(args.stopSignal)
} catch (error) {
await gate.finish(false)
throw error
}
if (deletable) {
return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal)
@@ -274,3 +294,8 @@ async function isCheckoutMissingGitLink(
return getErrorCode(error) === 'ENOENT'
}
}
function localRemovalRepo(store: RuntimeStore, repoId: string) {
const owner = resolveWorktreeRemovalRepoOwner(store, repoId, LOCAL_EXECUTION_HOST_ID)
return owner.kind === 'resolved' ? owner.repo : undefined
}
@@ -1,3 +1,4 @@
import { worktreeRemovalLifetime } from '../../../workspaces/removal/worktree-removal-lifetime'
import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types'
import {
authorizeWindowsCheckout,
@@ -18,7 +19,8 @@ import { getWorktreeSharedLinkPaths } from '../../../git/worktrees/content-paths
import { runHook, getEffectiveHooks } from '../../../workspace-hooks/hooks'
import { findExistingWorktreeSymlinkPaths } from '../../../git/worktrees/content-paths/worktree-symlink-detection'
import { removeWorktreeLinkedPaths } from '../../../ipc/worktree-symlinks'
import { cleanupUnusedWorktreePushTargetRemote } from '../../../ipc/worktree-remote'
import { cleanupRemovedWorktreePushTarget } from './runtime-worktree-push-target-cleanup'
export { cleanupRemovedWorktreePushTarget } from './runtime-worktree-push-target-cleanup'
import { runWorktreeChangeInvalidators } from '../../../ipc/worktrees/listing/worktree-change-invalidators'
import {
formatWorktreeRemovalError,
@@ -29,6 +31,7 @@ import { cleanupLocalOrphanedWorktreeDirectory } from '../../../workspaces/remov
import { recoverPartialLocalWorktreeRemoval } from '../../../workspaces/removal/local-worktree-removal-recovery'
import { findRegisteredDeletableWorktree } from '../../../workspaces/removal/worktree-removal-safety'
import { CLIENT_REMOVAL_HOME } from '../../../workspaces/removal/worktree-removal-home-guard'
import type { RuntimePreservedBranchCleanup } from '../../repositories/runtime-preserved-branch-cleanup'
import type { RuntimeStore } from '../../construction/runtime-store-contract'
import type { RuntimeWorktreeRemovalTarget } from '../catalog/runtime-worktree-selection'
import {
@@ -77,10 +80,7 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: {
/** Runs after every refusal and the teardown, before any checkout file is deleted. */
beforeCheckoutDelete?: (worktree: GitWorktreeInfo) => Promise<void>
closeWatchers: (path: string) => Promise<void>
preserveBranchHead: (
result: RemoveWorktreeResult | undefined,
fallbackHead: string | undefined
) => RemoveWorktreeResult
preserveBranchHead: RuntimePreservedBranchCleanup['preserveHead']
finishRemoval: (
result: RemoveWorktreeResult | undefined,
rememberBranch: boolean,
@@ -176,50 +176,54 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: {
}
}
const gate = await args.acquireWatcherRemoval(canonicalPath)
let accepted = false
try {
await args.stopPtys()
await args.beforeCheckoutDelete?.(refreshed)
if (!markerMissing && linkedPaths.length > 0) {
await removeWorktreeLinkedPaths(canonicalPath, linkedPaths)
const background = removesInBackground(canonicalPath, localOptions)
const prepare = async (): Promise<RemoveWorktreeResult> => {
const gate = await args.acquireWatcherRemoval(canonicalPath)
let accepted = false
try {
await args.stopPtys()
await args.beforeCheckoutDelete?.(refreshed)
if (!markerMissing && linkedPaths.length > 0) {
await removeWorktreeLinkedPaths(canonicalPath, linkedPaths)
}
accepted = true
} finally {
if (!accepted) {
await gate.finish(false)
}
}
accepted = true
} finally {
if (!accepted) {
await gate.finish(false)
const acceptedFields = {
...(archiveHookOverride ? { archiveHookOverride } : {}),
...(warning ? { warning } : {})
}
if (!background) {
const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate)
args.publish()
return { ...result, ...acceptedFields }
}
// After all refusals, detach slow Git deletion so request timeout cannot cancel it; clients follow the removing marker.
void startBackgroundWorktreeRemoval({
removal: {
worktreeId: args.target.id,
repoId: repo.id,
repoPath: repo.path,
worktree: refreshed,
deleteBranch: args.deleteBranch,
force: args.force
},
run: async (stopSignal) => {
const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate, stopSignal)
args.onRemoved()
return result
},
publish: () => args.publish(),
admissionFailed: () => gate.finish(false),
cleanupPushTargetRemote: () => cleanupRemovedWorktreePushTarget(args)
})
await waitForWorktreeRemovalAcceptance(args.target.id)
return { removing: true, ...acceptedFields }
}
const acceptedFields = {
...(archiveHookOverride ? { archiveHookOverride } : {}),
...(warning ? { warning } : {})
}
if (!removesInBackground(canonicalPath, localOptions)) {
const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate)
args.publish()
return { ...result, ...acceptedFields }
}
// After all refusals, detach slow Git deletion so request timeout cannot cancel it; clients follow the removing marker.
void startBackgroundWorktreeRemoval({
removal: {
worktreeId: args.target.id,
repoId: repo.id,
repoPath: repo.path,
worktree: refreshed,
deleteBranch: args.deleteBranch,
force: args.force
},
run: async (stopSignal) => {
const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate, stopSignal)
args.onRemoved()
return result
},
publish: () => args.publish(),
admissionFailed: () => gate.finish(false),
cleanupPushTargetRemote: () => cleanupRemovedWorktreePushTarget(args)
})
await waitForWorktreeRemovalAcceptance(args.target.id)
return { removing: true, ...acceptedFields }
return background ? worktreeRemovalLifetime.withPreparation(prepare) : prepare()
}
export type RuntimeLocalWorktreeRemovalFinishArgs = Pick<
@@ -280,7 +284,8 @@ export async function finishRuntimeLocalWorktreeRemoval(
repo.path,
canonicalPath,
localOptions,
args.closeWatchers
args.closeWatchers,
checkoutDeleteSignal
)
await pruneGitWorktrees({ cwd: repo.path, ...localOptions }).catch(() => {})
await cleanupRemovedWorktreePushTarget(args)
@@ -306,15 +311,3 @@ export async function finishRuntimeLocalWorktreeRemoval(
await gate.finish(completed)
}
}
export async function cleanupRemovedWorktreePushTarget(
args: RuntimeLocalWorktreeRemovalFinishArgs
): Promise<void> {
await cleanupUnusedWorktreePushTargetRemote(
args.repo.path,
args.target.id,
args.removedPushTarget,
args.store,
args.localOptions
)
}
@@ -0,0 +1,285 @@
import { GitAdmissionScheduler } from '../../../../shared/git/admission/git-admission-scheduler'
import {
acquireGitAdmission,
_resetGitAdmissionForTests
} from '../../../git/command-runner/admission/git-subprocess-admission'
import { worktreeRemovalLifetime } from '../../../workspaces/removal/worktree-removal-lifetime'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import type { GitWorktreeInfo } from '../../../../shared/worktree/types'
import type { RuntimeStore } from '../../construction/runtime-store-contract'
import type { WorktreeRemovalRecord } from '../../../workspaces/removal/worktree-removal-records'
const { scan, nativeGraph } = vi.hoisted(() => ({ scan: vi.fn(), nativeGraph: vi.fn() }))
vi.mock('../../../git/worktrees/catalog/worktree-list-reader', async (original) => ({
...(await original<Record<string, unknown>>()),
readTranslatedWorktreeGraph: nativeGraph
}))
vi.mock('../../../git/worktrees/catalog/worktree-listing', () => ({
listWorktreesStrict: scan,
listWorktreeGraph: vi.fn(),
annotateSparseCheckoutStatus: vi.fn()
}))
vi.mock('../../../git/worktrees/removal/worktree-removal', () => ({
finishUnregisteredWorktreeRemoval: async () => ({}),
deleteBranchOfUnregisteredWorktree: vi.fn(),
removeWorktree: vi.fn()
}))
vi.mock('../../../ipc/worktree-remote', () => ({
cleanupUnusedWorktreePushTargetRemote: async () => {}
}))
vi.mock('./runtime-registered-local-worktree-removal', () => ({
cleanupRemovedWorktreePushTarget: async () => {},
finishRuntimeLocalWorktreeRemoval: vi.fn()
}))
vi.mock('../../../workspaces/execution-host/project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: () => ({})
}))
import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal'
import {
_resetWorktreeScanCacheForTests,
bumpWorktreeScanGeneration
} from '../../../git/worktrees/catalog/worktree-scan-cache'
import {
_resetPendingWorktreeRemovalsForTests,
_settlePendingWorktreeRemovalsForTests,
resumeInterruptedWorktreeRemovals
} from '../../../workspaces/removal/worktree-background-removal'
import { prepareWorktreeRemovalsForStore } from '../../../workspaces/removal/worktree-removal-records-load'
import {
writeWorktreeRemovalRecords,
readWorktreeRemovalRecords
} from '../../../workspaces/removal/worktree-removal-records'
const originalPlatform = process.platform
let directory = ''
afterEach(async () => {
_resetPendingWorktreeRemovalsForTests()
_resetWorktreeScanCacheForTests()
scan.mockReset()
nativeGraph.mockReset()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
_resetGitAdmissionForTests()
if (directory) {
await rm(directory, { recursive: true, force: true })
}
directory = ''
})
function record(index: number, repoId: string): WorktreeRemovalRecord {
return {
worktreeId: `${repoId}::${index}`,
repoId,
repoPath: `/missing/${repoId}`,
worktreePath: `/missing/${repoId}/checkout-${index}`,
branch: 'feature',
head: 'abc',
force: false,
deleteBranch: false,
requestedAt: 1
}
}
function host(repos: Repo[]) {
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: interrupted cleanup consumes only these repo and metadata reads; physical Git mutations are isolated below the scan seam.
store: {
getRepos: () => repos,
getRepo: (id: string) => repos.find((repo) => repo.id === id),
getWorktreeMeta: () => undefined
} as unknown as RuntimeStore,
acquireWatcherRemoval: vi.fn(async () => ({ finish: async () => {} })),
stopPtys: vi.fn(async () => {}),
closeWatchers: async () => {},
preservedBranchCleanup: { preserveHead: () => ({}), remember: () => {} },
purge: vi.fn(async (_record: WorktreeRemovalRecord) => {}),
onRemoved: vi.fn(),
publish: () => {}
}
}
it.each([10, 100])(
'shares the two owning repo inventories while completing %i saved removals',
async (population) => {
directory = await mkdtemp(join(tmpdir(), 'orca-recovery-scan-contract-'))
const repos = [0, 1].map((i) => ({
id: `repo-${i}`,
path: `/missing/repo-${i}`,
displayName: 'repo',
badgeColor: '',
addedAt: 0
}))
const records = Array.from({ length: population }, (_, index) =>
record(index, `repo-${index % 2}`)
)
await writeWorktreeRemovalRecords(directory, () => records)
await prepareWorktreeRemovalsForStore({
getProfileStorageDirectory: () => directory,
getRepos: () => repos
})
let release!: () => void
const reading = new Promise<void>((resolve) => {
release = resolve
})
scan.mockImplementation(async () => {
await reading
return []
})
const runtime = host(repos)
resumeInterruptedWorktreeRemovals((saved) => interruptedLocalWorktreeRemovalJob(saved, runtime))
// Each distinct repo needs one authoritative inventory; pending rows cannot require duplicate concurrent reads.
await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(2))
release()
await _settlePendingWorktreeRemovalsForTests()
expect(runtime.purge.mock.calls.map(([saved]) => saved.worktreeId).sort()).toEqual(
records.map((saved) => saved.worktreeId).sort()
)
expect(await readWorktreeRemovalRecords(directory)).toEqual([])
}
)
it('obtains a fresh inventory after a catalog generation changes and protects the replacement checkout', async () => {
const saved = record(0, 'repo-0')
const runtime = host([
{ id: 'repo-0', path: saved.repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 }
])
let release!: () => void
const oldInventory = new Promise<GitWorktreeInfo[]>((resolve) => {
release = () => resolve([])
})
scan.mockReturnValueOnce(oldInventory).mockResolvedValueOnce([
{
path: saved.worktreePath,
branch: 'replacement',
head: 'def',
isMainWorktree: false,
isBare: false
}
])
const signal = new AbortController().signal
const first = interruptedLocalWorktreeRemovalJob(saved, runtime).run(signal)
bumpWorktreeScanGeneration(saved.repoPath)
const replacement = interruptedLocalWorktreeRemovalJob(saved, runtime).run(signal)
await expect(replacement).rejects.toThrow('A different checkout is now at')
expect(scan).toHaveBeenCalledTimes(2)
expect(runtime.purge).not.toHaveBeenCalled()
release()
await first
})
it.each([10, 100])(
'cancels queued recovery before spawning an inventory and preserves %i accepted records',
async (population) => {
directory = await mkdtemp(join(tmpdir(), 'orca-recovery-cancel-'))
_resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }))
const blocker = await acquireGitAdmission({
args: ['status'],
cwd: '/held',
tier: 'background'
})
const repos = [
{ id: 'repo-0', path: '/missing/repo-0', displayName: 'repo', badgeColor: '', addedAt: 0 }
]
const records = Array.from({ length: population }, (_, index) => record(index, 'repo-0'))
const spawn = vi.fn()
scan.mockImplementation(async (cwd: string, options: { signal: AbortSignal }) => {
const grant = await acquireGitAdmission({
args: ['worktree', 'list', '--porcelain'],
cwd,
signal: options.signal,
tier: 'background'
})
try {
spawn()
return []
} finally {
grant.release()
}
})
try {
await writeWorktreeRemovalRecords(directory, () => records)
await prepareWorktreeRemovalsForStore({
getProfileStorageDirectory: () => directory,
getRepos: () => repos
})
const runtime = host(repos)
resumeInterruptedWorktreeRemovals((saved) =>
interruptedLocalWorktreeRemovalJob(saved, runtime)
)
await vi.waitFor(() => expect(scan).toHaveBeenCalledOnce())
await worktreeRemovalLifetime.close()
expect(spawn).not.toHaveBeenCalled()
expect(runtime.purge).not.toHaveBeenCalled()
expect(
(await readWorktreeRemovalRecords(directory)).map((saved) => saved.worktreeId).sort()
).toEqual(records.map((saved) => saved.worktreeId).sort())
} finally {
blocker.release()
}
}
)
it.each([10, 100])(
'cancels the shared native authorization after outer inventory for %i saved removals',
async (population) => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
directory = await mkdtemp(join(tmpdir(), 'orca-native-recovery-cancel-'))
_resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }))
const blocker = await acquireGitAdmission({
args: ['status'],
cwd: '/held',
tier: 'background'
})
const records = Array.from({ length: population }, (_, index) => record(index, 'repo-0'))
const repos = [
{ id: 'repo-0', path: '/missing/repo-0', displayName: 'repo', badgeColor: '', addedAt: 0 }
]
scan.mockResolvedValue(
records.map((saved) => ({
path: saved.worktreePath,
branch: 'refs/heads/feature',
head: 'abc',
isBare: false,
isMainWorktree: false
}))
)
const spawned = vi.fn()
nativeGraph.mockImplementation(async (cwd: string, options: { signal: AbortSignal }) => {
const grant = await acquireGitAdmission({
args: ['worktree', 'list', '--porcelain'],
cwd,
signal: options.signal,
tier: 'background'
})
try {
spawned()
return []
} finally {
grant.release()
}
})
try {
await writeWorktreeRemovalRecords(directory, () => records)
await prepareWorktreeRemovalsForStore({
getProfileStorageDirectory: () => directory,
getRepos: () => repos
})
const runtime = host(repos)
resumeInterruptedWorktreeRemovals((saved) =>
interruptedLocalWorktreeRemovalJob(saved, runtime)
)
await vi.waitFor(() => expect(nativeGraph).toHaveBeenCalledOnce())
await worktreeRemovalLifetime.close()
expect(spawned).not.toHaveBeenCalled()
expect(runtime.acquireWatcherRemoval).not.toHaveBeenCalled()
expect(runtime.stopPtys).not.toHaveBeenCalled()
expect(runtime.purge).not.toHaveBeenCalled()
expect(
(await readWorktreeRemovalRecords(directory)).map((saved) => saved.worktreeId).sort()
).toEqual(records.map((saved) => saved.worktreeId).sort())
} finally {
blocker.release()
}
}
)
@@ -0,0 +1,9 @@
import { cleanupUnusedWorktreePushTargetRemote } from '../../../ipc/worktree-remote'
import type { RuntimeLocalWorktreeRemovalFinishArgs } from './runtime-registered-local-worktree-removal'
export async function cleanupRemovedWorktreePushTarget(
args: RuntimeLocalWorktreeRemovalFinishArgs
): Promise<void> {
const { repo, target, removedPushTarget: pushTarget, store, localOptions: options } = args
await cleanupUnusedWorktreePushTargetRemote(repo.path, target.id, pushTarget, store, options)
}
@@ -1,9 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Store } from '../persistence/loading-store/store'
import {
closeTestStores,
createSqliteTestStore,
readPersistedStateJson
} from '../persistence/persistence-test-harness'
import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity'
import {
worktreeRemovalLifetime,
_resetWorktreeRemovalLifetimeForTests
} from '../workspaces/removal/worktree-removal-lifetime'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { holdRemovalMetadata } from '../workspaces/removal/worktree-removal-lifetime.test-fixtures'
import { readWorktreeRemovalRecords } from '../workspaces/removal/worktree-removal-records'
import { _resetPendingWorktreeRemovalsForTests } from '../workspaces/removal/worktree-background-removal'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ServeDesktopActivationGate } from './serve-launch/serve-desktop-activation'
const { state, foundation, runtime, i18n, launch } = vi.hoisted(() => ({
state: {
store: { freezeWritesAsync: vi.fn(async () => {}) },
profileShutdownCheckpoint: null as Promise<void> | null,
store: {
flushPendingOrThrowAsync: vi.fn(async () => {}),
flushFinalOrThrowAsync: vi.fn(async () => {}),
freezeWritesAsync: vi.fn(async () => {})
},
profileStateAdmission: initialAdmission(),
desktopActivationGate: initialActivationGate(),
mainProcessI18nReady: Promise.resolve()
@@ -39,10 +61,22 @@ const options = {
handleMacAppActivation: () => {}
}
const stubStore = state.store
afterEach(async () => {
await closeTestStores()
vi.restoreAllMocks()
vi.useRealTimers()
_resetPendingWorktreeRemovalsForTests()
})
beforeEach(() => {
state.store = stubStore
_resetWorktreeRemovalLifetimeForTests()
vi.clearAllMocks()
state.profileStateAdmission = { release: vi.fn() }
state.desktopActivationGate = null
state.profileShutdownCheckpoint = null
})
describe('startup persistence lifetime', () => {
@@ -82,3 +116,119 @@ describe('startup persistence lifetime', () => {
}
})
})
it('freezes the profile after a drained removal fails without releasing its ownership', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
await expect(
worktreeRemovalLifetime.prepare(
async () => {
throw new Error('load failed')
},
() => {},
async () => {}
)
).rejects.toThrow('load failed')
const failure = new Error('runtime startup failed')
runtime.mockRejectedValueOnce(failure)
await expect(initializeMainProcessReady(options)).rejects.toBe(failure)
expect(state.store.flushFinalOrThrowAsync).toHaveBeenCalledOnce()
expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()
expect(state.profileStateAdmission?.release).not.toHaveBeenCalled()
} finally {
log.mockRestore()
}
})
it('joins successful removal metadata and its journal before failed startup freezes or releases the profile', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-startup-removal-'))
try {
const pending = await holdRemovalMetadata(directory)
const failure = new Error('runtime startup failed')
runtime.mockRejectedValueOnce(failure)
const rejected = expect(initializeMainProcessReady(options)).rejects.toBe(failure)
await vi.waitFor(() => expect(worktreeRemovalLifetime.closing).toBe(true))
expect(state.store.freezeWritesAsync).not.toHaveBeenCalled()
expect(state.profileStateAdmission?.release).not.toHaveBeenCalled()
expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1)
pending.release()
await rejected
await pending.result
expect(await readFile(join(directory, 'metadata-finished'), 'utf8')).toBe('removed')
expect(await readWorktreeRemovalRecords(directory)).toEqual([])
expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()
expect(state.profileStateAdmission).toBeUndefined()
} finally {
vi.restoreAllMocks()
_resetPendingWorktreeRemovalsForTests()
await rm(directory, { recursive: true, force: true })
}
})
it('persists successful removal metadata after the early startup checkpoint before releasing its profile', async () => {
vi.useFakeTimers()
const directory = await mkdtemp(join(tmpdir(), 'orca-startup-final-save-'))
const dataFile = join(directory, 'orca-data.json')
const profile = createSqliteTestStore(Store, { dataFile })
const target = hostWorktreeTarget({ id: 'repo::feature', hostId: 'local' })
profile.setWorktreeMeta(target, { displayName: 'Pending workspace' })
await profile.flushPendingOrThrowAsync({ fullCheckpoint: true })
const checkpoint = Promise.withResolvers<void>()
state.store = {
flushPendingOrThrowAsync: vi.fn(async () => {
await profile.flushPendingOrThrowAsync({ fullCheckpoint: true })
checkpoint.resolve()
}),
flushFinalOrThrowAsync: vi.fn(() => profile.flushFinalOrThrowAsync()),
freezeWritesAsync: vi.fn(() => profile.freezeWritesAsync())
}
const admission = state.profileStateAdmission
const pending = await holdRemovalMetadata(directory, () => profile.removeWorktreeMeta(target))
const failure = new Error('runtime startup failed')
runtime.mockRejectedValueOnce(failure)
const rejected = expect(initializeMainProcessReady(options)).rejects.toBe(failure)
try {
await checkpoint.promise
expect(
JSON.parse(readPersistedStateJson(dataFile)).worktreeMeta['repo::feature']
).toMatchObject({ displayName: 'Pending workspace' })
expect(state.store.flushFinalOrThrowAsync).not.toHaveBeenCalled()
expect(admission?.release).not.toHaveBeenCalled()
pending.release()
await rejected
await pending.result
expect(
JSON.parse(readPersistedStateJson(dataFile)).worktreeMeta['repo::feature']
).toBeUndefined()
expect(await readWorktreeRemovalRecords(directory)).toEqual([])
expect(admission?.release).toHaveBeenCalledOnce()
} finally {
pending.release()
await closeTestStores()
await rm(directory, { recursive: true, force: true })
}
})
it.each(['final-save', 'early-checkpoint'] as const)(
'still freezes after a %s failure and preserves the startup error',
async (phase) => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
const failure = new Error('runtime startup failed')
runtime.mockRejectedValueOnce(failure)
if (phase === 'final-save') {
state.store.flushFinalOrThrowAsync.mockRejectedValueOnce(new Error('save failed'))
} else {
state.store.flushPendingOrThrowAsync.mockRejectedValueOnce(new Error('early save failed'))
}
const admission = state.profileStateAdmission
await expect(initializeMainProcessReady(options)).rejects.toBe(failure)
expect(state.store.flushFinalOrThrowAsync).toHaveBeenCalledOnce()
expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()
if (phase === 'final-save') {
expect(admission?.release).not.toHaveBeenCalled()
} else {
expect(admission?.release).toHaveBeenCalledOnce()
}
log.mockRestore()
}
)
@@ -37,7 +37,8 @@ import {
collectWorktreeTrashSweepRoots,
sweepStaleWorktreeTrash
} from '../workspaces/removal/worktree-trash'
import { loadWorktreeRemovalRecordsForStore } from './workspace-startup/worktree-removal-records-load'
import { prepareWorktreeRemovalsForStore } from '../workspaces/removal/worktree-removal-records-load'
import { worktreeRemovalLifetime } from '../workspaces/removal/worktree-removal-lifetime'
import { runAfterFirstWindowShown } from './main-window/first-window-deferral'
import { startSpeechModelWarmup } from '../speech/speech-model-warmup'
import { logStartupMilestone } from './diagnostics/startup-diagnostics'
@@ -62,7 +63,7 @@ export async function initializeReadyRuntimeServices(): Promise<void> {
// Renderers fork from zygotes that exist already, so this must land before the first window.
await refuseRealtimeSchedulingUnderZeroRtTime()
// Load interrupted deletion records before listings so first paint shows Deleting.
await loadWorktreeRemovalRecordsForStore(store)
await prepareWorktreeRemovalsForStore(store)
initializeMainProcessObservers()
initializeMainProcessAccountServices()
const runtime = initializeMainProcessRuntime()
@@ -110,7 +111,7 @@ export async function initializeReadyRuntimeServices(): Promise<void> {
).catch((error) => {
console.warn('[worktrees] Failed to sweep leftover worktree directories:', error)
})
runtime.finishInterruptedWorktreeRemovals()
worktreeRemovalLifetime.resume(() => runtime.finishInterruptedWorktreeRemovals())
}, WORKTREE_TRASH_SWEEP_FALLBACK_MS)
// Defer noncritical plugin-file refresh past first paint.
runAfterFirstWindowShown(() => {
+29 -3
View File
@@ -1,3 +1,5 @@
import { beginProfileShutdownCheckpoint } from '../persistence/profile-shutdown-checkpoint'
import { worktreeRemovalLifetime } from '../workspaces/removal/worktree-removal-lifetime'
import { initializeMainProcessI18nAndMenu } from './main-window/main-process-i18n-menu'
import { mainProcessState as state } from './main-process-state'
import { initializeReadyFoundation } from './main-process-ready-foundation'
@@ -39,9 +41,33 @@ export async function initializeMainProcessReady(
// Startup now exits after failure; reopening would use a closing profile writer.
state.desktopActivationGate = null
try {
await state.store?.freezeWritesAsync()
state.profileStateAdmission?.release()
state.profileStateAdmission = undefined
state.profileShutdownCheckpoint ??= beginProfileShutdownCheckpoint(
state.store,
state.runtime?.flushPaneGrids()
)
const removalStopping = worktreeRemovalLifetime.close().then(
() => true,
(closeError) => {
console.error(
'[worktrees] Failed to stop workspace deletion after startup failure:',
closeError
)
return false
}
)
const [removalStopped] = await Promise.all([
removalStopping,
state.profileShutdownCheckpoint.catch(() => {})
])
try {
await state.store?.flushFinalOrThrowAsync()
} finally {
await state.store?.freezeWritesAsync()
}
if (removalStopped) {
state.profileStateAdmission?.release()
state.profileStateAdmission = undefined
}
} catch (closeError) {
console.error(
'[persistence] Failed to close profile persistence after startup failure:',
+1
View File
@@ -86,6 +86,7 @@ export const mainProcessState = {
/** User quit bypasses running-process confirmation during close. */
isQuitting: false,
store: null as Store | null,
profileShutdownCheckpoint: null as Promise<void> | null,
profileStateStartup: createInitialProfileStateStartup(),
profileStateAdmission: createInitialProfileStateAdmission(),
stats: null as StatsCollector | null,
@@ -1,5 +1,13 @@
import { EventEmitter } from 'node:events'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import {
closeTestStores,
createSqliteTestStore,
readPersistedStateJson
} from '../../persistence/persistence-test-harness'
import { WILL_QUIT_TEARDOWN_DEADLINE_MS } from '../../../shared/quit-teardown-deadline'
const settled = () => vi.fn(async () => {})
@@ -8,17 +16,19 @@ const stopAgentHookServer = idle()
const stopStructuredAgentSessionRuntime = settled()
const beginSshShutdown = settled()
const killAllPty = idle()
const disconnectDaemon = settled()
const closeAllWatchers = settled()
// Every teardown member settles except the profile store, so only the writer can hold quit.
const dependencies: Record<string, Record<string, unknown>> = {
'../../ipc/filesystem/watchers/filesystem-watcher-shutdown': { closeAllWatchers: settled() },
'../../ipc/filesystem/watchers/filesystem-watcher-shutdown': { closeAllWatchers },
'../../ipc/worktrees/creation/worktree-base-directory-watcher': {
disposeWorktreeBaseDirectoryWatchers: settled()
},
'../../ipc/folder-repo-git-upgrade': { stopFolderRepoGitUpgradeWatch: idle() },
'../../ipc/pty/kill-all': { killAllPty },
'../../daemon/providers/lifecycle/daemon-provider-state': {
disconnectDaemon: settled(),
disconnectDaemon,
shutdownDaemon: settled()
},
'../../ipc/ssh/ssh-shutdown-drain': { beginSshShutdown },
@@ -63,9 +73,6 @@ const dependencies: Record<string, Record<string, unknown>> = {
'../../git/refs/maintenance/local-repo-ref-maintenance': {
awaitPackedRefsLockRelease: settled()
},
'../../workspaces/removal/worktree-background-removal': {
stopBackgroundWorktreeRemovals: idle()
},
'../../dock/unread-badge': { setUnreadDockBadgeCount: idle() },
'../../tray/system-tray': { destroySystemTray: idle() },
'../../telemetry/client': { shutdownTelemetry: settled() },
@@ -94,8 +101,11 @@ afterEach(() => {
async function startQuit(
flushFinalOrThrowAsync: () => Promise<void>,
services: Record<string, unknown> = {}
services: Record<string, unknown> = {},
beforeQuit?: () => Promise<void>
) {
disconnectDaemon.mockClear()
closeAllWatchers.mockClear()
beginSshShutdown.mockClear()
killAllPty.mockClear()
vi.resetModules()
@@ -103,6 +113,7 @@ async function startQuit(
const app = Object.assign(new EventEmitter(), { quit: vi.fn() })
const admission = { release: vi.fn() }
const store = {
flushPendingOrThrowAsync: settled(),
flushFinalOrThrowAsync: vi.fn(flushFinalOrThrowAsync),
freezeWritesAsync: settled()
}
@@ -124,6 +135,7 @@ async function startQuit(
const { quitTeardownStartGate } = await import('./quit-teardown-start-gate')
quitTeardownStartGate.resetForTests()
installMainProcessQuitHandlers()
await beforeQuit?.()
app.emit('before-quit', { defaultPrevented: false })
app.emit('will-quit', { preventDefault: vi.fn() })
return { app, admission, store, state }
@@ -143,3 +155,143 @@ it('quits at the teardown deadline without releasing admission while the writer
expect(admission.release).not.toHaveBeenCalled()
expect(state.profileStateAdmission).toBe(admission)
})
it('keeps profile, watcher and daemon ownership until successful removal metadata and journal settle', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-quit-removal-'))
let release!: () => void
let readRecords!: () => Promise<unknown[]>
let reset!: () => void
const { store, admission } = await startQuit(
async () => {},
{ watcherShutdownDone: false },
async () => {
const { worktreeRemovalLifetime } =
await import('../../workspaces/removal/worktree-removal-lifetime')
const { holdRemovalMetadata } =
await import('../../workspaces/removal/worktree-removal-lifetime.test-fixtures')
release = (await holdRemovalMetadata(directory)).release
const records = await import('../../workspaces/removal/worktree-removal-records')
readRecords = () => records.readWorktreeRemovalRecords(directory)
reset = (await import('../../workspaces/removal/worktree-background-removal'))
._resetPendingWorktreeRemovalsForTests
expect(worktreeRemovalLifetime.closing).toBe(false)
}
)
await vi.advanceTimersByTimeAsync(0)
expect(store.flushFinalOrThrowAsync).not.toHaveBeenCalled()
expect(admission.release).not.toHaveBeenCalled()
expect(disconnectDaemon).not.toHaveBeenCalled()
expect(closeAllWatchers).not.toHaveBeenCalled()
expect(await readRecords()).toHaveLength(1)
release()
await vi.waitFor(() => expect(admission.release).toHaveBeenCalledOnce())
expect(await readFile(join(directory, 'metadata-finished'), 'utf8')).toBe('removed')
expect(await readRecords()).toEqual([])
reset()
await rm(directory, { recursive: true, force: true })
expect(store.freezeWritesAsync).toHaveBeenCalledOnce()
expect(admission.release).toHaveBeenCalledOnce()
expect(disconnectDaemon).toHaveBeenCalledOnce()
expect(closeAllWatchers).toHaveBeenCalledOnce()
})
it('finishes saving after a quiescent removal reports failure, retaining the failed ownership fence', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
const { store, admission } = await startQuit(
async () => {},
{ watcherShutdownDone: false },
async () => {
const { worktreeRemovalLifetime } =
await import('../../workspaces/removal/worktree-removal-lifetime')
await expect(
worktreeRemovalLifetime.prepare(
async () => {
throw new Error('load failed')
},
() => {},
async () => {}
)
).rejects.toThrow('load failed')
}
)
await vi.advanceTimersByTimeAsync(0)
expect(store.flushFinalOrThrowAsync).toHaveBeenCalledOnce()
expect(store.freezeWritesAsync).toHaveBeenCalledOnce()
expect(admission.release).not.toHaveBeenCalled()
expect(disconnectDaemon).toHaveBeenCalledOnce()
expect(closeAllWatchers).toHaveBeenCalledOnce()
expect(log).toHaveBeenCalledWith(
'[worktrees] Failed to finish stopping workspace deletion:',
expect.any(Error)
)
})
it('checkpoints pane state during a held synchronous deletion, then saves later metadata before releasing ownership', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-quit-checkpoint-'))
const { Store } = await import('../../persistence/loading-store/store')
const dataFile = join(directory, 'orca-data.json')
const profile = createSqliteTestStore(Store, { dataFile })
const savedTab = () => JSON.parse(readPersistedStateJson(dataFile)).workspaceSession.activeTabId
await profile.flushPendingOrThrowAsync({ fullCheckpoint: true })
const checkpointSaved = Promise.withResolvers<void>()
const savePending = profile.flushPendingOrThrowAsync.bind(profile)
vi.spyOn(profile, 'flushPendingOrThrowAsync').mockImplementation(async (...args) => {
try {
await savePending(...args)
checkpointSaved.resolve()
} catch (error) {
checkpointSaved.reject(error)
throw error
}
})
const final = vi.spyOn(profile, 'flushFinalOrThrowAsync')
const freeze = vi.spyOn(profile, 'freezeWritesAsync')
let release!: () => void
let captureGrid!: () => void
const grid = new Promise<void>((resolve) => {
captureGrid = () => {
profile.getWorkspaceSession().activeTabId = 'pane-grid-before-stop'
resolve()
}
})
const runtime = {
flushPaneGrids: () => grid,
getOffscreenBrowserBackend: () => null,
getAgentBrowserBridge: () => null,
getEmulatorBridge: () => null,
disposeSkillUploadSessions: async () => {},
getRuntimeId: () => undefined,
closeNotificationDismissals: async () => {}
}
try {
const { admission, app } = await startQuit(
async () => {},
{ store: profile, runtime, watcherShutdownDone: false },
async () => {
const { worktreeRemovalLifetime } =
await import('../../workspaces/removal/worktree-removal-lifetime')
release = worktreeRemovalLifetime.acquirePreparation()
}
)
captureGrid()
await grid
await checkpointSaved.promise
expect(savedTab()).toBe('pane-grid-before-stop')
await vi.advanceTimersByTimeAsync(WILL_QUIT_TEARDOWN_DEADLINE_MS)
expect(app.quit).toHaveBeenCalledOnce()
expect(final).not.toHaveBeenCalled()
expect(freeze).not.toHaveBeenCalled()
expect(admission.release).not.toHaveBeenCalled()
expect(closeAllWatchers).not.toHaveBeenCalled()
expect(disconnectDaemon).not.toHaveBeenCalled()
profile.getWorkspaceSession().activeTabId = 'metadata-after-delete'
release()
await vi.waitFor(() => expect(admission.release).toHaveBeenCalledOnce())
expect(savedTab()).toBe('metadata-after-delete')
expect(freeze).toHaveBeenCalledOnce()
} finally {
release?.()
await closeTestStores()
await rm(directory, { recursive: true, force: true })
}
})
+28 -28
View File
@@ -1,3 +1,4 @@
import { beginProfileShutdownCheckpoint } from '../../persistence/profile-shutdown-checkpoint'
import { killWslChannelsInCloseGrace } from '../../wsl/execution/wsl-command-channel'
import { markHostShuttingDown } from './host-shutdown-state'
import { app, type Event } from 'electron'
@@ -23,7 +24,7 @@ import { browserManager } from '../../browser/guest-lifecycle/manager/browser-ma
import { stopCodexStateDbBackfillRecoveries } from '../../codex/history/index/codex-state-db-backfill-recovery'
import { stopCodexAccountSessionBridges } from '../../codex/history/bridges/codex-account-session-bridge'
import { awaitPackedRefsLockRelease } from '../../git/refs/maintenance/local-repo-ref-maintenance'
import { stopBackgroundWorktreeRemovals } from '../../workspaces/removal/worktree-background-removal'
import { worktreeRemovalLifetime } from '../../workspaces/removal/worktree-removal-lifetime'
import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline'
import { quitTeardownStartGate } from './quit-teardown-start-gate'
import { setUnreadDockBadgeCount } from '../../dock/unread-badge'
@@ -34,7 +35,7 @@ import { isQuittingForUpdate } from '../../updater/updater-service'
import { recordUpdaterLifecycle } from '../../updater/updater-lifecycle-diagnostics'
import { stopTccPromptNotice } from '../../macos-permissions/macos-tcc-prompt-notice'
import { cancelHistoryGc } from '../../pty/history/terminal-history-gc'
import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy'
import { installWindowAllClosedHandler } from './window-all-closed-quit-handler'
import { desktopConnectorCloud } from '../cloud-startup/main-process-connector-startup'
import { mainProcessState as state } from '../main-process-state'
import { isDevParentShutdownRequested } from '../configure-process'
@@ -117,6 +118,15 @@ function installWillQuitHandler(): void {
const paneGridFlush = state.runtime?.flushPaneGrids() ?? null
// A renderer can veto before-quit; push must survive until quit is committed.
markHostShuttingDown()
state.profileShutdownCheckpoint ??= beginProfileShutdownCheckpoint(state.store, paneGridFlush)
const removalShutdown = worktreeRemovalLifetime.close()
const removalQuiescence = removalShutdown.then(
() => true,
(error) => {
console.error('[worktrees] Failed to finish stopping workspace deletion:', error)
return false
}
)
state.desktopPushService?.stop()
state.unsubscribeSystemResumeBroadcast?.()
state.unsubscribeSystemResumeBroadcast = null
@@ -169,8 +179,6 @@ function installWillQuitHandler(): void {
REF_MAINTENANCE_QUIT_DEADLINE_MS
).then(() => {})
state.uninstallRepoMaintenanceIdleGate = null
// Deletes may take minutes; stop now and let durable records resume them next launch.
stopBackgroundWorktreeRemovals()
// Chat teardown reads background work afterward; clearing status loses subagent/command-only resume offers.
agentHookServer.stop({ keepStatus: true })
// Windows bare hook paths lack POSIX ORCA_PANE_KEY guards, so remove Grok hooks on quit; bound cleanup because GROK_HOME can be a stalled network mount.
@@ -214,7 +222,7 @@ function installWillQuitHandler(): void {
const emulatorShutdown =
state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve()
const finalStore = state.store
const beginSshShutdownAndFinalSave = () => {
const beginSshShutdownAndFinalSave = (removalStopped: boolean) => {
// Mark SSH leases detached synchronously immediately before final flush; no await may separate mutation from save.
const sshShutdown = beginSshShutdown()
killAllPty()
@@ -226,18 +234,22 @@ function installWillQuitHandler(): void {
try {
await finalStore.flushFinalOrThrowAsync()
await finalStore.freezeWritesAsync()
state.profileStateAdmission?.release()
state.profileStateAdmission = undefined
if (removalStopped) {
state.profileStateAdmission?.release()
state.profileStateAdmission = undefined
}
} catch (error) {
console.error('[persistence] Failed to finalize profile state:', error)
}
})()
return { sshShutdown, watcherShutdown, storeFlush }
}
// Flush pending pane grids first (bounded at 1s); without one, begin final save synchronously.
const finalSaves = paneGridFlush
? paneGridFlush.then(beginSshShutdownAndFinalSave)
: beginSshShutdownAndFinalSave()
// Removal writers and pending pane grids must settle before the profile freezes.
const finalSaves = Promise.all([
paneGridFlush,
removalQuiescence,
state.profileShutdownCheckpoint.catch(() => {})
]).then(([, stopped]) => beginSshShutdownAndFinalSave(stopped))
const sshShutdown = Promise.resolve(finalSaves).then((saves) => saves.sshShutdown)
const storeFlush = Promise.resolve(finalSaves).then((saves) => saves.storeFlush)
const watcherShutdown = Promise.resolve(finalSaves).then((saves) => saves.watcherShutdown)
@@ -269,9 +281,13 @@ function installWillQuitHandler(): void {
rpcStopAndClear
]).then(() => state.runtime?.closeNotificationDismissals())
// Normal quits preserve the detached daemon for warm reattach; dev-parent death leaves its temporary profile ownerless.
const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon()
const daemonTeardown = removalQuiescence.then(() =>
isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon()
)
// Bound unsettled transports and final writes so quit cannot hang (#9447). Failures still exit; atomic swaps preserve old files if the final debounce is lost.
settleTeardownWithinDeadline([
{ name: 'worktree-removals', promise: removalShutdown },
{ name: 'state-checkpoint', promise: state.profileShutdownCheckpoint },
{ name: 'daemon', promise: daemonTeardown },
{ name: 'browser', promise: browserShutdown },
{ name: 'runtime-rpc', promise: rpcStopAndClear },
@@ -308,22 +324,6 @@ function installWillQuitHandler(): void {
})
}
function installWindowAllClosedHandler(): void {
app.on('window-all-closed', () => {
// Keep serve RPC alive after disposable windows close; re-trigger macOS quit after renderer buffer-capture deferral.
if (
shouldQuitWhenAllWindowsClosed({
platform: process.platform,
isQuitting: state.isQuitting,
isServeMode: state.isServeMode,
hasOpenedMainWindow: state.hasOpenedMainWindow
})
) {
app.quit()
}
})
}
export function installMainProcessQuitHandlers(): void {
// app.exit skips Electron quit events; stop the log child on forced exits too.
process.once('exit', stopTccPromptNotice)
@@ -0,0 +1,19 @@
import { app } from 'electron'
import { mainProcessState as state } from '../main-process-state'
import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy'
export function installWindowAllClosedHandler(): void {
app.on('window-all-closed', () => {
// Keep serve RPC alive after disposable windows close; re-trigger macOS quit after renderer buffer-capture deferral.
if (
shouldQuitWhenAllWindowsClosed({
platform: process.platform,
isQuitting: state.isQuitting,
isServeMode: state.isServeMode,
hasOpenedMainWindow: state.hasOpenedMainWindow
})
) {
app.quit()
}
})
}
@@ -1,17 +1 @@
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { Repo } from '../../../shared/repo-types'
import { loadWorktreeRemovalRecords } from '../../workspaces/removal/worktree-background-removal'
/** Retain failed deletions only while this host has the local repo copy; an SSH copy under the same ID cannot show its deletion row. */
export function loadWorktreeRemovalRecordsForStore(store: {
getProfileStorageDirectory: () => string
getRepos: () => readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
}): Promise<void> {
return loadWorktreeRemovalRecords(store.getProfileStorageDirectory(), (repoId) =>
store
.getRepos()
.some(
(repo) => repo.id === repoId && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
)
)
}
export { loadWorktreeRemovalRecordsForStore } from '../../workspaces/removal/worktree-removal-records-load'
@@ -0,0 +1,62 @@
import { beforeEach, expect, it, vi } from 'vitest'
const { prove, missing, remove } = vi.hoisted(() => ({
prove: vi.fn(),
missing: vi.fn(),
remove: vi.fn()
}))
vi.mock('./local-worktree-filesystem', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
removeLocalWorktreePath: remove
}))
vi.mock('./worktree-removal-safety', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
canSafelyRemoveOrphanedWorktreeDirectory: prove,
isWorktreePathMissing: missing
}))
import { cleanupLocalOrphanedWorktreeDirectory } from './local-orphaned-worktree-cleanup'
beforeEach(() => {
prove.mockReset().mockResolvedValue(true)
missing.mockReset().mockResolvedValue(true)
remove.mockReset().mockResolvedValue(undefined)
})
it.each(['proof', 'watcher', 'delete', 'success'] as const)(
'does not convert cancelled orphan cleanup into absent-path success at %s',
async (phase) => {
const controller = new AbortController()
if (phase === 'proof') {
prove.mockImplementationOnce(async () => {
controller.abort()
return true
})
}
const watcher = vi.fn(async () => {
if (phase === 'watcher') {
controller.abort()
}
})
remove.mockImplementationOnce(async () => {
controller.abort()
if (phase === 'delete') {
throw controller.signal.reason
}
})
const pending = cleanupLocalOrphanedWorktreeDirectory(
'/repo',
'/repo/feature',
{},
watcher,
controller.signal
)
if (phase === 'success') {
await expect(pending).resolves.toBeUndefined()
expect(missing).toHaveBeenCalledOnce()
} else {
const error = await pending.catch((error: unknown) => error)
expect(error).toBe(controller.signal.reason)
expect(missing).not.toHaveBeenCalled()
}
if (phase === 'proof' || phase === 'watcher') {
expect(remove).not.toHaveBeenCalled()
}
}
)
@@ -10,25 +10,31 @@ import {
ORPHANED_WORKTREE_DIRECTORY_MESSAGE
} from './worktree-removal-safety'
import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard'
import { throwIfSignalAborted } from '../../../shared/abort-signal-reason'
export async function cleanupLocalOrphanedWorktreeDirectory(
repoPath: string,
path: string,
options: LocalWorktreeFilesystemOptions,
closeWatchers: (path: string) => Promise<void>
closeWatchers: (path: string) => Promise<void>,
signal?: AbortSignal
): Promise<void> {
throwIfSignalAborted(signal)
const access = getLocalWorktreePathAccess(options)
const runtimePath = toLocalWorktreeRuntimePath(path, options)
if (
await canSafelyRemoveOrphanedWorktreeDirectory(
runtimePath,
toLocalWorktreeRuntimePath(repoPath, options),
CLIENT_REMOVAL_HOME,
access
)
) {
const safe = await canSafelyRemoveOrphanedWorktreeDirectory(
runtimePath,
toLocalWorktreeRuntimePath(repoPath, options),
CLIENT_REMOVAL_HOME,
access
)
throwIfSignalAborted(signal)
if (safe) {
await closeWatchers(path)
await removeLocalWorktreePath(path, options).catch(() => {})
throwIfSignalAborted(signal)
await removeLocalWorktreePath(path, options, signal).catch(() => {
throwIfSignalAborted(signal)
})
} else {
console.warn(`[worktrees] Refusing recursive cleanup for unproven worktree directory: ${path}`)
}
@@ -1,4 +1,5 @@
import { posix } from 'node:path'
import { throwIfSignalAborted } from '../../../shared/abort-signal-reason'
import { runProcess } from '@orca/process-host'
import { lstat, stat, readFile, realpath } from 'node:fs/promises'
import { buildWslExecArgs } from '../../../shared/wsl-login-shell-command'
@@ -121,6 +122,7 @@ export async function removeLocalWorktreePath(
): Promise<void> {
const distro = options.wslDistro?.trim()
if (!shouldUseWslFilesystem(options) || !distro) {
throwIfSignalAborted(signal)
await removeHostTree(targetPath, { signal })
return
}
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NESTED_WORKTREE_REMOVAL_PREFIX } from '../../../shared/worktree/removal/nested-removal'
import type * as LocalWorktreeFilesystem from './local-worktree-filesystem'
const { gitExecFileAsyncMock, listWorktreesStrictMock, removeLocalWorktreePathMock, hostRmMock } =
@@ -79,6 +80,114 @@ describe('recoverPartialLocalWorktreeRemoval', () => {
signal: controller.signal
})
).rejects.toThrow(/aborted/i)
expect(removeLocalWorktreePathMock).toHaveBeenCalledWith(
'C:/repo/worktree/feature',
{},
controller.signal
)
expect(closeWatcher).toHaveBeenCalledOnce()
expect(hostRmMock).toHaveBeenCalledOnce()
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})
it.each(['inventory', 'watcher', 'delete', 'success'] as const)(
'keeps partial-removal cancellation distinct from success at %s',
async (phase) => {
const controller = new AbortController()
if (phase === 'inventory') {
listWorktreesStrictMock.mockImplementationOnce(async () => {
controller.abort()
return []
})
}
const closeWatcher = vi.fn(async () => {
if (phase === 'watcher') {
controller.abort()
}
})
removeLocalWorktreePathMock.mockImplementationOnce(async () => {
controller.abort()
if (phase === 'delete') {
throw controller.signal.reason
}
})
const pending = recoverPartialLocalWorktreeRemoval({
error: new Error('Git deregistered but left files'),
force: true,
canonicalWorktreePath: '/repo/feature',
repoPath: '/repo',
localWorktreeGitOptions: {},
registeredWorktree: { branch: 'feature', head: 'abc' },
deleteBranch: true,
closeWatcher,
signal: controller.signal
})
if (phase === 'success') {
await expect(pending).resolves.toBeDefined()
} else {
const error = await pending.catch((error: unknown) => error)
expect(error).toBe(controller.signal.reason)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
}
if (phase === 'inventory' || phase === 'watcher') {
expect(removeLocalWorktreePathMock).not.toHaveBeenCalled()
}
}
)
it('does not recursively delete while watcher teardown remains unconfirmed', async () => {
await withPlatform('win32', async () => {
const watcherError = new Error('file watcher process did not exit after termination deadline')
await expect(
recoverPartialLocalWorktreeRemoval({
error: new Error('git worktree remove failed'),
force: true,
canonicalWorktreePath: 'C:/repo/worktree/feature',
repoPath: 'C:/repo',
localWorktreeGitOptions: {},
registeredWorktree: { branch: 'refs/heads/feature', head: 'abc123' },
deleteBranch: true,
closeWatcher: vi.fn().mockRejectedValue(watcherError)
})
).rejects.toBe(watcherError)
expect(removeLocalWorktreePathMock).not.toHaveBeenCalled()
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})
it('does not delete a checkout that now contains a registered worktree', async () => {
await withPlatform('darwin', async () => {
listWorktreesStrictMock.mockResolvedValue([
{
path: '/Users/dev/orca/workspaces/feature/nested',
head: 'def456',
branch: 'refs/heads/nested',
isBare: false,
isMainWorktree: false
}
])
const closeWatcher = vi.fn().mockResolvedValue(undefined)
await expect(
recoverPartialLocalWorktreeRemoval({
error: Object.assign(new Error('git worktree remove failed'), {
stderr:
"error: failed to delete '/Users/dev/orca/workspaces/feature': Directory not empty"
}),
force: true,
canonicalWorktreePath: '/Users/dev/orca/workspaces/feature',
repoPath: '/Users/dev/repo',
localWorktreeGitOptions: {},
registeredWorktree: { branch: 'refs/heads/feature', head: 'abc123' },
deleteBranch: true,
closeWatcher
})
).rejects.toThrow(NESTED_WORKTREE_REMOVAL_PREFIX)
expect(closeWatcher).not.toHaveBeenCalled()
expect(removeLocalWorktreePathMock).not.toHaveBeenCalled()
})
})
@@ -12,6 +12,7 @@ import {
import { deleteBranchOfUnregisteredWorktree } from '../../git/worktrees/removal/worktree-removal'
import { removeLocalWorktreePath } from './local-worktree-filesystem'
import { assertWorktreeDoesNotContainRegisteredWorktree } from './worktree-removal-safety'
import { throwIfSignalAborted } from '../../../shared/abort-signal-reason'
type LocalPartialRemovalRecoveryArgs = {
error: unknown
@@ -107,15 +108,19 @@ export async function removeStaleLocalWorktreeRegistration(
export async function recoverPartialLocalWorktreeRemoval(
args: LocalPartialRemovalRecoveryArgs
): Promise<RemoveWorktreeResult | undefined> {
throwIfSignalAborted(args.signal)
// Reject Git locks before recovery can recursively delete files.
assertWorktreeUnlockedForRemoval(args.registeredWorktree)
if (!(await isRecoverablePartialRemovalFailure(args))) {
const recoverable = await isRecoverablePartialRemovalFailure(args)
throwIfSignalAborted(args.signal)
if (!recoverable) {
return undefined
}
// Release watcher handles before recursively finishing Git’s partial removal; abort if closing fails.
await args.closeWatcher(args.canonicalWorktreePath)
throwIfSignalAborted(args.signal)
try {
await removeLocalWorktreePath(
args.canonicalWorktreePath,
@@ -123,6 +128,7 @@ export async function recoverPartialLocalWorktreeRemoval(
args.signal
)
} catch (error) {
throwIfSignalAborted(args.signal)
throw new Error(formatWorktreeRemovalError(error, args.canonicalWorktreePath, args.force))
}
const { branch, head } = args.registeredWorktree
@@ -40,6 +40,7 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
describe('background worktree removal', () => {
let directory = ''
beforeEach(async () => {
_resetPendingWorktreeRemovalsForTests()
directory = await mkdtemp(join(tmpdir(), 'orca-background-removal-'))
await loadWorktreeRemovalRecords(directory)
})
@@ -21,6 +21,7 @@ import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktr
import { failureLeftByGit } from './worktree-removal-failure'
import { differentCheckoutAtPathError, isCheckoutRegistered } from './worktree-removal-leftover'
import {
closeWorktreeRemovalRecords,
failedWorktreeRemovals,
finishedWorktreeRemovals,
pendingWorktreeRemovals,
@@ -29,6 +30,10 @@ import {
recordWorktreeRemoval,
setWorktreeRemovalRecordsDirectory
} from './worktree-removal-table'
import {
worktreeRemovalLifetime,
_resetWorktreeRemovalLifetimeForTests
} from './worktree-removal-lifetime'
export type BackgroundWorktreeRemovalJob = {
/** Abort only the checkout delete on orderly quit. */
@@ -42,7 +47,6 @@ export type BackgroundWorktreeRemovalJob = {
}
const jobsByWorktreeId = new Map<string, Promise<void>>()
const stopControllers = new Set<AbortController>()
// Loaded removals' terminal/watcher fences, held until the resumed job takes its own gate.
const startupFencesByWorktreeId = new Map<string, WatcherRemovalGate>()
@@ -144,27 +148,35 @@ export function retryFailedWorktreeRemoval(
return waitForPendingWorktreeRemoval(worktreeId, hostId, deleteBranch)
}
assertAcceptedBranchChoice(failed, deleteBranch)
const { failure: _failure, ...original } = failed
const record = { ...original, force: force ?? original.force }
const settlement = addPendingRemoval(record)
const job = jobFor(record)
const leftoverOnly: BackgroundWorktreeRemovalJob = {
...job,
run: async (stopSignal) => {
// A newly registered checkout at this path needs a fresh delete, not the leftover's recorded choices.
if (await isCheckoutRegistered(record)) {
throw differentCheckoutAtPathError(record.worktreePath)
const release = worktreeRemovalLifetime.acquirePreparation()
try {
const { failure: _failure, ...original } = failed
const record = { ...original, force: force ?? original.force }
const settlement = addPendingRemoval(record)
const job = jobFor(record)
const leftoverOnly: BackgroundWorktreeRemovalJob = {
...job,
run: async (stopSignal) => {
// A newly registered checkout at this path needs a fresh delete, not the leftover's recorded choices.
if (await isCheckoutRegistered(record, { signal: stopSignal })) {
throw differentCheckoutAtPathError(record.worktreePath)
}
return job.run(stopSignal)
}
return job.run(stopSignal)
}
runBackgroundWorktreeRemoval(record, leftoverOnly, recordWorktreeRemoval(record))
return settlement.result
} finally {
release()
}
runBackgroundWorktreeRemoval(record, leftoverOnly, recordWorktreeRemoval(record))
return settlement.result
}
export function resumeInterruptedWorktreeRemovals(
jobFor: (record: WorktreeRemovalRecord) => BackgroundWorktreeRemovalJob
): void {
if (worktreeRemovalLifetime.closing) {
return
}
for (const record of pendingWorktreeRemovals.values()) {
if (!jobsByWorktreeId.has(record.worktreeId)) {
runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve())
@@ -174,9 +186,7 @@ export function resumeInterruptedWorktreeRemovals(
/** Abort checkout deletes on quit without waiting for Git to exit. */
export function stopBackgroundWorktreeRemovals(): void {
for (const controller of stopControllers) {
controller.abort()
}
worktreeRemovalLifetime.abortCleanup()
}
function runBackgroundWorktreeRemoval(
@@ -184,16 +194,14 @@ function runBackgroundWorktreeRemoval(
job: BackgroundWorktreeRemovalJob,
recorded: Promise<void>
): void {
const controller = new AbortController()
stopControllers.add(controller)
const signal = worktreeRemovalLifetime.signal
const settled: Promise<void> = settleBackgroundWorktreeRemoval(
record,
settlementsByWorktreeId.get(record.worktreeId),
job,
recorded,
controller.signal
signal
).finally(() => {
stopControllers.delete(controller)
if (jobsByWorktreeId.get(record.worktreeId) === settled) {
jobsByWorktreeId.delete(record.worktreeId)
}
@@ -291,12 +299,21 @@ export async function _settlePendingWorktreeRemovalsForTests(): Promise<void> {
}
}
export async function drainBackgroundWorktreeRemovals(): Promise<void> {
const results = await Promise.allSettled(jobsByWorktreeId.values())
await closeWorktreeRemovalRecords()
const errors = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []))
if (errors.length > 0) {
throw new AggregateError(errors, 'Could not finish stopping workspace deletion.')
}
}
export function _resetPendingWorktreeRemovalsForTests(): void {
_resetWorktreeRemovalLifetimeForTests()
pendingWorktreeRemovals.clear()
failedWorktreeRemovals.clear()
jobsByWorktreeId.clear()
settlementsByWorktreeId.clear()
stopControllers.clear()
for (const fence of startupFencesByWorktreeId.values()) {
fence.release()
}
@@ -5,7 +5,8 @@ import { lstat } from 'node:fs/promises'
import { join } from 'node:path'
import type { RemoveWorktreeResult } from '../../../shared/worktree/create-types'
import type { GitWorktreeInfo } from '../../../shared/worktree/types'
import { listWorktreesStrict } from '../../git/worktrees/catalog/worktree-listing'
import { listWorktreesSharedStrict as listWorktreesStrict } from '../../git/worktrees/catalog/worktree-scan-cache'
import type { GitWorktreeExecOptions } from '../../git/worktrees/worktree-operation-options'
import {
getErrorCode,
normalizeLocalBranchRef
@@ -39,20 +40,26 @@ export function differentCheckoutAtPathError(worktreePath: string): Error {
)
}
export async function registeredRemovalCheckout(record: {
repoPath: string
worktreePath: string
}): Promise<GitWorktreeInfo | undefined> {
return (await listWorktreesStrict(record.repoPath)).find((worktree) =>
export async function registeredRemovalCheckout(
record: {
repoPath: string
worktreePath: string
},
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo | undefined> {
return (await listWorktreesStrict(record.repoPath, options)).find((worktree) =>
areWorktreePathsEqual(worktree.path, record.worktreePath)
)
}
export async function isCheckoutRegistered(record: {
repoPath: string
worktreePath: string
}): Promise<boolean> {
return !!(await registeredRemovalCheckout(record))
export async function isCheckoutRegistered(
record: {
repoPath: string
worktreePath: string
},
options: GitWorktreeExecOptions = {}
): Promise<boolean> {
return !!(await registeredRemovalCheckout(record, options))
}
export function isRecordedCheckout(
@@ -0,0 +1,80 @@
import { join } from 'node:path'
import { writeFile } from 'node:fs/promises'
import { vi } from 'vitest'
import * as gitRemoval from '../../git/worktrees/removal/worktree-removal'
import * as pushTarget from '../../ipc/worktree-remote'
import { finishRuntimeLocalWorktreeRemoval } from '../../runtime/workspaces/removal/runtime-registered-local-worktree-removal'
import type { RuntimeStore } from '../../runtime/construction/runtime-store-contract'
import { prepareWorktreeRemovalsForStore } from './worktree-removal-records-load'
import { startBackgroundWorktreeRemoval } from './worktree-background-removal'
/** Hold the real successful job after Git finishes, inside its host metadata callback. */
export async function holdRemovalMetadata(
directory: string,
finishMetadata: () => void = () => {}
) {
await prepareWorktreeRemovalsForStore({
getProfileStorageDirectory: () => directory,
getRepos: () => [{ id: 'repo' }]
})
vi.spyOn(gitRemoval, 'removeWorktree').mockResolvedValue({})
vi.spyOn(pushTarget, 'cleanupUnusedWorktreePushTargetRemote').mockResolvedValue()
let entered!: () => void
const metadata = new Promise<void>((resolve) => {
entered = resolve
})
let release!: () => void
const held = new Promise<void>((resolve) => {
release = resolve
})
const worktree = {
path: join(directory, 'checkout'),
branch: 'refs/heads/feature',
head: 'abc',
isMainWorktree: false,
isBare: false
}
const result = startBackgroundWorktreeRemoval({
removal: {
worktreeId: 'repo::feature',
repoId: 'repo',
repoPath: join(directory, 'repo'),
worktree,
force: false,
deleteBranch: false
},
publish: () => {},
run: (signal) =>
finishRuntimeLocalWorktreeRemoval(
{
repo: {
id: 'repo',
path: join(directory, 'repo'),
displayName: 'repo',
badgeColor: '',
addedAt: 0
},
target: { id: 'repo::feature' },
removedPushTarget: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked push-target seam does not read the store; metadata completion below is real awaited I/O.
store: {} as RuntimeStore,
localOptions: {},
force: false,
deleteBranch: false,
closeWatchers: async () => {},
preserveBranchHead: (value) => value ?? {},
finishRemoval: async () => {
entered()
await held
await writeFile(join(directory, 'metadata-finished'), 'removed')
finishMetadata()
}
},
worktree,
{ finish: async () => {} },
signal
)
})
await metadata
return { release, result }
}
@@ -0,0 +1,208 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
_resetPendingWorktreeRemovalsForTests,
_settlePendingWorktreeRemovalsForTests,
startBackgroundWorktreeRemoval,
resumeInterruptedWorktreeRemovals,
waitForWorktreeRemovalAcceptance
} from './worktree-background-removal'
import { prepareWorktreeRemovalsForStore } from './worktree-removal-records-load'
import { worktreeRemovalLifetime } from './worktree-removal-lifetime'
import {
readWorktreeRemovalRecords,
worktreeRemovalRecordsFile,
writeWorktreeRemovalRecords
} from './worktree-removal-records'
import {
assertNoPendingWorktreeRemovalConflict,
persistWorktreeRemovalRecords
} from './worktree-removal-table'
import { withSidecarSnapshotQueue } from '../../filesystem/sidecar-snapshot-file'
import * as failureInspection from './worktree-removal-failure'
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
const removal = {
worktreeId: 'repo::feature',
repoId: 'repo',
repoPath: '/unused/repo',
worktree: { path: '/unused/feature', branch: 'feature', head: 'abc' },
deleteBranch: false,
force: true
}
let directory = ''
const prepare = () =>
prepareWorktreeRemovalsForStore({
getProfileStorageDirectory: () => directory,
getRepos: () => [{ id: 'repo' }]
})
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-removal-lifetime-'))
})
afterEach(async () => {
vi.restoreAllMocks()
_resetPendingWorktreeRemovalsForTests()
await rm(directory, { recursive: true, force: true })
})
it('lets owned teardown admit during close without running cleanup, and recovers its saved choices', async () => {
await prepare()
const release = worktreeRemovalLifetime.acquirePreparation()
const closing = worktreeRemovalLifetime.close()
expect(worktreeRemovalLifetime.close()).toBe(closing)
expect(() => worktreeRemovalLifetime.acquirePreparation()).toThrow('Orca is stopping')
const run = vi.fn(async () => ({}))
void startBackgroundWorktreeRemoval({ removal, run, publish: () => {} })
release()
await waitForWorktreeRemovalAcceptance(removal.worktreeId)
await closing
expect(run).not.toHaveBeenCalled()
expect(await readWorktreeRemovalRecords(directory)).toEqual([
expect.objectContaining({
worktreeId: 'repo::feature',
force: true,
deleteBranch: false
})
])
_resetPendingWorktreeRemovalsForTests()
await prepare()
resumeInterruptedWorktreeRemovals(() => ({ run, publish: () => {} }))
await _settlePendingWorktreeRemovalsForTests()
expect(run).toHaveBeenCalledOnce()
expect(await readWorktreeRemovalRecords(directory)).toEqual([])
})
it('keeps failure writes live through inspection and parks that failure across restart', async () => {
await prepare()
const inspecting = deferred()
const inspected = deferred()
vi.spyOn(failureInspection, 'failureLeftByGit').mockImplementation(async () => {
inspecting.resolve()
await inspected.promise
return { message: 'Files are in use. Retry deletion.', failedAt: 123 }
})
const result = startBackgroundWorktreeRemoval({
removal,
run: async () => {
throw new Error('files in use')
},
publish: () => {}
})
const refused = expect(result).rejects.toThrow('files in use')
await inspecting.promise
const closing = worktreeRemovalLifetime.close()
inspected.resolve()
await closing
await refused
_resetPendingWorktreeRemovalsForTests()
await prepare()
const run = vi.fn(async () => ({}))
resumeInterruptedWorktreeRemovals(() => ({ run, publish: () => {} }))
expect(run).not.toHaveBeenCalled()
expect(() =>
assertNoPendingWorktreeRemovalConflict('/unused/repo', { branch: 'feature' })
).toThrow('Cleanup failed')
expect(await readWorktreeRemovalRecords(directory)).toEqual([
expect.objectContaining({
failure: { message: 'Files are in use. Retry deletion.', failedAt: 123 }
})
])
})
it('does not start deferred loaded recovery after close, but retains it for the next host', async () => {
const { worktree, ...fields } = removal
await writeWorktreeRemovalRecords(directory, () => [
{ ...fields, worktreePath: worktree.path, branch: 'feature', head: 'abc', requestedAt: 1 }
])
await prepare()
const queuedCallback = () =>
worktreeRemovalLifetime.resume(() =>
resumeInterruptedWorktreeRemovals(() => ({
run: async () => {
throw new Error('late recovery ran')
},
publish: () => {}
}))
)
await worktreeRemovalLifetime.close()
queuedCallback()
expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1)
_resetPendingWorktreeRemovalsForTests()
await prepare()
resumeInterruptedWorktreeRemovals(() => ({ run: async () => ({}), publish: () => {} }))
await _settlePendingWorktreeRemovalsForTests()
expect(await readWorktreeRemovalRecords(directory)).toEqual([])
})
it('joins listing writes outside jobs and seals submissions before a successor writes', async () => {
await prepare()
const writer = deferred()
const entered = deferred()
void withSidecarSnapshotQueue(worktreeRemovalRecordsFile(directory), async () => {
entered.resolve()
await writer.promise
})
await entered.promise
void persistWorktreeRemovalRecords()
let released = false
const closing = worktreeRemovalLifetime.close().then(() => {
released = true
})
await Promise.resolve()
expect(released).toBe(false)
writer.resolve()
await closing
await writeWorktreeRemovalRecords(directory, () => [
{
worktreeId: 'successor',
repoId: 'repo',
repoPath: '/next',
worktreePath: '/next/feature',
branch: 'next',
head: 'def',
force: false,
deleteBranch: false,
requestedAt: 2
}
])
await persistWorktreeRemovalRecords()
expect(await readWorktreeRemovalRecords(directory)).toEqual([
expect.objectContaining({ worktreeId: 'successor' })
])
})
it('joins preparation that is still loading before allowing a failed startup to unwind', async () => {
const loading = deferred()
const draining = deferred()
const ready = worktreeRemovalLifetime.prepare(
() => loading.promise,
() => {},
() => draining.promise
)
const startupFailure = expect(ready).rejects.toThrow('Orca stopped while preparing')
const closing = worktreeRemovalLifetime.close()
const stopFailure = expect(closing).rejects.toThrow('Orca stopped while preparing')
loading.resolve()
await startupFailure
let released = false
void closing.then(
() => {
released = true
},
() => {
released = true
}
)
await Promise.resolve()
expect(released).toBe(false)
draining.resolve()
await stopFailure
})
@@ -0,0 +1,83 @@
/** Own destructive preparation through its handoff to a journaled job, before host resources close. */
class WorktreeRemovalLifetime {
private readonly cleanup = new AbortController()
get signal(): AbortSignal {
return this.cleanup.signal
}
abortCleanup(): void {
this.cleanup.abort()
}
private ready: Promise<void> = Promise.resolve()
private readonly preparations = new Set<Promise<void>>()
private stop = (): void => {}
private drain = async (): Promise<void> => {}
private completion?: Promise<void>
closing = false
prepare(load: () => Promise<void>, stop: () => void, drain: () => Promise<void>): Promise<void> {
if (this.closing) {
throw new Error('Orca is stopping. Reconnect or restart Orca, then retry deletion.')
}
this.stop = stop
this.drain = drain
this.ready = load().then(() => {
if (this.closing) {
throw new Error('Orca stopped while preparing workspace deletion. Restart Orca and retry.')
}
})
return this.ready
}
acquirePreparation(): () => void {
if (this.closing) {
throw new Error('Orca is stopping. Reconnect or restart Orca, then retry deletion.')
}
let release!: () => void
const preparation = new Promise<void>((resolve) => {
release = resolve
})
this.preparations.add(preparation)
return () => {
this.preparations.delete(preparation)
release()
}
}
async withPreparation<T>(prepare: () => Promise<T>): Promise<T> {
const release = this.acquirePreparation()
try {
return await prepare()
} finally {
release()
}
}
resume(resume: () => void): void {
if (!this.closing) {
resume()
}
}
close(): Promise<void> {
if (!this.completion) {
this.closing = true
this.abortCleanup()
this.stop()
this.completion = (async () => {
const loaded = await Promise.allSettled([this.ready])
await Promise.all(this.preparations)
await this.drain()
if (loaded[0].status === 'rejected') {
throw loaded[0].reason
}
})()
}
return this.completion
}
}
export let worktreeRemovalLifetime = new WorktreeRemovalLifetime()
export function _resetWorktreeRemovalLifetimeForTests(): void {
worktreeRemovalLifetime = new WorktreeRemovalLifetime()
}
@@ -0,0 +1,32 @@
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { Repo } from '../../../shared/repo-types'
import {
loadWorktreeRemovalRecords,
stopBackgroundWorktreeRemovals,
drainBackgroundWorktreeRemovals
} from './worktree-background-removal'
import { worktreeRemovalLifetime } from './worktree-removal-lifetime'
/** Retain failed deletions only while this host has the local repo copy; an SSH copy under the same ID cannot show its deletion row. */
export function loadWorktreeRemovalRecordsForStore(store: {
getProfileStorageDirectory: () => string
getRepos: () => readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
}): Promise<void> {
return loadWorktreeRemovalRecords(store.getProfileStorageDirectory(), (repoId) =>
store
.getRepos()
.some(
(repo) => repo.id === repoId && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
)
)
}
export function prepareWorktreeRemovalsForStore(
store: Parameters<typeof loadWorktreeRemovalRecordsForStore>[0]
): Promise<void> {
return worktreeRemovalLifetime.prepare(
() => loadWorktreeRemovalRecordsForStore(store),
stopBackgroundWorktreeRemovals,
drainBackgroundWorktreeRemovals
)
}
@@ -2,7 +2,12 @@ import { WorktreeRemovalReservations } from './worktree-removal-reservations'
import { lstat } from 'node:fs/promises'
import { getErrorCode } from '../../git/worktrees/worktree-operation-options'
import { areWorktreePathsEqual } from '../../git/worktrees/catalog/worktree-path-comparison'
import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records'
import {
writeWorktreeRemovalRecords,
worktreeRemovalRecordsFile,
type WorktreeRemovalRecord
} from './worktree-removal-records'
import { withSidecarSnapshotQueue } from '../../filesystem/sidecar-snapshot-file'
export const pendingWorktreeRemovals = new WorktreeRemovalReservations()
export const recordedWorktreeRemovals = new WeakSet<WorktreeRemovalRecord>()
@@ -16,6 +21,14 @@ export function setWorktreeRemovalRecordsDirectory(directory: string | null): vo
recordsDirectory = directory
}
export function closeWorktreeRemovalRecords(): Promise<void> {
const directory = recordsDirectory
recordsDirectory = null
return directory
? withSidecarSnapshotQueue(worktreeRemovalRecordsFile(directory), async () => {})
: Promise.resolve()
}
export function persistWorktreeRemovalRecords(): Promise<void> {
if (!recordsDirectory) {
return Promise.resolve()