mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
fix(codex): await the pretrust cleanup before reporting a worktree removed
Coderabbit review: the cleanup promise was discarded (void) while purgeRemovedWorktree and its completion callbacks were synchronous, so the removal response could precede the trusted project table's deletion — a worktree recreated inside that window would inherit trust_level=trusted, re-opening the hazard this PR closes. The cleanup now returns its promise and the await propagates through the removal graph (managed/orphan-or-folder/interrupted/leftover/retry/folder forget all converge on removeWorktreeMetadataAndHistory) before the removal resolves. Await is for ordering, not failure propagation: cleanup failures degrade to a console warn and keep the ownership record, so an unwritable config.toml cannot turn an accepted removal into a failed one. The interrupted leftover branch awaits like the registered one, and the folder-workspace controller dep widens to 'void | Promise<void>' with a fire-then-await so the delete resolves only after the purge lands. A sweep of both cleanup functions' callers found no other gaps. Also fixes a latent bug the new tests exposed: the history-dir delete step of the removal purge evaluated the app environment outside any guard, so an uninitialized AppEnvironment turned the whole purge into a rejection against its non-fatal contract; the guard now lives at the purge's process-cleanup caller. Rebase adaptations onto the current removal-flow shape (workspace-removal* modules, releaseRemovedWorktreeHostSessions, scoped authorized-roots invalidation): the purge stays inline in the class, the removal abstracts widen to Promise<void>, and the execution-host spec fake goes async. Tests hold the purge mid-flight and assert neither the interrupted leftover nor the folder delete reports before the pretrust deletion lands. Generated-by: GLM-5.3-Flash (ZCode)
This commit is contained in:
@@ -308,9 +308,13 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW
|
||||
},
|
||||
...target
|
||||
),
|
||||
cleanupRemovedFolderWorkspaceState: (worktreeId) => {
|
||||
cleanupRemovedFolderWorkspaceState: (worktreeId): void | Promise<void> => {
|
||||
if (this.store) {
|
||||
this.removeWorktreeMetadataAndHistory(this.store, worktreeId)
|
||||
// Returned, not voided: the controller awaits it at the end of the
|
||||
// delete, so a forgotten folder workspace cannot report before the
|
||||
// Codex pretrust deletion inside the purge has landed. Failures
|
||||
// degrade inside the cleanup.
|
||||
return this.removeWorktreeMetadataAndHistory(this.store, worktreeId)
|
||||
}
|
||||
// Folder ids are minted by this store and shared with no other host.
|
||||
this.dropAgentStatusForRemovedWorktreeFn?.(worktreeId, ALL_EXECUTION_HOSTS_SCOPE)
|
||||
|
||||
@@ -163,12 +163,12 @@ class RemovalRuntime extends OrcaRuntimeService {
|
||||
return this.removeManagedWorktreeInFlight
|
||||
}
|
||||
|
||||
protected override purgeRemovedWorktree(
|
||||
protected override async purgeRemovedWorktree(
|
||||
runtimeStore: RuntimeStore,
|
||||
worktreeId: string,
|
||||
repoId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): void {
|
||||
): Promise<void> {
|
||||
this.purges.push({ store: runtimeStore, worktreeId, repoId, hostId })
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ type RuntimeProjectGroupDependencies = {
|
||||
path: string,
|
||||
connectionId?: string
|
||||
) => Promise<{ finish(removed: boolean): Promise<void> }>
|
||||
cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void
|
||||
cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void | Promise<void>
|
||||
emitWorktreeLifecycle: (event: RuntimeWorktreeLifecycleEvent) => void
|
||||
}
|
||||
|
||||
@@ -274,6 +274,7 @@ export class RuntimeProjectGroupController {
|
||||
let gate:
|
||||
| Awaited<ReturnType<RuntimeProjectGroupDependencies['acquireFileWatcherRemoval']>>
|
||||
| undefined
|
||||
let cleanupState: void | Promise<void> = undefined
|
||||
try {
|
||||
if (workspace) {
|
||||
const worktreeId = folderWorkspaceKey(folderWorkspaceId)
|
||||
@@ -291,7 +292,11 @@ export class RuntimeProjectGroupController {
|
||||
)
|
||||
await this.deps.teardownFolderWorkspacePtys(worktreeId, connectionId)
|
||||
}
|
||||
this.deps.cleanupRemovedFolderWorkspaceState(worktreeId)
|
||||
// Fired here (timing unchanged) but awaited at the end: the delete may
|
||||
// only resolve after the purge has landed. `folder:<uuid>` ids parse to
|
||||
// no filesystem path, so the pretrust drop inside the purge skips here —
|
||||
// safe: the folder itself stays and sibling workspaces may share its path.
|
||||
cleanupState = this.deps.cleanupRemovedFolderWorkspaceState(worktreeId)
|
||||
}
|
||||
const deleted = store.removeFolderWorkspace(folderWorkspaceId)
|
||||
if (deleted) {
|
||||
@@ -305,6 +310,7 @@ export class RuntimeProjectGroupController {
|
||||
})
|
||||
}
|
||||
}
|
||||
await cleanupState
|
||||
return { deleted }
|
||||
} finally {
|
||||
await gate?.finish(false)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeProjectGroupController } from './runtime-project-group-controller'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
||||
|
||||
const workspace = {
|
||||
id: 'ws-1',
|
||||
projectGroupId: 'group-1',
|
||||
folderPath: '/tmp/ws'
|
||||
} as FolderWorkspace
|
||||
|
||||
function createController(
|
||||
resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null
|
||||
) {
|
||||
const removeFolderWorkspace = vi.fn(() => true)
|
||||
const teardownFolderWorkspacePtys = vi.fn(async () => undefined)
|
||||
const cleanupRemovedFolderWorkspaceState = vi.fn()
|
||||
const finishFileWatcherRemoval = vi.fn(async () => undefined)
|
||||
const notifyReposChanged = vi.fn()
|
||||
const emitWorktreeLifecycle = vi.fn()
|
||||
const controller = new RuntimeProjectGroupController({
|
||||
getStore: () => ({ getFolderWorkspaces: () => [workspace], removeFolderWorkspace }) as never,
|
||||
resolveRepo: async () => {
|
||||
throw new Error('unused')
|
||||
},
|
||||
notifyReposChanged,
|
||||
resolveFolderConnectionId,
|
||||
// Rebase adaptation: the delete now runs inside the file-watcher removal
|
||||
// gate; the fake hands back a gate that resolves immediately.
|
||||
acquireFileWatcherRemoval: async () => ({ finish: finishFileWatcherRemoval }),
|
||||
teardownFolderWorkspacePtys,
|
||||
cleanupRemovedFolderWorkspaceState,
|
||||
emitWorktreeLifecycle
|
||||
})
|
||||
return {
|
||||
emitWorktreeLifecycle,
|
||||
controller,
|
||||
removeFolderWorkspace,
|
||||
teardownFolderWorkspacePtys,
|
||||
cleanupRemovedFolderWorkspaceState,
|
||||
notifyReposChanged,
|
||||
finishFileWatcherRemoval
|
||||
}
|
||||
}
|
||||
|
||||
describe('RuntimeProjectGroupController.deleteFolderWorkspace', () => {
|
||||
it('does not resolve the delete before the removed state cleanup lands', async () => {
|
||||
let releaseCleanup!: () => void
|
||||
const cleanupParked = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve
|
||||
})
|
||||
const deps = createController(() => 'ssh-1')
|
||||
deps.cleanupRemovedFolderWorkspaceState.mockReturnValue(cleanupParked)
|
||||
|
||||
const deletion = deps.controller.deleteFolderWorkspace('ws-1')
|
||||
let settledEarly = false
|
||||
void deletion.then(
|
||||
() => {
|
||||
settledEarly = true
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
// One macrotask is past the PTY teardown await: the cleanup has fired and
|
||||
// the catalog row is removed, while the cleanup itself is still parked. A
|
||||
// delete that resolves here is the bug — the ack must wait for the purge
|
||||
// (and the Codex pretrust deletion inside it).
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(deps.removeFolderWorkspace).toHaveBeenCalledWith('ws-1')
|
||||
expect(settledEarly).toBe(false)
|
||||
releaseCleanup()
|
||||
await expect(deletion).resolves.toEqual({ deleted: true })
|
||||
})
|
||||
})
|
||||
@@ -117,7 +117,9 @@ function jobHost(purged: string[], stopPtys = vi.fn(async () => {}), pushTarget?
|
||||
closeWatchers: async () => {},
|
||||
stopPtys,
|
||||
preservedBranchCleanup: { preserveHead: (result) => result ?? {}, remember: vi.fn() },
|
||||
purge: ({ worktreeId: id }: WorktreeRemovalRecord) => purged.push(id),
|
||||
purge: ({ worktreeId: id }: WorktreeRemovalRecord) => {
|
||||
purged.push(id)
|
||||
},
|
||||
onRemoved: () => {},
|
||||
publish: () => {}
|
||||
} satisfies Parameters<typeof interruptedLocalWorktreeRemovalJob>[1]
|
||||
|
||||
+79
@@ -718,6 +718,85 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: refusal }])
|
||||
})
|
||||
|
||||
it('does not report an unregistered leftover removed while its purge is still in flight', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// The folder vanished outside Orca and the stale registration was pruned,
|
||||
// so the resume finishes through the unregistered branch with nothing left
|
||||
// to delete; the branch is kept by the record.
|
||||
await removeTree(worktreePath)
|
||||
await git(['worktree', 'prune'])
|
||||
|
||||
const record: WorktreeRemovalRecord = {
|
||||
worktreeId: `repo-1::${worktreePath}`,
|
||||
repoId: 'repo-1',
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch: 'feature',
|
||||
head: (await git(['rev-parse', 'feature'])).trim(),
|
||||
deleteBranch: false,
|
||||
force: false,
|
||||
requestedAt: 1
|
||||
}
|
||||
await writeWorktreeRemovalRecords(recordsDir, () => [record])
|
||||
await loadWorktreeRemovalRecords(recordsDir)
|
||||
const joined = waitForPendingWorktreeRemoval(record.worktreeId)
|
||||
expect(joined).toBeDefined()
|
||||
|
||||
// Why parked: the unregistered finish used to skip its await, reporting
|
||||
// the removal while the purge — and the Codex pretrust deletion inside
|
||||
// it — was still in flight.
|
||||
let releasePurge!: () => void
|
||||
const purgeParked = new Promise<void>((resolve) => {
|
||||
releasePurge = resolve
|
||||
})
|
||||
const purged: string[] = []
|
||||
const storeStub = {
|
||||
getRepo: () => repo,
|
||||
getRepos: () => [repo],
|
||||
getWorktreeMeta: () => undefined
|
||||
}
|
||||
resumeInterruptedWorktreeRemovals((interrupted) =>
|
||||
interruptedLocalWorktreeRemovalJob(interrupted, {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options and push-target cleanup are stubbed or short-circuit without a push target.
|
||||
store: storeStub as unknown as Store,
|
||||
acquireWatcherRemoval: async (path) => {
|
||||
const gate = acquireWatcherRemovalGate(path)
|
||||
return { finish: async () => gate.release() }
|
||||
},
|
||||
closeWatchers: async () => {},
|
||||
preservedBranchCleanup: {
|
||||
preserveHead: (result) => result ?? {},
|
||||
remember: () => {}
|
||||
},
|
||||
purge: ({ worktreeId }) => {
|
||||
purged.push(worktreeId)
|
||||
return purgeParked
|
||||
},
|
||||
onRemoved: () => {},
|
||||
publish: () => {}
|
||||
})
|
||||
)
|
||||
|
||||
// The leftover finish has reached its purge; the removal must not be
|
||||
// reported until that purge lands.
|
||||
await vi.waitFor(() => expect(purged).toEqual([record.worktreeId]))
|
||||
let settledEarly = false
|
||||
void joined!.then(
|
||||
() => {
|
||||
settledEarly = true
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(settledEarly).toBe(false)
|
||||
releasePurge()
|
||||
const outcome = await joined!.then(
|
||||
(result) => ({ status: 'removed' as const, ...result }),
|
||||
(error: unknown) => ({ status: 'failed' as const, error: String(error) })
|
||||
)
|
||||
expect(outcome).toMatchObject({ status: 'removed' })
|
||||
})
|
||||
|
||||
it('leaves a different checkout created at the same path since the quit', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const head = (await git(['rev-parse', 'feature'])).trim()
|
||||
|
||||
@@ -52,7 +52,7 @@ type InterruptedWorktreeRemovalHost = {
|
||||
/** A retry's teardown: terminals opened in the folder before the user removed it. */
|
||||
stopPtys?: () => Promise<void>
|
||||
/** Drops the worktree's host state (metadata, history, caches), as every removal path does. */
|
||||
purge: (record: WorktreeRemovalRecord) => void
|
||||
purge: (record: WorktreeRemovalRecord) => void | Promise<void>
|
||||
onRemoved: (record: WorktreeRemovalRecord) => void
|
||||
publish: (repoId: string) => void
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function interruptedLocalWorktreeRemovalJob(
|
||||
preserveBranchHead: (result, fallbackHead) =>
|
||||
host.preservedBranchCleanup.preserveHead(result, fallbackHead),
|
||||
// remember() clears the cleanup target when no branch was preserved.
|
||||
finishRemoval: (result, _rememberBranch, fallbackHead) => {
|
||||
finishRemoval: async (result, _rememberBranch, fallbackHead) => {
|
||||
host.preservedBranchCleanup.remember(
|
||||
record.worktreeId,
|
||||
undefined,
|
||||
@@ -95,7 +95,7 @@ export function interruptedLocalWorktreeRemovalJob(
|
||||
fallbackHead,
|
||||
removedPushTarget
|
||||
)
|
||||
host.purge(record)
|
||||
await host.purge(record)
|
||||
}
|
||||
})
|
||||
host.onRemoved(record)
|
||||
@@ -248,7 +248,9 @@ async function finishInterruptedLocalWorktreeRemoval(
|
||||
await gate.finish(removed)
|
||||
}
|
||||
await cleanupRemovedWorktreePushTarget(finishArgs)
|
||||
args.finishRemoval(result, true, record.head)
|
||||
// Awaited like the registered branch: the removal may only be reported
|
||||
// after the purge — and the Codex pretrust deletion inside it — landed.
|
||||
await args.finishRemoval(result, true, record.head)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: {
|
||||
rememberBranch: boolean,
|
||||
// Archive hooks may move the branch; re-read the fallback head afterward.
|
||||
fallbackHead: string | undefined
|
||||
) => void
|
||||
) => void | Promise<void>
|
||||
/** Fired when Git finished, not on acceptance. */
|
||||
onRemoved: () => void
|
||||
publish: () => void
|
||||
@@ -274,7 +274,7 @@ export async function finishRuntimeLocalWorktreeRemoval(
|
||||
)
|
||||
await pruneGitWorktrees({ cwd: repo.path, ...localOptions }).catch(() => {})
|
||||
await cleanupRemovedWorktreePushTarget(args)
|
||||
args.finishRemoval(undefined, false, refreshed.head)
|
||||
await args.finishRemoval(undefined, false, refreshed.head)
|
||||
completed = true
|
||||
return {}
|
||||
} else {
|
||||
@@ -287,7 +287,9 @@ export async function finishRuntimeLocalWorktreeRemoval(
|
||||
try {
|
||||
await cleanupRemovedWorktreePushTarget(args)
|
||||
} finally {
|
||||
args.finishRemoval(removalResult, true, refreshed.head)
|
||||
// Awaited so the removal result lands only after the host-state purge (and
|
||||
// the Codex pretrust deletion inside it) has finished.
|
||||
await args.finishRemoval(removalResult, true, refreshed.head)
|
||||
}
|
||||
return removalResult ?? {}
|
||||
} finally {
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function removeRuntimeUnregisteredWorktree(args: {
|
||||
store: RuntimeStore
|
||||
acquireWatcherRemoval: (path: string) => Promise<RemovalGate>
|
||||
stopPtys: (worktreeId: string, allow: boolean) => Promise<void>
|
||||
finishRemoval: () => void
|
||||
finishRemoval: () => void | Promise<void>
|
||||
}): Promise<{}> {
|
||||
const { repo, target, registeredWorktrees, removedMeta, route } = args
|
||||
const removalHome = resolveWorktreeRemovalHome(route)
|
||||
@@ -77,7 +77,7 @@ export async function removeRuntimeUnregisteredWorktree(args: {
|
||||
try {
|
||||
await cleanupPushTarget(args)
|
||||
} finally {
|
||||
args.finishRemoval()
|
||||
await args.finishRemoval()
|
||||
}
|
||||
} finally {
|
||||
await gate.finish(true)
|
||||
@@ -105,7 +105,7 @@ async function deleteUnregisteredDirectory(
|
||||
try {
|
||||
await cleanupPushTarget(args)
|
||||
} finally {
|
||||
args.finishRemoval()
|
||||
await args.finishRemoval()
|
||||
}
|
||||
} finally {
|
||||
await gate.finish(completed)
|
||||
|
||||
@@ -85,7 +85,7 @@ export async function removeOrphanOrFolderWorktree({
|
||||
.catch(() => {})
|
||||
}
|
||||
runtime.clearOptimisticReconcileToken(removalTarget.id)
|
||||
runtime.removeWorktreeMetadataAndHistory(
|
||||
await runtime.removeWorktreeMetadataAndHistory(
|
||||
store,
|
||||
removalTarget.id,
|
||||
cleanupHostId ?? orphanHost?.id
|
||||
@@ -120,7 +120,7 @@ export async function removeOrphanOrFolderWorktree({
|
||||
removalTarget.id,
|
||||
folderConnectionId
|
||||
)
|
||||
runtime.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId)
|
||||
await runtime.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId)
|
||||
runtime.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId)
|
||||
runtime.invalidateResolvedWorktreeCache()
|
||||
runtime.notifyWorktreesChanged(repo.id)
|
||||
|
||||
@@ -43,7 +43,7 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
store: RuntimeStore,
|
||||
worktreeId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): void
|
||||
): Promise<void>
|
||||
protected abstract resolveWorktreeRemovalTarget(
|
||||
worktreeSelector: string,
|
||||
requiredHostId?: ExecutionHostId
|
||||
@@ -57,7 +57,7 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
worktreeId: string,
|
||||
repoId: string,
|
||||
removalHostId?: ExecutionHostId
|
||||
): void
|
||||
): Promise<void>
|
||||
protected abstract retryFailedRemoval(
|
||||
route: { kind: string },
|
||||
target: { id: string; path: string },
|
||||
@@ -136,7 +136,7 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
}
|
||||
const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined
|
||||
const removalHostId = repo ? (cleanupHostId ?? getRepoExecutionHostId(repo)) : cleanupHostId
|
||||
const purgeRemovedWorktree = (): void =>
|
||||
const purgeRemovedWorktree = (): Promise<void> =>
|
||||
this.purgeRemovedWorktree(store, removalTarget.id, removalTarget.repoId, removalHostId)
|
||||
const orphanOrFolderResult = await removeOrphanOrFolderWorktree({
|
||||
runtime: this,
|
||||
@@ -205,9 +205,9 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
this.stopPtysForDestructiveWorktreeRemoval(worktreeId, {
|
||||
allowUnverifiedStop: allowUnverifiedPtyStop
|
||||
}),
|
||||
finishRemoval: () => {
|
||||
finishRemoval: async () => {
|
||||
this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId)
|
||||
purgeRemovedWorktree()
|
||||
await purgeRemovedWorktree()
|
||||
this.notifyWorktreesChanged(repo!.id)
|
||||
}
|
||||
})
|
||||
@@ -243,7 +243,7 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
registeredWorktree.head,
|
||||
removedPushTarget
|
||||
)
|
||||
purgeRemovedWorktree()
|
||||
await purgeRemovedWorktree()
|
||||
this.notifyWorktreesChanged(repo!.id)
|
||||
return removalResult ?? {}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
closeWatchers: (path) => this.closeFileWatchersForRemoval(path),
|
||||
preserveBranchHead: (result, fallbackHead) =>
|
||||
this.preservedBranchCleanup.preserveHead(result, fallbackHead),
|
||||
finishRemoval: (result, rememberBranch, fallbackHead) => {
|
||||
finishRemoval: async (result, rememberBranch, fallbackHead) => {
|
||||
if (rememberBranch) {
|
||||
this.preservedBranchCleanup.remember(
|
||||
removalTarget.id,
|
||||
@@ -281,7 +281,7 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
} else {
|
||||
this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId)
|
||||
}
|
||||
purgeRemovedWorktree()
|
||||
await purgeRemovedWorktree()
|
||||
},
|
||||
onRemoved: emitRemoved,
|
||||
publish: () => this.publishWorktreeRemovalChange(repo!.id)
|
||||
|
||||
@@ -40,16 +40,35 @@ import {
|
||||
resumeInterruptedWorktreeRemovals,
|
||||
retryFailedWorktreeRemoval,
|
||||
waitForPendingWorktreeRemoval
|
||||
<<<<<<< HEAD
|
||||
} from '../../workspaces/removal/worktree-background-removal'
|
||||
import { interruptedLocalWorktreeRemovalJob } from './removal/runtime-interrupted-local-worktree-removal'
|
||||
=======
|
||||
} from '../../worktree-background-removal'
|
||||
import { interruptedLocalWorktreeRemovalJob } from '../runtime-interrupted-local-worktree-removal'
|
||||
import { invalidateAuthorizedRootsCacheForRepo } from '../../ipc/registered-worktree-roots-scoped-invalidation'
|
||||
import { dropOrcaCreatedCodexPretrustForRemovedWorktree } from '../worktree-removal-codex-pretrust-cleanup'
|
||||
import { splitWorktreeId, worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
|
||||
import {
|
||||
dropRemovedWorktreeProcessState,
|
||||
pruneRemovedWorktreeSnapshots
|
||||
} from '../../worktree-removal-process-cleanup'
|
||||
import { removeWorktreeMetaRow } from '../../persistence/host-qualified-worktree-meta'
|
||||
>>>>>>> 738ffaefdd3 (fix(codex): await the pretrust cleanup before reporting a worktree removed)
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { retryFailedRemovalUnlessRegistered } from '../../workspaces/removal/worktree-removal-leftover'
|
||||
import type { GitWorktreeInfo } from '../../../shared/worktree/types'
|
||||
import {
|
||||
resolveAgentTerminalCreateOptions,
|
||||
<<<<<<< HEAD
|
||||
prepareHostAgentTerminalLaunch
|
||||
} from '../terminal/launch/runtime-agent-terminal-startup'
|
||||
import { removeWorktreeMetaRow } from '../../persistence/host-qualified-worktree-meta'
|
||||
=======
|
||||
withHostAccountLaunchEnv
|
||||
} from '../runtime-agent-terminal-startup'
|
||||
>>>>>>> 738ffaefdd3 (fix(codex): await the pretrust cleanup before reporting a worktree removed)
|
||||
|
||||
export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree {
|
||||
protected abstract resolveExplicitWorktreeIdScoped(
|
||||
@@ -142,24 +161,31 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu
|
||||
}
|
||||
|
||||
// Host state every removal path drops once Git has let go of the checkout.
|
||||
protected purgeRemovedWorktree(
|
||||
// Resolves only after the Codex pretrust deletion landed, so a removal ack
|
||||
// cannot precede the trust table purge.
|
||||
protected async purgeRemovedWorktree(
|
||||
store: RuntimeStore,
|
||||
worktreeId: string,
|
||||
repoId: string,
|
||||
removalHostId?: ExecutionHostId
|
||||
): void {
|
||||
): Promise<void> {
|
||||
this.clearOptimisticReconcileToken(worktreeId)
|
||||
this.removeWorktreeMetadataAndHistory(store, worktreeId, removalHostId)
|
||||
const metadataAndHistory = this.removeWorktreeMetadataAndHistory(
|
||||
store,
|
||||
worktreeId,
|
||||
removalHostId
|
||||
)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(repoId)
|
||||
invalidateAuthorizedRootsCacheForRepo(store, repoId)
|
||||
await metadataAndHistory
|
||||
}
|
||||
|
||||
removeWorktreeMetadataAndHistory(
|
||||
async removeWorktreeMetadataAndHistory(
|
||||
store: RuntimeStore,
|
||||
worktreeId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Purge history/caches before path-derived ids can name a recreated workspace.
|
||||
const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId
|
||||
const repoId = splitWorktreeId(worktreeId)?.repoId
|
||||
@@ -176,7 +202,7 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu
|
||||
if (!preservesSameIdOwner) {
|
||||
// Why: worktree IDs are path-derived and can be recreated; a stale Codex
|
||||
// pretrust entry must not pre-trust the next occupant of the path.
|
||||
dropOrcaCreatedCodexPretrustForRemovedWorktree(worktreeId)
|
||||
const pretrustDrop = dropOrcaCreatedCodexPretrustForRemovedWorktree(worktreeId)
|
||||
// Do not rescue surviving paired PTYs into a recreated occupant of the same path-derived id.
|
||||
for (const ptyId of this.pairedRendererSessionOwnedPtyIds) {
|
||||
const ptyWorktreeId = this.ptysById.get(ptyId)?.worktreeId
|
||||
@@ -200,6 +226,10 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu
|
||||
this.notifyMobileSessionTabsRemoved(worktreeId)
|
||||
dropRemovedWorktreeProcessState(worktreeId)
|
||||
this.releaseRemovedWorktreeHostSessions(worktreeId, 'worktree-removal')
|
||||
// Awaited last so every in-memory invalidation above stays synchronous,
|
||||
// while callers holding the returned promise still wait for the entry
|
||||
// deletion (failures degrade inside the helper, never fail the removal).
|
||||
await pretrustDrop
|
||||
}
|
||||
const snapshotDirectory = store.getProfileStorageDirectory?.()
|
||||
if (snapshotDirectory) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { upsertOrcaCreatedProjectTrustLevel } from '../codex/config-toml-trust'
|
||||
import { runExclusivelyForCodexTrustConfig } from '../codex/codex-trust-config-mutation-queue'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/tmp/worktrees/feature-r1'
|
||||
const PROJECT = '/tmp/worktrees/feature-r1'
|
||||
@@ -18,20 +19,23 @@ const FOLDER_SUFFIX = '::workspace:5e8cc6b2-1d3f-4a5b-9c2d-7f6e5a4b3c2d'
|
||||
function makeRuntimePurge(meta: Record<string, unknown> = {}) {
|
||||
const store = {
|
||||
getWorktreeMeta: () => meta,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake only supplies the meta/query members these cases drive.
|
||||
getRepos: () => [] as never[],
|
||||
removeWorktreeMeta: () => {},
|
||||
getRepo: () => undefined,
|
||||
getSettings: () => ({})
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service constructor only reads the store members the fake supplies.
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
return (worktreeId: string, hostId?: string) =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite spies one private member by its exact declared shape.
|
||||
(
|
||||
runtime as unknown as {
|
||||
removeWorktreeMetadataAndHistory: (
|
||||
store: unknown,
|
||||
worktreeId: string,
|
||||
hostId?: string
|
||||
) => void
|
||||
) => void | Promise<void>
|
||||
}
|
||||
).removeWorktreeMetadataAndHistory(store, worktreeId, hostId)
|
||||
}
|
||||
@@ -103,4 +107,90 @@ describe('worktree removal cleans up the Codex pretrust Orca wrote', () => {
|
||||
expect(readFileSync(configPath, 'utf-8')).not.toContain(`[projects."${PROJECT}"]`)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not complete the purge before the recorded entry is deleted', async () => {
|
||||
upsertOrcaCreatedProjectTrustLevel(configPath, PROJECT, 'trusted')
|
||||
// Why: holding the config's mutation lane parks the cleanup, so "the purge
|
||||
// finished" and "the entry is gone" can be told apart. A recreated path
|
||||
// inherits trust_level = "trusted" if the purge reports completion first.
|
||||
let releaseLane!: () => void
|
||||
const laneHeld = new Promise<void>((resolve) => {
|
||||
releaseLane = resolve
|
||||
})
|
||||
const hold = runExclusivelyForCodexTrustConfig(configPath, () => laneHeld)
|
||||
const purge = makeRuntimePurge()
|
||||
|
||||
const removalAck = purge(WORKTREE_ID)
|
||||
|
||||
// The cleanup is queued behind the held lane: nothing landed yet.
|
||||
expect(readFileSync(configPath, 'utf-8')).toContain(`[projects."${PROJECT}"]`)
|
||||
// A settled purge here is the bug: the removal ack must wait for the
|
||||
// deletion that is still parked on the config lane. A 0ms macrotask loses
|
||||
// to any settled ack (microtasks first) and wins only against a pending one.
|
||||
const settledBeforeCleanup = await Promise.race([
|
||||
Promise.resolve(removalAck).then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
setTimeout(() => resolve(false), 0)
|
||||
})
|
||||
])
|
||||
expect(settledBeforeCleanup).toBe(false)
|
||||
releaseLane()
|
||||
await hold
|
||||
await removalAck
|
||||
expect(readFileSync(configPath, 'utf-8')).not.toContain(`[projects."${PROJECT}"]`)
|
||||
})
|
||||
|
||||
it('hands the folder-workspace forgetting dep the purge promise, not void', async () => {
|
||||
// Why parked: the wired dep used to void the purge promise, so the folder
|
||||
// delete had nothing to await and a forgotten workspace could report
|
||||
// before the recorded entry was deleted.
|
||||
let releasePurge!: () => void
|
||||
const purgeParked = new Promise<void>((resolve) => {
|
||||
releasePurge = resolve
|
||||
})
|
||||
const store = {
|
||||
getWorktreeMeta: () => ({}),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake only supplies the meta/query members these cases drive.
|
||||
getRepos: () => [] as never[],
|
||||
removeWorktreeMeta: () => {},
|
||||
getRepo: () => undefined,
|
||||
getSettings: () => ({})
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service constructor only reads the store members the fake supplies.
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
const purgeSpy = vi
|
||||
.spyOn(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite spies one private member by its exact declared shape.
|
||||
runtime as unknown as {
|
||||
removeWorktreeMetadataAndHistory: (store: unknown, worktreeId: string) => Promise<void>
|
||||
},
|
||||
'removeWorktreeMetadataAndHistory'
|
||||
)
|
||||
.mockReturnValue(purgeParked)
|
||||
const cleanupRemovedFolderWorkspaceState =
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite reads one private member by its exact declared shape.
|
||||
(
|
||||
runtime as unknown as {
|
||||
projectGroups: {
|
||||
deps: {
|
||||
cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void | Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
).projectGroups.deps.cleanupRemovedFolderWorkspaceState
|
||||
|
||||
const cleanup = cleanupRemovedFolderWorkspaceState('folder:ws-1')
|
||||
expect(purgeSpy).toHaveBeenCalledWith(expect.anything(), 'folder:ws-1')
|
||||
|
||||
// Voiding the promise made this resolve instantly; the dep must hand the
|
||||
// caller the still-pending purge to await.
|
||||
let settledEarly = false
|
||||
void Promise.resolve(cleanup).then(() => {
|
||||
settledEarly = true
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(settledEarly).toBe(false)
|
||||
releasePurge()
|
||||
await cleanup
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,10 +9,20 @@ import { removeOrcaCreatedProjectTrustEntries } from '../codex/config-toml-trust
|
||||
* pretrust entry Orca wrote for the path is the same hazard: drop it so a
|
||||
* recreated worktree is not born pre-trusted (#24697). The grant keyed the bare
|
||||
* path, so a folder session's ::workspace:<uuid> suffix must not hide it.
|
||||
*
|
||||
* Resolves only once every recorded entry has been written out, so a removal
|
||||
* can await it and never report the worktree gone while the path would still
|
||||
* inherit `trust_level = "trusted"` on rebuild. Await is for ordering, not
|
||||
* failure propagation: a write that cannot land (read-only config.toml and
|
||||
* alike) must not turn an accepted removal into a failed one, so errors
|
||||
* degrade to this warn — the ownership record stays for a later pass.
|
||||
*/
|
||||
export function dropOrcaCreatedCodexPretrustForRemovedWorktree(worktreeId: string): void {
|
||||
export function dropOrcaCreatedCodexPretrustForRemovedWorktree(worktreeId: string): Promise<void> {
|
||||
const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
|
||||
if (worktreePath) {
|
||||
void removeOrcaCreatedProjectTrustEntries(worktreePath)
|
||||
if (!worktreePath) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return removeOrcaCreatedProjectTrustEntries(worktreePath).catch((error) => {
|
||||
console.warn('[codex-config] Failed to drop an Orca-created project trust entry:', error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,15 @@ import { pruneWorkspaceSpaceAnalysisSnapshot } from '../space-analysis/workspace
|
||||
export function dropRemovedWorktreeProcessState(worktreeId: string): void {
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
// Guarded here: removal purges must degrade, not reject, when AppEnvironment
|
||||
// is uninitialized and the history root cannot be resolved.
|
||||
try {
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[pty:history] Failed to delete history dir: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. */
|
||||
|
||||
Reference in New Issue
Block a user