rebase(sta-4343): replay #14606 host-qualified removal onto main

Squashed replay of the 14 commits on brennanb2025/sta-4343-fix. Twelve of
them predate the branch's own main-merge, so a commit-by-commit rebase
re-conflicts against the worktrees/persistence slice splits every time.

Re-homed by hand where main moved the code:
- persistence.ts -> persistence/loading-store/store.ts (removeWorktreeMeta)
- persistence.test.ts -> persistence-host-partitioned-sessions.test.ts
- ipc/worktrees.test.ts -> worktrees-remove-host-disambiguation.test.ts,
  worktrees-removal-recovery.test.ts,
  worktrees-preserved-branch-fork-remote.test.ts

Kept main's side where it moved ahead: the folder-workspace
deleteRemoteWorktreeHistory call, and cleanup success toasts no longer
firing from the background-removal path.
This commit is contained in:
Brennan Benson
2026-08-16 21:58:35 -07:00
parent a9f93172cf
commit 4052ea8b8c
79 changed files with 4791 additions and 1141 deletions
+13 -1
View File
@@ -277,8 +277,20 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatWorktreeShow)
},
'worktree rm': async ({ flags, client, cwd, json }) => {
const worktree = await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
const resolved = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree
})
const hostId = resolved.result.worktree.hostId
if (!hostId) {
throw new RuntimeClientError(
'worktree_host_unresolved',
'Orca cannot tell which host owns this workspace. Refresh projects and try again.'
)
}
const result = await client.call<RuntimeWorktreeRemoveResult>('worktree.rm', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
worktree,
hostId,
force: flags.get('force') === true,
// Why (#11960): --force is explicit here, so it may also waive PTY-stop proof.
allowUnverifiedPtyStop: flags.get('force') === true,
+31 -6
View File
@@ -88,27 +88,52 @@ describe('command aliases dispatch to the canonical handler', () => {
})
it('runs `worktree remove` as the canonical `worktree rm` (the incident)', async () => {
queueFixtures(callMock, okFixture('req', { removed: true }))
queueFixtures(
callMock,
okFixture('req_show', { worktree: { hostId: 'local' } }),
okFixture('req', { removed: true })
)
await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
expect(callMock).toHaveBeenNthCalledWith(
2,
'worktree.rm',
expect.objectContaining({ worktree: 'id:wt-1', force: true })
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'local', force: true })
)
})
it('runs `worktree delete` as the canonical `worktree rm`', async () => {
queueFixtures(callMock, okFixture('req', { removed: true }))
queueFixtures(
callMock,
okFixture('req_show', { worktree: { hostId: 'runtime:env-1' } }),
okFixture('req', { removed: true })
)
await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
expect(callMock).toHaveBeenNthCalledWith(
2,
'worktree.rm',
expect.objectContaining({ worktree: 'id:wt-1' })
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'runtime:env-1' })
)
})
it('fails closed when worktree removal cannot resolve a host', async () => {
queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } }))
const priorExitCode = process.exitCode
try {
await main(['worktree', 'rm', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
expect(process.exitCode).toBe(1)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.show', { worktree: 'id:wt-1' })
} finally {
process.exitCode = priorExitCode
}
})
it('still runs `terminal focus` after the handler de-duplication', async () => {
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
@@ -183,6 +183,53 @@ describe('workspace cleanup broad scan opt-in', () => {
).toBe('runtime:env-1')
})
it('does not borrow same-id local metadata for a connected SSH cleanup row', async () => {
const sharedPath = '/shared/workspace'
const worktreeId = `${REPO.id}::${sharedPath}`
const localRepo = { ...REPO, path: '/local/repo' }
const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' }
const sharedWorktree: GitWorktreeInfo = {
path: sharedPath,
head: 'shared123',
branch: 'refs/heads/shared',
isBare: false,
isMainWorktree: false
}
const localMeta = makeWorktreeMeta({
displayName: 'Local metadata only',
hostId: 'local',
isPinned: true,
lastActivityAt: NOW - 40 * DAY_MS
})
listRepoWorktreesMock.mockResolvedValue([sharedWorktree])
getSshGitProviderMock.mockReturnValue({
listWorktrees: vi.fn().mockResolvedValue([sharedWorktree])
})
const result = await scanWorkspaceCleanup(
makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }),
{ includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] }
)
expect(result.candidates).toHaveLength(2)
expect(result.candidates).toEqual(
expect.arrayContaining([
expect.objectContaining({
worktreeId,
executionHostId: 'local',
displayName: 'Local metadata only',
blockers: ['pinned']
}),
expect.objectContaining({
worktreeId,
executionHostId: 'ssh:ssh-1',
displayName: 'shared',
blockers: []
})
])
)
})
it('keeps the legacy suggestion-only projection when the flag is absent', async () => {
const result = await scanWorkspaceCleanup(makeStore())
@@ -245,6 +292,31 @@ describe('workspace cleanup broad scan opt-in', () => {
])
})
it('does not publish a same-id local folder instance on an SSH host', async () => {
const localFolderRepo = { ...FOLDER_REPO }
const sshFolderRepo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555`
getSshGitProviderMock.mockReturnValue({})
const result = await scanWorkspaceCleanup(
makeStore([localFolderRepo, sshFolderRepo], {
[instanceId]: makeWorktreeMeta({
displayName: 'Local folder session',
hostId: 'local'
})
}),
{ includeAllWorkspaces: true }
)
expect(result.candidates).toHaveLength(3)
expect(result.candidates.filter((candidate) => candidate.worktreeId === instanceId)).toEqual([
expect.objectContaining({
executionHostId: 'local',
displayName: 'Local folder session'
})
])
})
it('does not mark connected SSH folder workspaces as disconnected', async () => {
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
getSshGitProviderMock.mockReturnValue({})
@@ -414,6 +486,34 @@ describe('workspace cleanup broad scan opt-in', () => {
})
})
it('does not synthesize a disconnected SSH row from same-id local metadata', async () => {
const sharedPath = '/shared/workspace'
const worktreeId = `${REPO.id}::${sharedPath}`
const localRepo = { ...REPO, path: '/local/repo' }
const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' }
const sharedWorktree: GitWorktreeInfo = {
path: sharedPath,
head: 'shared123',
branch: 'refs/heads/shared',
isBare: false,
isMainWorktree: false
}
listRepoWorktreesMock.mockResolvedValue([sharedWorktree])
const result = await scanWorkspaceCleanup(
makeStore([localRepo, sshRepo], {
[worktreeId]: makeWorktreeMeta({ hostId: 'local' })
}),
{ includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] }
)
expect(result.candidates).toHaveLength(1)
expect(result.candidates[0]).toMatchObject({
worktreeId,
executionHostId: 'local'
})
})
it('uses the backing path for disconnected SSH folder instances', async () => {
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555`
@@ -14,11 +14,13 @@ import {
getWorkspaceCleanupInactivityReasonsForWorkspace,
isWorkspaceInactiveForCleanup
} from './workspace-cleanup-candidate'
import { getRepoOwnedWorktreeMeta, isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership'
export function synthesizeDisconnectedSshCleanupCandidates(
store: Store,
repo: Repo,
scannedAt: number,
repoOwnerCount: number,
targetWorktreeIds?: ReadonlySet<string>,
includeAllWorkspaces = false
): WorkspaceCleanupCandidate[] {
@@ -32,7 +34,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
continue
}
const meta = store.getWorktreeMeta(worktreeId)
if (meta) {
if (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) {
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
}
}
@@ -45,7 +47,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
continue
}
const meta = allMeta[worktreeId]
const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount)
if (!meta || (!includeAllWorkspaces && !isWorkspaceInactiveForCleanup(meta, scannedAt))) {
continue
}
@@ -2,21 +2,36 @@ import type { Store } from '../persistence'
import { createFolderWorktree } from '../repo-worktrees'
import type { Repo } from '../../shared/repo-types'
import type { Worktree } from '../../shared/worktree/types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id'
import { mergeWorktree } from './worktree-logic'
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
export function listWorkspaceCleanupFolderWorkspaces(
store: Store,
repo: Repo,
repoOwnerCount: number
): Worktree[] {
const rootId = `${repo.id}::${repo.path}`
const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
const allMeta = store.getAllWorktreeMeta()
const allMeta: Readonly<Record<string, WorktreeMeta>> = store.getAllWorktreeMeta()
const worktreeIds = [
rootId,
...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix))
...Object.keys(allMeta).filter(
(worktreeId) =>
worktreeId.startsWith(instancePrefix) &&
getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount) !== undefined
)
]
const folderWorktree = createFolderWorktree(repo)
return worktreeIds.map((worktreeId) => ({
...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName),
...mergeWorktree(
repo.id,
folderWorktree,
getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount),
repo.displayName
),
id: worktreeId,
isMainWorktree: worktreeId === rootId
}))
+15 -4
View File
@@ -43,6 +43,7 @@ import {
getTargetWorktreeIdsByRepo,
hasTargetedWorkspaceCleanupScan
} from './workspace-cleanup-scan-targets'
import { isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership'
const WORKTREE_SCAN_CONCURRENCY = 3
// Why: SSH repos pay a worktree-list round trip each; strictly serial repos
@@ -66,10 +67,15 @@ export async function scanWorkspaceCleanup(
if (hasTargetedWorkspaceCleanupScan(args) && targetWorktreeIdsByRepo.size === 0) {
return { scannedAt, candidates: [], errors: [] }
}
const allRepos = store.getRepos()
const repos =
targetWorktreeIdsByRepo.size > 0
? store.getRepos().filter((repo) => targetWorktreeIdsByRepo.has(repo.id))
: store.getRepos()
? allRepos.filter((repo) => targetWorktreeIdsByRepo.has(repo.id))
: allRepos
const repoOwnerCountById = new Map<string, number>()
for (const repo of allRepos) {
repoOwnerCountById.set(repo.id, (repoOwnerCountById.get(repo.id) ?? 0) + 1)
}
const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options)
const errors: WorkspaceCleanupScanResult['errors'] = []
const candidates: WorkspaceCleanupCandidate[] = []
@@ -83,6 +89,7 @@ export async function scanWorkspaceCleanup(
return scanRepoWorkspaces({
store,
repo,
repoOwnerCount: repoOwnerCountById.get(repo.id) ?? 1,
scannedAt,
targetWorktreeIds: targetWorktreeIdsByRepo.get(repo.id),
refreshTargetActivity: args.worktreeId !== undefined || args.refreshActivity === true,
@@ -109,6 +116,7 @@ async function scanRepoWorkspaces(
args: {
store: Store
repo: Repo
repoOwnerCount: number
scannedAt: number
targetWorktreeIds?: ReadonlySet<string>
refreshTargetActivity: boolean
@@ -120,6 +128,7 @@ async function scanRepoWorkspaces(
const {
store,
repo,
repoOwnerCount,
scannedAt,
targetWorktreeIds,
refreshTargetActivity,
@@ -161,6 +170,7 @@ async function scanRepoWorkspaces(
store,
repo,
scannedAt,
repoOwnerCount,
targetWorktreeIds,
includeAllWorkspaces
)
@@ -174,11 +184,12 @@ async function scanRepoWorkspaces(
const mergedWorktrees =
repoIsFolder && includeAllWorkspaces
? listWorkspaceCleanupFolderWorkspaces(store, repo)
? listWorkspaceCleanupFolderWorkspaces(store, repo, repoOwnerCount)
: gitWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
const ownedMeta = isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined
return mergeWorktree(repo.id, gitWorktree, ownedMeta, repo.displayName)
})
// Why: with includeAllWorkspaces the browser shows every workspace and lets
// filters narrow it; an age threshold here would hide rows from all views.
@@ -260,6 +260,41 @@ describe('workspace cleanup snapshot IPC', () => {
expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan')
})
it('persists same-id dismissals by host and prunes every key by worktree id', async () => {
let dismissals = {}
const store = {
...makeEmptyStore(),
getUI: () => ({ workspaceCleanup: { dismissals } }),
updateUI: (update: { workspaceCleanup: { dismissals: typeof dismissals } }) => {
dismissals = update.workspaceCleanup.dismissals
}
} as unknown as Store
registerWorkspaceCleanupHandlers(store)
const handler = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)[
'workspaceCleanup:dismiss'
]
const base = {
worktreeId: 'repo-1::/same',
dismissedAt: NOW,
fingerprint: 'fp',
classifierVersion: 2
}
await handler?.({} as never, {
dismissals: [
{ ...base, executionHostId: 'local' },
{ ...base, executionHostId: 'ssh:ssh-1' }
]
})
expect(Object.keys(dismissals).sort()).toEqual(
['local\0repo-1::/same', 'ssh:ssh-1\0repo-1::/same'].sort()
)
await handler?.({} as never, { dismissals: [], removedWorktreeIds: [base.worktreeId] })
expect(dismissals).toEqual({})
})
it('routes a validated removal snapshot prune batch through its explicit boundary', async () => {
registerWorkspaceCleanupHandlers(makeEmptyStore())
const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)
+12 -3
View File
@@ -15,6 +15,7 @@ import {
type WorkspaceCleanupSnapshotPruneRecordArgs
} from '../../shared/workspace-cleanup'
import { parseExecutionHostId } from '../../shared/execution-host'
import { getWorkspaceCleanupHostIdentity } from '../../shared/workspace-cleanup-host-identity'
import { scanWorkspaceCleanup } from './workspace-cleanup-scan'
import { hasTargetedWorkspaceCleanupScan } from './workspace-cleanup-scan-targets'
import {
@@ -135,16 +136,24 @@ export function registerWorkspaceCleanupHandlers(
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
const next = { ...store.getUI().workspaceCleanup?.dismissals }
for (const worktreeId of args.removedWorktreeIds ?? []) {
delete next[worktreeId]
for (const [identity, dismissal] of Object.entries(next)) {
if (dismissal.worktreeId === worktreeId) {
delete next[identity]
}
}
}
for (const dismissal of args.dismissals ?? []) {
if (
dismissal &&
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION &&
typeof dismissal.worktreeId === 'string' &&
typeof dismissal.fingerprint === 'string'
typeof dismissal.fingerprint === 'string' &&
(dismissal.executionHostId === undefined || parseExecutionHostId(dismissal.executionHostId))
) {
next[dismissal.worktreeId] = dismissal
const identity = dismissal.executionHostId
? getWorkspaceCleanupHostIdentity(dismissal.executionHostId, dismissal.worktreeId)
: dismissal.worktreeId
next[identity] = dismissal
}
}
store.updateUI({ workspaceCleanup: { dismissals: next } })
@@ -140,7 +140,7 @@ describe('registerWorktreeHandlers', () => {
)
})
it('force-deletes an SSH branch that was preserved by safe worktree removal', async () => {
it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -151,6 +151,7 @@ describe('registerWorktreeHandlers', () => {
worktreeBaseRef: null
}
const worktreeId = 'repo-ssh::/remote/feature-wt'
const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined }
const provider = {
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
@@ -175,16 +176,21 @@ describe('registerWorktreeHandlers', () => {
}),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
store.getRepos.mockReturnValue([localRepo, repo])
store.getRepo.mockReturnValue(localRepo)
store.getWorktreeMeta.mockReturnValue(
makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true })
)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() })
await handlers['worktrees:remove'](null, { worktreeId })
await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' })
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
worktreeId,
branchName: 'feature/test',
expectedHead: 'def456'
expectedHead: 'def456',
hostId: 'ssh:conn-1'
})
expect(result).toMatchObject({ deleted: true })
@@ -511,6 +511,9 @@ describe('registerWorktreeHandlers', () => {
includeProviderInventory: true,
includeLocalRegistry: false
})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1')
expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled()
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
})
it('fences a mirrored runtime folder workspace sweep to its environment', async () => {
@@ -338,4 +338,42 @@ describe('registerWorktreeHandlers', () => {
finishFirst()
await expect(first).resolves.toEqual({})
})
// The repos are gone (host removed), so only the single global meta key is left to
// key on — and it names one host. It must not merge two hosts' forgets into one.
it('does not let stale metadata coalesce an explicit host forget onto another host', async () => {
const worktreeId = 'repo-gone::/same/path'
store.getRepos.mockReturnValue([])
store.getRepo.mockReturnValue(undefined)
store.getWorktreeMeta.mockReturnValue({ hostId: 'local' })
let finishFirst!: () => void
killAllProcessesForWorktreeMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirst = () =>
resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
})
)
.mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
const first = handlers['worktrees:forgetLocal'](null, {
worktreeId,
hostId: 'local'
}) as Promise<unknown>
await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1))
await expect(
handlers['worktrees:forgetLocal'](null, {
worktreeId,
hostId: 'ssh:conn-1'
})
).resolves.toEqual({})
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2)
finishFirst()
await expect(first).resolves.toEqual({})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1')
})
})
+32 -24
View File
@@ -215,21 +215,15 @@ function getRepoForWorktreeRemoval(
repoId: string,
hostId?: ExecutionHostId
): Repo | undefined {
const matches = store
.getRepos()
.filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
// Why: deletion must never guess between host owners; legacy unscoped calls work only while the repo id has one unique owner.
if (matches.length === 1) {
return matches[0]
}
if (matches.length > 1) {
return undefined
}
const legacyMatch = store.getRepo(repoId)
return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId)
? legacyMatch
: undefined
const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId)
return owner.kind === 'resolved' ? owner.repo : undefined
}
import {
hasWorktreeRemovalRepoOwnerOnOtherHost,
resolveWorktreeRemovalMetadata,
resolveWorktreeRemovalRepoOwner
} from '../worktree-removal-repo-owner'
import { classifyWorkspaceCreateError } from './workspace-create-error-classifier'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy'
@@ -328,8 +322,8 @@ function resolveWorktreeRemovalOwnerHostId(
fallbackHostId?: ExecutionHostId
): ExecutionHostId | undefined {
return (
store.getWorktreeMeta(worktreeId)?.hostId ??
(repo ? getRepoExecutionHostId(repo) : fallbackHostId)
fallbackHostId ??
(repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId)
)
}
@@ -339,19 +333,28 @@ function removeWorktreeMetadataAndTransientState(
hostId?: ExecutionHostId,
snapshotPruneBatchId?: string
): void {
const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId
const repoId = getRepoIdFromWorktreeId(worktreeId)
const preservesSameIdOwner = Boolean(
hostId &&
((persistedHostId && persistedHostId !== hostId) ||
hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId))
)
// Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace.
if (hostId) {
store.removeWorktreeMeta(worktreeId, hostId)
} else {
store.removeWorktreeMeta(worktreeId)
}
advertisedUrlWatcher.forgetWorktree(worktreeId)
// Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session.
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
// Why: schedule async history tree removal — never recursive-rmSync on the delete critical path.
deleteWorktreeHistoryDir(worktreeId)
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
pruneWorktreePRRefreshAliases(worktreeId)
if (!preservesSameIdOwner) {
advertisedUrlWatcher.forgetWorktree(worktreeId)
// Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session.
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
// Why: schedule async history tree removal — never recursive-rmSync on the delete critical path.
deleteWorktreeHistoryDir(worktreeId)
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
pruneWorktreePRRefreshAliases(worktreeId)
}
// Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots.
const snapshotDirectory = store.getProfileStorageDirectory()
if (snapshotPruneBatchId) {
@@ -2549,7 +2552,12 @@ export function registerWorktreeHandlers(
: hasLocalWorktreeGitOptions
? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions)
: await listGitWorktreesStrict(repo.path)
const removedMeta = store.getWorktreeMeta(args.worktreeId)
const removedMeta = resolveWorktreeRemovalMetadata(
store,
repoId,
args.worktreeId,
removalHostId
)
const removedPushTarget = removedMeta?.pushTarget
const registeredWorktree = findRegisteredDeletableWorktree(
repo.path,
@@ -3146,7 +3154,7 @@ export function registerWorktreeHandlers(
args.worktreeId,
repo
? getRepoExecutionHostId(repo)
: (store.getWorktreeMeta(args.worktreeId)?.hostId ?? args.hostId)
: (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId)
)
const optionsKey = 'forget-local'
const inFlight = worktreeRemovalsInFlight.get(inFlightKey)
@@ -12,8 +12,11 @@ import {
writeDataFile,
readDataFile,
makeRepo,
makeTerminalTab
makeTerminalTab,
makeWorktreeLineage,
makeWorkspaceLineage
} from './persistence-test-harness'
import { worktreeWorkspaceKey } from '../shared/workspace-scope'
import { TEST_LEAF_1 } from './persistence-session-fixtures'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
@@ -516,14 +519,15 @@ describe('Store host-partitioned workspace sessions', () => {
expect(
store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.['repo-split']
).toBe(1)
).toBeUndefined()
expect(
store.getWorkspaceSession('local').terminalTopologyRevisionByRepoId?.['repo-split']
).toBeUndefined()
expect(store.getWorkspaceSession('local').tabsByWorktree[otherWorktreeId]).toHaveLength(1)
expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('runtime:env-a')
})
it('trusts persisted ownership over a stale caller hostId', async () => {
it('preserves a same-id persisted owner when another qualified host is removed', async () => {
const store = await createStore()
const worktreeId = 'repo-split::/workspace/stale'
const session = {
@@ -532,16 +536,62 @@ describe('Store host-partitioned workspace sessions', () => {
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
}
}
store.setWorkspaceSession(session, 'runtime:env-a')
store.setWorkspaceSession(session, 'local')
store.setWorkspaceSession(session, 'runtime:env-b')
store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' })
store.setWorktreeMeta(worktreeId, { hostId: 'local' })
const worktreeLineage = makeWorktreeLineage({ worktreeId })
const workspaceLineage = makeWorkspaceLineage({
childWorkspaceKey: worktreeWorkspaceKey(worktreeId)
})
store.setWorktreeLineage(worktreeId, worktreeLineage)
store.setWorkspaceLineage(workspaceLineage)
// A caller's hostId comes from live routing and can go stale mid-removal; the same
// repoId::path can name a live worktree on env-b, whose tabs must survive.
// The confirmed removal target is env-b. Bare metadata belongs to the same-id
// local owner and must not redirect the post-delete purge back to local.
store.removeWorktreeMeta(worktreeId, 'runtime:env-b')
expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined()
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toHaveLength(1)
expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('local')
expect(store.getWorktreeLineage(worktreeId)).toEqual(worktreeLineage)
expect(store.getWorkspaceLineage(workspaceLineage.childWorkspaceKey)).toEqual(workspaceLineage)
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toHaveLength(1)
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toBeUndefined()
})
it('preserves a surviving local session when removed-host metadata owns the shared id', async () => {
const store = await createStore()
const worktreeId = 'repo-split::/workspace/stale'
store.addRepo(makeRepo({ id: 'repo-split', path: '/local/repo' }))
store.addRepo(
makeRepo({
id: 'repo-split',
path: '/remote/repo',
connectionId: 'ssh-b',
executionHostId: 'ssh:ssh-b'
})
)
const localSession = {
...makeHostSession('repo-split'),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'same-id-local-tab', worktreeId })]
}
}
const remoteSession = {
...makeHostSession('repo-split'),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'removed-remote-tab', worktreeId })]
}
}
store.setWorkspaceSession(localSession, 'local')
store.setWorkspaceSession(remoteSession, 'ssh:ssh-b')
store.setWorktreeMeta(worktreeId, { hostId: 'ssh:ssh-b' })
store.removeWorktreeMeta(worktreeId, 'ssh:ssh-b')
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toEqual([
expect.objectContaining({ id: 'same-id-local-tab' })
])
expect(store.getWorkspaceSession('ssh:ssh-b').tabsByWorktree[worktreeId]).toBeUndefined()
expect(store.getWorktreeMeta(worktreeId)).toBeUndefined()
})
it('falls back to the caller hostId only when no ownership was recorded', async () => {
+28 -8
View File
@@ -117,6 +117,7 @@ import {
type RetiredNameRegistry
} from '../../../shared/worktree/retired-name-registry'
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id'
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
import { normalizeTaskProviderSettings } from '../../../shared/task-providers'
@@ -2484,17 +2485,34 @@ export class Store {
}
removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void {
// Persisted ownership beats stale live routing; hostId is only an ownerless fallback.
const owner = this.state.worktreeMeta[worktreeId]?.hostId ?? hostId
// A host-qualified removal names the owner; the persisted host is the fallback.
const persistedOwner = this.state.worktreeMeta[worktreeId]?.hostId
const owner = hostId ?? persistedOwner
const preservesDifferentPersistedOwner = Boolean(
hostId && persistedOwner && persistedOwner !== hostId
)
const ownerPartition = workspaceSessionOwnerPartitionForHost(owner)
const preservesSameIdSessionOwner = Boolean(
preservesDifferentPersistedOwner ||
(owner &&
hasWorktreeRemovalRepoOwnerOnOtherHost(
this,
getRepoIdFromWorktreeId(worktreeId),
ownerPartition
))
)
// Skip partitions main never wrote: materializing one fences every sibling worktree of the repo.
const partitions = new Set<ExecutionHostId>(
workspaceSessionPartitionIdsForHost(owner).filter((partition) =>
this.hasPersistedWorkspaceSession(partition)
workspaceSessionPartitionIdsForHost(owner).filter(
(partition) =>
this.hasPersistedWorkspaceSession(partition) &&
// The local partition can be a remote spill surface or a same-id owner.
// Preserve it whenever another owner may still use the bare id.
(!preservesSameIdSessionOwner || partition === ownerPartition)
)
)
// A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill
// partition that never held this worktree has no claim on the repo at all.
const ownerPartition = workspaceSessionOwnerPartitionForHost(owner)
const fencedPartitions = new Set(
[...partitions].filter(
(partition) =>
@@ -2503,9 +2521,11 @@ export class Store {
!this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition))
)
)
delete this.state.worktreeMeta[worktreeId]
delete this.state.worktreeLineageById[worktreeId]
delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)]
if (!preservesDifferentPersistedOwner) {
delete this.state.worktreeMeta[worktreeId]
delete this.state.worktreeLineageById[worktreeId]
delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)]
}
for (const partition of partitions) {
this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, {
advanceTerminalTopologyRevision: fencedPartitions.has(partition)
+439
View File
@@ -17,6 +17,7 @@ import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { Worktree } from '../../shared/worktree/types'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types'
import {
reviewHeadRemoteRefComponent,
@@ -2441,6 +2442,97 @@ describe('OrcaRuntimeService', () => {
expect(closeTab).toHaveBeenCalledTimes(2)
})
it('preserves bare-id runtime state when removing a different qualified owner', () => {
const removeWorktreeMeta = vi.fn()
const runtimeStore = {
...store,
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' }),
removeWorktreeMeta
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const internals = runtime as unknown as {
mobileSessionTabsByWorktree: Map<string, unknown>
removeWorktreeMetadataAndHistory: (
runtimeStore: typeof store,
worktreeId: string,
hostId: string
) => void
}
const localSession = { tabs: [{ id: 'local-tab' }] }
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, localSession)
internals.removeWorktreeMetadataAndHistory(
runtimeStore as typeof store,
TEST_WORKTREE_ID,
'runtime:env-b'
)
expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'runtime:env-b')
expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(localSession)
})
it('preserves bare-id runtime state when removed-host metadata masks another owner', () => {
const localRepo = store.getRepo(TEST_REPO_ID)!
const remoteRepo = { ...localRepo, connectionId: 'ssh-1' }
const removeWorktreeMeta = vi.fn()
const runtimeStore = {
...store,
getRepos: () => [localRepo, remoteRepo],
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'ssh:ssh-1' }),
removeWorktreeMeta
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const internals = runtime as unknown as {
mobileSessionTabsByWorktree: Map<string, unknown>
removeWorktreeMetadataAndHistory: (
runtimeStore: typeof store,
worktreeId: string,
hostId: string
) => void
}
const survivingSession = { tabs: [{ id: 'same-id-local-tab' }] }
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, survivingSession)
deleteWorktreeHistoryDirMock.mockClear()
internals.removeWorktreeMetadataAndHistory(
runtimeStore as typeof store,
TEST_WORKTREE_ID,
'ssh:ssh-1'
)
expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:ssh-1')
expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(survivingSession)
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
})
it('rejects a qualified removal when only another host has persisted ownership', async () => {
const runtimeStore = {
...store,
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' })
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const internals = runtime as unknown as {
listResolvedWorktrees: () => Promise<unknown[]>
resolveWorktreeRemovalTarget: (
worktreeSelector: string,
requiredHostId?: string
) => Promise<unknown>
}
internals.listResolvedWorktrees = vi.fn().mockResolvedValue([
{
id: TEST_WORKTREE_ID,
repoId: TEST_REPO_ID,
path: TEST_WORKTREE_PATH,
hostId: 'local'
}
])
await expect(
internals.resolveWorktreeRemovalTarget(TEST_WORKTREE_ID, 'runtime:env-b')
).rejects.toThrow('selector_not_found')
expect(internals.listResolvedWorktrees).not.toHaveBeenCalled()
})
it('claims the first window as authoritative and ignores later windows', () => {
const runtime = createRuntime()
@@ -41072,6 +41164,58 @@ describe('OrcaRuntimeService', () => {
])
})
it('keeps colliding folder workspace metadata scoped to its owning host', async () => {
const localRepo = {
id: 'folder-repo',
path: '/workspace/folder',
displayName: 'local-folder',
badgeColor: 'blue' as const,
addedAt: 1,
kind: 'folder' as const
}
const remoteRepo = {
...localRepo,
displayName: 'remote-folder',
connectionId: 'ssh-1'
}
const rootId = `${localRepo.id}::${localRepo.path}`
const childId = `${rootId}::workspace:local-child`
const metaById: Record<string, WorktreeMeta> = {
[rootId]: makeWorktreeMeta({
hostId: 'local',
displayName: 'local-root-name',
comment: 'local-only-comment',
isPinned: true
}),
[childId]: makeWorktreeMeta({
hostId: 'local',
instanceId: 'local-child',
displayName: 'local-child-name'
})
}
const setWorktreeMeta = vi.fn()
const runtime = new OrcaRuntimeService({
...store,
getRepos: () => [localRepo, remoteRepo],
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta
} as never)
const result = await runtime.listDetectedManagedWorktrees(`id:${remoteRepo.id}`, 'ssh-1')
expect(result.worktrees).toEqual([
expect.objectContaining({
id: rootId,
hostId: 'ssh:ssh-1',
displayName: 'remote-folder',
comment: '',
isPinned: false
})
])
expect(setWorktreeMeta).not.toHaveBeenCalled()
})
it('hides agent scratch created inside a linked checkout from runtime listings', async () => {
const linkedCheckoutPath = '/tmp/worktree-a'
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
@@ -41153,6 +41297,135 @@ describe('OrcaRuntimeService', () => {
expect(listWorktrees).toHaveBeenCalledTimes(1)
})
it('worktree scan cache: keeps colliding local and SSH owners warm independently', async () => {
vi.mocked(listWorktrees).mockClear()
const localRepo = store.getRepo(TEST_REPO_ID)!
const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' }
const provider = {
listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/worktree')])
}
registerSshGitProvider('ssh-1', provider as never)
const runtime = new OrcaRuntimeService({
...store,
getRepos: () => [localRepo, remoteRepo]
} as never)
vi.mocked(listWorktrees).mockResolvedValue([makeWorktreeInfo(TEST_WORKTREE_PATH)])
try {
await runtime.listManagedWorktrees()
runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID)
await runtime.listManagedWorktrees()
expect(listWorktrees).toHaveBeenCalledTimes(1)
expect(provider.listWorktrees).toHaveBeenCalledTimes(1)
unregisterSshGitProvider('ssh-1')
const replacementProvider = {
listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/replacement')])
}
registerSshGitProvider('ssh-1', replacementProvider as never)
runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID)
await runtime.listManagedWorktrees()
expect(listWorktrees).toHaveBeenCalledTimes(1)
expect(replacementProvider.listWorktrees).toHaveBeenCalledTimes(1)
} finally {
unregisterSshGitProvider('ssh-1')
}
})
it('keeps detected metadata scoped to the selected colliding host', async () => {
const localRepo = store.getRepo(TEST_REPO_ID)!
const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' }
const worktreePath = '/same/worktree'
const worktreeId = `${TEST_REPO_ID}::${worktreePath}`
const localMeta = makeWorktreeMeta({
hostId: 'local',
displayName: 'local-only-name',
comment: 'local-only-comment',
isPinned: true
})
const runtimeStore = {
...store,
getRepos: () => [localRepo, remoteRepo],
getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined),
getAllWorktreeMeta: () => ({ [worktreeId]: localMeta }),
getWorktreeMeta: (id: string) => (id === worktreeId ? localMeta : undefined)
}
const provider = { listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo(worktreePath)]) }
registerSshGitProvider('ssh-1', provider as never)
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`, 'ssh-1')
expect(result.worktrees).toEqual([
expect.objectContaining({
id: worktreeId,
hostId: 'ssh:ssh-1',
comment: '',
isPinned: false
})
])
expect(result.worktrees[0]?.displayName).not.toBe('local-only-name')
} finally {
unregisterSshGitProvider('ssh-1')
}
})
it('does not project one colliding host lineage onto another host rows', async () => {
const localRepo = store.getRepo(TEST_REPO_ID)!
const remoteRepo = { ...localRepo, connectionId: 'ssh-1' }
const parentPath = '/same/parent'
const childPath = '/same/child'
const parentId = `${TEST_REPO_ID}::${parentPath}`
const childId = `${TEST_REPO_ID}::${childPath}`
const metaById: Record<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', instanceId: 'remote-parent' }),
[childId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', instanceId: 'remote-child' })
}
const lineageById: Record<string, WorktreeLineage> = {
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'remote-child',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'remote-parent',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1
}
}
const runtimeStore = {
...store,
getRepos: () => [localRepo, remoteRepo],
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (id: string) => metaById[id],
getAllWorktreeLineage: () => lineageById
}
const rows = [makeWorktreeInfo(childPath), makeWorktreeInfo(parentPath)]
vi.mocked(listWorktrees).mockResolvedValue(rows)
const provider = { listWorktrees: vi.fn().mockResolvedValue(rows) }
registerSshGitProvider('ssh-1', provider as never)
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
const resolved = await (
runtime as unknown as { listResolvedWorktrees: () => Promise<Worktree[]> }
).listResolvedWorktrees()
const localChild = resolved.find(
(worktree) => worktree.id === childId && worktree.hostId === 'local'
)
const remoteChild = resolved.find(
(worktree) => worktree.id === childId && worktree.hostId === 'ssh:ssh-1'
)
expect(localChild).toMatchObject({ parentWorktreeId: null, lineage: null })
expect(remoteChild).toMatchObject({ parentWorktreeId: parentId })
} finally {
unregisterSshGitProvider('ssh-1')
}
})
it('worktree scan cache: rescans immediately after worktree invalidation', async () => {
vi.mocked(listWorktrees).mockClear()
const runtime = createRuntime()
@@ -47592,6 +47865,55 @@ describe('OrcaRuntimeService', () => {
expect(stopAndWait).not.toHaveBeenCalled()
})
it('fails closed before teardown when a qualified target loses its host owner', async () => {
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, {
hostId: 'local'
})
const orphanStore = {
...runtimeStore,
getRepos: () => [],
getRepo: () => undefined
}
const localProvider = {
listProcesses: vi.fn(async () => [{ id: `${TEST_WORKTREE_ID}@@1` }]),
shutdown: vi.fn(async () => {})
}
const runtime = new OrcaRuntimeService(orphanStore as never, undefined, {
getLocalProvider: () => localProvider as never
})
const stopAndWait = vi.fn().mockResolvedValue(true)
runtime.setPtyController({
write: () => true,
kill: () => true,
stopAndWait,
getForegroundProcess: async () => null
})
syncSinglePty(runtime, 'local-pty-1')
runtime.registerPty('local-pty-1', TEST_WORKTREE_ID)
const internals = runtime as unknown as {
resolveWorktreeRemovalTarget: () => Promise<{
id: string
repoId: string
path: string
}>
}
internals.resolveWorktreeRemovalTarget = vi.fn().mockResolvedValue({
id: TEST_WORKTREE_ID,
repoId: TEST_REPO_ID,
path: TEST_WORKTREE_PATH
})
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'runtime:env-b')
).rejects.toThrow('no longer belongs to runtime:env-b')
expect(localProvider.listProcesses).not.toHaveBeenCalled()
expect(localProvider.shutdown).not.toHaveBeenCalled()
expect(stopAndWait).not.toHaveBeenCalled()
expect(removeWorktreeMeta).not.toHaveBeenCalled()
expect(closeLocalWatcherForWorktreePathMock).not.toHaveBeenCalled()
})
it('still sweeps the local host for an ownerless orphan', async () => {
const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
const orphanStore = {
@@ -48271,6 +48593,85 @@ describe('OrcaRuntimeService', () => {
}
})
it('force-deletes a preserved branch on the qualified host when repo ids collide', async () => {
const localRepo = store.getRepo(TEST_REPO_ID)!
const remoteRepo = {
...localRepo,
path: '/remote/repo',
connectionId: 'ssh-1'
}
const remoteWorktree = {
path: TEST_WORKTREE_PATH,
head: 'def456',
branch: 'feature/test',
isBare: false,
isMainWorktree: false
}
const metaById: Record<string, WorktreeMeta> = {
[TEST_WORKTREE_ID]: makeWorktreeMeta({
hostId: 'local',
preserveBranchOnDelete: true
})
}
const runtimeStore = {
...store,
getRepos: () => [localRepo, remoteRepo],
getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
return metaById[worktreeId]
},
removeWorktreeMeta: (worktreeId: string, hostId?: string) => {
if (!hostId || metaById[worktreeId]?.hostId === hostId) {
delete metaById[worktreeId]
}
}
}
const provider = {
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: remoteRepo.path,
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
remoteWorktree
]),
removeWorktree: vi.fn().mockResolvedValue({
preservedBranch: { branchName: 'feature/test', head: 'def456' }
})
}
registerSshGitProvider('ssh-1', provider as never)
const runtime = createWorktreeRemovalRuntime(runtimeStore)
try {
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'ssh:ssh-1')
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false)
expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local')
const result = await runtime.forceDeletePreservedBranch(
TEST_WORKTREE_ID,
'feature/test',
'def456',
'ssh:ssh-1'
)
expect(result).toEqual({ deleted: true })
expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith(
remoteRepo.path,
'feature/test',
'def456'
)
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
} finally {
unregisterSshGitProvider('ssh-1')
}
})
it('routes runtime preserved-branch force-delete through the selected WSL project runtime', async () => {
setPlatform('win32')
const runtimeStore = {
@@ -48347,6 +48748,44 @@ describe('OrcaRuntimeService', () => {
await expect(Promise.all([first, second])).resolves.toEqual([{}, {}])
})
it('does not coalesce concurrent same-id removals on different hosts', async () => {
const runtimeStore = {
...store,
getRepos: () => [
{ ...store.getRepos()[0], executionHostId: 'local' },
{ ...store.getRepos()[0], executionHostId: 'runtime:env-1' }
]
}
const runtime = createWorktreeRemovalRuntime(runtimeStore)
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
const bothStarted = deferred<void>()
const finishRemovals = deferred<void>()
let startedCount = 0
vi.mocked(removeWorktree).mockImplementation(async () => {
startedCount += 1
if (startedCount === 2) {
bothStarted.resolve()
}
await finishRemovals.promise
return {}
})
const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local')
const paired = runtime.removeManagedWorktree(
TEST_WORKTREE_ID,
true,
false,
false,
'runtime:env-1'
)
await bothStarted.promise
expect(removeWorktree).toHaveBeenCalledTimes(2)
finishRemovals.resolve()
await expect(Promise.all([local, paired])).resolves.toEqual([{}, {}])
})
it('rejects concurrent runtime worktree removals for the same id with different options', async () => {
const runtime = createWorktreeRemovalRuntime()
const removeStarted = deferred<void>()
+190 -75
View File
@@ -823,6 +823,7 @@ import {
RESOLVED_WORKTREE_REPO_TIMEOUT_MS,
type RepoWorktreeRowDeps
} from './repo-worktree-row-resolution'
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import {
getLocalWorktreePathAccess,
@@ -1088,6 +1089,11 @@ import {
stripOrcaProvenanceMetaUpdates,
UNREGISTERED_MISSING_WORKTREE_MESSAGE
} from '../worktree-removal-safety'
import {
hasWorktreeRemovalRepoOwnerOnOtherHost,
resolveWorktreeRemovalMetadata,
resolveWorktreeRemovalRepoOwner
} from '../worktree-removal-repo-owner'
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import {
@@ -2326,13 +2332,17 @@ function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: strin
}
function listRuntimeFolderWorkspaces(
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'setWorktreeMeta'>,
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'getRepos' | 'setWorktreeMeta'>,
repo: Repo
): Worktree[] {
const rootId = getRuntimeFolderWorkspaceRootId(repo)
const allMeta = store.getAllWorktreeMeta()
const ids = Object.keys(allMeta).filter((worktreeId) =>
isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId)
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const ids = Object.keys(allMeta).filter(
(worktreeId) =>
isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) &&
(repoOwnerCount === 1 || allMeta[worktreeId]?.hostId === expectedHostId)
)
if (!ids.includes(rootId)) {
ids.unshift(rootId)
@@ -2349,14 +2359,19 @@ function listRuntimeFolderWorkspaces(
}
return ids.map((worktreeId) => {
const existing = allMeta[worktreeId]
const meta = existing?.instanceId
const existing = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount)
const meta: Partial<WorktreeMeta> = existing?.instanceId
? existing
: store.setWorktreeMeta(worktreeId, {
instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId),
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
})
return mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
: existing || repoOwnerCount === 1
? store.setWorktreeMeta(worktreeId, {
instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId),
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
})
: {}
return {
...mergeRuntimeFolderWorkspace(repo, worktreeId, meta),
hostId: repoOwnerCount === 1 ? (meta.hostId ?? expectedHostId) : expectedHostId
}
})
}
@@ -22259,12 +22274,20 @@ export class OrcaRuntimeService {
const visibilitySettings = { ...settings, worktreeVisibilityDefaults: visibilityDefaults }
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(store, repo)
const metaById = store.getAllWorktreeMeta()
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
const detected = worktrees.map((worktree) =>
this.toRuntimeDetectedWorktree(repo, worktree, matcher, visibilitySettings)
this.toRuntimeDetectedWorktree(
repo,
worktree,
matcher,
visibilitySettings,
getRepoOwnedWorktreeMeta(repo, worktree.id, metaById, repoOwnerCount) ?? null
)
)
return {
repoId: repo.id,
@@ -22286,18 +22309,22 @@ export class OrcaRuntimeService {
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const metaById = store.getAllWorktreeMeta()
const detected = scan.worktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
const worktree = {
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
hostId: meta?.hostId ?? getRepoExecutionHostId(repo)
hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId
}
const detectedWorktree = this.toRuntimeDetectedWorktree(
repo,
worktree,
worktreeVisibilitySourceMatcher,
visibilitySettings
visibilitySettings,
meta ?? null
)
if (scan.ok) {
return detectedWorktree
@@ -22402,7 +22429,8 @@ export class OrcaRuntimeService {
repo: Repo,
worktree: Worktree,
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher,
providedSettings?: ReturnType<RuntimeStore['getSettings']>
providedSettings?: ReturnType<RuntimeStore['getSettings']>,
providedMeta?: WorktreeMeta | null
): DetectedWorktree {
const settings = providedSettings ?? this.store?.getSettings()
if (!settings) {
@@ -22416,7 +22444,10 @@ export class OrcaRuntimeService {
return toDetectedWorktree({
repo,
worktree,
meta: this.store?.getWorktreeMeta(worktree.id),
meta:
providedMeta === undefined
? this.store?.getWorktreeMeta(worktree.id)
: (providedMeta ?? undefined),
settings,
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo),
isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo),
@@ -25523,10 +25554,18 @@ export class OrcaRuntimeService {
}
private async resolveWorktreeRemovalTarget(
worktreeSelector: string
worktreeSelector: string,
requiredHostId?: ExecutionHostId
): Promise<RuntimeWorktreeRemovalTarget> {
try {
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const exactTarget = parseExactWorktreeIdSelector(worktreeSelector)
const worktree =
exactTarget && requiredHostId
? ((await this.resolveExplicitWorktreeIdScoped(exactTarget.id, requiredHostId)) ??
(() => {
throw new Error('selector_not_found')
})())
: await this.resolveWorktreeSelector(worktreeSelector)
const removalTarget = {
id: worktree.id,
repoId: worktree.repoId,
@@ -25541,7 +25580,11 @@ export class OrcaRuntimeService {
}
const removalTarget = parseExactWorktreeIdSelector(worktreeSelector)
const meta = removalTarget ? this.store?.getWorktreeMeta(removalTarget.id) : undefined
if (!removalTarget || !meta) {
if (
!removalTarget ||
!meta ||
(requiredHostId !== undefined && meta.hostId !== requiredHostId)
) {
throw error
}
// Why: delete requests can arrive after Git no longer lists the worktree.
@@ -25551,21 +25594,33 @@ export class OrcaRuntimeService {
}
}
private removeWorktreeMetadataAndHistory(store: RuntimeStore, worktreeId: string): void {
private removeWorktreeMetadataAndHistory(
store: RuntimeStore,
worktreeId: string,
hostId?: ExecutionHostId
): void {
// Why: worktree IDs are path-derived and can be recreated, so removal must
// purge history and process-local caches before the ID points at new state.
const hostId = store.getWorktreeMeta(worktreeId)?.hostId
const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId
const repoId = splitWorktreeId(worktreeId)?.repoId
const preservesSameIdOwner = Boolean(
hostId &&
((persistedHostId && persistedHostId !== hostId) ||
(repoId && hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)))
)
if (hostId) {
store.removeWorktreeMeta(worktreeId, hostId)
} else {
store.removeWorktreeMeta(worktreeId)
}
this.mobileSessionTabsByWorktree.delete(worktreeId)
this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId)
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
advertisedUrlWatcher.forgetWorktree(worktreeId)
deleteWorktreeHistoryDir(worktreeId)
this.closeHeadlessBrowserPagesForWorktree(worktreeId)
if (!preservesSameIdOwner) {
this.mobileSessionTabsByWorktree.delete(worktreeId)
this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId)
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
advertisedUrlWatcher.forgetWorktree(worktreeId)
deleteWorktreeHistoryDir(worktreeId)
this.closeHeadlessBrowserPagesForWorktree(worktreeId)
}
}
// Why: headless offscreen browser pages are main-process BrowserWindows that
@@ -25662,7 +25717,17 @@ export class OrcaRuntimeService {
throw new Error(`No preserved branch cleanup is pending for "${branchName}".`)
}
const repo = this.store.getRepo(removalTarget.repoId)
const repoOwner = resolveWorktreeRemovalRepoOwner(
this.store,
removalTarget.repoId,
cleanupTarget.hostId
)
if (repoOwner.kind === 'ambiguous') {
throw new Error(
`Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.`
)
}
const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined
if (!repo) {
throw new Error('repo_not_found')
}
@@ -25729,13 +25794,13 @@ export class OrcaRuntimeService {
}
const store = this.store
const cleanupHostId = parseExecutionHostId(hostId)?.id
const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector)
const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId)
const cleanupScopeKey = preservedBranchCleanupScopeKey({
worktreeId: removalTarget.id,
hostId: cleanupHostId
})
const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop)
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id)
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(cleanupScopeKey)
if (inFlightRemoval) {
if (inFlightRemoval.optionsKey === optionsKey) {
return inFlightRemoval.promise
@@ -25744,14 +25809,32 @@ export class OrcaRuntimeService {
}
// Why: runtime callers can race the same workspace through CLI/mobile
// retries. Share one destructive Git/filesystem operation per worktree ID.
// retries. Share one destructive operation per host-qualified workspace.
const removal = (async (): Promise<RemoveWorktreeResult & { warning?: string }> => {
// Why: CLI, mobile and headless serve delete through here rather than the IPC handler; without
// this span their freezes are as invisible as desktop deletes were before `worktree.remove`.
return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => {
const repo = store.getRepo(removalTarget.repoId)
// Why (STA-4343): a repo id can exist once per host. Honor the caller's
// host qualifier, and refuse an unqualified delete the runtime cannot
// pin to one owner rather than deleting a same-id workspace elsewhere.
const repoOwner = resolveWorktreeRemovalRepoOwner(
store,
removalTarget.repoId,
cleanupHostId
)
if (repoOwner.kind === 'ambiguous') {
throw new Error(
`Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.`
)
}
const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined
if (!repo) {
const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId)
if (cleanupHostId && orphanHost?.id !== cleanupHostId) {
throw new Error(
`Workspace identity for ${removalTarget.id} no longer belongs to ${cleanupHostId}. Refresh projects and try again.`
)
}
const sshPtyProvider =
orphanHost?.kind === 'ssh' ? this.getSshProviderFn?.(orphanHost.targetId) : undefined
const ptyProvider = sshPtyProvider ?? this.getLocalProvider()
@@ -25796,7 +25879,11 @@ export class OrcaRuntimeService {
.catch(() => {})
}
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(
store,
removalTarget.id,
cleanupHostId ?? orphanHost?.id
)
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
@@ -25839,7 +25926,7 @@ export class OrcaRuntimeService {
console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)
})
}
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
this.invalidateResolvedWorktreeCache()
this.notifyWorktreesChanged(repo.id)
@@ -25856,7 +25943,12 @@ export class OrcaRuntimeService {
: hasLocalWorktreeGitOptions
? await listWorktreesStrict(repo.path, localWorktreeGitOptions)
: await listWorktreesStrict(repo.path)
const removedMeta = store.getWorktreeMeta(removalTarget.id)
const removedMeta = resolveWorktreeRemovalMetadata(
store,
removalTarget.repoId,
removalTarget.id,
cleanupHostId ?? getRepoExecutionHostId(repo)
)
const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget
const registeredWorktree = findRegisteredDeletableWorktree(
repo.path,
@@ -25944,7 +26036,7 @@ export class OrcaRuntimeService {
)
}
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
@@ -25993,7 +26085,7 @@ export class OrcaRuntimeService {
localWorktreeGitOptions
)
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
@@ -26029,7 +26121,7 @@ export class OrcaRuntimeService {
localWorktreeGitOptions
))
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
@@ -26083,7 +26175,7 @@ export class OrcaRuntimeService {
removedPushTarget
)
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
invalidateAuthorizedRootsCache()
@@ -26129,7 +26221,7 @@ export class OrcaRuntimeService {
removedPushTarget
)
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
invalidateAuthorizedRootsCache()
@@ -26285,7 +26377,7 @@ export class OrcaRuntimeService {
localWorktreeGitOptions
)
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.preservedBranchCleanupByScope.delete(cleanupScopeKey)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
@@ -26319,7 +26411,7 @@ export class OrcaRuntimeService {
removedPushTarget
)
this.clearOptimisticReconcileToken(removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId)
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId)
invalidateAuthorizedRootsCache()
@@ -26330,7 +26422,7 @@ export class OrcaRuntimeService {
}
})
})()
this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal })
this.removeManagedWorktreeInFlight.set(cleanupScopeKey, { optionsKey, promise: removal })
try {
const result = await removal
this.emitWorktreeLifecycle({
@@ -26340,8 +26432,8 @@ export class OrcaRuntimeService {
})
return result
} finally {
if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) {
this.removeManagedWorktreeInFlight.delete(removalTarget.id)
if (this.removeManagedWorktreeInFlight.get(cleanupScopeKey)?.promise === removal) {
this.removeManagedWorktreeInFlight.delete(cleanupScopeKey)
}
}
}
@@ -30773,9 +30865,9 @@ export class OrcaRuntimeService {
async (repo) => await resolveRepoWorktreeRows(deps, repo, metaById, projectRuntimeByRepoId)
)
)
const worktrees = projectResolvedWorktreeLineage(
perRepoWorktrees.flat(),
this.store?.getAllWorktreeLineage?.() ?? {}
const lineageById = this.store?.getAllWorktreeLineage?.() ?? {}
const worktrees = perRepoWorktrees.flatMap((rows) =>
projectResolvedWorktreeLineage(rows, lineageById)
)
// Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca.
// Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an
@@ -30802,12 +30894,13 @@ export class OrcaRuntimeService {
}
private async resolveExplicitWorktreeIdScoped(
worktreeId: string
worktreeId: string,
requiredHostId?: ExecutionHostId
): Promise<ResolvedWorktree | null> {
if (!this.store) {
return null
}
return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId)
return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId, requiredHostId)
}
private async listRepoWorktreesForResolution(
@@ -30815,12 +30908,13 @@ export class OrcaRuntimeService {
projectRuntimeByRepoId?: ReadonlyMap<string, ProjectExecutionRuntimeResolution>
): Promise<RuntimeWorktreeScanResult> {
const now = Date.now()
const generation = this.worktreeScanGenerations.get(repo.id) ?? 0
const projectRuntime = projectRuntimeByRepoId
? projectRuntimeByRepoId.get(repo.id)
: !repo.connectionId
? resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
: undefined
const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}`
const generation = this.worktreeScanGenerations.get(scanScopeKey) ?? 0
const projectRuntime = repo.connectionId
? undefined
: projectRuntimeByRepoId
? projectRuntimeByRepoId.get(repo.id)
: resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
const runtimeKey = projectRuntime
? projectRuntime.status === 'resolved'
? projectRuntime.runtime.cacheKey
@@ -30828,7 +30922,7 @@ export class OrcaRuntimeService {
: repo.connectionId
? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}`
: 'local:default'
const cached = this.worktreeScanCache.get(repo.id)
const cached = this.worktreeScanCache.get(scanScopeKey)
if (
cached?.generation === generation &&
cached.runtimeKey === runtimeKey &&
@@ -30836,21 +30930,21 @@ export class OrcaRuntimeService {
) {
return cached.result
}
const inFlight = this.worktreeScanInFlight.get(repo.id)
const inFlight = this.worktreeScanInFlight.get(scanScopeKey)
if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) {
return (await inFlight.promise).result
}
const reusableCached =
cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null
const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached)
this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise })
this.worktreeScanInFlight.set(scanScopeKey, { generation, runtimeKey, promise })
try {
const refresh = await promise
// Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll.
if (
(refresh.result.ok || !repo.connectionId) &&
generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) &&
this.worktreeScanInFlight.get(repo.id)?.promise === promise
generation === (this.worktreeScanGenerations.get(scanScopeKey) ?? 0) &&
this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise
) {
const entry: RuntimeWorktreeScanCache = {
generation,
@@ -30860,19 +30954,19 @@ export class OrcaRuntimeService {
adminFingerprint: refresh.adminFingerprint,
scannedAt: refresh.scannedAt
}
this.worktreeScanCache.set(repo.id, entry)
this.worktreeScanCache.set(scanScopeKey, entry)
// Why a writeback instead of storing the promise: a probe that never settles must not be
// awaited by a later refresh. Identity check keeps a stale probe out of a newer entry.
void refresh.adminFingerprintProbe?.then((fingerprint) => {
if (this.worktreeScanCache.get(repo.id) === entry) {
if (this.worktreeScanCache.get(scanScopeKey) === entry) {
entry.adminFingerprint = fingerprint
}
})
}
return refresh.result
} finally {
if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) {
this.worktreeScanInFlight.delete(repo.id)
if (this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise) {
this.worktreeScanInFlight.delete(scanScopeKey)
}
}
}
@@ -30980,22 +31074,43 @@ export class OrcaRuntimeService {
}
private invalidateWorktreeScanCacheForRepo(repoId: string): void {
this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1)
this.worktreeScanCache.delete(repoId)
this.worktreeScanInFlight.delete(repoId)
const prefix = `${repoId}\0`
const scopeKeys = new Set(
this.store
?.getRepos()
.filter((repo) => repo.id === repoId)
.map((repo) => `${repoId}\0${getRepoExecutionHostId(repo)}`) ?? []
)
for (const keys of [
this.worktreeScanGenerations.keys(),
this.worktreeScanCache.keys(),
this.worktreeScanInFlight.keys()
]) {
for (const key of keys) {
if (key.startsWith(prefix)) {
scopeKeys.add(key)
}
}
}
for (const key of scopeKeys) {
this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1)
this.worktreeScanCache.delete(key)
this.worktreeScanInFlight.delete(key)
}
}
private invalidateSshWorktreeScanCacheInternal(targetId: string): void {
const repos = this.store?.getRepos() ?? []
const affectedRepoIds = new Set(
repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id)
const affectedRepos = repos.filter((repo) => repo.connectionId === targetId)
const affectedScopeKeys = new Set(
affectedRepos.map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`)
)
for (const repoId of affectedRepoIds) {
this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1)
this.worktreeScanCache.delete(repoId)
this.worktreeScanInFlight.delete(repoId)
for (const key of affectedScopeKeys) {
this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1)
this.worktreeScanCache.delete(key)
this.worktreeScanInFlight.delete(key)
}
if (affectedRepoIds.size > 0) {
if (affectedScopeKeys.size > 0) {
this.resolvedWorktreeGeneration += 1
this.resolvedWorktreeCache = null
}
@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import type { Store } from '../persistence'
import {
resolveScopedWorktreeIdRow,
type RepoWorktreeRowDeps
} from './repo-worktree-row-resolution'
function repo(
id: string,
path: string,
options: Pick<Repo, 'connectionId' | 'executionHostId' | 'kind'> = {}
): Repo {
return {
id,
path,
displayName: id,
badgeColor: 'blue',
addedAt: 1,
...options
}
}
function gitWorktree(path: string): GitWorktreeInfo {
return {
path,
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
}
function createDeps(repos: Repo[]): RepoWorktreeRowDeps & {
metaById: Record<string, WorktreeMeta>
scanRepo: ReturnType<typeof vi.fn<RepoWorktreeRowDeps['scanRepo']>>
listFolderWorkspaces: ReturnType<typeof vi.fn<RepoWorktreeRowDeps['listFolderWorkspaces']>>
} {
const metaById: Record<string, WorktreeMeta> = {}
const store = {
getRepos: () => repos,
getAllWorktreeMeta: () => metaById,
getAllWorktreeLineage: () => ({}),
getProjects: () => [],
getSettings: () => ({}),
setWorktreeMeta: (worktreeId: string, updates: Partial<WorktreeMeta>) => {
const next = { ...metaById[worktreeId], ...updates } as WorktreeMeta
metaById[worktreeId] = next
return next
}
} as unknown as Store
const scanRepo = vi.fn<RepoWorktreeRowDeps['scanRepo']>(async (owner) => ({
ok: true,
worktrees: [gitWorktree(owner.id === 'unrelated' ? '/unrelated/worktree' : '/same/worktree')]
}))
const listFolderWorkspaces = vi.fn<RepoWorktreeRowDeps['listFolderWorkspaces']>(() => [])
return { store, metaById, scanRepo, listFolderWorkspaces }
}
describe('host-qualified scoped worktree resolution', () => {
it('scans only the selected local or SSH owner when repo ids and paths collide', async () => {
const owners = [
repo('shared', '/local/repo', { executionHostId: 'local' }),
repo('shared', '/remote/repo', {
connectionId: 'builder',
executionHostId: 'ssh:builder'
}),
repo('unrelated', '/unrelated/repo', { connectionId: 'slow-box' })
]
const deps = createDeps(owners)
const worktreeId = 'shared::/same/worktree'
await expect(
resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder')
).resolves.toMatchObject({ id: worktreeId, hostId: 'ssh:builder' })
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[1]])
deps.scanRepo.mockClear()
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({
id: worktreeId,
hostId: 'local'
})
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[0]])
})
it('does not project another host metadata onto a colliding scoped row', async () => {
const deps = createDeps([
repo('shared', '/local/repo', { executionHostId: 'local' }),
repo('shared', '/remote/repo', {
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
])
const worktreeId = 'shared::/same/worktree'
deps.metaById[worktreeId] = {
displayName: 'local workspace',
hostId: 'local',
instanceId: 'local-instance',
preserveBranchOnDelete: true
} as WorktreeMeta
await expect(
resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder')
).resolves.toMatchObject({
id: worktreeId,
hostId: 'ssh:builder',
displayName: 'feature'
})
})
it.each([
['runtime:windows', String.raw`C:\Users\dev\orca worktree`],
['local', '/mnt/c/Users/dev/orca worktree']
] satisfies [ExecutionHostId, string][])(
'keeps %s path resolution scoped',
async (hostId, path) => {
const target = repo('shared', path, { executionHostId: hostId })
const deps = createDeps([
target,
repo('shared', '/other-host/repo', { executionHostId: 'ssh:unrelated' }),
repo('unrelated', '/unrelated/repo')
])
deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] }))
const worktreeId = `shared::${path}`
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, hostId)).resolves.toMatchObject({
id: worktreeId,
hostId,
path
})
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([target])
}
)
it('resolves only the selected folder-workspace owner without scanning Git providers', async () => {
const local = repo('folders', '/local/folders', {
kind: 'folder',
executionHostId: 'local'
})
const remote = repo('folders', '/remote/folders', {
kind: 'folder',
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
const deps = createDeps([local, remote, repo('unrelated', '/unrelated/repo')])
const worktreeId = 'folders::/shared/folder'
deps.listFolderWorkspaces.mockImplementation((owner) =>
owner === remote
? ([
{
id: worktreeId,
repoId: owner.id,
path: '/shared/folder',
head: '',
branch: '',
isBare: false,
isMainWorktree: false,
displayName: 'folder',
comment: '',
hostId: 'ssh:builder'
} as unknown as Worktree
] as Worktree[])
: []
)
await expect(
resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder')
).resolves.toMatchObject({ id: worktreeId, hostId: 'ssh:builder' })
expect(deps.listFolderWorkspaces).toHaveBeenCalledExactlyOnceWith(remote)
expect(deps.scanRepo).not.toHaveBeenCalled()
})
it('does not scan when the requested host does not own the repo id', async () => {
const deps = createDeps([
repo('shared', '/local/repo', { executionHostId: 'local' }),
repo('unrelated', '/unrelated/repo', { executionHostId: 'runtime:other' })
])
await expect(
resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree', 'ssh:builder')
).resolves.toBeNull()
expect(deps.scanRepo).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,5 @@
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import { isFolderRepo } from '../../shared/repo-kind'
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
import { withTimeout } from '../../shared/promise-timeout-fallback'
@@ -11,6 +11,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-exe
import type { Store } from '../persistence'
import { areWorktreePathsEqual, mergeWorktree } from '../ipc/worktree-logic'
import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning'
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
import { resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options'
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
@@ -120,15 +121,21 @@ export async function resolveRepoWorktreeRows(
if (scan.ok) {
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
}
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
return gitWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
// Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp.
const existingMeta = metaById[worktreeId]
const meta =
existingMeta && existingMeta.instanceId ? existingMeta : store.setWorktreeMeta(worktreeId, {})
const ownedExistingMeta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
const meta = ownedExistingMeta?.instanceId
? ownedExistingMeta
: ownedExistingMeta || (!existingMeta && repoOwnerCount === 1)
? store.setWorktreeMeta(worktreeId, {})
: undefined
const merged = {
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
hostId: existingMeta?.hostId ?? meta?.hostId ?? getRepoExecutionHostId(repo)
hostId: repoOwnerCount === 1 ? (existingMeta?.hostId ?? expectedHostId) : expectedHostId
}
return {
...merged,
@@ -157,16 +164,23 @@ export async function resolveRepoWorktreeRows(
*/
export async function resolveScopedWorktreeIdRow(
deps: RepoWorktreeRowDeps,
worktreeId: string
worktreeId: string,
requiredHostId?: ExecutionHostId
): Promise<RepoWorktreeRow | null> {
const { store } = deps
const parsed = splitWorktreeIdForFilesystem(worktreeId)
if (!parsed?.repoId || !parsed.worktreePath) {
return null
}
const owners = store.getRepos().filter((repo) => repo.id === parsed.repoId)
const owners = store
.getRepos()
.filter(
(repo) =>
repo.id === parsed.repoId &&
(requiredHostId === undefined || getRepoExecutionHostId(repo) === requiredHostId)
)
// Why: one repo id can be registered on several execution hosts, and only the fleet scan decides
// between their rows. Hand those back to the unscoped path rather than guessing.
// between unqualified rows. A host qualifier narrows the same-id set without scanning other owners.
if (owners.length !== 1) {
return null
}
@@ -23,6 +23,13 @@ describe('workspaceCleanup client-ui schema', () => {
expect(WorkspaceCleanup.parse({ dismissals: {} })).toEqual({ dismissals: {} })
})
it('preserves the optional host qualifier on a dismissal', () => {
const qualified = { ...DISMISSAL, executionHostId: 'ssh:ssh-1' }
const parsed = WorkspaceCleanup.parse({ dismissals: { 'ssh:ssh-1\0wt-1': qualified } })
expect(parsed.dismissals['ssh:ssh-1\0wt-1']).toEqual(qualified)
})
// The wire contract: a NEWER client's filter groups must never fail an older
// host's schema, or the whole ui.set payload is refused (dismissals included).
it('accepts a browse state carrying unknown future fields and narrows it', () => {
@@ -8,7 +8,8 @@ const WorkspaceCleanupDismissal = z.object({
worktreeId: z.string(),
dismissedAt: z.number().finite(),
fingerprint: z.string(),
classifierVersion: z.number().finite()
classifierVersion: z.number().finite(),
executionHostId: z.string().min(1).optional()
})
/**
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { WORKTREE_METHODS } from './worktree'
function makeRuntime(): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
showManagedWorktree: vi.fn().mockResolvedValue({ id: 'wt-1', hostId: 'local' }),
removeManagedWorktree: vi.fn().mockResolvedValue({})
} as unknown as OrcaRuntimeService
}
function makeRequest(params: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params }
}
describe('worktree.rm host qualification', () => {
it('routes an explicitly qualified removal to that host', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest({ worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false })
)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
false,
'local'
)
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
it('resolves an old-client removal through the ambiguity-aware worktree lookup', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false })
)
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
false,
'local'
)
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
it('fails closed when an old-client selector is ambiguous', async () => {
const runtime = makeRuntime()
vi.mocked(runtime.showManagedWorktree).mockRejectedValue(new Error('selector_ambiguous'))
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false })
)
expect(response).toMatchObject({ ok: false, error: { message: 'selector_ambiguous' } })
expect(runtime.removeManagedWorktree).not.toHaveBeenCalled()
})
it('fails closed when an old-client row has no host evidence', async () => {
const runtime = makeRuntime()
vi.mocked(runtime.showManagedWorktree).mockResolvedValue({ id: 'wt-1' } as never)
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false })
)
expect(response).toMatchObject({
ok: false,
error: { message: 'worktree.rm could not resolve the workspace host' }
})
expect(runtime.removeManagedWorktree).not.toHaveBeenCalled()
})
})
@@ -25,10 +25,22 @@ describe('worktree.rm PTY-stop waiver', () => {
id: 'req-1',
authToken: 'tok',
method: 'worktree.rm',
params: { worktree: 'id:wt-1', force: true, allowUnverifiedPtyStop: true, runHooks: false }
params: {
worktree: 'id:wt-1',
hostId: 'local',
force: true,
allowUnverifiedPtyStop: true,
runHooks: false
}
} satisfies RpcRequest)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, true)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
true,
'local'
)
})
it('does not infer a waiver from force alone', async () => {
@@ -39,9 +51,15 @@ describe('worktree.rm PTY-stop waiver', () => {
id: 'req-1',
authToken: 'tok',
method: 'worktree.rm',
params: { worktree: 'id:wt-1', force: true, runHooks: false }
params: { worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false }
} satisfies RpcRequest)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, false)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
false,
'local'
)
})
})
@@ -76,27 +76,6 @@ describe('worktree RPC methods', () => {
})
})
it('routes dirty-file force to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
dedupeWorktreeCreate: passthroughDedupe,
removeManagedWorktree: vi.fn().mockResolvedValue({})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.rm', {
worktree: 'id:wt-1',
force: true,
runHooks: false
})
)
// Why (#11960): dirty-file force alone must not waive the PTY-stop proof.
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, false)
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
it('routes create options to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+6 -3
View File
@@ -201,15 +201,18 @@ export const WORKTREE_METHODS: RpcMethod[] = [
name: 'worktree.rm',
params: WorktreeRemove,
handler: async (params, { runtime }) => {
// Older mobile clients omit hostId, so resolve through the ambiguity gate before pinning removal.
const hostId = params.hostId ?? (await runtime.showManagedWorktree(params.worktree)).hostId
if (!hostId) {
throw new Error('worktree.rm could not resolve the workspace host')
}
const removalArgs = [
params.worktree,
params.force === true,
params.runHooks === true,
params.allowUnverifiedPtyStop === true
] as const
const result = params.hostId
? await runtime.removeManagedWorktree(...removalArgs, params.hostId)
: await runtime.removeManagedWorktree(...removalArgs)
const result = await runtime.removeManagedWorktree(...removalArgs, hostId)
return { removed: true, ...result }
}
}),
+1 -1
View File
@@ -24,7 +24,7 @@ export function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string
export function mergeRuntimeFolderWorkspace(
repo: Repo,
worktreeId: string,
meta: WorktreeMeta
meta: Partial<WorktreeMeta>
): Worktree {
const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta.creatorProvenance)
return {
+22
View File
@@ -0,0 +1,22 @@
import { getRepoExecutionHostId } from '../shared/execution-host'
import type { Repo } from '../shared/repo-types'
import type { WorktreeMeta } from '../shared/worktree/meta-types'
/** Same repo/worktree ids can exist on several hosts; bare metadata cannot choose between them. */
export function getRepoOwnedWorktreeMeta(
repo: Repo,
worktreeId: string,
metaById: Readonly<Record<string, WorktreeMeta>>,
repoOwnerCount: number
): WorktreeMeta | undefined {
const existingMeta = metaById[worktreeId]
return isWorktreeMetaOwnedByRepo(repo, existingMeta, repoOwnerCount) ? existingMeta : undefined
}
export function isWorktreeMetaOwnedByRepo(
repo: Repo,
meta: WorktreeMeta | undefined,
repoOwnerCount: number
): meta is WorktreeMeta {
return Boolean(meta && (repoOwnerCount === 1 || meta.hostId === getRepoExecutionHostId(repo)))
}
@@ -0,0 +1,110 @@
/**
* STA-4343 (host side): a repo id registered on two hosts has no single owner,
* so unqualified legacy resolution is ambiguous and a qualified call picks the
* named host. The destructive RPC boundary rejects missing qualifiers earlier.
*/
import { describe, expect, it } from 'vitest'
import {
hasWorktreeRemovalRepoOwnerOnOtherHost,
resolveWorktreeRemovalMetadata,
resolveWorktreeRemovalRepoOwner
} from './worktree-removal-repo-owner'
import type { Repo } from '../shared/repo-types'
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo {
return {
path: '/repos/shared',
displayName: 'Shared',
badgeColor: '#000',
addedAt: 0,
...overrides
} as Repo
}
function makeStore(repos: readonly Repo[]): {
getRepos: () => readonly Repo[]
getRepo: (repoId: string) => Repo | undefined
} {
return {
getRepos: () => repos,
getRepo: (repoId) => repos.find((repo) => repo.id === repoId)
}
}
describe('resolveWorktreeRemovalRepoOwner', () => {
const localRepo = makeRepo({ id: 'repo1' })
const sshRepo = makeRepo({ id: 'repo1', connectionId: 'ssh-1' })
it('fails closed when an unqualified request matches two hosts', () => {
expect(resolveWorktreeRemovalRepoOwner(makeStore([localRepo, sshRepo]), 'repo1')).toEqual({
kind: 'ambiguous'
})
})
it('selects the named host even when a same-id repo is registered first', () => {
const owner = resolveWorktreeRemovalRepoOwner(
makeStore([localRepo, sshRepo]),
'repo1',
'ssh:ssh-1'
)
expect(owner).toEqual({ kind: 'resolved', repo: sshRepo })
})
it('reports missing when the named host owns no such repo', () => {
expect(resolveWorktreeRemovalRepoOwner(makeStore([localRepo]), 'repo1', 'ssh:ssh-1')).toEqual({
kind: 'missing'
})
expect(resolveWorktreeRemovalRepoOwner(makeStore([]), 'repo1')).toEqual({ kind: 'missing' })
})
it('detects a surviving same-id owner only on another host', () => {
const store = makeStore([localRepo, sshRepo])
expect(hasWorktreeRemovalRepoOwnerOnOtherHost(store, 'repo1', 'ssh:ssh-1')).toBe(true)
expect(hasWorktreeRemovalRepoOwnerOnOtherHost(store, 'repo1', 'local')).toBe(true)
expect(hasWorktreeRemovalRepoOwnerOnOtherHost(makeStore([sshRepo]), 'repo1', 'ssh:ssh-1')).toBe(
false
)
})
it('does not borrow destructive metadata from a colliding host', () => {
const localMeta = { hostId: 'local', preserveBranchOnDelete: true }
const metadataStore = {
getRepos: () => [localRepo, sshRepo],
getWorktreeMeta: () => localMeta
}
expect(
resolveWorktreeRemovalMetadata(
metadataStore as never,
'repo1',
'repo1::/shared/worktree',
'ssh:ssh-1'
)
).toBeUndefined()
expect(
resolveWorktreeRemovalMetadata(
metadataStore as never,
'repo1',
'repo1::/shared/worktree',
'local'
)
).toBe(localMeta)
})
it('keeps legacy persisted host metadata when the repo id has one owner', () => {
const runtimeMeta = { hostId: 'runtime:env-1', preserveBranchOnDelete: true }
expect(
resolveWorktreeRemovalMetadata(
{
getRepos: () => [localRepo],
getWorktreeMeta: () => runtimeMeta
} as never,
'repo1',
'repo1::/shared/worktree',
'local'
)
).toBe(runtimeMeta)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host'
import type { Repo } from '../shared/repo-types'
import type { WorktreeMeta } from '../shared/worktree/meta-types'
export type WorktreeRemovalRepoSource = {
getRepos: () => readonly Repo[]
getRepo: (repoId: string) => Repo | undefined
}
export type WorktreeRemovalRepoOwner =
| { kind: 'resolved'; repo: Repo }
| { kind: 'ambiguous' }
| { kind: 'missing' }
/**
* Which repo a destructive worktree removal belongs to.
*
* A repo id can be registered once per execution host. Destructive RPC callers
* reject a missing host before reaching this resolver; the unqualified branch
* remains only for direct legacy callers and still refuses multiple owners.
*/
export function resolveWorktreeRemovalRepoOwner(
store: WorktreeRemovalRepoSource,
repoId: string,
hostId?: ExecutionHostId
): WorktreeRemovalRepoOwner {
const matches = store
.getRepos()
.filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
if (matches.length === 1 && matches[0]) {
return { kind: 'resolved', repo: matches[0] }
}
if (matches.length > 1) {
return { kind: 'ambiguous' }
}
const legacyMatch = store.getRepo(repoId)
return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId)
? { kind: 'resolved', repo: legacyMatch }
: { kind: 'missing' }
}
export function hasWorktreeRemovalRepoOwnerOnOtherHost(
store: Pick<WorktreeRemovalRepoSource, 'getRepos'>,
repoId: string,
hostId: ExecutionHostId
): boolean {
return store
.getRepos()
.some((repo) => repo.id === repoId && getRepoExecutionHostId(repo) !== hostId)
}
export function resolveWorktreeRemovalMetadata(
store: Pick<WorktreeRemovalRepoSource, 'getRepos'> & {
getWorktreeMeta: (worktreeId: string) => WorktreeMeta | undefined
},
repoId: string,
worktreeId: string,
hostId: ExecutionHostId
): WorktreeMeta | undefined {
const meta = store.getWorktreeMeta(worktreeId)
if (!meta) {
return undefined
}
const repoOwnerCount = store.getRepos().filter((repo) => repo.id === repoId).length
if (repoOwnerCount <= 1) {
return meta
}
return meta.hostId === hostId ? meta : undefined
}
@@ -17,8 +17,8 @@ vi.mock('./use-workspace-cleanup-facet-rows', () => ({
probes.facets()
return {
rows: [],
selectableWorktreeIds: [],
facetMatchedWorktreeIds: new Set(),
selectableIdentities: [],
facetMatchedIdentities: new Set(),
matchedCount: 0,
totalCount: 0,
facetCounts: {
@@ -161,7 +161,7 @@ function rowNames(): string[] {
}
function rowCheckbox(name: string): HTMLElement | null {
return container?.querySelector<HTMLElement>(`[aria-label="Select ${name}"]`) ?? null
return container?.querySelector<HTMLElement>(`[aria-label^="Select ${name} on "]`) ?? null
}
function cachedFleet(): WorkspaceCleanupScanResult {
@@ -11,10 +11,12 @@ import {
canQueueWorkspaceCleanupCandidate,
type WorkspaceCleanupCandidate
} from '../../../../shared/workspace-cleanup'
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row'
import { WorkspaceCleanupBrowseToolbar } from './workspace-cleanup-browse-toolbar'
import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove'
import { WorkspaceCleanupDialogHeader } from './workspace-cleanup-dialog-header'
import { getWorkspaceCleanupDeletionPhaseByIdentity } from './workspace-cleanup-deletion-phases'
import {
WorkspaceCleanupInitialScanBanner,
WorkspaceCleanupNotice,
@@ -36,7 +38,7 @@ import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evid
import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order'
import {
formatVanishedSelectionNotice,
getDefaultSelectedWorkspaceCleanupIds,
getDefaultSelectedWorkspaceCleanupIdentities,
toggleSetMember
} from './workspace-cleanup-selection-model'
@@ -68,7 +70,7 @@ function WorkspaceCleanupDialogContent({
const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace)
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
const workspaceSpaceProgress = useAppStore((s) => s.workspaceSpaceScanProgress)
const deletionPhaseByWorktreeId = useAppStore(
const genericDeletionPhaseByWorktreeId = useAppStore(
useShallow((s) => {
const phases: Record<string, WorkspaceCleanupDeletionPhase> = {}
for (const [worktreeId, state] of Object.entries(s.deleteStateByWorktreeId)) {
@@ -79,11 +81,6 @@ function WorkspaceCleanupDialogContent({
return phases
})
)
const deletingWorktreeIds = useMemo(
() => new Set(Object.keys(deletionPhaseByWorktreeId)),
[deletionPhaseByWorktreeId]
)
const browse = useWorkspaceCleanupBrowseState()
// Why: facet counts/options are only rendered inside the filter popover;
// tracking its open state lets their O(N) passes skip while it is closed.
@@ -113,6 +110,19 @@ function WorkspaceCleanupDialogContent({
}, [open])
const { removalInFlightRef } = removal
const deletionPhaseByIdentity = useMemo(
() =>
getWorkspaceCleanupDeletionPhaseByIdentity(
scan?.candidates ?? [],
removal.deletionPhaseByIdentity,
genericDeletionPhaseByWorktreeId
),
[genericDeletionPhaseByWorktreeId, removal.deletionPhaseByIdentity, scan?.candidates]
)
const deletingIdentities = useMemo(
() => new Set(Object.keys(deletionPhaseByIdentity)),
[deletionPhaseByIdentity]
)
const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates])
const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort)
@@ -122,8 +132,8 @@ function WorkspaceCleanupDialogContent({
scannedAt: scan?.scannedAt ?? null
})
const evidencedCandidates = useMemo(
() => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByWorktreeId),
[candidates, gitEvidence.evidenceByWorktreeId]
() => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByIdentity),
[candidates, gitEvidence.evidenceByIdentity]
)
// Why: a live clock would re-run every facet on each render; the newer of
// scan time and open time is the honest stable "as of" moment — a fresh scan
@@ -141,8 +151,11 @@ function WorkspaceCleanupDialogContent({
streaming: loading,
sort: browse.sort
})
const candidateIds = useMemo(
() => new Set(candidates.map((candidate) => candidate.worktreeId)),
// Why (STA-4343): rows are keyed by host-qualified identity, not by
// `worktreeId` — two hosts can list the same `repoId::path` workspace, and an
// id-keyed selection would confirm one host's row and delete the other's.
const candidateIdentities = useMemo(
() => new Set(candidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))),
[candidates]
)
@@ -171,16 +184,16 @@ function WorkspaceCleanupDialogContent({
}, [mountedRef, refreshWorkspaceSpace])
const selectedCandidates = useMemo(() => {
const byId = new Map(rows.map((row) => [row.worktreeId, row.candidate]))
const byIdentity = new Map(rows.map((row) => [row.identity, row.candidate]))
return [...selectedIds]
.map((id) => byId.get(id))
.map((identity) => byIdentity.get(identity))
.filter(
(candidate): candidate is WorkspaceCleanupCandidate =>
candidate != null &&
canQueueWorkspaceCleanupCandidate(candidate) &&
!deletingWorktreeIds.has(candidate.worktreeId)
!deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
)
}, [deletingWorktreeIds, rows, selectedIds])
}, [deletingIdentities, rows, selectedIds])
const selectedCount = selectedCandidates.length
useEffect(() => {
@@ -195,14 +208,17 @@ function WorkspaceCleanupDialogContent({
return
}
defaultsAppliedForOpenRef.current = true
setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds))
}, [deletingWorktreeIds, loading, removalInFlightRef, scan, setSelectedIds])
setSelectedIds(
getDefaultSelectedWorkspaceCleanupIdentities(scan.candidates, deletingIdentities)
)
}, [deletingIdentities, loading, removalInFlightRef, scan, setSelectedIds])
const pruneSelectionToVisibleRows = useEffectEvent(() => {
setSelectedIds((current) => {
const next = new Set(
[...current].filter(
(id) => facetRows.facetMatchedWorktreeIds.has(id) && !deletingWorktreeIds.has(id)
(identity) =>
facetRows.facetMatchedIdentities.has(identity) && !deletingIdentities.has(identity)
)
)
return next.size === current.size ? current : next
@@ -221,14 +237,15 @@ function WorkspaceCleanupDialogContent({
}, [browse.filters, open, removal.confirming])
const pruneVanishedSelections = useEffectEvent(() => {
const isDeleting = (identity: string): boolean => deletingIdentities.has(identity)
const kept = [...selectedIds].filter(
(id) => candidateIds.has(id) && !deletingWorktreeIds.has(id)
(identity) => candidateIdentities.has(identity) && !isDeleting(identity)
)
if (kept.length === selectedIds.size) {
return
}
const vanishedCount = [...selectedIds].filter(
(id) => !candidateIds.has(id) && !deletingWorktreeIds.has(id)
(identity) => !candidateIdentities.has(identity) && !isDeleting(identity)
).length
setSelectedIds(new Set(kept))
if (vanishedCount > 0) {
@@ -240,10 +257,10 @@ function WorkspaceCleanupDialogContent({
if (loading) {
return
}
// Why: a settled refresh reconciles by worktreeId — selections survive it,
// Why: a settled refresh reconciles by host-qualified identity — selections survive it,
// and only rows that truly no longer exist drop (with a visible reason).
pruneVanishedSelections()
}, [candidateIds, deletingWorktreeIds, loading])
}, [candidateIdentities, deletingIdentities, loading])
const ignoreCandidate = useCallback(
(candidate: WorkspaceCleanupCandidate) => {
@@ -252,7 +269,7 @@ function WorkspaceCleanupDialogContent({
if (mountedRef.current) {
setSelectedIds((current) => {
const next = new Set(current)
next.delete(candidate.worktreeId)
next.delete(getWorkspaceCleanupCandidateIdentity(candidate))
return next
})
}
@@ -272,31 +289,31 @@ function WorkspaceCleanupDialogContent({
[dismissCandidates, mountedRef, setSelectedIds]
)
const toggleExpandedRow = useCallback((worktreeId: string) => {
setExpandedRowIds((current) => toggleSetMember(current, worktreeId))
const toggleExpandedRow = useCallback((identity: string) => {
setExpandedRowIds((current) => toggleSetMember(current, identity))
}, [])
const toggleSelectedRow = useCallback(
(worktreeId: string) => {
(identity: string) => {
selectionTouchedRef.current = true
setSelectedIds((current) => toggleSetMember(current, worktreeId))
setSelectedIds((current) => toggleSetMember(current, identity))
},
[setSelectedIds]
)
const selectableWorktreeIds = useMemo(
const selectableIdentities = useMemo(
() =>
removal.removalInFlight
? []
: facetRows.selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id)),
[deletingWorktreeIds, facetRows.selectableWorktreeIds, removal.removalInFlight]
: facetRows.selectableIdentities.filter((identity) => !deletingIdentities.has(identity)),
[deletingIdentities, facetRows.selectableIdentities, removal.removalInFlight]
)
const toggleSelectAll = useCallback(
(selectAll: boolean) => {
selectionTouchedRef.current = true
setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set())
setSelectedIds(selectAll ? new Set(selectableIdentities) : new Set())
},
[selectableWorktreeIds, setSelectedIds]
[selectableIdentities, setSelectedIds]
)
// Why: stable per-row handlers so React.memo keeps unchanged CandidateRow
@@ -310,7 +327,7 @@ function WorkspaceCleanupDialogContent({
return
}
selectionTouchedRef.current = true
setSelectedIds(new Set([candidate.worktreeId]))
setSelectedIds(new Set([getWorkspaceCleanupCandidateIdentity(candidate)]))
openConfirmRemove([candidate])
},
[openConfirmRemove, removalInFlightRef, setSelectedIds]
@@ -372,7 +389,7 @@ function WorkspaceCleanupDialogContent({
facetRows={facetRows}
facetPanelOpen={facetPanelOpen}
onFacetPanelOpenChange={setFacetPanelOpen}
selectableCount={selectableWorktreeIds.length}
selectableCount={selectableIdentities.length}
selectedCount={selectedCount}
spaceScanning={workspaceSpaceScanning}
spaceProgress={workspaceSpaceProgress}
@@ -391,13 +408,12 @@ function WorkspaceCleanupDialogContent({
scannedCount={candidates.length}
hasScanned={scan != null}
loading={loading}
deletionPhaseByWorktreeId={deletionPhaseByWorktreeId}
deletingWorktreeIds={deletingWorktreeIds}
deletionPhaseByIdentity={deletionPhaseByIdentity}
deletingIdentities={deletingIdentities}
expandedRowIds={expandedRowIds}
selectedIds={selectedIds}
gitPendingWorktreeIds={gitEvidence.pendingWorktreeIds}
rowFailures={removal.rowFailures}
removalInFlight={removal.removalInFlight}
scrollElement={rowsScrollElement}
onClearFilters={browse.clearFilters}
onToggleExpanded={toggleExpandedRow}
@@ -13,6 +13,7 @@ export type WorkspaceCleanupDialogLifecycle = {
mountedContent: boolean
loading: boolean
closeModal: () => void
/** Host-qualified row identities, not worktree ids (STA-4343). */
selectedIds: Set<string>
setSelectedIds: Dispatch<SetStateAction<Set<string>>>
removal: WorkspaceCleanupRemovalController
@@ -27,19 +28,22 @@ export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLife
const open = activeModal === 'workspace-cleanup'
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const deselectRemovedIds = useCallback((removedIds: readonly string[]) => {
if (removedIds.length === 0) {
const deselectRemovedIdentities = useCallback((removedIdentities: readonly string[]) => {
if (removedIdentities.length === 0) {
return
}
setSelectedIds((current) => {
const next = new Set(current)
for (const id of removedIds) {
next.delete(id)
for (const identity of removedIdentities) {
next.delete(identity)
}
return next
})
}, [])
const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal })
const removal = useWorkspaceCleanupRemoval({
onDeselect: deselectRemovedIdentities,
closeModal
})
const { removalInFlightRef, resetForReopen, resetRowFailures } = removal
const onFreshOpen = useCallback(() => {
@@ -208,7 +208,7 @@ describe('useWorkspaceCleanupFacetRows hot paths', () => {
{ initialProps: { current: candidates } }
)
const initialCounts = { ...counts }
const matched = view.result.current.facetMatchedWorktreeIds
const matched = view.result.current.facetMatchedIdentities
const rows = view.result.current.rows
// A no-op progress tick delivers a fresh array of the same candidate objects.
@@ -216,7 +216,7 @@ describe('useWorkspaceCleanupFacetRows hot paths', () => {
expect(counts).toEqual(initialCounts)
expect(view.result.current.rows).toBe(rows)
expect(view.result.current.facetMatchedWorktreeIds).toBe(matched)
expect(view.result.current.facetMatchedIdentities).toBe(matched)
})
it('keeps facet identity for untouched rows when a tick replaces one candidate', () => {
@@ -39,8 +39,8 @@ import {
export type WorkspaceCleanupFacetRows = {
rows: WorkspaceCleanupFacets[]
selectableWorktreeIds: string[]
facetMatchedWorktreeIds: ReadonlySet<string>
selectableIdentities: string[]
facetMatchedIdentities: ReadonlySet<string>
matchedCount: number
totalCount: number
facetCounts: WorkspaceCleanupFacetCounts
@@ -238,9 +238,9 @@ export function useWorkspaceCleanupFacetRows({
)
// Identity churn here is harmless: the only consumer reads the latest set
// inside a useEffectEvent body and never keys an effect on it.
const facetMatchedWorktreeIds = useMemo<ReadonlySet<string>>(
const facetMatchedIdentities = useMemo<ReadonlySet<string>>(
() =>
new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.worktreeId)),
new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.identity)),
[facetFilters, facets, now]
)
const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets])
@@ -275,8 +275,8 @@ export function useWorkspaceCleanupFacetRows({
return {
rows: result.rows,
selectableWorktreeIds: result.selectableWorktreeIds,
facetMatchedWorktreeIds,
selectableIdentities: result.selectableIdentities,
facetMatchedIdentities,
matchedCount: result.matchedCount,
totalCount: result.totalCount,
facetCounts,
@@ -85,7 +85,7 @@ describe('useWorkspaceCleanupGitEvidence', () => {
})
await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2))
expect(view.result.current.pendingWorktreeIds.size).toBe(6)
expect(view.result.current.evidenceByWorktreeId.size).toBe(0)
expect(view.result.current.evidenceByIdentity.size).toBe(0)
await act(async () => {
pending[1]?.onProgress?.({
@@ -98,7 +98,7 @@ describe('useWorkspaceCleanupGitEvidence', () => {
candidateMode: 'append'
})
})
await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1))
await waitFor(() => expect(view.result.current.evidenceByIdentity.size).toBe(1))
expect(view.result.current.pendingWorktreeIds.size).toBe(5)
})
@@ -177,7 +177,7 @@ describe('useWorkspaceCleanupGitEvidence', () => {
})
})
await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2))
expect(view.result.current.evidenceByWorktreeId.size).toBe(0)
expect(view.result.current.evidenceByIdentity.size).toBe(0)
expect(view.result.current.pendingWorktreeIds).toEqual(new Set(['a']))
await act(async () => {
@@ -187,6 +187,6 @@ describe('useWorkspaceCleanupGitEvidence', () => {
errors: []
})
})
await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1))
await waitFor(() => expect(view.result.current.evidenceByIdentity.size).toBe(1))
})
})
@@ -1,21 +1,22 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
import {
selectWorkspaceCleanupGitEvidenceTargets,
WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS
} from './workspace-cleanup-git-evidence'
export type WorkspaceCleanupGitEvidenceState = {
/** Focused re-scan results, keyed by worktree id. */
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
/** Focused re-scan results, keyed by host-qualified identity. */
evidenceByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>
pendingWorktreeIds: ReadonlySet<string>
checkedCount: number
totalCount: number
}
const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = {
evidenceByWorktreeId: new Map(),
evidenceByIdentity: new Map(),
pendingWorktreeIds: new Set(),
checkedCount: 0,
totalCount: 0
@@ -61,7 +62,7 @@ export function useWorkspaceCleanupGitEvidence({
}
publishDirtyRef.current = false
setState({
evidenceByWorktreeId: new Map(evidenceRef.current),
evidenceByIdentity: new Map(evidenceRef.current),
pendingWorktreeIds: new Set([...queuedRef.current, ...inFlightRef.current]),
checkedCount: totalRef.current - queuedRef.current.size - inFlightRef.current.size,
totalCount: totalRef.current
@@ -95,7 +96,7 @@ export function useWorkspaceCleanupGitEvidence({
if (!activeRequestWorktreeIdsRef.current.has(candidate.worktreeId)) {
continue
}
evidenceRef.current.set(candidate.worktreeId, candidate)
evidenceRef.current.set(getWorkspaceCleanupCandidateIdentity(candidate), candidate)
inFlightRef.current.delete(candidate.worktreeId)
publishDirtyRef.current = true
}
@@ -2,6 +2,11 @@ import { useCallback, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity
} from '../../../../shared/workspace-cleanup-host-identity'
import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup'
import {
startWorkspaceCleanupBackgroundRemoval,
type WorkspaceCleanupRemovalProgress
@@ -13,8 +18,10 @@ export type WorkspaceCleanupRemovalController = {
confirmCandidates: WorkspaceCleanupCandidate[]
removalProgress: WorkspaceCleanupRemovalProgress | null
removalInFlight: boolean
deletionPhaseByIdentity: Record<string, 'queued' | 'deleting'>
/** Synchronous guard; state alone lags a second confirm click. */
removalInFlightRef: { current: boolean }
/** Keyed by host-qualified identity so a failure marks only the confirmed host's row. */
rowFailures: Record<string, string>
resetRowFailures: () => void
resetForReopen: () => void
@@ -32,7 +39,7 @@ export function useWorkspaceCleanupRemoval({
onDeselect,
closeModal
}: {
onDeselect: (removedIds: readonly string[]) => void
onDeselect: (removedIdentities: readonly string[]) => void
closeModal: () => void
}): WorkspaceCleanupRemovalController {
const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates)
@@ -48,6 +55,9 @@ export function useWorkspaceCleanupRemoval({
// Why: `removalProgress` only arrives once the batch reports, so rendering
// needs its own in-flight flag; removalInFlightRef stays the synchronous guard.
const [removalInFlight, setRemovalInFlight] = useState(false)
const [deletionPhaseByIdentity, setDeletionPhaseByIdentity] = useState<
Record<string, 'queued' | 'deleting'>
>({})
const [rowFailures, setRowFailures] = useState<Record<string, string>>({})
const removalInFlightRef = useRef(false)
// Why: the dialog stays mounted across cleanup runs, so late settlements from
@@ -112,6 +122,7 @@ export function useWorkspaceCleanupRemoval({
setRemovalInFlight(false)
setConfirming(false)
setConfirmCandidates([])
setDeletionPhaseByIdentity({})
}, [])
const confirmRemove = useCallback(() => {
@@ -134,7 +145,11 @@ export function useWorkspaceCleanupRemoval({
// Why: a hung late settlement retains these callbacks for the renderer's
// lifetime; capture only ids so it cannot pin the candidate objects.
const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId)
const removableIdentities = removableCandidates.map(getWorkspaceCleanupCandidateIdentity)
setRowFailures({})
setDeletionPhaseByIdentity(
Object.fromEntries(removableIdentities.map((identity) => [identity, 'queued' as const]))
)
markWorktreesQueuedForDeletion(removableWorktreeIds)
const handleRemovalError = (): void => {
for (const worktreeId of removableWorktreeIds) {
@@ -169,21 +184,33 @@ export function useWorkspaceCleanupRemoval({
onProgress: (progress) => {
if (mountedRef.current) {
setRemovalProgress(progress)
setDeletionPhaseByIdentity((current) =>
Object.fromEntries(Object.keys(current).map((identity) => [identity, 'deleting']))
)
}
},
onRowFailed: (failure) => {
clearQueuedDeleteState(failure.worktreeId)
if (!mountedRef.current) {
return
}
const identity = getWorkspaceCleanupFailureIdentity(failure)
setDeletionPhaseByIdentity((current) => {
const next = { ...current }
delete next[identity]
return next
})
},
onResult: (result) => {
const nextFailures: Record<string, string> = {}
for (const failure of result.failures) {
nextFailures[failure.worktreeId] = failure.message
nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure.message
// Why: defensively covers failures that never reached onRowFailed.
clearQueuedDeleteState(failure.worktreeId)
}
if (mountedRef.current) {
setRowFailures(nextFailures)
onDeselect(result.removedIds)
onDeselect(result.removedIdentities)
settle()
}
removalInFlightRef.current = false
@@ -199,15 +226,15 @@ export function useWorkspaceCleanupRemoval({
}
setRowFailures((current) => {
const next = { ...current }
for (const id of result.removedIds) {
delete next[id]
for (const identity of result.removedIdentities) {
delete next[identity]
}
for (const failure of result.failures) {
next[failure.worktreeId] = failure.message
next[getWorkspaceCleanupFailureIdentity(failure)] = failure.message
}
return next
})
onDeselect(result.removedIds)
onDeselect(result.removedIdentities)
},
onError: handleRemovalError
})
@@ -230,6 +257,7 @@ export function useWorkspaceCleanupRemoval({
confirmCandidates,
removalProgress,
removalInFlight,
deletionPhaseByIdentity,
removalInFlightRef,
rowFailures,
resetRowFailures,
@@ -240,3 +268,10 @@ export function useWorkspaceCleanupRemoval({
backToList
}
}
/** Row key for a failure; an id-only failure lands on the local row, as before. */
function getWorkspaceCleanupFailureIdentity(failure: WorkspaceCleanupFailure): string {
return failure.executionHostId
? getWorkspaceCleanupHostIdentity(failure.executionHostId, failure.worktreeId)
: getWorkspaceCleanupCandidateIdentity({ worktreeId: failure.worktreeId })
}
@@ -5,6 +5,7 @@ import {
} from '../../../../shared/cross-platform-path'
import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup'
import { translate } from '@/i18n/i18n'
import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity'
// Why: an ancestor skip is provisional while every blocking descendant is still
// removing; it hardens or lifts once the blockers settle authoritatively.
@@ -31,7 +32,7 @@ export function isStrictWorkspaceCleanupDescendant(
child: WorkspaceCleanupCandidate
): boolean {
return (
parent.connectionId === child.connectionId &&
getWorkspaceCleanupCandidateHostId(parent) === getWorkspaceCleanupCandidateHostId(child) &&
isStrictWorkspaceCleanupDescendantPath(parent.path, child.path)
)
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup'
import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
@@ -46,23 +47,27 @@ describe('workspace cleanup late settlement reconciliation', () => {
branch: 'other',
path: '/repo/other'
})
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {}
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveChild = resolve
})
)
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveUnrelated = resolve
})
)
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
.mockResolvedValueOnce({
removedIds: [parent.worktreeId],
removedIdentities: [parent.worktreeId],
failures: []
})
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
@@ -76,9 +81,17 @@ describe('workspace cleanup late settlement reconciliation', () => {
await vi.advanceTimersByTimeAsync(10)
expect(removeCandidates).toHaveBeenCalledTimes(2)
resolveChild({ removedIds: [child.worktreeId], failures: [] })
resolveChild({
removedIds: [child.worktreeId],
removedIdentities: [child.worktreeId],
failures: []
})
await settleBackgroundRemoval()
resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] })
resolveUnrelated({
removedIds: [unrelated.worktreeId],
removedIdentities: [unrelated.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], {
@@ -86,6 +99,7 @@ describe('workspace cleanup late settlement reconciliation', () => {
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
removedIdentities: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
failures: []
})
expect(toast.error).not.toHaveBeenCalled()
@@ -116,24 +130,28 @@ describe('workspace cleanup late settlement reconciliation', () => {
branch: 'second-child',
path: '/repo/bb/c'
})
let resolveFirstChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
let resolveSecondChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
let resolveFirstChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
let resolveSecondChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveFirstChild = resolve
})
)
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveSecondChild = resolve
})
)
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValueOnce({ removedIds: [secondParent.worktreeId], failures: [] })
.mockResolvedValueOnce({
removedIds: [secondParent.worktreeId],
removedIdentities: [secondParent.worktreeId],
failures: []
})
const onLateResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
@@ -148,7 +166,11 @@ describe('workspace cleanup late settlement reconciliation', () => {
await vi.advanceTimersByTimeAsync(20)
await settleBackgroundRemoval()
resolveFirstChild({ removedIds: [firstChild.worktreeId], failures: [] })
resolveFirstChild({
removedIds: [firstChild.worktreeId],
removedIdentities: [firstChild.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(3, [firstParent.worktreeId], {
@@ -156,14 +178,20 @@ describe('workspace cleanup late settlement reconciliation', () => {
})
expect(onLateResult).toHaveBeenNthCalledWith(1, {
removedIds: [firstChild.worktreeId],
removedIdentities: [firstChild.worktreeId],
failures: []
})
resolveSecondChild({ removedIds: [secondChild.worktreeId], failures: [] })
resolveSecondChild({
removedIds: [secondChild.worktreeId],
removedIdentities: [secondChild.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(onLateResult).toHaveBeenNthCalledWith(2, {
removedIds: [secondChild.worktreeId],
removedIdentities: [secondChild.worktreeId],
failures: []
})
expect(removeCandidates).toHaveBeenCalledTimes(3)
@@ -176,6 +204,7 @@ describe('workspace cleanup late settlement reconciliation', () => {
})
expect(onLateResult).toHaveBeenCalledWith({
removedIds: [secondParent.worktreeId],
removedIdentities: [secondParent.worktreeId],
failures: []
})
expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1')
@@ -194,16 +223,20 @@ describe('workspace cleanup late settlement reconciliation', () => {
branch: 'child',
path: '/repo/parent/child'
})
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveChild = resolve
})
)
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
.mockResolvedValueOnce({
removedIds: [parent.worktreeId],
removedIdentities: [parent.worktreeId],
failures: []
})
vi.mocked(toast.success).mockImplementationOnce(() => {
throw new Error('toast renderer failed')
})
@@ -220,7 +253,11 @@ describe('workspace cleanup late settlement reconciliation', () => {
await vi.advanceTimersByTimeAsync(10)
await settleBackgroundRemoval()
resolveChild({ removedIds: [child.worktreeId], failures: [] })
resolveChild({
removedIds: [child.worktreeId],
removedIdentities: [child.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
@@ -23,7 +23,7 @@ it('holds one snapshot prune batch across the sequential cleanup removals', asyn
const events: string[] = []
const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => {
events.push(`remove:${worktreeIds[0]}`)
return { removedIds: [...worktreeIds], failures: [] }
return { removedIds: [...worktreeIds], removedIdentities: [...worktreeIds], failures: [] }
})
startWorkspaceCleanupBackgroundRemoval({
@@ -5,6 +5,7 @@ import {
type WorkspaceCleanupBackgroundRemovalArgs
} from './workspace-cleanup-background-removal'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
import { getWorkspaceCleanupHostIdentity } from '../../../../shared/workspace-cleanup-host-identity'
vi.mock('sonner', () => ({
toast: {
@@ -50,7 +51,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
expect(removeCandidates).not.toHaveBeenCalled()
expect(onProgress).not.toHaveBeenCalled()
expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [] })
expect(onResult).toHaveBeenCalledWith({ removedIds: [], removedIdentities: [], failures: [] })
expect(toast.success).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
@@ -89,7 +90,11 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
})
expect(onResult).not.toHaveBeenCalled()
resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] })
resolveRemoval!({
removedIds: [candidate.worktreeId],
removedIdentities: [candidate.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(onProgress).toHaveBeenLastCalledWith({
@@ -99,7 +104,11 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
failedCount: 0
})
expect(toast.success).not.toHaveBeenCalled()
expect(onResult).toHaveBeenCalledWith({ removedIds: [candidate.worktreeId], failures: [] })
expect(onResult).toHaveBeenCalledWith({
removedIds: [candidate.worktreeId],
removedIdentities: [candidate.worktreeId],
failures: []
})
})
it('removes candidates one at a time for per-row progress', async () => {
@@ -112,8 +121,16 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
})
const removeCandidates = vi
.fn()
.mockResolvedValueOnce({ removedIds: [first.worktreeId], failures: [] })
.mockResolvedValueOnce({ removedIds: [second.worktreeId], failures: [] })
.mockResolvedValueOnce({
removedIds: [first.worktreeId],
removedIdentities: [first.worktreeId],
failures: []
})
.mockResolvedValueOnce({
removedIds: [second.worktreeId],
removedIdentities: [second.worktreeId],
failures: []
})
const onProgress = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
@@ -163,11 +180,13 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
.fn()
.mockResolvedValueOnce({
removedIds: [first.worktreeId],
removedIdentities: [first.worktreeId],
failures: [],
preservedBranches: [firstBranch]
})
.mockResolvedValueOnce({
removedIds: [second.worktreeId],
removedIdentities: [second.worktreeId],
failures: [],
preservedBranches: [secondBranch]
}),
@@ -178,6 +197,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
expect(onResult).toHaveBeenCalledWith({
removedIds: [first.worktreeId, second.worktreeId],
removedIdentities: [first.worktreeId, second.worktreeId],
failures: [],
preservedBranches: [firstBranch, secondBranch]
})
@@ -200,6 +220,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
})
const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => ({
removedIds: [...worktreeIds],
removedIdentities: [...worktreeIds],
failures: []
}))
@@ -233,6 +254,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
})
const removeCandidates = vi.fn().mockResolvedValueOnce({
removedIds: [],
removedIdentities: [],
failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }]
})
const onProgress = vi.fn()
@@ -258,10 +280,12 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [],
removedIdentities: [],
failures: [
{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' },
{
worktreeId: parent.worktreeId,
executionHostId: 'local',
displayName: parent.displayName,
message: 'Skipped because a nested workspace could not be removed.'
}
@@ -284,6 +308,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
})
const removeCandidates = vi.fn().mockResolvedValueOnce({
removedIds: [],
removedIdentities: [],
failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }]
})
const onRowFailed = vi.fn()
@@ -325,15 +350,21 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
.fn()
.mockResolvedValueOnce({
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: failedChild.worktreeId,
executionHostId: 'local',
displayName: failedChild.displayName,
message: 'busy'
}
]
})
.mockResolvedValueOnce({ removedIds: [unrelatedParent.worktreeId], failures: [] })
.mockResolvedValueOnce({
removedIds: [unrelatedParent.worktreeId],
removedIdentities: [unrelatedParent.worktreeId],
failures: []
})
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
@@ -352,7 +383,69 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [unrelatedParent.worktreeId],
failures: [{ worktreeId: failedChild.worktreeId, displayName: 'child', message: 'busy' }]
removedIdentities: [unrelatedParent.worktreeId],
failures: [
{
worktreeId: failedChild.worktreeId,
executionHostId: 'local',
displayName: 'child',
message: 'busy'
}
]
})
})
it('does not skip a paired-runtime ancestor after another runtime child fails', async () => {
const failedChild = makeCandidate({
worktreeId: 'repo-1::/repo/parent/child',
displayName: 'child',
branch: 'child',
path: '/repo/parent/child',
executionHostId: 'runtime:hub-a'
})
const unrelatedParent = makeCandidate({
worktreeId: 'repo-2::/repo/parent',
repoId: 'repo-2',
repoName: 'Repo 2',
displayName: 'parent',
branch: 'parent',
path: '/repo/parent',
executionHostId: 'runtime:hub-b'
})
const removeCandidates = vi
.fn()
.mockResolvedValueOnce({
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: failedChild.worktreeId,
executionHostId: failedChild.executionHostId,
displayName: failedChild.displayName,
message: 'busy'
}
]
})
.mockResolvedValueOnce({
removedIds: [unrelatedParent.worktreeId],
removedIdentities: [
getWorkspaceCleanupHostIdentity('runtime:hub-b', unrelatedParent.worktreeId)
],
failures: []
})
startWorkspaceCleanupBackgroundRemoval({
candidates: [unrelatedParent, failedChild],
removeCandidates,
onProgress: vi.fn()
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(1, [failedChild.worktreeId], {
approvedCandidates: [failedChild]
})
expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId], {
approvedCandidates: [unrelatedParent]
})
})
@@ -363,6 +456,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
candidates: [candidate],
removeCandidates: vi.fn().mockResolvedValue({
removedIds: [],
removedIdentities: [],
failures: [
{ worktreeId: candidate.worktreeId, displayName: candidate.displayName, message: 'busy' }
]
@@ -377,441 +471,6 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
)
})
it('uses a success confirmed after the initial timeout and proceeds with its parent', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/repo/parent',
displayName: 'parent',
branch: 'parent',
path: '/repo/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/repo/parent/child',
displayName: 'child',
branch: 'child',
path: '/repo/parent/child'
})
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
resolveChild = resolve
})
)
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(5)
resolveChild({ removedIds: [child.worktreeId], failures: [] })
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
approvedCandidates: [parent]
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [child.worktreeId, parent.worktreeId],
failures: []
})
expect(toast.error).not.toHaveBeenCalled()
})
it('reports a definitive late failure and skips its parent', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::C:\\repo\\parent',
displayName: 'parent',
branch: 'parent',
path: 'C:\\repo\\parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::C:\\repo\\parent\\child',
displayName: 'child',
branch: 'child',
path: 'C:\\repo\\parent\\child'
})
let rejectChild: (error: Error) => void = () => {}
const removeCandidates = vi.fn(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
rejectChild = reject
})
)
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(5)
rejectChild(new Error('remote removal failed'))
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenCalledTimes(1)
expect(onResult).toHaveBeenCalledWith({
removedIds: [],
failures: [
{
worktreeId: child.worktreeId,
displayName: child.displayName,
message: 'remote removal failed'
},
{
worktreeId: parent.worktreeId,
displayName: parent.displayName,
message: 'Skipped because a nested workspace could not be removed.'
}
]
})
})
it('reports a timeout, skips its parent, then reports the authoritative result', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/repo/parent',
displayName: 'parent',
branch: 'parent',
path: '/repo/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/repo/parent/child',
displayName: 'child',
branch: 'child',
path: '/repo/parent/child'
})
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
resolveChild = resolve
})
)
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
const onProgress = vi.fn()
const onResult = vi.fn()
const onLateResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates,
onProgress,
onResult,
onLateResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(5)
expect(onResult).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5)
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenCalledTimes(1)
expect(onProgress).toHaveBeenLastCalledWith({
totalCount: 2,
processedCount: 2,
removedCount: 0,
failedCount: 2
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [],
failures: [
{
worktreeId: child.worktreeId,
displayName: child.displayName,
message:
'Removing child is taking longer than expected. It will keep running in the background.'
},
{
worktreeId: parent.worktreeId,
displayName: parent.displayName,
message: 'Skipped because a nested workspace has not finished removing.'
}
]
})
// The still-pending row is reported as still removing, not as a failure.
expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1')
expect(toast.error).toHaveBeenCalledWith(
'Workspaces not removed: 1',
expect.objectContaining({
description: 'Skipped because a nested workspace has not finished removing.'
})
)
resolveChild({ removedIds: [child.worktreeId], failures: [] })
await settleBackgroundRemoval()
// Why: post-batch child success must reclassify the provisional parent skip
// and retry the parent so rowFailures do not keep the stale skip message.
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
approvedCandidates: [parent]
})
expect(onResult).toHaveBeenCalledTimes(1)
expect(onLateResult).toHaveBeenNthCalledWith(1, {
removedIds: [child.worktreeId],
failures: []
})
expect(onLateResult).toHaveBeenNthCalledWith(2, {
removedIds: [parent.worktreeId],
failures: []
})
expect(toast.success).not.toHaveBeenCalled()
})
it('hardens a provisional parent skip after the child late-fails post-batch', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/repo/parent',
displayName: 'parent',
branch: 'parent',
path: '/repo/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/repo/parent/child',
displayName: 'child',
branch: 'child',
path: '/repo/parent/child'
})
let rejectChild: (error: Error) => void = () => {}
const onLateResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates: vi.fn(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
rejectChild = reject
})
),
onProgress: vi.fn(),
onResult: vi.fn(),
onLateResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(10)
await settleBackgroundRemoval()
rejectChild(new Error('remote removal failed'))
await settleBackgroundRemoval()
expect(onLateResult).toHaveBeenNthCalledWith(1, {
removedIds: [],
failures: [
expect.objectContaining({
worktreeId: child.worktreeId,
message: 'remote removal failed'
})
]
})
expect(onLateResult).toHaveBeenNthCalledWith(2, {
removedIds: [],
failures: [
expect.objectContaining({
worktreeId: parent.worktreeId,
message: 'Skipped because a nested workspace could not be removed.'
})
]
})
})
it('retries a skipped parent after its blocking child succeeds mid-batch', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/rp/parent',
displayName: 'parent',
branch: 'parent',
path: '/rp/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/rp/parent/c',
displayName: 'child',
branch: 'child',
path: '/rp/parent/c'
})
const unrelated = makeCandidate({
worktreeId: 'repo-1::/zz',
displayName: 'other',
branch: 'other',
path: '/zz'
})
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
resolveChild = resolve
})
)
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
resolveUnrelated = resolve
})
)
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child, unrelated],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
// Child times out, the parent is provisionally skipped, and the loop is
// held open by the unrelated row when the child's success reconciles.
await vi.advanceTimersByTimeAsync(10)
expect(removeCandidates).toHaveBeenCalledTimes(2)
resolveChild({ removedIds: [child.worktreeId], failures: [] })
await settleBackgroundRemoval()
resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] })
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], {
approvedCandidates: [parent]
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
failures: []
})
expect(toast.error).not.toHaveBeenCalled()
expect(toast.info).not.toHaveBeenCalled()
})
it('hardens a provisional skip into a definitive one after the child late-fails mid-batch', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/rp/parent',
displayName: 'parent',
branch: 'parent',
path: '/rp/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/rp/parent/c',
displayName: 'child',
branch: 'child',
path: '/rp/parent/c'
})
const unrelated = makeCandidate({
worktreeId: 'repo-1::/zz',
displayName: 'other',
branch: 'other',
path: '/zz'
})
let rejectChild: (error: Error) => void = () => {}
let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
rejectChild = reject
})
)
.mockImplementationOnce(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
resolveUnrelated = resolve
})
)
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child, unrelated],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(10)
rejectChild(new Error('remote removal failed'))
await settleBackgroundRemoval()
resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] })
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenCalledTimes(2)
expect(onResult).toHaveBeenCalledWith({
removedIds: [unrelated.worktreeId],
failures: [
{
worktreeId: parent.worktreeId,
displayName: parent.displayName,
message: 'Skipped because a nested workspace could not be removed.'
},
{
worktreeId: child.worktreeId,
displayName: child.displayName,
message: 'remote removal failed'
}
]
})
expect(toast.error).toHaveBeenCalledWith(
'Workspaces not removed: 2',
expect.objectContaining({
description:
'Skipped because a nested workspace could not be removed.; remote removal failed'
})
)
})
it('reports an authoritative rejection after the timeout result', async () => {
vi.useFakeTimers()
const candidate = makeCandidate()
let rejectRemoval: (error: Error) => void = () => {}
const onLateResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [candidate],
removeCandidates: vi.fn(
() =>
new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => {
rejectRemoval = reject
})
),
onProgress: vi.fn(),
onLateResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(10)
rejectRemoval(new Error('remote removal failed'))
await settleBackgroundRemoval()
expect(onLateResult).toHaveBeenCalledWith({
removedIds: [],
failures: [expect.objectContaining({ message: 'remote removal failed' })]
})
expect(toast.error).toHaveBeenLastCalledWith(
'Workspaces not removed: 1',
expect.objectContaining({ description: 'remote removal failed' })
)
})
it('keeps removal outcome toasts when the result callback throws', async () => {
const candidate = makeCandidate()
@@ -819,6 +478,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
candidates: [candidate],
removeCandidates: vi.fn().mockResolvedValue({
removedIds: [candidate.worktreeId],
removedIdentities: [candidate.worktreeId],
failures: []
}),
onProgress: vi.fn(),
@@ -851,12 +511,14 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
.fn()
.mockResolvedValueOnce({
removedIds: [],
removedIdentities: [],
failures: [
{ worktreeId: first.worktreeId, displayName: first.displayName, message: 'busy' }
]
})
.mockResolvedValueOnce({
removedIds: [],
removedIdentities: [],
failures: [
{ worktreeId: second.worktreeId, displayName: second.displayName, message: 'dirty' }
]
@@ -6,6 +6,7 @@ import type {
WorkspaceCleanupRemoveResult
} from '@/store/slices/workspace-cleanup'
import { translate } from '@/i18n/i18n'
import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity'
import {
getSkippedAncestorMessage,
isStrictWorkspaceCleanupDescendant,
@@ -71,7 +72,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
}: WorkspaceCleanupBackgroundRemovalArgs): void {
if (candidates.length === 0) {
try {
onResult?.({ removedIds: [], failures: [] })
onResult?.({ removedIds: [], removedIdentities: [], failures: [] })
} catch (callbackError) {
console.error('Workspace cleanup result callback failed', callbackError)
}
@@ -80,6 +81,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
const count = candidates.length
const removedIds: string[] = []
const removedIdentities: string[] = []
const failures: WorkspaceCleanupFailure[] = []
const preservedBranches: NonNullable<WorkspaceCleanupRemoveResult['preservedBranches']> = []
const failedCandidates: WorkspaceCleanupCandidate[] = []
@@ -141,6 +143,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
const provisional = blockers.every((blocker) => provisionallyBlocked.has(blocker))
const failure: WorkspaceCleanupFailure = {
worktreeId: candidate.worktreeId,
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
displayName: candidate.displayName,
message: getSkippedAncestorMessage(provisional)
}
@@ -222,6 +225,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
pendingSettlementFailures.delete(timeoutFailure)
provisionallyBlocked.delete(candidate)
removedIds.push(...lateResult.removedIds)
removedIdentities.push(...(lateResult.removedIdentities ?? []))
preservedBranches.push(...(lateResult.preservedBranches ?? []))
reportFailures(lateResult.failures)
if (lateResult.failures.length === 0) {
@@ -235,6 +239,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
}
const result = outcome.result
removedIds.push(...result.removedIds)
removedIdentities.push(...(result.removedIdentities ?? []))
preservedBranches.push(...(result.preservedBranches ?? []))
reportFailures(result.failures)
if (result.failures.length > 0) {
@@ -245,6 +250,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
reportFailures([
{
worktreeId: candidate.worktreeId,
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
displayName: candidate.displayName,
message: error instanceof Error ? error.message : String(error)
}
@@ -279,6 +285,7 @@ export function startWorkspaceCleanupBackgroundRemoval({
)
const result: WorkspaceCleanupRemoveResult = {
removedIds,
removedIdentities,
failures,
...(preservedBranches.length > 0 ? { preservedBranches } : {})
}
@@ -3,7 +3,9 @@ import type { ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getWorkspaceCleanupCandidateIdentity } from './workspace-cleanup-host-identity'
import { CandidateRow } from './workspace-cleanup-candidate-row'
import { getWorkspaceCleanupCandidateAccessibleName } from './workspace-cleanup-host-label'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
vi.mock('@/components/ui/tooltip', () => ({
@@ -37,6 +39,7 @@ describe('CandidateRow', () => {
act(() => {
root?.render(
<CandidateRow
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
candidate={candidate}
expanded={false}
last
@@ -68,6 +71,7 @@ describe('CandidateRow', () => {
act(() => {
root?.render(
<CandidateRow
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
candidate={candidate}
expanded={false}
last
@@ -103,6 +107,7 @@ describe('CandidateRow', () => {
act(() => {
root?.render(
<CandidateRow
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
candidate={candidate}
expanded={false}
last
@@ -124,7 +129,9 @@ describe('CandidateRow', () => {
)
})
const openButton = container?.querySelector(`[aria-label="Open ${candidate.displayName}"]`)
const openButton = container?.querySelector(
`[aria-label="Open ${getWorkspaceCleanupCandidateAccessibleName(candidate)}"]`
)
expect(openButton?.querySelector('.lucide-external-link')).not.toBeNull()
})
@@ -134,6 +141,7 @@ describe('CandidateRow', () => {
act(() => {
root?.render(
<CandidateRow
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
candidate={candidate}
expanded={false}
last
@@ -156,8 +164,9 @@ describe('CandidateRow', () => {
)
})
expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull()
expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull()
const accessibleName = getWorkspaceCleanupCandidateAccessibleName(candidate)
expect(container?.querySelector(`[aria-label="Select ${accessibleName}"]`)).toBeNull()
expect(container?.querySelector(`[aria-label="Remove ${accessibleName}"]`)).toBeNull()
})
it.each([
@@ -169,6 +178,7 @@ describe('CandidateRow', () => {
act(() => {
root?.render(
<CandidateRow
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
candidate={candidate}
deletionPhase={deletionPhase}
expanded={false}
@@ -195,4 +205,46 @@ describe('CandidateRow', () => {
expect(container?.textContent).toContain(label)
expect(container?.textContent).not.toContain('Ready')
})
it('visibly and accessibly distinguishes colliding local and SSH rows', () => {
const local = makeCandidate({ executionHostId: 'local' })
const remote = makeCandidate({ connectionId: 'builder', executionHostId: 'ssh:builder' })
act(() => {
root?.render(
<>
{[local, remote].map((candidate) => (
<CandidateRow
key={candidate.executionHostId}
identity={getWorkspaceCleanupCandidateIdentity(candidate)}
candidate={candidate}
expanded={false}
last={false}
lastActivityLabel="1d ago"
reviewInfo={{
hasReview: false,
label: null,
provider: null,
state: null,
title: null
}}
selected={false}
onIgnore={vi.fn()}
onRemove={vi.fn()}
onToggleExpanded={vi.fn()}
onToggleSelected={vi.fn()}
onView={vi.fn()}
/>
))}
</>
)
})
const localName = getWorkspaceCleanupCandidateAccessibleName(local)
const remoteName = getWorkspaceCleanupCandidateAccessibleName(remote)
expect(localName).not.toBe(remoteName)
expect(container?.querySelector(`[aria-label="Select ${localName}"]`)).not.toBeNull()
expect(container?.querySelector(`[aria-label="Select ${remoteName}"]`)).not.toBeNull()
expect(container?.querySelector(`[aria-label="Host: builder"]`)).not.toBeNull()
})
})
@@ -12,6 +12,7 @@ import {
GitPullRequest,
HardDrive,
Loader2,
Monitor,
SquareTerminal,
Trash2
} from 'lucide-react'
@@ -41,11 +42,17 @@ import {
} from './workspace-cleanup-candidate-row-data'
import { StatusPill } from './workspace-cleanup-status-pill'
import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip'
import {
getWorkspaceCleanupCandidateAccessibleName,
getWorkspaceCleanupCandidateHostLabel
} from './workspace-cleanup-host-label'
export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued'
type CandidateRowProps = {
candidate: WorkspaceCleanupCandidate
/** Host-qualified row key; the same `worktreeId` can appear once per host. */
identity: string
deletionPhase?: WorkspaceCleanupDeletionPhase
expanded: boolean
failure?: string
@@ -62,8 +69,8 @@ type CandidateRowProps = {
selected: boolean
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
onRemove: (candidate: WorkspaceCleanupCandidate) => void
onToggleExpanded: (worktreeId: string) => void
onToggleSelected: (worktreeId: string) => void
onToggleExpanded: (identity: string) => void
onToggleSelected: (identity: string) => void
onView: (candidate: WorkspaceCleanupCandidate) => void
}
@@ -74,6 +81,7 @@ type CandidateRowProps = {
// prop identity changes); virtualization, not memo, bounds that cost.
export const CandidateRow = React.memo(function CandidateRow({
candidate,
identity,
deletionPhase,
expanded,
failure,
@@ -104,6 +112,8 @@ export const CandidateRow = React.memo(function CandidateRow({
const gitLabel = getWorkspaceCleanupGitLabel(candidate)
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
const contextCount = getContextCount(candidate)
const candidateAccessibleName = getWorkspaceCleanupCandidateAccessibleName(candidate)
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
const sizeValue =
sizeLabel ?? translate('components.workspace.cleanup.browse.notMeasured', 'Not measured')
const hasExpandableDetails =
@@ -131,9 +141,9 @@ export const CandidateRow = React.memo(function CandidateRow({
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.bbb1ab6a6f',
'Select {{value0}}',
{ value0: candidate.displayName }
{ value0: candidateAccessibleName }
)}
onClick={() => onToggleSelected(candidate.worktreeId)}
onClick={() => onToggleSelected(identity)}
className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
@@ -149,6 +159,13 @@ export const CandidateRow = React.memo(function CandidateRow({
<span data-workspace-cleanup-row-name className="min-w-0 truncate text-sm font-medium">
{candidate.displayName}
</span>
<WorkspaceCleanupMetadataChip
icon={Monitor}
label={translate('components.workspace.cleanup.host.label', 'Host: {{value0}}', {
value0: hostLabel
})}
value={hostLabel}
/>
{deletionPhase ? (
<StatusPill tone="destructive">
{deletionPhase === 'queued'
@@ -272,7 +289,7 @@ export const CandidateRow = React.memo(function CandidateRow({
)
}
aria-expanded={expanded}
onClick={() => onToggleExpanded(candidate.worktreeId)}
onClick={() => onToggleExpanded(identity)}
>
<ChevronDown
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
@@ -300,7 +317,7 @@ export const CandidateRow = React.memo(function CandidateRow({
aria-label={translate(
'components.workspace.cleanup.browse.openWorkspaceNamed',
'Open {{value0}}',
{ value0: candidate.displayName }
{ value0: candidateAccessibleName }
)}
onClick={() => onView(candidate)}
>
@@ -320,7 +337,7 @@ export const CandidateRow = React.memo(function CandidateRow({
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a9957007eb',
'Ignore {{value0}}',
{ value0: candidate.displayName }
{ value0: candidateAccessibleName }
)}
onClick={() => onIgnore(candidate)}
>
@@ -344,7 +361,7 @@ export const CandidateRow = React.memo(function CandidateRow({
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.3828408538',
'Remove {{value0}}',
{ value0: candidate.displayName }
{ value0: candidateAccessibleName }
)}
className="text-destructive hover:text-destructive"
onClick={() => onRemove(candidate)}
@@ -4,6 +4,10 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove'
import {
getWorkspaceCleanupCandidateAccessibleName,
getWorkspaceCleanupCandidateHostLabel
} from './workspace-cleanup-host-label'
const listProbe = vi.hoisted(() => vi.fn())
@@ -31,6 +35,12 @@ vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
let container: HTMLDivElement
let root: Root
@@ -73,4 +83,36 @@ describe('WorkspaceCleanupConfirmRemove', () => {
expect(listProbe).toHaveBeenCalledWith(candidates.length)
expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5)
})
it('visibly and accessibly identifies each host for colliding confirmations', () => {
const local = makeFacetCandidate({ executionHostId: 'local' })
const remote = makeFacetCandidate({
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
act(() => {
root.render(
<WorkspaceCleanupConfirmRemove
candidates={[local, remote]}
now={Date.now()}
reviewInfoByWorktreeId={new Map()}
progress={null}
onBack={vi.fn()}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>
)
})
expect(getWorkspaceCleanupCandidateAccessibleName(local)).not.toBe(
getWorkspaceCleanupCandidateAccessibleName(remote)
)
for (const candidate of [local, remote]) {
const name = getWorkspaceCleanupCandidateAccessibleName(candidate)
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
expect(container.querySelector(`[role="group"][aria-label="${name}"]`)).not.toBeNull()
expect(container.querySelector(`[aria-label="Host: ${hostLabel}"]`)).not.toBeNull()
}
})
})
@@ -1,5 +1,5 @@
import React from 'react'
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'
import { AlertTriangle, Loader2, Monitor, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
@@ -21,8 +21,14 @@ import { StatusPill } from './workspace-cleanup-status-pill'
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
import {
getWorkspaceCleanupCandidateHostId,
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity
} from './workspace-cleanup-host-identity'
import {
getWorkspaceCleanupCandidateAccessibleName,
getWorkspaceCleanupCandidateHostLabel
} from './workspace-cleanup-host-label'
import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip'
const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76
@@ -138,7 +144,7 @@ export function WorkspaceCleanupConfirmRemove({
estimatedRowHeight={CONFIRM_REMOVE_ROW_ESTIMATE_PX}
renderRow={(candidate, index) => (
<ConfirmRemoveRow
key={candidate.worktreeId}
key={getWorkspaceCleanupCandidateIdentity(candidate)}
candidate={candidate}
now={now}
reviewInfo={
@@ -200,10 +206,22 @@ function ConfirmRemoveRow({
const contextPillLabel = getContextPillLabel(candidate)
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
const factStatus = getCandidateFactStatus(candidate)
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
return (
<div className={cn('border-b border-border/60 px-5 py-2.5', last && 'border-b-0')}>
<div
role="group"
aria-label={getWorkspaceCleanupCandidateAccessibleName(candidate)}
className={cn('border-b border-border/60 px-5 py-2.5', last && 'border-b-0')}
>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="min-w-0 truncate text-sm font-medium">{candidate.displayName}</span>
<WorkspaceCleanupMetadataChip
icon={Monitor}
label={translate('components.workspace.cleanup.host.label', 'Host: {{value0}}', {
value0: hostLabel
})}
value={hostLabel}
/>
<span className="text-xs text-muted-foreground">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc',
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
import { getWorkspaceCleanupDeletionPhaseByIdentity } from './workspace-cleanup-deletion-phases'
describe('workspace cleanup deletion phases', () => {
it('marks only the qualified row during a same-id cleanup removal', () => {
const local = makeCandidate({ executionHostId: 'local' })
const remote = makeCandidate({
worktreeId: local.worktreeId,
executionHostId: 'ssh:ssh-1',
connectionId: 'ssh-1'
})
const localIdentity = getWorkspaceCleanupCandidateIdentity(local)
const remoteIdentity = getWorkspaceCleanupCandidateIdentity(remote)
expect(
getWorkspaceCleanupDeletionPhaseByIdentity(
[local, remote],
{ [remoteIdentity]: 'deleting' },
{ [local.worktreeId]: 'deleting' }
)
).toEqual({ [remoteIdentity]: 'deleting' })
expect(localIdentity).not.toBe(remoteIdentity)
})
it('applies a non-cleanup deletion to every ambiguous same-id row', () => {
const local = makeCandidate({ executionHostId: 'local' })
const remote = makeCandidate({
worktreeId: local.worktreeId,
executionHostId: 'runtime:env-1'
})
expect(
getWorkspaceCleanupDeletionPhaseByIdentity(
[local, remote],
{},
{ [local.worktreeId]: 'queued' }
)
).toEqual({
[getWorkspaceCleanupCandidateIdentity(local)]: 'queued',
[getWorkspaceCleanupCandidateIdentity(remote)]: 'queued'
})
})
})
@@ -0,0 +1,27 @@
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupIdentityWorktreeId
} from '../../../../shared/workspace-cleanup-host-identity'
import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row'
export function getWorkspaceCleanupDeletionPhaseByIdentity(
candidates: readonly WorkspaceCleanupCandidate[],
cleanupPhases: Readonly<Record<string, WorkspaceCleanupDeletionPhase>>,
genericPhasesByWorktreeId: Readonly<Record<string, WorkspaceCleanupDeletionPhase>>
): Record<string, WorkspaceCleanupDeletionPhase> {
const cleanupWorktreeIds = new Set(
Object.keys(cleanupPhases).map(getWorkspaceCleanupIdentityWorktreeId)
)
const phases = { ...cleanupPhases }
for (const candidate of candidates) {
if (cleanupWorktreeIds.has(candidate.worktreeId)) {
continue
}
const phase = genericPhasesByWorktreeId[candidate.worktreeId]
if (phase) {
phases[getWorkspaceCleanupCandidateIdentity(candidate)] = phase
}
}
return phases
}
@@ -500,7 +500,9 @@ describe('query pipeline', () => {
)
expect(result.totalCount).toBe(1)
expect(result.matchedCount).toBe(1)
expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha'])
expect(result.selectableIdentities).toEqual([
getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/alpha')
])
})
it('counts per-facet matches independently of the other groups', () => {
@@ -37,6 +37,8 @@ export type WorkspaceCleanupFacetSources = {
export type WorkspaceCleanupFacets = {
candidate: WorkspaceCleanupCandidate
worktreeId: string
/** Host-qualified row key; `worktreeId` alone repeats across hosts (STA-4343). */
identity: string
repoId: string
repoName: string
displayName: string
@@ -107,6 +109,7 @@ export function buildWorkspaceCleanupFacets(
const facets: Omit<WorkspaceCleanupFacets, 'searchText'> = {
candidate,
worktreeId: candidate.worktreeId,
identity: hostIdentity,
repoId: candidate.repoId,
repoName: candidate.repoName,
displayName: candidate.displayName,
@@ -11,6 +11,7 @@ import {
import { CandidateRow } from './workspace-cleanup-candidate-row'
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
import { getWorkspaceCleanupHostIdentity } from './workspace-cleanup-host-identity'
import { runWorkspaceCleanupQuery } from './workspace-cleanup-query'
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
@@ -41,7 +42,8 @@ function renderRows(rows: readonly WorkspaceCleanupFacets[]): void {
scrollElement={null}
renderRow={(row, index) => (
<CandidateRow
key={row.worktreeId}
key={row.identity}
identity={row.identity}
candidate={row.candidate}
reviewInfo={row.review}
expanded={false}
@@ -124,7 +126,9 @@ describe('workspace cleanup flat list', () => {
})
it('reports only the rows the user could actually queue for deletion', () => {
expect(query().selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one')
expect(query().selectableIdentities).not.toContain(
getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/protected-one')
)
})
it('shows status and disk-size facts without expanding a row', () => {
@@ -1,3 +1,4 @@
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
import { describe, expect, it } from 'vitest'
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
@@ -102,11 +103,35 @@ describe('applyWorkspaceCleanupGitEvidence', () => {
worktreeId: 'a',
git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 }
})
const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]]))
const [row] = applyWorkspaceCleanupGitEvidence(
[deferred],
new Map([[getWorkspaceCleanupCandidateIdentity(refreshed), refreshed]])
)
expect(row.git.clean).toBe(false)
expect(row.git.checkedAt).toBe(5)
})
it("never applies one host's git evidence to another host's same-id row", () => {
// STA-4343: a dirty remote row must not mark the local same-id row dirty
// (or vice versa) — the evidence index is keyed by host, not by id.
const localRow = { ...deferredCandidate('a'), executionHostId: 'local' as const }
const remoteEvidence = {
...makeFacetCandidate({
worktreeId: 'a',
blockers: ['dirty-files'],
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 }
}),
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1' as const
}
const [row] = applyWorkspaceCleanupGitEvidence(
[localRow],
new Map([[getWorkspaceCleanupCandidateIdentity(remoteEvidence), remoteEvidence]])
)
expect(row.git).toEqual(localRow.git)
expect(row.blockers).not.toContain('dirty-files')
})
it('returns the input untouched when nothing was re-scanned', () => {
const candidates = [deferredCandidate('a')]
expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates)
@@ -125,7 +150,10 @@ describe('applyWorkspaceCleanupGitEvidence', () => {
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 }
})
const [row] = applyWorkspaceCleanupGitEvidence([current], new Map([['a', refreshed]]))
const [row] = applyWorkspaceCleanupGitEvidence(
[current],
new Map([[getWorkspaceCleanupCandidateIdentity(refreshed), refreshed]])
)
expect(row.displayName).toBe('current name')
expect(row.fingerprint).toBe('current-fingerprint')
@@ -6,6 +6,7 @@ import {
applyWorkspaceCleanupPolicy,
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT
} from '../../../../shared/workspace-cleanup'
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
import type {
WorkspaceCleanupFilterState,
WorkspaceCleanupSortField,
@@ -72,16 +73,20 @@ export function selectWorkspaceCleanupGitEvidenceTargets(
return targets
}
/** Focused re-scan results win over the deferred broad-scan row. */
/**
* Focused re-scan results win over the deferred broad-scan row. Keyed by
* host-qualified identity: applying one host's git state to another host's
* same-id row would mislabel a dirty workspace as safe to delete (STA-4343).
*/
export function applyWorkspaceCleanupGitEvidence(
candidates: readonly WorkspaceCleanupCandidate[],
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
evidenceByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>
): readonly WorkspaceCleanupCandidate[] {
if (evidenceByWorktreeId.size === 0) {
if (evidenceByIdentity.size === 0) {
return candidates
}
return candidates.map((candidate) => {
const evidence = evidenceByWorktreeId.get(candidate.worktreeId)
const evidence = evidenceByIdentity.get(getWorkspaceCleanupCandidateIdentity(candidate))
if (
evidence === undefined ||
(candidate.git.checkedAt !== null &&
@@ -1,8 +1,6 @@
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
@@ -12,6 +10,15 @@ import type {
WorkspaceSpaceWorktreeMeasurement
} from '../../../../shared/workspace-space-types'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { getWorkspaceCleanupHostIdentity } from '../../../../shared/workspace-cleanup-host-identity'
export {
getWorkspaceCleanupCandidateHostId,
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity,
getWorkspaceCleanupIdentityWorktreeId,
resolveWorkspaceCleanupRemovalHostId
} from '../../../../shared/workspace-cleanup-host-identity'
/** Structural subset of `Worktree` kept in sync with the persisted source. */
export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
@@ -35,21 +42,6 @@ export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
>
>
export function getWorkspaceCleanupCandidateHostId(
candidate: Pick<WorkspaceCleanupCandidate, 'connectionId' | 'executionHostId'>
): ExecutionHostId {
return (
normalizeExecutionHostId(candidate.executionHostId) ??
(candidate.connectionId
? toSshExecutionHostId(candidate.connectionId)
: LOCAL_EXECUTION_HOST_ID)
)
}
export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string {
return `${hostId}\0${id}`
}
function countIds<Row extends { worktreeId: string }>(
rows: readonly Row[],
include: (row: Row) => boolean = () => true
@@ -0,0 +1,22 @@
import { translate } from '@/i18n/i18n'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { resolveWorkspaceCleanupRemovalHostId } from './workspace-cleanup-host-identity'
export function getWorkspaceCleanupCandidateHostLabel(
candidate: WorkspaceCleanupCandidate
): string {
const hostId = resolveWorkspaceCleanupRemovalHostId(candidate)
return hostId
? getExecutionHostLabel(hostId)
: translate('components.workspace.cleanup.host.unknown', 'Unknown host')
}
export function getWorkspaceCleanupCandidateAccessibleName(
candidate: WorkspaceCleanupCandidate
): string {
return translate('components.workspace.cleanup.host.candidateName', '{{value0}} on {{value1}}', {
value0: candidate.displayName,
value1: getWorkspaceCleanupCandidateHostLabel(candidate)
})
}
@@ -17,6 +17,10 @@ import {
type WorkspaceCleanupLateSettlementReporter
} from './workspace-cleanup-removal-settlement'
import { reclassifySkippedWorkspaceCleanupAncestors } from './workspace-cleanup-skipped-ancestor-reclassification'
import {
getWorkspaceCleanupCandidateHostId,
getWorkspaceCleanupCandidateIdentity
} from '../../../../shared/workspace-cleanup-host-identity'
type WorkspaceCleanupRemoveCandidates = (
worktreeIds: readonly string[],
@@ -69,7 +73,8 @@ export function createPostBatchLateSettlementReporter({
provisionallyBlocked.has(candidate) &&
retainedSkippedAncestors.some(
(entry) =>
entry.candidate.worktreeId === candidate.worktreeId ||
getWorkspaceCleanupCandidateIdentity(entry.candidate) ===
getWorkspaceCleanupCandidateIdentity(candidate) ||
isStrictWorkspaceCleanupDescendant(entry.candidate, candidate)
)
)
@@ -103,7 +108,9 @@ export function createPostBatchLateSettlementReporter({
})
reportResult(result)
}
const retainedCandidateIds = new Set(retainedCandidates.map((candidate) => candidate.worktreeId))
const retainedCandidateIdentities = new Set(
retainedCandidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))
)
const reportWithoutReconciliation: WorkspaceCleanupLateSettlementReporter = (
_candidate,
result
@@ -111,7 +118,7 @@ export function createPostBatchLateSettlementReporter({
reportResult(result)
}
return (candidate) =>
retainedCandidateIds.has(candidate.worktreeId)
retainedCandidateIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
? reportLateSettlement
: reportWithoutReconciliation
}
@@ -125,8 +132,9 @@ async function reconcilePostBatchLateSettlement(
result: WorkspaceCleanupRemoveResult
pendingSettlementFailures?: ReadonlySet<WorkspaceCleanupFailure>
}> {
const settledIdentity = getWorkspaceCleanupCandidateIdentity(settledCandidateIdentity)
const settledCandidate = state.failedCandidates.find(
(candidate) => candidate.worktreeId === settledCandidateIdentity.worktreeId
(candidate) => getWorkspaceCleanupCandidateIdentity(candidate) === settledIdentity
)
if (settledCandidate) {
state.provisionallyBlocked.delete(settledCandidate)
@@ -136,6 +144,7 @@ async function reconcilePostBatchLateSettlement(
}
const removedIds: string[] = []
const removedIdentities: string[] = []
const lateFailures: WorkspaceCleanupFailure[] = []
const pendingSettlementFailures = new Set<WorkspaceCleanupFailure>()
const findBlockingDescendants = (
@@ -163,6 +172,7 @@ async function reconcilePostBatchLateSettlement(
const provisional = blockers.every((blocker) => state.provisionallyBlocked.has(blocker))
const failure: WorkspaceCleanupFailure = {
worktreeId: ancestor.worktreeId,
executionHostId: getWorkspaceCleanupCandidateHostId(ancestor),
displayName: ancestor.displayName,
message: getSkippedAncestorMessage(provisional)
}
@@ -189,6 +199,7 @@ async function reconcilePostBatchLateSettlement(
state.failedCandidates.push(ancestor)
lateFailures.push({
worktreeId: ancestor.worktreeId,
executionHostId: getWorkspaceCleanupCandidateHostId(ancestor),
displayName: ancestor.displayName,
message: error instanceof Error ? error.message : String(error)
})
@@ -214,9 +225,11 @@ async function reconcilePostBatchLateSettlement(
? outcome.result
: {
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: ancestor.worktreeId,
executionHostId: getWorkspaceCleanupCandidateHostId(ancestor),
displayName: ancestor.displayName,
message:
outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
@@ -224,6 +237,7 @@ async function reconcilePostBatchLateSettlement(
]
}
removedIds.push(...result.removedIds)
removedIdentities.push(...(result.removedIdentities ?? []))
if (result.failures.length > 0) {
state.failedCandidates.push(ancestor)
lateFailures.push(...result.failures)
@@ -232,7 +246,7 @@ async function reconcilePostBatchLateSettlement(
releaseSettledPostBatchState(state)
return {
result: { removedIds, failures: lateFailures },
result: { removedIds, removedIdentities, failures: lateFailures },
pendingSettlementFailures:
pendingSettlementFailures.size > 0 ? pendingSettlementFailures : undefined
}
@@ -30,8 +30,8 @@ export type WorkspaceCleanupQuery = {
export type WorkspaceCleanupQueryResult = {
/** Sorted rows that passed every facet. */
rows: WorkspaceCleanupFacets[]
/** Rows the user could actually queue for deletion, for the select-all affordance. */
selectableWorktreeIds: string[]
/** Host-qualified keys of rows the user could queue, for the select-all affordance. */
selectableIdentities: string[]
totalCount: number
matchedCount: number
}
@@ -88,7 +88,7 @@ export function runWorkspaceCleanupQuery(
const rows = sortWorkspaceCleanupFacets(matched, query.sort)
return {
rows,
selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId),
selectableIdentities: rows.filter((row) => row.isSelectable).map((row) => row.identity),
totalCount: facets.length,
matchedCount: rows.length
}
@@ -20,8 +20,10 @@ describe('workspace cleanup late settlement tracking', () => {
reconcileBeforeBatchResult
)
// The compact copy keeps the host facts a late failure needs to name its row.
expect(tracker.candidate).toEqual({
worktreeId: candidate.worktreeId,
connectionId: candidate.connectionId,
displayName: candidate.displayName
})
expect(tracker.candidate).not.toBe(candidate)
@@ -32,12 +34,18 @@ describe('workspace cleanup late settlement tracking', () => {
expect(reconcileBeforeBatchResult).not.toHaveBeenCalled()
expect(reportAfterBatchResult).toHaveBeenCalledWith(
{ worktreeId: candidate.worktreeId, displayName: candidate.displayName },
{
worktreeId: candidate.worktreeId,
connectionId: candidate.connectionId,
displayName: candidate.displayName
},
{
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: candidate.worktreeId,
executionHostId: 'local',
displayName: candidate.displayName,
message: 'remote removal failed'
}
@@ -4,6 +4,7 @@ import type {
WorkspaceCleanupRemoveResult
} from '@/store/slices/workspace-cleanup'
import { translate } from '@/i18n/i18n'
import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity'
export type WorkspaceCleanupRemovalSettlement =
| { status: 'fulfilled'; result: WorkspaceCleanupRemoveResult }
@@ -15,7 +16,7 @@ export type WorkspaceCleanupRemovalWaitResult =
export type WorkspaceCleanupLateSettlementCandidate = Pick<
WorkspaceCleanupCandidate,
'worktreeId' | 'displayName'
'worktreeId' | 'displayName' | 'connectionId' | 'executionHostId'
>
export type WorkspaceCleanupLateSettlementReporter = (
@@ -51,6 +52,7 @@ export function getWorkspaceCleanupTimeoutFailure(
): WorkspaceCleanupFailure {
return {
worktreeId: candidate.worktreeId,
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
displayName: candidate.displayName,
message: translate(
'auto.components.workspace.cleanup.backgroundRemoval.timedOut',
@@ -67,6 +69,8 @@ export function trackWorkspaceCleanupLateSettlement(
): WorkspaceCleanupLateSettlementTracker {
const candidateIdentity: WorkspaceCleanupLateSettlementCandidate = {
worktreeId: candidate.worktreeId,
connectionId: candidate.connectionId,
...(candidate.executionHostId ? { executionHostId: candidate.executionHostId } : {}),
displayName: candidate.displayName
}
const state: {
@@ -127,9 +131,11 @@ function toWorkspaceCleanupRemoveResult(
}
return {
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: candidate.worktreeId,
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
displayName: candidate.displayName,
message:
settlement.error instanceof Error ? settlement.error.message : String(settlement.error)
@@ -0,0 +1,518 @@
/**
* Timeout-boundary behavior of the cleanup removal batch: renderer IPC cannot be
* cancelled, so a row past its deadline stays provisional and its authoritative
* settlement — success or failure — must still reconcile the batch's row state,
* its ancestor skips, and its toasts. Split from the batch-behavior suite.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup'
import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
info: vi.fn(),
success: vi.fn()
}
}))
async function settleBackgroundRemoval(): Promise<void> {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve()
}
}
describe('workspace cleanup removal timeout recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('uses a success confirmed after the initial timeout and proceeds with its parent', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/repo/parent',
displayName: 'parent',
branch: 'parent',
path: '/repo/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/repo/parent/child',
displayName: 'child',
branch: 'child',
path: '/repo/parent/child'
})
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveChild = resolve
})
)
.mockResolvedValueOnce({
removedIds: [parent.worktreeId],
removedIdentities: [parent.worktreeId],
failures: []
})
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(5)
resolveChild({
removedIds: [child.worktreeId],
removedIdentities: [child.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
approvedCandidates: [parent]
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [child.worktreeId, parent.worktreeId],
removedIdentities: [child.worktreeId, parent.worktreeId],
failures: []
})
expect(toast.error).not.toHaveBeenCalled()
})
it('reports a definitive late failure and skips its parent', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::C:\\repo\\parent',
displayName: 'parent',
branch: 'parent',
path: 'C:\\repo\\parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::C:\\repo\\parent\\child',
displayName: 'child',
branch: 'child',
path: 'C:\\repo\\parent\\child'
})
let rejectChild: (error: Error) => void = () => {}
const removeCandidates = vi.fn(
() =>
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
rejectChild = reject
})
)
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(5)
rejectChild(new Error('remote removal failed'))
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenCalledTimes(1)
expect(onResult).toHaveBeenCalledWith({
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: child.worktreeId,
executionHostId: 'local',
displayName: child.displayName,
message: 'remote removal failed'
},
{
worktreeId: parent.worktreeId,
executionHostId: 'local',
displayName: parent.displayName,
message: 'Skipped because a nested workspace could not be removed.'
}
]
})
})
it('reports a timeout, skips its parent, then reports the authoritative result', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/repo/parent',
displayName: 'parent',
branch: 'parent',
path: '/repo/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/repo/parent/child',
displayName: 'child',
branch: 'child',
path: '/repo/parent/child'
})
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveChild = resolve
})
)
.mockResolvedValueOnce({
removedIds: [parent.worktreeId],
removedIdentities: [parent.worktreeId],
failures: []
})
const onProgress = vi.fn()
const onResult = vi.fn()
const onLateResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates,
onProgress,
onResult,
onLateResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(5)
expect(onResult).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5)
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenCalledTimes(1)
expect(onProgress).toHaveBeenLastCalledWith({
totalCount: 2,
processedCount: 2,
removedCount: 0,
failedCount: 2
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: child.worktreeId,
executionHostId: 'local',
displayName: child.displayName,
message:
'Removing child is taking longer than expected. It will keep running in the background.'
},
{
worktreeId: parent.worktreeId,
executionHostId: 'local',
displayName: parent.displayName,
message: 'Skipped because a nested workspace has not finished removing.'
}
]
})
// The still-pending row is reported as still removing, not as a failure.
expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1')
expect(toast.error).toHaveBeenCalledWith(
'Workspaces not removed: 1',
expect.objectContaining({
description: 'Skipped because a nested workspace has not finished removing.'
})
)
resolveChild({
removedIds: [child.worktreeId],
removedIdentities: [child.worktreeId],
failures: []
})
await settleBackgroundRemoval()
// Why: post-batch child success must reclassify the provisional parent skip
// and retry the parent so rowFailures do not keep the stale skip message.
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
approvedCandidates: [parent]
})
expect(onResult).toHaveBeenCalledTimes(1)
expect(onLateResult).toHaveBeenNthCalledWith(1, {
removedIds: [child.worktreeId],
removedIdentities: [child.worktreeId],
failures: []
})
expect(onLateResult).toHaveBeenNthCalledWith(2, {
removedIds: [parent.worktreeId],
removedIdentities: [parent.worktreeId],
failures: []
})
expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 1')
})
it('hardens a provisional parent skip after the child late-fails post-batch', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/repo/parent',
displayName: 'parent',
branch: 'parent',
path: '/repo/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/repo/parent/child',
displayName: 'child',
branch: 'child',
path: '/repo/parent/child'
})
let rejectChild: (error: Error) => void = () => {}
const onLateResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child],
removeCandidates: vi.fn(
() =>
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
rejectChild = reject
})
),
onProgress: vi.fn(),
onResult: vi.fn(),
onLateResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(10)
await settleBackgroundRemoval()
rejectChild(new Error('remote removal failed'))
await settleBackgroundRemoval()
expect(onLateResult).toHaveBeenNthCalledWith(1, {
removedIds: [],
removedIdentities: [],
failures: [
expect.objectContaining({
worktreeId: child.worktreeId,
message: 'remote removal failed'
})
]
})
expect(onLateResult).toHaveBeenNthCalledWith(2, {
removedIds: [],
removedIdentities: [],
failures: [
expect.objectContaining({
worktreeId: parent.worktreeId,
message: 'Skipped because a nested workspace could not be removed.'
})
]
})
})
it('retries a skipped parent after its blocking child succeeds mid-batch', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/rp/parent',
displayName: 'parent',
branch: 'parent',
path: '/rp/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/rp/parent/c',
displayName: 'child',
branch: 'child',
path: '/rp/parent/c'
})
const unrelated = makeCandidate({
worktreeId: 'repo-1::/zz',
displayName: 'other',
branch: 'other',
path: '/zz'
})
let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {}
let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveChild = resolve
})
)
.mockImplementationOnce(
() =>
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveUnrelated = resolve
})
)
.mockResolvedValueOnce({
removedIds: [parent.worktreeId],
removedIdentities: [parent.worktreeId],
failures: []
})
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child, unrelated],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
// Child times out, the parent is provisionally skipped, and the loop is
// held open by the unrelated row when the child's success reconciles.
await vi.advanceTimersByTimeAsync(10)
expect(removeCandidates).toHaveBeenCalledTimes(2)
resolveChild({
removedIds: [child.worktreeId],
removedIdentities: [child.worktreeId],
failures: []
})
await settleBackgroundRemoval()
resolveUnrelated({
removedIds: [unrelated.worktreeId],
removedIdentities: [unrelated.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], {
approvedCandidates: [parent]
})
expect(onResult).toHaveBeenCalledWith({
removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
removedIdentities: [child.worktreeId, unrelated.worktreeId, parent.worktreeId],
failures: []
})
expect(toast.error).not.toHaveBeenCalled()
expect(toast.info).not.toHaveBeenCalled()
})
it('hardens a provisional skip into a definitive one after the child late-fails mid-batch', async () => {
vi.useFakeTimers()
const parent = makeCandidate({
worktreeId: 'repo-1::/rp/parent',
displayName: 'parent',
branch: 'parent',
path: '/rp/parent'
})
const child = makeCandidate({
worktreeId: 'repo-1::/rp/parent/c',
displayName: 'child',
branch: 'child',
path: '/rp/parent/c'
})
const unrelated = makeCandidate({
worktreeId: 'repo-1::/zz',
displayName: 'other',
branch: 'other',
path: '/zz'
})
let rejectChild: (error: Error) => void = () => {}
let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {}
const removeCandidates = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
rejectChild = reject
})
)
.mockImplementationOnce(
() =>
new Promise<WorkspaceCleanupRemoveResult>((resolve) => {
resolveUnrelated = resolve
})
)
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [parent, child, unrelated],
removeCandidates,
onProgress: vi.fn(),
onResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(10)
rejectChild(new Error('remote removal failed'))
await settleBackgroundRemoval()
resolveUnrelated({
removedIds: [unrelated.worktreeId],
removedIdentities: [unrelated.worktreeId],
failures: []
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenCalledTimes(2)
expect(onResult).toHaveBeenCalledWith({
removedIds: [unrelated.worktreeId],
removedIdentities: [unrelated.worktreeId],
failures: [
{
worktreeId: parent.worktreeId,
executionHostId: 'local',
displayName: parent.displayName,
message: 'Skipped because a nested workspace could not be removed.'
},
{
worktreeId: child.worktreeId,
executionHostId: 'local',
displayName: child.displayName,
message: 'remote removal failed'
}
]
})
expect(toast.error).toHaveBeenCalledWith(
'Workspaces not removed: 2',
expect.objectContaining({
description:
'Skipped because a nested workspace could not be removed.; remote removal failed'
})
)
})
it('reports an authoritative rejection after the timeout result', async () => {
vi.useFakeTimers()
const candidate = makeCandidate()
let rejectRemoval: (error: Error) => void = () => {}
const onLateResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [candidate],
removeCandidates: vi.fn(
() =>
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
rejectRemoval = reject
})
),
onProgress: vi.fn(),
onLateResult,
removalTimeoutMs: 5,
removalSettlementGraceMs: 5
})
await vi.advanceTimersByTimeAsync(10)
rejectRemoval(new Error('remote removal failed'))
await settleBackgroundRemoval()
expect(onLateResult).toHaveBeenCalledWith({
removedIds: [],
removedIdentities: [],
failures: [expect.objectContaining({ message: 'remote removal failed' })]
})
expect(toast.error).toHaveBeenLastCalledWith(
'Workspaces not removed: 1',
expect.objectContaining({ description: 'remote removal failed' })
)
})
})
@@ -16,17 +16,18 @@ export type WorkspaceCleanupRowListState = {
scannedCount: number
hasScanned: boolean
loading: boolean
deletionPhaseByWorktreeId: Record<string, WorkspaceCleanupDeletionPhase>
deletingWorktreeIds: ReadonlySet<string>
deletionPhaseByIdentity: Record<string, WorkspaceCleanupDeletionPhase>
deletingIdentities: ReadonlySet<string>
/** Host-qualified identities (see WorkspaceCleanupFacets.identity). */
expandedRowIds: ReadonlySet<string>
selectedIds: ReadonlySet<string>
gitPendingWorktreeIds: ReadonlySet<string>
/** Keyed by host-qualified identity so a failure marks only its own host's row. */
rowFailures: Record<string, string>
removalInFlight: boolean
scrollElement: HTMLDivElement | null
onClearFilters: () => void
onToggleExpanded: (worktreeId: string) => void
onToggleSelected: (worktreeId: string) => void
onToggleExpanded: (identity: string) => void
onToggleSelected: (identity: string) => void
onView: (candidate: WorkspaceCleanupCandidate) => void
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
onRemove: (candidate: WorkspaceCleanupCandidate) => void
@@ -67,24 +68,24 @@ export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): Re
scrollElement={props.scrollElement}
renderRow={(row, index) => (
<CandidateRow
key={row.worktreeId}
key={row.identity}
identity={row.identity}
candidate={row.candidate}
reviewInfo={row.review}
last={rows.length > 1 && index === rows.length - 1}
expanded={props.expandedRowIds.has(row.worktreeId)}
expanded={props.expandedRowIds.has(row.identity)}
lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt, props.now)}
sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)}
workspaceStatusLabel={row.workspaceStatusLabel}
gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)}
deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]}
deletionPhase={props.deletionPhaseByIdentity[row.identity]}
// Why: a background rescan must not lock rows; only an actual
// removal batch or this row's own deletion disables it.
removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)}
removing={props.deletingIdentities.has(row.identity)}
selected={
props.selectedIds.has(row.worktreeId) &&
!props.deletingWorktreeIds.has(row.worktreeId)
props.selectedIds.has(row.identity) && !props.deletingIdentities.has(row.identity)
}
failure={props.rowFailures[row.worktreeId]}
failure={props.rowFailures[row.identity]}
onToggleExpanded={props.onToggleExpanded}
onToggleSelected={props.onToggleSelected}
onView={props.onView}
@@ -0,0 +1,232 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import type { GitStatusResult } from '../../../../shared/git-status-types'
import type { Repo } from '../../../../shared/repo-types'
import type { GitWorktreeInfo } from '../../../../shared/worktree/types'
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
import type {
WorkspaceCleanupScanArgs,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
const {
listRepoWorktreesMock,
getStatusMock,
gitExecFileAsyncMock,
getLocalProjectWorktreeGitOptionsMock,
getSshGitProviderMock
} = vi.hoisted(() => ({
listRepoWorktreesMock: vi.fn(),
getStatusMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
getLocalProjectWorktreeGitOptionsMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../../../../main/repo-worktrees', () => ({
listRepoWorktrees: listRepoWorktreesMock,
createFolderWorktree: vi.fn()
}))
vi.mock('../../../../main/git/status', () => ({ getStatus: getStatusMock }))
vi.mock('../../../../main/git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('../../../../main/project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock
}))
vi.mock('../../../../main/providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } }))
import { useAppStore } from '@/store'
import { makeWorktree } from '@/store/slices/store-test-helpers'
import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from '@/store/slices/worktrees'
import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal'
const SSH_CONNECTION_ID = 'host-b'
const HOST_A_ID = 'local'
const HOST_B_ID = `ssh:${SSH_CONNECTION_ID}` as const
const REPO_ID = 'colliding-repo'
const DAY_MS = 24 * 60 * 60 * 1000
const SCANNER_MODULE_PATH = ['../../../../main/ipc/', 'workspace-cleanup-scan'].join('')
const initialState = useAppStore.getInitialState()
const temporaryRoots: string[] = []
type ScanWorkspaceCleanup = (
store: unknown,
args?: WorkspaceCleanupScanArgs
) => Promise<WorkspaceCleanupScanResult>
function makeRepo(overrides: Partial<Repo>): Repo {
return {
id: REPO_ID,
path: '/repo',
displayName: 'Colliding repo',
badgeColor: '#000',
addedAt: 0,
...overrides
}
}
function toHostRelativePath(worktreeId: string): string {
const workspacePath = worktreeId.slice(worktreeId.indexOf('::') + 2)
return workspacePath
.replace(/^[a-zA-Z]:/, '')
.split(/[/\\]+/)
.filter(Boolean)
.join(path.sep)
}
function createMarker(root: string, worktreeId: string, name: string): string {
const worktreePath = path.join(root, toHostRelativePath(worktreeId))
fs.mkdirSync(worktreePath, { recursive: true })
const markerPath = path.join(worktreePath, name)
fs.writeFileSync(markerPath, name)
return markerPath
}
describe('workspace cleanup scanned host confirmation removal', () => {
beforeEach(() => {
resetAuthoritativelyRemovedWorktreeMemoryForTests()
getStatusMock.mockReset().mockResolvedValue({
entries: [],
conflictOperation: 'unknown',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
} satisfies GitStatusResult)
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' })
getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({})
})
afterEach(() => {
useAppStore.setState(initialState, true)
Reflect.deleteProperty(window, 'api')
vi.clearAllMocks()
for (const root of temporaryRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true })
}
})
it('deletes only host B after its real scanned candidate crosses the confirmation path', async () => {
const scanRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-scan-'))
const hostARoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-a-'))
const hostBRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-b-'))
temporaryRoots.push(scanRoot, hostARoot, hostBRoot)
const sharedPath = path.join(scanRoot, 'shared-workspace')
const gitPath = path.join(sharedPath, '.git')
fs.mkdirSync(gitPath, { recursive: true })
const oldTime = new Date(Date.now() - 40 * DAY_MS)
fs.utimesSync(sharedPath, oldTime, oldTime)
fs.utimesSync(gitPath, oldTime, oldTime)
const worktreeId = `${REPO_ID}::${sharedPath}`
const hostAMarker = createMarker(hostARoot, worktreeId, 'HOST_A_MARKER')
const hostBMarker = createMarker(hostBRoot, worktreeId, 'HOST_B_MARKER')
const localRepo = makeRepo({ executionHostId: HOST_A_ID })
const sshRepo = makeRepo({
path: '/remote/repo',
connectionId: SSH_CONNECTION_ID,
executionHostId: HOST_B_ID
})
const gitWorktree: GitWorktreeInfo = {
path: sharedPath,
head: 'abc123',
branch: 'refs/heads/shared-workspace',
isBare: false,
isMainWorktree: false
}
const sshProvider = {
listWorktrees: vi.fn().mockResolvedValue([gitWorktree]),
getStatus: vi.fn().mockResolvedValue({
entries: [],
conflictOperation: 'unknown',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
} satisfies GitStatusResult),
exec: vi.fn().mockResolvedValue({ stdout: '0\n', stderr: '' })
}
listRepoWorktreesMock.mockResolvedValue([gitWorktree])
getSshGitProviderMock.mockReturnValue(sshProvider)
const scanStore = {
getRepos: () => [localRepo, sshRepo],
getWorktreeMeta: () => undefined,
getAllWorktreeMeta: () => ({}),
getGitHubCache: () => ({ pr: {}, issue: {} })
}
const { scanWorkspaceCleanup } = await vi.importActual<{
scanWorkspaceCleanup: ScanWorkspaceCleanup
}>(SCANNER_MODULE_PATH)
const scan = vi.fn((args) => scanWorkspaceCleanup(scanStore, args))
const routedHostIds: string[] = []
const remove = vi.fn(async (args: { worktreeId: string; hostId?: string }) => {
routedHostIds.push(args.hostId ?? '<missing>')
const root =
args.hostId === HOST_A_ID ? hostARoot : args.hostId === HOST_B_ID ? hostBRoot : null
if (root) {
fs.rmSync(path.join(root, toHostRelativePath(args.worktreeId)), {
recursive: true,
force: true
})
}
return { ok: true as const }
})
Object.assign(window, {
api: {
worktrees: { remove, forgetLocal: vi.fn() },
hooks: { check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null }) },
workspaceCleanup: {
scan,
cancelScan: vi.fn().mockResolvedValue(undefined),
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined)
},
pty: { kill: vi.fn().mockResolvedValue(undefined) },
runtimeEnvironments: { call: vi.fn() },
ephemeralVm: { listRuntimes: vi.fn().mockResolvedValue([]), cleanup: vi.fn() }
}
})
useAppStore.setState({
repos: [localRepo, sshRepo],
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_ID,
worktreesByRepo: {
[REPO_ID]: [
makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_A_ID }),
makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_B_ID })
]
}
})
expect(fs.existsSync(hostAMarker)).toBe(true)
expect(fs.existsSync(hostBMarker)).toBe(true)
const scanned = await useAppStore.getState().scanWorkspaceCleanup()
const hostBCandidate = scanned.candidates.find(
(candidate) => candidate.executionHostId === HOST_B_ID
)
expect(hostBCandidate).toMatchObject({ worktreeId, executionHostId: HOST_B_ID })
expect(
scanned.candidates.filter((candidate) => candidate.worktreeId === worktreeId)
).toHaveLength(2)
const onDeselect = vi.fn()
const removal = renderHook(() =>
useWorkspaceCleanupRemoval({ onDeselect, closeModal: vi.fn() })
)
act(() => removal.result.current.openConfirmRemove([hostBCandidate!]))
expect(removal.result.current.confirmCandidates[0]).toBe(hostBCandidate)
act(() => removal.result.current.confirmRemove())
await waitFor(() => expect(removal.result.current.removalInFlight).toBe(false))
expect(fs.existsSync(hostAMarker), 'host A marker must survive host B confirmation').toBe(true)
expect(fs.existsSync(hostBMarker), 'host B marker must be deleted').toBe(false)
expect(routedHostIds).toEqual([HOST_B_ID])
expect(remove).toHaveBeenCalledTimes(1)
expect(scan).toHaveBeenCalledTimes(2)
expect(scan.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ worktreeIds: [worktreeId], refreshActivity: true })
)
expect(onDeselect).toHaveBeenCalledWith([getWorkspaceCleanupCandidateIdentity(hostBCandidate!)])
})
})
@@ -1,5 +1,6 @@
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
export function formatVanishedSelectionNotice(count: number): string {
return count === 1
@@ -14,16 +15,19 @@ export function formatVanishedSelectionNotice(count: number): string {
)
}
export function getDefaultSelectedWorkspaceCleanupIds(
/** Host-qualified identities, so a default selection cannot span two hosts' rows. */
export function getDefaultSelectedWorkspaceCleanupIdentities(
candidates: readonly WorkspaceCleanupCandidate[],
deletingWorktreeIds: ReadonlySet<string> = new Set()
deletingIdentities: ReadonlySet<string> = new Set()
): Set<string> {
return new Set(
candidates
.filter(
(candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId)
(candidate) =>
candidate.selectedByDefault &&
!deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
)
.map((candidate) => candidate.worktreeId)
.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))
)
}
+5
View File
@@ -16123,6 +16123,11 @@
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago"
},
"host": {
"label": "Host: {{value0}}",
"unknown": "Unknown host",
"candidateName": "{{value0}} on {{value1}}"
}
}
},
@@ -0,0 +1,115 @@
/**
* Owner-catalog half of worktree operation routing: resolve a route from the
* worktree/detected-worktree rows and their repos, with any disagreement
* between owners reported as `ambiguous` rather than silently picked.
*/
import { getRepoExecutionHostId } from '../../../shared/execution-host'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route'
import {
findIndexedDetectedWorktrees,
resolveIndexedWorktreeOwner
} from './worktree-runtime-owner-index'
// Type-only, so routing's shared vocabulary stays in one place without a runtime cycle.
import type {
WorktreeOperationRoute,
WorktreeOperationRouteResolution,
WorktreeOperationRouteState
} from './worktree-operation-route'
const repoOperationRouteIndexCache = new WeakMap<
NonNullable<WorktreeOperationRouteState['repos']>,
ReadonlyMap<string, WorktreeOperationRouteResolution>
>()
export function resolveExplicitWorktreeOperationRouteResult(
state: WorktreeOperationRouteState,
worktreeId: string
): WorktreeOperationRouteResolution {
const exactRoutes = new Map<string, WorktreeOperationRoute>()
const exactRepoIds = new Set<string>()
const indexedWorktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
if (indexedWorktree.kind === 'ambiguous') {
return { kind: 'ambiguous' }
}
if (indexedWorktree.kind === 'resolved') {
exactRepoIds.add(indexedWorktree.owner.repoId)
const resolution = resolveExactWorktreeRoute(state, indexedWorktree.owner)
if (resolution.kind === 'ambiguous') {
return resolution
}
if (resolution.kind === 'resolved') {
addRoute(exactRoutes, resolution.route)
}
}
for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) {
exactRepoIds.add(worktree.repoId)
const resolution = resolveExactWorktreeRoute(state, worktree)
if (resolution.kind === 'ambiguous') {
return resolution
}
if (resolution.kind === 'resolved') {
addRoute(exactRoutes, resolution.route)
}
}
if (exactRoutes.size > 0) {
const route = exactRoutes.values().next().value
return exactRoutes.size === 1 && route ? { kind: 'resolved', route } : { kind: 'ambiguous' }
}
if (exactRepoIds.size === 0) {
exactRepoIds.add(getRepoIdFromWorktreeId(worktreeId))
}
const repoRoutes = new Map<string, WorktreeOperationRoute>()
for (const repoId of exactRepoIds) {
const resolution = resolveIndexedRepoOperationRoute(state.repos, repoId)
if (resolution.kind === 'ambiguous') {
return resolution
}
if (resolution.kind === 'resolved') {
addRoute(repoRoutes, resolution.route)
}
}
const route = repoRoutes.values().next().value
if (repoRoutes.size === 1 && route) {
return { kind: 'resolved', route }
}
if (repoRoutes.size > 1) {
return { kind: 'ambiguous' }
}
return { kind: 'missing' }
}
function resolveIndexedRepoOperationRoute(
repos: WorktreeOperationRouteState['repos'],
repoId: string
): WorktreeOperationRouteResolution {
if (!repos) {
return { kind: 'missing' }
}
let index = repoOperationRouteIndexCache.get(repos)
if (!index) {
const next = new Map<string, WorktreeOperationRouteResolution>()
for (const repo of repos) {
const repoId = repo.id
if (!repo.executionHostId?.trim() && !repo.connectionId?.trim()) {
continue
}
const route = routeForOwner({ hostId: getRepoExecutionHostId(repo) })
if (!route) {
continue
}
const current = next.get(repoId)
if (!current) {
next.set(repoId, { kind: 'resolved', route })
} else if (
current.kind === 'resolved' &&
JSON.stringify(current.route) !== JSON.stringify(route)
) {
next.set(repoId, { kind: 'ambiguous' })
}
}
index = next
repoOperationRouteIndexCache.set(repos, index)
}
return index.get(repoId) ?? { kind: 'missing' }
}
+73 -112
View File
@@ -1,23 +1,22 @@
import type { AppState } from '@/store/types'
import {
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route'
import { resolveExactWorktreeRoute } from './worktree-owner-route'
import {
findIndexedDetectedWorktrees,
hasIndexedDetectedWorktree,
resolveIndexedWorktreeOwner
} from './worktree-runtime-owner-index'
import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route'
import {
findFolderWorkspaceOwner,
getExecutionHostIdForFolderWorkspace,
type FolderWorkspaceRuntimeOwnerState
} from './folder-workspace-runtime-owner'
export { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route'
export type WorktreeOperationRoute = {
executionHostId: ExecutionHostId | null
runtimeEnvironmentId: string | null
@@ -44,11 +43,6 @@ export type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & {
removedRuntimeEnvironmentIds?: ReadonlySet<string>
}
const repoOperationRouteIndexCache = new WeakMap<
NonNullable<WorktreeOperationRouteState['repos']>,
ReadonlyMap<string, WorktreeOperationRouteResolution>
>()
function ownerRecordsOnHost(
state: WorktreeOperationRouteState,
worktreeId: string,
@@ -86,18 +80,50 @@ export function resolveActiveWorkspaceRoute(
state.activeWorktreeId === worktreeId
? parseExecutionHostId(state.activeWorkspaceExecutionHostId)
: null
if (!activeHost) {
return null
}
if (activeHost.kind === 'runtime') {
return { executionHostId: activeHost.id, runtimeEnvironmentId: activeHost.environmentId }
return activeHost ? resolveSelectedHostRoute(state, worktreeId, activeHost) : null
}
/**
* Route an operation at the host the CALLER named rather than whichever host
* the active workspace happens to select. `repoId::path` ids repeat across
* hosts, so a destructive path that resolves host-blind can delete the same-id
* workspace on the wrong machine (STA-4343); qualified callers resolve here.
*/
export function resolveWorktreeOperationRouteResultForHost(
state: WorktreeOperationRouteState,
worktreeId: string,
executionHostId: ExecutionHostId
): WorktreeOperationRouteResolution {
const host = parseExecutionHostId(executionHostId)
// Why: an unparseable qualifier is not evidence of an owner — fail closed.
return host
? { kind: 'resolved', route: resolveSelectedHostRoute(state, worktreeId, host) }
: { kind: 'missing' }
}
export function resolveWorktreeOperationRouteForHost(
state: WorktreeOperationRouteState,
worktreeId: string,
executionHostId: ExecutionHostId
): WorktreeOperationRoute | null {
const resolution = resolveWorktreeOperationRouteResultForHost(state, worktreeId, executionHostId)
return resolution.kind === 'resolved' ? resolution.route : null
}
function resolveSelectedHostRoute(
state: WorktreeOperationRouteState,
worktreeId: string,
selectedHost: NonNullable<ReturnType<typeof parseExecutionHostId>>
): WorktreeOperationRoute {
if (selectedHost.kind === 'runtime') {
return { executionHostId: selectedHost.id, runtimeEnvironmentId: selectedHost.environmentId }
}
// Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path.
if (activeHost.kind !== 'ssh') {
return { executionHostId: activeHost.id, runtimeEnvironmentId: null }
if (selectedHost.kind !== 'ssh') {
return { executionHostId: selectedHost.id, runtimeEnvironmentId: null }
}
const environmentIds = new Set<string>()
for (const owner of ownerRecordsOnHost(state, worktreeId, activeHost.id)) {
for (const owner of ownerRecordsOnHost(state, worktreeId, selectedHost.id)) {
const resolution = resolveExactWorktreeRoute(state, owner)
if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) {
environmentIds.add(resolution.route.runtimeEnvironmentId)
@@ -105,12 +131,39 @@ export function resolveActiveWorkspaceRoute(
}
const environmentId = environmentIds.values().next().value
return {
executionHostId: activeHost.id,
executionHostId: selectedHost.id,
// Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone.
runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null
}
}
/**
* Distinct execution hosts the store knows as owners of this id. More than one
* means an unqualified destructive call cannot pick a target without guessing;
* empty means no owner row carries host provenance (legacy hydration).
*/
export function getWorktreeOperationOwnerHostIds(
state: WorktreeOperationRouteState,
worktreeId: string
): ExecutionHostId[] {
const hostIds = new Set<ExecutionHostId>()
for (const worktrees of Object.values(state.worktreesByRepo ?? {})) {
for (const worktree of worktrees) {
const hostId = worktree.id === worktreeId ? parseExecutionHostId(worktree.hostId)?.id : null
if (hostId) {
hostIds.add(hostId)
}
}
}
for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) {
const hostId = parseExecutionHostId(worktree.hostId)?.id
if (hostId) {
hostIds.add(hostId)
}
}
return [...hostIds]
}
export function resolveWorktreeOperationRoute(
state: WorktreeOperationRouteState,
worktreeId: string
@@ -197,98 +250,6 @@ function resolveFolderWorkspaceOperationRoute(
}
}
export function resolveExplicitWorktreeOperationRouteResult(
state: WorktreeOperationRouteState,
worktreeId: string
): WorktreeOperationRouteResolution {
const exactRoutes = new Map<string, WorktreeOperationRoute>()
const exactRepoIds = new Set<string>()
const indexedWorktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
if (indexedWorktree.kind === 'ambiguous') {
return { kind: 'ambiguous' }
}
if (indexedWorktree.kind === 'resolved') {
exactRepoIds.add(indexedWorktree.owner.repoId)
const resolution = resolveExactWorktreeRoute(state, indexedWorktree.owner)
if (resolution.kind === 'ambiguous') {
return resolution
}
if (resolution.kind === 'resolved') {
addRoute(exactRoutes, resolution.route)
}
}
for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) {
exactRepoIds.add(worktree.repoId)
const resolution = resolveExactWorktreeRoute(state, worktree)
if (resolution.kind === 'ambiguous') {
return resolution
}
if (resolution.kind === 'resolved') {
addRoute(exactRoutes, resolution.route)
}
}
if (exactRoutes.size > 0) {
const route = exactRoutes.values().next().value
return exactRoutes.size === 1 && route ? { kind: 'resolved', route } : { kind: 'ambiguous' }
}
if (exactRepoIds.size === 0) {
exactRepoIds.add(getRepoIdFromWorktreeId(worktreeId))
}
const repoRoutes = new Map<string, WorktreeOperationRoute>()
for (const repoId of exactRepoIds) {
const resolution = resolveIndexedRepoOperationRoute(state.repos, repoId)
if (resolution.kind === 'ambiguous') {
return resolution
}
if (resolution.kind === 'resolved') {
addRoute(repoRoutes, resolution.route)
}
}
const route = repoRoutes.values().next().value
if (repoRoutes.size === 1 && route) {
return { kind: 'resolved', route }
}
if (repoRoutes.size > 1) {
return { kind: 'ambiguous' }
}
return { kind: 'missing' }
}
function resolveIndexedRepoOperationRoute(
repos: WorktreeOperationRouteState['repos'],
repoId: string
): WorktreeOperationRouteResolution {
if (!repos) {
return { kind: 'missing' }
}
let index = repoOperationRouteIndexCache.get(repos)
if (!index) {
const next = new Map<string, WorktreeOperationRouteResolution>()
for (const repo of repos) {
const repoId = repo.id
if (!repo.executionHostId?.trim() && !repo.connectionId?.trim()) {
continue
}
const route = routeForOwner({ hostId: getRepoExecutionHostId(repo) })
if (!route) {
continue
}
const current = next.get(repoId)
if (!current) {
next.set(repoId, { kind: 'resolved', route })
} else if (
current.kind === 'resolved' &&
JSON.stringify(current.route) !== JSON.stringify(route)
) {
next.set(repoId, { kind: 'ambiguous' })
}
}
index = next
repoOperationRouteIndexCache.set(repos, index)
}
return index.get(repoId) ?? { kind: 'missing' }
}
export function settingsForWorktreeOperationRoute(
settings: AppState['settings'],
route: WorktreeOperationRoute
@@ -0,0 +1,173 @@
/**
* STA-4343 (list state): two hosts publishing the same `repoId::path` row must
* stay two rows. Before the fix the streamed-progress merge, the dismissal
* match, and the post-removal prune all keyed on `worktreeId` alone, so one
* host's row silently replaced — or silently hid, or silently dropped — the
* other's, which is what let a user confirm a row that was not the one shown.
*/
import { describe, expect, it, vi } from 'vitest'
import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity,
resolveWorkspaceCleanupRemovalHostId
} from '../../../../shared/workspace-cleanup-host-identity'
import {
shouldHideWorkspaceCleanupCandidate,
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupDismissal
} from '../../../../shared/workspace-cleanup'
import { resolveWorkspaceCleanupRemovalTargets } from './workspace-cleanup-removal-targets'
import type { AppState } from '../types'
import { createTestStore, seedStore } from './store-test-helpers'
const dismissMock = vi.fn().mockResolvedValue(undefined)
// @ts-expect-error -- minimal window.api stub for dismissal persistence
globalThis.window = { api: { workspaceCleanup: { dismiss: dismissMock } } }
const WORKTREE_ID = 'repo1::/shared/workspace'
function makeCandidate(
executionHostId: WorkspaceCleanupCandidate['executionHostId'],
connectionId: string | null = null
): WorkspaceCleanupCandidate {
return {
worktreeId: WORKTREE_ID,
repoId: 'repo1',
repoName: 'Repo 1',
connectionId,
...(executionHostId ? { executionHostId } : {}),
displayName: 'shared-workspace',
branch: 'old',
path: '/shared/workspace',
tier: 'ready',
selectedByDefault: true,
reasons: ['idle-clean'],
blockers: [],
lastActivityAt: 0,
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: 0,
newestDiffCommentAt: null,
retainedDoneAgentCount: 0
},
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 },
fingerprint: 'fp-1'
}
}
function makeDismissal(
executionHostId?: WorkspaceCleanupDismissal['executionHostId']
): WorkspaceCleanupDismissal {
return {
worktreeId: WORKTREE_ID,
dismissedAt: 0,
fingerprint: 'fp-1',
classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
...(executionHostId ? { executionHostId } : {})
}
}
describe('workspace cleanup host-qualified identity', () => {
it('separates two hosts publishing the same worktree id', () => {
const local = makeCandidate('local')
const remote = makeCandidate('ssh:ssh-1', 'ssh-1')
expect(getWorkspaceCleanupCandidateIdentity(local)).not.toBe(
getWorkspaceCleanupCandidateIdentity(remote)
)
expect(getWorkspaceCleanupCandidateIdentity(remote)).toBe(
getWorkspaceCleanupHostIdentity('ssh:ssh-1', WORKTREE_ID)
)
})
it('reports no removal host for a row with no host evidence', () => {
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined))).toBeNull()
// A pre-executionHostId remote row still names its connection.
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined, 'ssh-1'))).toBe(
'ssh:ssh-1'
)
})
})
describe('workspace cleanup dismissals', () => {
it('hides only the dismissed host row', () => {
const dismissal = makeDismissal('ssh:ssh-1')
expect(
shouldHideWorkspaceCleanupCandidate(makeCandidate('ssh:ssh-1', 'ssh-1'), dismissal)
).toBe(true)
expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), dismissal)).toBe(false)
})
it('keeps a legacy id-only dismissal hiding its row', () => {
expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), makeDismissal())).toBe(true)
})
it('stores same-id dismissals independently and still reads a legacy id-only key', async () => {
const local = makeCandidate('local')
const remote = makeCandidate('ssh:ssh-1', 'ssh-1')
const store = createTestStore()
seedStore(store, {
workspaceCleanupScan: { scannedAt: 1, candidates: [local, remote], errors: [] }
} as Partial<AppState>)
await store.getState().dismissWorkspaceCleanupCandidates([local, remote])
expect(Object.keys(store.getState().workspaceCleanupDismissals).sort()).toEqual(
[
getWorkspaceCleanupCandidateIdentity(local),
getWorkspaceCleanupCandidateIdentity(remote)
].sort()
)
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) }),
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) })
])
seedStore(store, {
workspaceCleanupDismissals: { [WORKTREE_ID]: makeDismissal() },
workspaceCleanupScan: { scannedAt: 1, candidates: [local], errors: [] }
} as Partial<AppState>)
await store.getState().dismissWorkspaceCleanupCandidates([])
expect(store.getState().workspaceCleanupScan?.candidates[0]?.blockers).toContain('dismissed')
})
})
describe('workspace cleanup removal targets', () => {
const emptyState = { worktreesByRepo: {}, detectedWorktreesByRepo: {} } as unknown as AppState
it('carries the confirmed host through as the removal owner', () => {
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
approvedCandidates: [makeCandidate('ssh:ssh-1', 'ssh-1')]
})
expect(target).toMatchObject({ kind: 'target', executionHostId: 'ssh:ssh-1' })
})
it('fails closed when one id is confirmed for two hosts at once', () => {
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
approvedCandidates: [makeCandidate('local'), makeCandidate('ssh:ssh-1', 'ssh-1')]
})
expect(target?.kind).toBe('unresolved')
})
it('fails closed for a confirmed row with no host evidence despite one known owner', () => {
const state = {
worktreesByRepo: { repo1: [{ id: WORKTREE_ID, repoId: 'repo1', hostId: 'ssh:ssh-1' }] },
detectedWorktreesByRepo: {}
} as unknown as AppState
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], state, {
approvedCandidates: [makeCandidate(undefined)]
})
expect(target?.kind).toBe('unresolved')
})
it('fails closed for a confirmed row when no owner row carries a host', () => {
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
approvedCandidates: [makeCandidate(undefined)]
})
expect(target?.kind).toBe('unresolved')
})
})
@@ -1,3 +1,7 @@
import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity
} from '../../../../shared/workspace-cleanup-host-identity'
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
@@ -68,6 +72,9 @@ describe('workspace cleanup removal and protection', () => {
.removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId))
).resolves.toEqual({
removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)),
removedIdentities: expect.arrayContaining(
candidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))
),
failures: []
})
@@ -103,6 +110,7 @@ describe('workspace cleanup removal and protection', () => {
store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId])
).resolves.toEqual({
removedIds: [candidate.worktreeId],
removedIdentities: [getWorkspaceCleanupHostIdentity('local', candidate.worktreeId)],
failures: [],
preservedBranches: [
{
@@ -242,6 +250,7 @@ describe('workspace cleanup removal and protection', () => {
await expect(removal).resolves.toEqual({
removedIds: [WORKTREE_ID],
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
failures: []
})
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, {
@@ -355,6 +364,7 @@ describe('workspace cleanup removal and protection', () => {
await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual(
{
removedIds: [WORKTREE_ID],
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
failures: []
}
)
@@ -364,8 +374,9 @@ describe('workspace cleanup removal and protection', () => {
})
it('fails a queued removal that now needs a force the user never approved', async () => {
const approvedCandidate = makeCandidate()
const approvedCandidate = makeCandidate({ executionHostId: 'local' })
const dirtySinceConfirmation = makeCandidate({
executionHostId: 'local',
tier: 'review',
blockers: ['dirty-files'],
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
@@ -385,9 +396,11 @@ describe('workspace cleanup removal and protection', () => {
})
).resolves.toEqual({
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: WORKTREE_ID,
executionHostId: 'local',
displayName: 'old-workspace',
message: 'Workspace changed after confirmation. Refresh to review it before removing.'
}
@@ -398,6 +411,7 @@ describe('workspace cleanup removal and protection', () => {
it('still force-removes rows whose approved candidate already carried git risk', async () => {
const approvedCandidate = makeCandidate({
executionHostId: 'local',
tier: 'review',
blockers: ['dirty-files'],
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
@@ -415,19 +429,26 @@ describe('workspace cleanup removal and protection', () => {
store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [approvedCandidate]
})
).resolves.toEqual({ removedIds: [WORKTREE_ID], failures: [] })
).resolves.toEqual({
removedIds: [WORKTREE_ID],
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
failures: []
})
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, true, {
suppressPreservedBranchToast: true
suppressPreservedBranchToast: true,
requiredExecutionHostId: 'local'
})
})
it('fails a removal that reveals concrete git risk after an unverified force approval', async () => {
const approvedCandidate = makeCandidate({
executionHostId: 'local',
tier: 'review',
blockers: ['git-status-error'],
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }
})
const nowRevealsUnpushed = makeCandidate({
executionHostId: 'local',
tier: 'review',
blockers: ['unpushed-commits'],
git: { clean: true, upstreamAhead: 3, upstreamBehind: 0, checkedAt: NOW }
@@ -447,9 +468,11 @@ describe('workspace cleanup removal and protection', () => {
})
).resolves.toEqual({
removedIds: [],
removedIdentities: [],
failures: [
{
worktreeId: WORKTREE_ID,
executionHostId: 'local',
displayName: 'old-workspace',
message: 'Workspace changed after confirmation. Refresh to review it before removing.'
}
@@ -0,0 +1,263 @@
/**
* Host-qualified removal identity for workspace cleanup (STA-4343).
*
* A cleanup row's `worktreeId` is `repoId::path`, which two execution hosts can
* both own. Selection, confirmation, preflight and removal therefore travel as
* a (worktreeId, executionHostId) pair. Where the owner cannot be pinned — an
* id-only row that the store knows on several hosts, a confirmation naming two
* hosts at once, or a refreshed scan that no longer shows the row on the
* confirmed host — the target resolves to a failure instead of a best guess:
* deleting the wrong host's workspace destroys uncommitted work.
*/
import type { AppState } from '../types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import {
canQueueWorkspaceCleanupCandidate,
shouldForceWorkspaceCleanupRemoval,
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT,
type WorkspaceCleanupCandidate
} from '../../../../shared/workspace-cleanup'
import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity,
resolveWorkspaceCleanupRemovalHostId
} from '../../../../shared/workspace-cleanup-host-identity'
import { getWorktreeOperationOwnerHostIds } from '@/lib/worktree-operation-route'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupFailure } from './workspace-cleanup'
export type WorkspaceCleanupRemovalTarget = {
kind: 'target'
worktreeId: string
/**
* The host the user confirmed. Null is reserved for legacy internal callers
* that did not supply confirmation candidates.
*/
executionHostId: ExecutionHostId | null
displayName: string
approvedCandidate?: WorkspaceCleanupCandidate
}
export type WorkspaceCleanupUnresolvedTarget = {
kind: 'unresolved'
failure: WorkspaceCleanupFailure
}
export type WorkspaceCleanupRemovalTargetResolution =
| WorkspaceCleanupRemovalTarget
| WorkspaceCleanupUnresolvedTarget
export type WorkspaceCleanupPreflightResult =
| { ok: true; target: WorkspaceCleanupRemovalTarget; candidate: WorkspaceCleanupCandidate }
| { ok: false; failure: WorkspaceCleanupFailure }
type WorkspaceCleanupRemovalTargetState = Pick<
AppState,
'worktreesByRepo' | 'detectedWorktreesByRepo'
>
function ambiguousHostFailure(
worktreeId: string,
displayName: string
): WorkspaceCleanupUnresolvedTarget {
return {
kind: 'unresolved',
failure: {
worktreeId,
displayName,
message: translate(
'auto.store.slices.workspace.cleanup.hostUnresolved',
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
)
}
}
}
export function resolveWorkspaceCleanupRemovalTargets(
worktreeIds: readonly string[],
state: WorkspaceCleanupRemovalTargetState,
options: { approvedCandidates?: readonly WorkspaceCleanupCandidate[] } = {}
): WorkspaceCleanupRemovalTargetResolution[] {
const approvedByWorktreeId = new Map<string, WorkspaceCleanupCandidate[]>()
for (const candidate of options.approvedCandidates ?? []) {
const approved = approvedByWorktreeId.get(candidate.worktreeId) ?? []
approved.push(candidate)
approvedByWorktreeId.set(candidate.worktreeId, approved)
}
return worktreeIds.map((worktreeId) => {
const approved = approvedByWorktreeId.get(worktreeId) ?? []
const displayName = approved[0]?.displayName ?? worktreeId
// Why: one id confirmed for two hosts in a single batch cannot say which
// row the user meant, and picking either one may delete the other's work.
if (
new Set(approved.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))).size > 1
) {
return ambiguousHostFailure(worktreeId, displayName)
}
const confirmedCandidate = approved[0]
const confirmedHostId = confirmedCandidate
? resolveWorkspaceCleanupRemovalHostId(confirmedCandidate)
: null
if (confirmedCandidate && confirmedHostId) {
return {
kind: 'target',
worktreeId,
executionHostId: confirmedHostId,
displayName,
approvedCandidate: confirmedCandidate
}
}
// A displayed row without host evidence cannot prove where the user
// intended to delete, even if the current catalog happens to list one owner.
if (confirmedCandidate) {
return ambiguousHostFailure(worktreeId, displayName)
}
// No host evidence on the row: accept it only while the store itself knows
// a single owner. This compatibility path is reachable only by internal
// callers that did not provide a confirmed candidate.
const ownerHostIds = getWorktreeOperationOwnerHostIds(state, worktreeId)
if (ownerHostIds.length > 1) {
return ambiguousHostFailure(worktreeId, displayName)
}
return {
kind: 'target',
worktreeId,
executionHostId: ownerHostIds[0] ?? null,
displayName,
...(confirmedCandidate ? { approvedCandidate: confirmedCandidate } : {})
}
})
}
export async function preflightWorkspaceCleanupCandidates(
targets: readonly WorkspaceCleanupRemovalTarget[],
getState: () => AppState,
enrich: (
candidates: readonly WorkspaceCleanupCandidate[],
state: AppState
) => Promise<WorkspaceCleanupCandidate[]>
): Promise<WorkspaceCleanupPreflightResult[]> {
// Why: one batched scan per chunk replaces a git worktree-list + activity
// read per row; chunks stay under main's silent target truncation limit.
const candidatesByIdentity = new Map<string, WorkspaceCleanupCandidate>()
const identitiesByWorktreeId = new Map<string, Set<string>>()
const worktreeIds = targets.map((target) => target.worktreeId)
for (let start = 0; start < worktreeIds.length; start += WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) {
const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
const scan = await window.api.workspaceCleanup.scan({
worktreeIds: [...chunk],
scanId: crypto.randomUUID(),
refreshActivity: true
})
const enriched = await enrich(scan.candidates, getState())
for (const candidate of enriched) {
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
candidatesByIdentity.set(identity, candidate)
const identities = identitiesByWorktreeId.get(candidate.worktreeId) ?? new Set<string>()
identities.add(identity)
identitiesByWorktreeId.set(candidate.worktreeId, identities)
}
}
return targets.map((target) =>
evaluateWorkspaceCleanupPreflight(target, candidatesByIdentity, identitiesByWorktreeId)
)
}
function resolvePreflightCandidate(
target: WorkspaceCleanupRemovalTarget,
candidatesByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>,
identitiesByWorktreeId: ReadonlyMap<string, ReadonlySet<string>>
): { ok: true; candidate: WorkspaceCleanupCandidate | undefined } | { ok: false } {
const identities = identitiesByWorktreeId.get(target.worktreeId)
if (target.executionHostId) {
// Why: the refreshed row must be the SAME host's row. Another host's
// evidence would decide force/blockers for a workspace it does not own.
return {
ok: true,
candidate: candidatesByIdentity.get(
getWorkspaceCleanupHostIdentity(target.executionHostId, target.worktreeId)
)
}
}
// An unqualified target can only proceed while the rescan agrees there is one owner.
if ((identities?.size ?? 0) > 1) {
return { ok: false }
}
const identity = identities?.values().next().value
return { ok: true, candidate: identity ? candidatesByIdentity.get(identity) : undefined }
}
export function evaluateWorkspaceCleanupPreflight(
target: WorkspaceCleanupRemovalTarget,
candidatesByIdentity: ReadonlyMap<string, WorkspaceCleanupCandidate>,
identitiesByWorktreeId: ReadonlyMap<string, ReadonlySet<string>>
): WorkspaceCleanupPreflightResult {
const resolved = resolvePreflightCandidate(target, candidatesByIdentity, identitiesByWorktreeId)
if (!resolved.ok) {
return {
ok: false,
failure: ambiguousHostFailure(target.worktreeId, target.displayName).failure
}
}
const candidate = resolved.candidate
if (!candidate) {
return {
ok: false,
failure: {
worktreeId: target.worktreeId,
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
displayName: target.displayName,
message: translate(
'auto.store.slices.workspace.cleanup.9d6e531da6',
'Workspace no longer exists.'
)
}
}
}
const failure = (message: string): WorkspaceCleanupPreflightResult => ({
ok: false,
failure: {
worktreeId: target.worktreeId,
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
displayName: candidate.displayName,
message
}
})
if (!canQueueWorkspaceCleanupCandidate(candidate)) {
return failure(
candidate.blockers.length
? candidate.blockers.join(', ')
: 'Workspace needs another look before removal.'
)
}
// Why: this row may be removed minutes after the confirm click. If it now
// needs a force removal the user never approved (new dirt, unpushed work,
// or a git error since confirmation), fail it instead of force-deleting.
const approvedCandidate = target.approvedCandidate
if (approvedCandidate) {
const escalatedToForce =
shouldForceWorkspaceCleanupRemoval(candidate) &&
!shouldForceWorkspaceCleanupRemoval(approvedCandidate)
// Why: an approved row that was already force-flagged for an unverifiable
// reason must still fail when real dirt/unpushed work is now visible.
const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some(
(blocker) =>
candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker)
)
if (escalatedToForce || revealedConcreteRisk) {
return failure(
translate(
'auto.store.slices.workspace.cleanup.changedSinceConfirmation',
'Workspace changed after confirmation. Refresh to review it before removing.'
)
)
}
}
return { ok: true, target, candidate }
}
// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base
// and git-status-error only mean "couldn't verify". A row approved while
// unverifiable must still fail if real work becomes visible before removal.
const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const
@@ -114,6 +114,54 @@ describe('workspace cleanup scan progress', () => {
})
})
it('keeps same-id host rows distinct across streamed enrichment frames', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const localCandidate = makeCandidate({ executionHostId: 'local' })
const remoteCandidate = makeCandidate({ executionHostId: 'ssh:ssh-1', connectionId: 'ssh-1' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [localCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: [remoteCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
})
pending.resolve({ scannedAt: NOW, candidates: [localCandidate, remoteCandidate], errors: [] })
await scanPromise
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([
expect.objectContaining({ executionHostId: 'local' }),
expect.objectContaining({ executionHostId: 'ssh:ssh-1' })
])
})
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
@@ -0,0 +1,609 @@
/**
* STA-4343: workspace cleanup must delete on the host whose row was confirmed.
*
* Two hosts can expose cleanup candidates with the SAME `repoId::path` identity
* (the scan composes `worktreeId` from `repo.id` + `::` + the workspace path
* with no host component). Before the fix, selection, confirmation, preflight
* and removal kept only `worktreeId` and removal routing preferred the ACTIVE
* workspace's host, so confirming host B's row issued the destructive IPC
* against host A and destroyed host A's real directory (and uncommitted work).
*
* Rig: two REAL temp directories stand in for the two hosts. The removal
* boundary (`window.api.worktrees.remove` for the local/SSH transport,
* `callRuntimeRpc('worktree.rm')` for a paired runtime) is the only fake, and
* it ACTUALLY deletes the directory on the host it is routed to. The gate is
* filesystem state, not arguments: after confirming host B's row, host A's
* marker must still exist and host B's must be gone.
*
* The soundness controls matter as much as the wrong-host cases: the same rig
* with host B ACTIVE (and with host A confirmed) still deletes exactly the
* confirmed row, so a passing assertion cannot come from a rig that never
* deletes anything.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client'
const NOW = 1_700_000_000_000
const HOST_A_HOST_ID: ExecutionHostId = 'local'
const HOST_B_HOST_ID: ExecutionHostId = 'ssh:ssh-1'
const HOST_B_RUNTIME_HOST_ID: ExecutionHostId = 'runtime:hub-1'
const mockApi = {
worktrees: {
remove: vi.fn(),
forgetLocal: vi.fn().mockResolvedValue({ ok: false, error: 'unused in this scenario' })
},
hooks: {
check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false })
},
workspaceCleanup: {
scan: vi.fn(),
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined)
},
pty: { kill: vi.fn().mockResolvedValue(undefined) },
runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} } as never) },
ephemeralVm: {
listRuntimes: vi.fn().mockResolvedValue([]),
cleanup: vi.fn().mockResolvedValue({})
}
}
// @ts-expect-error -- minimal window.api stub for the store under test
globalThis.window = { api: mockApi }
const runtimeRpc = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeRpcClientModule>()),
callRuntimeRpc: runtimeRpc.callRuntimeRpc
}))
import { createTestStore, seedStore, makeTab, makeWorktree } from './store-test-helpers'
import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees'
import type { AppState } from '../types'
import type {
WorkspaceCleanupCandidate,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
function makeHostCandidate(
worktreeId: string,
executionHostId: ExecutionHostId | undefined,
worktreePath: string,
connectionId: string | null = executionHostId === HOST_B_HOST_ID ? 'ssh-1' : null
): WorkspaceCleanupCandidate {
return {
worktreeId,
repoId: 'repo1',
repoName: 'Repo 1',
connectionId,
...(executionHostId ? { executionHostId } : {}),
displayName: 'shared-workspace',
branch: 'old-branch',
path: worktreePath,
tier: 'ready',
selectedByDefault: true,
reasons: ['idle-clean'],
blockers: [],
lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000,
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: 0,
newestDiffCommentAt: null,
retainedDoneAgentCount: 0
},
git: {
clean: true,
upstreamAhead: 0,
upstreamBehind: 0,
checkedAt: NOW
},
fingerprint: `fingerprint-${executionHostId ?? 'unqualified'}`
}
}
function parseIdentityPath(worktreeId: string): string {
const separatorIndex = worktreeId.indexOf('::')
return worktreeId.slice(separatorIndex + 2)
}
/** Maps a workspace id onto a per-host temp directory; Windows ids keep their shape. */
function toHostRelativePath(worktreeId: string): string {
return parseIdentityPath(worktreeId)
.replace(/^[a-zA-Z]:/, '')
.split(/[/\\]+/)
.filter((segment) => segment.length > 0)
.join(path.sep)
}
type HostDirectories = {
hostARoot: string
hostBRoot: string
hostAMarkerPath: string
hostBMarkerPath: string
}
const hostDirCleanup: (() => void)[] = []
function createHostDirectories(worktreeId: string): HostDirectories {
const hostARoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sta4343-host-a-'))
const hostBRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sta4343-host-b-'))
hostDirCleanup.push(() => {
fs.rmSync(hostARoot, { recursive: true, force: true })
fs.rmSync(hostBRoot, { recursive: true, force: true })
})
const relativePath = toHostRelativePath(worktreeId)
const hostAWorktreeDir = path.join(hostARoot, relativePath)
const hostBWorktreeDir = path.join(hostBRoot, relativePath)
fs.mkdirSync(hostAWorktreeDir, { recursive: true })
fs.mkdirSync(hostBWorktreeDir, { recursive: true })
const hostAMarkerPath = path.join(hostAWorktreeDir, 'HOST_A_MARKER')
const hostBMarkerPath = path.join(hostBWorktreeDir, 'HOST_B_MARKER')
fs.writeFileSync(hostAMarkerPath, 'uncommitted-data-on-host-a')
fs.writeFileSync(hostBMarkerPath, 'uncommitted-data-on-host-b')
return { hostARoot, hostBRoot, hostAMarkerPath, hostBMarkerPath }
}
function parseRuntimeSelector(selector: string): string {
return selector.startsWith('id:') ? selector.slice('id:'.length) : selector
}
/** Deletes for real on whichever host the removal is routed to. */
function installRemovalTransports(
hostRootsByHostId: Record<string, string>,
routedHostIds: string[],
options: { preservedBranch?: { branchName: string; head: string } } = {}
): void {
const deleteOnHost = (hostId: string | undefined, worktreeId: string): void => {
routedHostIds.push(hostId ?? '<missing>')
const hostRoot = hostId ? hostRootsByHostId[hostId] : undefined
if (hostRoot) {
fs.rmSync(path.join(hostRoot, toHostRelativePath(worktreeId)), {
recursive: true,
force: true
})
}
}
mockApi.worktrees.remove.mockImplementation(
async (args: { worktreeId: string; hostId?: string }) => {
deleteOnHost(args.hostId, args.worktreeId)
return options.preservedBranch ? { preservedBranch: options.preservedBranch } : { ok: true }
}
)
runtimeRpc.callRuntimeRpc.mockImplementation(
async (_target: unknown, method: string, params: { hostId?: string; worktree?: string }) => {
if (method !== 'worktree.rm') {
return { hasHooks: false, hooks: null, mayNeedUpdate: false }
}
deleteOnHost(params.hostId, parseRuntimeSelector(params.worktree ?? ''))
return { removed: true }
}
)
}
function seedCollidingScan(candidates: readonly WorkspaceCleanupCandidate[]): void {
mockApi.workspaceCleanup.scan.mockResolvedValue({
scannedAt: NOW,
candidates: [...candidates],
errors: []
} satisfies WorkspaceCleanupScanResult)
}
beforeEach(() => {
resetAuthoritativelyRemovedWorktreeMemoryForTests()
})
afterEach(() => {
vi.clearAllMocks()
for (const cleanup of hostDirCleanup.splice(0)) {
cleanup()
}
})
describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
it('confirming host B row deletes host B and leaves ACTIVE host A intact', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
// Host A owns the ACTIVE workspace, so removal routing used to prefer host A.
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
}),
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_B_HOST_ID
})
]
},
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'host-a-tab', worktreeId })]
}
} as Partial<AppState>)
// The user confirms HOST B's cleanup row.
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.removedIds).toEqual([worktreeId])
expect(removal.failures).toEqual([])
expect(
fs.existsSync(hosts.hostAMarkerPath),
`HOST A (active) worktree directory must still exist after confirming HOST B's row. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
).toBe(true)
expect(
fs.existsSync(hosts.hostBMarkerPath),
'HOST B (confirmed) worktree directory must be gone'
).toBe(false)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
expect(store.getState().worktreesByRepo.repo1).toEqual([
expect.objectContaining({ id: worktreeId, hostId: HOST_A_HOST_ID })
])
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
expect.objectContaining({ id: 'host-a-tab' })
])
expect(store.getState().activeWorktreeId).toBe(worktreeId)
})
it('soundness control: with host B ACTIVE, confirming host B still deletes only host B', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_B_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
})
it('soundness control: confirming ACTIVE host A deletes host A and leaves host B intact', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostACandidate = makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)
seedCollidingScan([hostACandidate, makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostACandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
})
it('keeps the confirmed host on the preserved-branch follow-up', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds,
{ preservedBranch: { branchName: 'feature/kept', head: 'abc123' } }
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.preservedBranches).toEqual([
{
worktreeId,
branchName: 'feature/kept',
expectedHead: 'abc123',
hostId: HOST_B_HOST_ID
}
])
})
it('routes a Windows-path colliding identity to the confirmed host', async () => {
const worktreeId = 'repo1::C:\\Users\\me\\workspaces\\shared'
const worktreePath = 'C:\\Users\\me\\workspaces\\shared'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A (active) must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
})
})
describe('STA-4343 wrong-host cleanup removal (folder workspace identity)', () => {
it('folder workspace identity deletes the confirmed host, not the ACTIVE one', async () => {
const worktreeId = 'repo1::/shared/folder-workspace'
const worktreePath = '/shared/folder-workspace'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
// Folder workspaces are not Git worktrees: no worktree owner rows exist.
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'host-a-folder-tab', worktreeId })]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.removedIds).toEqual([worktreeId])
expect(removal.failures).toEqual([])
expect(
fs.existsSync(hosts.hostAMarkerPath),
`HOST A (active) folder workspace must still exist. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
).toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
expect.objectContaining({ id: 'host-a-folder-tab' })
])
expect(store.getState().activeWorktreeId).toBe(worktreeId)
})
})
describe('STA-4343 wrong-host cleanup removal (paired runtime host)', () => {
it('confirming a paired runtime row deletes over the runtime RPC, not the local host', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_RUNTIME_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_RUNTIME_HOST_ID, worktreePath, null)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostAMarkerPath), 'local host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(routedHostIds).toEqual([HOST_B_RUNTIME_HOST_ID])
// The destructive call left over the paired transport, not the local IPC.
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'hub-1' },
'worktree.rm',
expect.objectContaining({ hostId: HOST_B_RUNTIME_HOST_ID }),
expect.anything()
)
})
})
describe('STA-4343 cleanup removal fails closed on an unresolved owner', () => {
it('deletes nothing when the confirmed host no longer lists the row', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
// The confirm-time list had both hosts; the preflight rescan sees only host A.
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.removedIds).toEqual([])
expect(removal.failures).toHaveLength(1)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([])
})
it('deletes nothing for an unqualified row the store knows on two hosts', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
// A row published before host-qualified candidates existed: no host evidence.
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
seedCollidingScan([legacyCandidate])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
}),
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_B_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
expect(removal.removedIds).toEqual([])
expect(removal.failures).toHaveLength(1)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([])
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
})
it('deletes nothing for an old-client row with no host qualifier or catalog owner', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
seedCollidingScan([legacyCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
expect(removal.removedIds).toEqual([])
expect(removal.failures).toHaveLength(1)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([])
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
})
})
+143 -218
View File
@@ -3,15 +3,14 @@
one store state contract. */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import {
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT,
applyWorkspaceCleanupPolicy,
canQueueWorkspaceCleanupCandidate,
canSelectWorkspaceCleanupCandidate,
shouldForceWorkspaceCleanupRemoval,
shouldHideWorkspaceCleanupCandidate,
@@ -22,8 +21,17 @@ import {
type WorkspaceCleanupScanProgress,
type WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import {
getWorkspaceCleanupCandidateHostId,
getWorkspaceCleanupCandidateIdentity
} from '../../../../shared/workspace-cleanup-host-identity'
import { mapWithConcurrency } from '../../../../shared/map-with-concurrency'
import { hydrateWorkspaceCleanupScanFromCache } from './workspace-cleanup-cache-hydration'
import {
preflightWorkspaceCleanupCandidates,
resolveWorkspaceCleanupRemovalTargets,
type WorkspaceCleanupRemovalTarget
} from './workspace-cleanup-removal-targets'
import {
getInFlightWorkspaceCleanupScan,
hasInFlightWorkspaceCleanupScan,
@@ -34,21 +42,20 @@ import {
throwIfWorkspaceCleanupScanSuperseded
} from './workspace-cleanup-broad-scan-registry'
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { translate } from '@/i18n/i18n'
import { resolveWorktreeOperationRoute } from '@/lib/worktree-operation-route'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { resolveWorkspaceCleanupRemovalHostId } from '../../../../shared/workspace-cleanup-host-identity'
import { isWorkspaceCleanupRemovalHostCertain } from './workspace-cleanup-removal-host-guard'
import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup'
export type WorkspaceCleanupFailure = {
worktreeId: string
/** Which host's row failed; absent only for a row with no host evidence. */
executionHostId?: ExecutionHostId
displayName: string
message: string
}
export type WorkspaceCleanupRemoveResult = {
removedIds: string[]
/** Host-qualified keys of the rows actually removed, parallel to removedIds. */
removedIdentities: string[]
failures: WorkspaceCleanupFailure[]
preservedBranches?: PreservedBranchCleanup[]
}
@@ -108,11 +115,6 @@ type WorkspaceCleanupEnrichmentProjection = {
const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000
const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000
export const WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY = 8
// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base
// and git-status-error only mean "couldn't verify". A row approved while
// unverifiable must still fail if real work becomes visible before removal.
const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const
let latestWorkspaceCleanupScanToken = 0
let finalizedWorkspaceCleanupScanToken = 0
let workspaceCleanupProgressQueue: {
@@ -130,7 +132,7 @@ let workspaceCleanupProgressCandidateIndex: {
scanToken: number
scanId: string
candidates: WorkspaceCleanupCandidate[]
indexesByWorktreeId: Map<string, number>
indexesByIdentity: Map<string, number>
} | null = null
const SHELL_PROCESS_NAMES = new Set([
@@ -311,6 +313,9 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
const now = Date.now()
const dismissals = candidates.map((candidate) => ({
worktreeId: candidate.worktreeId,
// Why (STA-4343): ignoring one host's row must not hide the same-id row
// on another host; the stored host scopes the match.
executionHostId: getWorkspaceCleanupCandidateHostId(candidate),
dismissedAt: now,
fingerprint: candidate.fingerprint,
classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION
@@ -319,7 +324,12 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
set((state) => {
const nextDismissals = { ...state.workspaceCleanupDismissals }
for (const dismissal of dismissals) {
nextDismissals[dismissal.worktreeId] = dismissal
nextDismissals[
getWorkspaceCleanupCandidateIdentity({
worktreeId: dismissal.worktreeId,
executionHostId: dismissal.executionHostId
})
] = dismissal
}
const nextScan = state.workspaceCleanupScan
? {
@@ -358,52 +368,51 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
removeWorkspaceCleanupCandidates: async (worktreeIds, options) => {
const removedIds: string[] = []
const removedIdentities = new Set<string>()
const failures: WorkspaceCleanupFailure[] = []
const preservedBranches: PreservedBranchCleanup[] = []
const approvedCandidatesByWorktreeId = new Map(
(options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate])
// Why (STA-4343): the confirmed row — not the id — names the host to delete
// on. Everything below carries that owner so nothing re-derives it from the
// active workspace, which owns the same `repoId::path` id on another host.
const targets = resolveWorkspaceCleanupRemovalTargets(
worktreeIds,
get(),
options?.approvedCandidates ? { approvedCandidates: options.approvedCandidates } : {}
)
const removableTargets: WorkspaceCleanupRemovalTarget[] = []
for (const target of targets) {
if (target.kind === 'unresolved') {
failures.push(target.failure)
continue
}
removableTargets.push(target)
}
const preflights = await preflightWorkspaceCleanupCandidates(
worktreeIds,
removableTargets,
get,
approvedCandidatesByWorktreeId
(candidates, state) =>
enrichWorkspaceCleanupCandidates(candidates, state, { applyDismissals: false })
)
const candidatesToRemove: WorkspaceCleanupCandidate[] = []
const targetsToRemove: {
target: WorkspaceCleanupRemovalTarget
candidate: WorkspaceCleanupCandidate
}[] = []
for (const preflight of preflights) {
if (!preflight.ok) {
failures.push(preflight.failure)
continue
}
candidatesToRemove.push(preflight.candidate)
targetsToRemove.push({ target: preflight.target, candidate: preflight.candidate })
}
// Why: nested workspaces can belong to different repos; parent removal must
// not race child cleanup hooks, PTY teardown, or metadata deletion.
for (const candidate of [...candidatesToRemove].sort((a, b) => b.path.length - a.path.length)) {
// Why: earlier rows can await long enough for active-host routing to change
// after the batched preflight; recheck in the same turn as removeWorktree.
const scannedHostId = resolveWorkspaceCleanupRemovalHostId(candidate)
if (
!isWorkspaceCleanupRemovalHostCertain({
confirmedCandidate: candidate,
scannedCandidate: candidate,
scannedHostIds: [scannedHostId],
routeHostId:
resolveWorktreeOperationRoute(get(), candidate.worktreeId)?.executionHostId ?? null
})
) {
failures.push({
worktreeId: candidate.worktreeId,
displayName: candidate.displayName,
message: translate(
'auto.store.slices.workspace.cleanup.hostUnresolved',
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
)
})
continue
}
for (const { target, candidate } of [...targetsToRemove].sort(
(a, b) => b.candidate.path.length - a.candidate.path.length
)) {
const result = await get().removeWorktree(
candidate.worktreeId,
shouldForceWorkspaceCleanupRemoval(candidate),
@@ -413,21 +422,29 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
suppressPreservedBranchToast: true,
...(options?.snapshotPruneBatchId
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
: {})
: {}),
// Fails the removal closed when routing cannot land on this host.
...(target.executionHostId ? { requiredExecutionHostId: target.executionHostId } : {})
}
)
if (result.ok) {
removedIds.push(candidate.worktreeId)
removedIdentities.add(getWorkspaceCleanupCandidateIdentity(candidate))
if (result.preservedBranch) {
preservedBranches.push({
worktreeId: candidate.worktreeId,
branchName: result.preservedBranch.branchName,
expectedHead: result.preservedBranch.head
expectedHead: result.preservedBranch.head,
...(result.preservedBranch.hostId ? { hostId: result.preservedBranch.hostId } : {}),
...(result.preservedBranch.runtimeEnvironmentId
? { runtimeEnvironmentId: result.preservedBranch.runtimeEnvironmentId }
: {})
})
}
} else {
failures.push({
worktreeId: candidate.worktreeId,
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
displayName: candidate.displayName,
message: result.error
})
@@ -436,37 +453,50 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
if (removedIds.length > 0) {
invalidateWorkspaceCleanupScanProgress()
const removedIdSet = new Set(removedIds)
set((state) => ({
workspaceCleanupLoading: false,
workspaceCleanupScan: state.workspaceCleanupScan
? {
...state.workspaceCleanupScan,
candidates: state.workspaceCleanupScan.candidates.filter(
(candidate) => !removedIdSet.has(candidate.worktreeId)
)
}
: state.workspaceCleanupScan,
// Why: dismissals and viewed marks for removed worktrees are dead
// weight in the store and in every persisted-dismissals write.
workspaceCleanupDismissals: pruneWorkspaceCleanupRecord(
state.workspaceCleanupDismissals,
removedIdSet
),
workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord(
state.workspaceCleanupViewedCandidates,
removedIdSet
let prunableWorktreeIds = new Set(removedIds)
set((state) => {
const remainingCandidates = state.workspaceCleanupScan?.candidates.filter(
(candidate) => !removedIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
)
}))
void window.api.workspaceCleanup
.dismiss({ dismissals: [], removedWorktreeIds: removedIds })
.catch((error: unknown) => {
console.warn('Failed to prune persisted cleanup dismissals', error)
})
// Why: a same-id row on another host survives this removal, and its
// dismissal/viewed marks are keyed by worktree id alone — dropping them
// would resurrect a row the user already ignored.
const survivingWorktreeIds = new Set(
(remainingCandidates ?? []).map((candidate) => candidate.worktreeId)
)
prunableWorktreeIds = new Set(
removedIds.filter((worktreeId) => !survivingWorktreeIds.has(worktreeId))
)
return {
workspaceCleanupLoading: false,
workspaceCleanupScan:
state.workspaceCleanupScan && remainingCandidates
? { ...state.workspaceCleanupScan, candidates: remainingCandidates }
: state.workspaceCleanupScan,
// Why: dismissals and viewed marks for removed worktrees are dead
// weight in the store and in every persisted-dismissals write.
workspaceCleanupDismissals: pruneWorkspaceCleanupDismissals(
state.workspaceCleanupDismissals,
prunableWorktreeIds
),
workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord(
state.workspaceCleanupViewedCandidates,
prunableWorktreeIds
)
}
})
if (prunableWorktreeIds.size > 0) {
void window.api.workspaceCleanup
.dismiss({ dismissals: [], removedWorktreeIds: [...prunableWorktreeIds] })
.catch((error: unknown) => {
console.warn('Failed to prune persisted cleanup dismissals', error)
})
}
}
return {
removedIds,
removedIdentities: [...removedIdentities],
failures,
...(preservedBranches.length > 0 ? { preservedBranches } : {})
}
@@ -680,9 +710,12 @@ function mergeWorkspaceCleanupProgressCandidates({
)
const merged = [...indexCache.candidates]
for (const candidate of nextCandidates) {
const existingIndex = indexCache.indexesByWorktreeId.get(candidate.worktreeId)
// Why (STA-4343): two hosts publish the same `repoId::path` id; keying the
// merge on the id alone made one host's row overwrite the other's.
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
const existingIndex = indexCache.indexesByIdentity.get(identity)
if (existingIndex === undefined) {
indexCache.indexesByWorktreeId.set(candidate.worktreeId, merged.length)
indexCache.indexesByIdentity.set(identity, merged.length)
merged.push(candidate)
continue
}
@@ -692,7 +725,7 @@ function mergeWorkspaceCleanupProgressCandidates({
scanToken,
scanId: progress.scanId,
candidates: merged,
indexesByWorktreeId: indexCache.indexesByWorktreeId
indexesByIdentity: indexCache.indexesByIdentity
}
return merged
}
@@ -703,7 +736,7 @@ function getWorkspaceCleanupProgressCandidateIndex(
scanToken: number
): {
candidates: WorkspaceCleanupCandidate[]
indexesByWorktreeId: Map<string, number>
indexesByIdentity: Map<string, number>
} {
if (
workspaceCleanupProgressCandidateIndex?.scanToken === scanToken &&
@@ -715,8 +748,8 @@ function getWorkspaceCleanupProgressCandidateIndex(
return {
candidates: [...candidates],
indexesByWorktreeId: new Map(
candidates.map((candidate, index) => [candidate.worktreeId, index])
indexesByIdentity: new Map(
candidates.map((candidate, index) => [getWorkspaceCleanupCandidateIdentity(candidate), index])
)
}
}
@@ -792,13 +825,14 @@ async function enrichWorkspaceCleanupCandidatesWithCache(
candidates,
WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY,
async (candidate) => {
const cached = cache.get(candidate.worktreeId)
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
const cached = cache.get(identity)
if (options.localStateUnchanged === true && cached?.candidateRef === candidate) {
return cached.candidate
}
const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate)
const localSignature = getWorkspaceCleanupLocalStateSignature(
candidate.worktreeId,
candidate,
state,
projection,
options
@@ -809,7 +843,7 @@ async function enrichWorkspaceCleanupCandidatesWithCache(
}
const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, projection, options)
cache.set(candidate.worktreeId, {
cache.set(identity, {
candidateRef: candidate,
inputSignature,
localSignature,
@@ -875,11 +909,12 @@ function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupC
}
function getWorkspaceCleanupLocalStateSignature(
worktreeId: string,
candidate: WorkspaceCleanupCandidate,
state: AppState,
projection: WorkspaceCleanupEnrichmentProjection,
options: EnrichOptions
): string {
const { worktreeId } = candidate
const tabs = state.tabsByWorktree[worktreeId] ?? []
const tabIds = tabs.map((tab) => tab.id)
const tabIdSet = new Set(tabIds)
@@ -911,7 +946,7 @@ function getWorkspaceCleanupLocalStateSignature(
const dismissal =
options.applyDismissals === false
? null
: (state.workspaceCleanupDismissals[worktreeId] ?? null)
: (getWorkspaceCleanupDismissal(candidate, state.workspaceCleanupDismissals) ?? null)
return JSON.stringify({
active: state.activeWorktreeId === worktreeId,
@@ -1013,7 +1048,12 @@ function applyDismissal(
candidate: WorkspaceCleanupCandidate,
dismissals: Record<string, WorkspaceCleanupDismissal>
): WorkspaceCleanupCandidate {
if (!shouldHideWorkspaceCleanupCandidate(candidate, dismissals[candidate.worktreeId])) {
if (
!shouldHideWorkspaceCleanupCandidate(
candidate,
getWorkspaceCleanupDismissal(candidate, dismissals)
)
) {
return candidate
}
return applyWorkspaceCleanupPolicy({
@@ -1022,9 +1062,26 @@ function applyDismissal(
})
}
type WorkspaceCleanupPreflightResult =
| { ok: true; candidate: WorkspaceCleanupCandidate }
| { ok: false; failure: WorkspaceCleanupFailure }
function getWorkspaceCleanupDismissal(
candidate: WorkspaceCleanupCandidate,
dismissals: Record<string, WorkspaceCleanupDismissal>
): WorkspaceCleanupDismissal | undefined {
return (
dismissals[getWorkspaceCleanupCandidateIdentity(candidate)] ?? dismissals[candidate.worktreeId]
)
}
function pruneWorkspaceCleanupDismissals(
dismissals: Record<string, WorkspaceCleanupDismissal>,
removedIds: ReadonlySet<string>
): Record<string, WorkspaceCleanupDismissal> {
if (!Object.values(dismissals).some((dismissal) => removedIds.has(dismissal.worktreeId))) {
return dismissals
}
return Object.fromEntries(
Object.entries(dismissals).filter(([, dismissal]) => !removedIds.has(dismissal.worktreeId))
)
}
function pruneWorkspaceCleanupRecord<T>(
record: Record<string, T>,
@@ -1036,138 +1093,6 @@ function pruneWorkspaceCleanupRecord<T>(
return Object.fromEntries(Object.entries(record).filter(([id]) => !removedIds.has(id)))
}
async function preflightWorkspaceCleanupCandidates(
worktreeIds: readonly string[],
getState: () => AppState,
approvedCandidatesByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
): Promise<WorkspaceCleanupPreflightResult[]> {
// Why: one batched scan per chunk replaces a git worktree-list + activity
// read per row; chunks stay under main's silent target truncation limit.
const candidatesByWorktreeId = new Map<string, WorkspaceCleanupCandidate>()
// Why: candidatesByWorktreeId collapses a colliding id to one row, so record
// every owner the scan reported before the collapse hides the collision.
const scannedHostIdsByWorktreeId = new Map<string, Set<ExecutionHostId | null>>()
for (let start = 0; start < worktreeIds.length; start += WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) {
const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
const scan = await window.api.workspaceCleanup.scan({
worktreeIds: [...chunk],
scanId: crypto.randomUUID(),
refreshActivity: true
})
for (const candidate of scan.candidates) {
const hostIds = scannedHostIdsByWorktreeId.get(candidate.worktreeId) ?? new Set()
hostIds.add(resolveWorkspaceCleanupRemovalHostId(candidate))
scannedHostIdsByWorktreeId.set(candidate.worktreeId, hostIds)
}
const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, getState(), {
applyDismissals: false
})
for (const candidate of enriched) {
candidatesByWorktreeId.set(candidate.worktreeId, candidate)
}
}
return worktreeIds.map((worktreeId) => {
const preflight = evaluateWorkspaceCleanupPreflight(
worktreeId,
candidatesByWorktreeId.get(worktreeId),
approvedCandidatesByWorktreeId.get(worktreeId)
)
if (!preflight.ok) {
return preflight
}
const scannedHostIds = [...(scannedHostIdsByWorktreeId.get(worktreeId) ?? [])]
const distinctKnownHostCount = new Set(
scannedHostIds.filter((hostId): hostId is ExecutionHostId => hostId !== null)
).size
// STA-4343: fail closed rather than delete another host's uncommitted work.
const hostIsCertain = isWorkspaceCleanupRemovalHostCertain({
confirmedCandidate: approvedCandidatesByWorktreeId.get(worktreeId),
scannedCandidate: preflight.candidate,
scannedHostIds,
routeHostId: resolveWorktreeOperationRoute(getState(), worktreeId)?.executionHostId ?? null
})
return hostIsCertain
? preflight
: {
ok: false as const,
failure: {
worktreeId,
displayName: preflight.candidate.displayName,
message:
distinctKnownHostCount > 1
? translate(
'auto.store.slices.workspace.cleanup.hostCollision',
'Error: this workspace exists on multiple hosts at the same path'
)
: translate(
'auto.store.slices.workspace.cleanup.hostUnresolved',
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
)
}
}
})
}
function evaluateWorkspaceCleanupPreflight(
worktreeId: string,
candidate: WorkspaceCleanupCandidate | undefined,
approvedCandidate?: WorkspaceCleanupCandidate
): WorkspaceCleanupPreflightResult {
if (!candidate) {
return {
ok: false,
failure: {
worktreeId,
displayName: worktreeId,
message: translate(
'auto.store.slices.workspace.cleanup.9d6e531da6',
'Workspace no longer exists.'
)
}
}
}
if (!canQueueWorkspaceCleanupCandidate(candidate)) {
return {
ok: false,
failure: {
worktreeId,
displayName: candidate.displayName,
message: candidate.blockers.length
? candidate.blockers.join(', ')
: 'Workspace needs another look before removal.'
}
}
}
// Why: this row may be removed minutes after the confirm click. If it now
// needs a force removal the user never approved (new dirt, unpushed work,
// or a git error since confirmation), fail it instead of force-deleting.
if (approvedCandidate) {
const escalatedToForce =
shouldForceWorkspaceCleanupRemoval(candidate) &&
!shouldForceWorkspaceCleanupRemoval(approvedCandidate)
// Why: an approved row that was already force-flagged for an unverifiable
// reason must still fail when real dirt/unpushed work is now visible.
const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some(
(blocker) =>
candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker)
)
if (escalatedToForce || revealedConcreteRisk) {
return {
ok: false,
failure: {
worktreeId,
displayName: candidate.displayName,
message: translate(
'auto.store.slices.workspace.cleanup.changedSinceConfirmation',
'Workspace changed after confirmation. Refresh to review it before removing.'
)
}
}
}
}
return { ok: true, candidate }
}
function buildWorkspaceCleanupAgentStatusIndex(
state: AppState,
includedTabIds?: ReadonlySet<string>
@@ -28,6 +28,7 @@ import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { RemoveWorktreeOptions } from './worktree-removal-options'
import type {
HostQualifiedDetectedWorktreeResult,
SshExecutionHostId
@@ -246,17 +247,7 @@ export type WorktreeSlice = {
removeWorktree: (
worktreeId: string,
force?: boolean,
// 'forget-local' drops the workspace from Orca only (no remote Git/FS work)
// for workspaces pinned to a removed/disconnected SSH host. Reuses the same
// renderer-side teardown/purge as a normal remove.
options?: {
mode?: 'remove' | 'forget-local'
suppressPreservedBranchToast?: boolean
// Why (#11960): only an explicit Force Delete waives the proof that every
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
allowUnverifiedPtyStop?: boolean
snapshotPruneBatchId?: string
}
options?: RemoveWorktreeOptions
) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }>
markWorktreesDeleting: (worktreeIds: readonly string[]) => void
markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void
@@ -0,0 +1,16 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
export type RemoveWorktreeOptions = {
// 'forget-local' drops the workspace from Orca only (no remote Git/FS work)
// for workspaces pinned to a removed/disconnected SSH host. Reuses the same
// renderer-side teardown/purge as a normal remove.
mode?: 'remove' | 'forget-local'
suppressPreservedBranchToast?: boolean
// Why (#11960): only an explicit Force Delete waives the proof that every
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
allowUnverifiedPtyStop?: boolean
snapshotPruneBatchId?: string
// Why (STA-4343): `repoId::path` ids repeat across hosts — pin the removal to
// the confirmed host and fail closed if routing cannot land there.
requiredExecutionHostId?: ExecutionHostId
}
@@ -2,6 +2,10 @@
export const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000
export const WORKTREE_REMOVAL_AMBIGUOUS_ERROR =
'Workspace identity is ambiguous across hosts. Refresh projects and try again.'
// Why (STA-4343): the confirmed row names the host to delete on. If the route
// no longer lands there, deleting anyway destroys another host's workspace.
export const WORKTREE_REMOVAL_HOST_CHANGED_ERROR =
'This workspace is no longer on the host you confirmed. Refresh and review it again.'
export const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300
export const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450
export const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180
@@ -0,0 +1,256 @@
/**
* STA-4343: a cleanup row is identified by `repoId::path`, which two execution
* hosts can both publish. Once a caller confirms ONE host's row, every step of
* the removal must stay pinned to that host — routing at the ACTIVE workspace's
* host instead deletes another machine's uncommitted work.
*/
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types'
import type { WorktreeSlice } from '../../worktree-helpers'
import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host'
import { resolveWorkspaceCleanupRemovalHostId } from '../../../../../../shared/workspace-cleanup-host-identity'
import {
getWorktreeOperationOwnerHostIds,
resolveWorktreeOperationRoute,
resolveWorktreeOperationRouteForHost,
type WorktreeOperationRoute
} from '@/lib/worktree-operation-route'
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
import { getRepoIdFromWorktreeId } from '../../worktree-helpers'
import {
WORKTREE_REMOVAL_AMBIGUOUS_ERROR,
WORKTREE_REMOVAL_HOST_CHANGED_ERROR
} from '../listing/worktree-slice-constants'
import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup'
import { rememberAuthoritativelyRemovedWorktrees } from '../listing/authoritative-worktree-removal-memory'
import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target'
import { worktreeHostMatchOptions, worktreeMatchesHost } from '../listing/worktree-host-ownership'
type PreservedBranchWorktree = Parameters<typeof showPreservedBranchToast>[1]
type RemoveWorktreeSliceResult = Awaited<ReturnType<WorktreeSlice['removeWorktree']>>
/** Route at the confirmed host when there is one, else the ordinary active-host route. */
function resolveHostQualifiedRemovalRoute(
get: WorktreeSliceGet,
worktreeId: string,
requiredExecutionHostId: ExecutionHostId | undefined
): WorktreeOperationRoute | null {
return requiredExecutionHostId
? resolveWorktreeOperationRouteForHost(get(), worktreeId, requiredExecutionHostId)
: resolveWorktreeOperationRoute(get(), worktreeId)
}
export type HostQualifiedRemovalStart =
| { ok: false; error: string }
| {
ok: true
removalRoute: WorktreeOperationRoute | null
hostId: ExecutionHostId | undefined
removalGenerationGuard: ReturnType<typeof captureWorktreeOperationGenerationGuard> | null
sameIdSurvivesOnAnotherHost: boolean
}
/**
* Resolve the route a removal may run on. A caller that confirmed one host's
* row fails closed unless the route still lands on exactly that host.
*/
export function beginHostQualifiedRemoval(
get: WorktreeSliceGet,
worktreeId: string,
requiredExecutionHostId: ExecutionHostId | undefined,
forgetLocalOnly: boolean
): HostQualifiedRemovalStart {
const resolveRemovalRoute = (): WorktreeOperationRoute | null =>
resolveHostQualifiedRemovalRoute(get, worktreeId, requiredExecutionHostId)
const removalRoute = resolveRemovalRoute()
if (!forgetLocalOnly && !removalRoute) {
return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR }
}
// Fail closed rather than delete on a host the caller never confirmed.
if (
requiredExecutionHostId &&
removalRoute &&
removalRoute.executionHostId !== requiredExecutionHostId
) {
return { ok: false, error: WORKTREE_REMOVAL_HOST_CHANGED_ERROR }
}
return {
ok: true,
removalRoute,
hostId: removalRoute?.executionHostId ?? requiredExecutionHostId ?? undefined,
removalGenerationGuard: removalRoute
? captureWorktreeOperationGenerationGuard(
get,
worktreeId,
removalRoute,
() =>
new Error(
requiredExecutionHostId
? WORKTREE_REMOVAL_HOST_CHANGED_ERROR
: WORKTREE_REMOVAL_AMBIGUOUS_ERROR
),
// Why: every mid-flight re-check must re-resolve at the CONFIRMED host,
// or the active host's route would read as "ownership changed".
requiredExecutionHostId ? resolveRemovalRoute : undefined
)
: null,
sameIdSurvivesOnAnotherHost: preservesSameIdRendererState(
get(),
worktreeId,
requiredExecutionHostId
)
}
}
/** The row on the confirmed host only — a same-id row elsewhere must not stand in for it. */
export function findWorktreeOnConfirmedHost(
get: WorktreeSliceGet,
worktreeId: string,
requiredExecutionHostId: ExecutionHostId | undefined
): PreservedBranchWorktree {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return get()
.allWorktrees()
.find(
(entry) =>
entry.id === worktreeId &&
(!requiredExecutionHostId ||
worktreeMatchesHost(
entry,
requiredExecutionHostId,
worktreeHostMatchOptions(get(), repoId, requiredExecutionHostId)
))
)
}
/**
* True when some OTHER host still owns this same id, so the shared renderer
* state (tabs, terminals, browsers, editor files) belongs to a workspace that
* is still alive and must survive this removal.
*/
export function preservesSameIdRendererState(
state: ReturnType<WorktreeSliceGet>,
worktreeId: string,
requiredExecutionHostId: ExecutionHostId | undefined
): boolean {
if (requiredExecutionHostId === undefined) {
return false
}
if (
getWorktreeOperationOwnerHostIds(state, worktreeId).some(
(ownerHostId) => ownerHostId !== requiredExecutionHostId
)
) {
return true
}
if (
state.activeWorktreeId === worktreeId &&
state.activeWorkspaceExecutionHostId !== null &&
state.activeWorkspaceExecutionHostId !== requiredExecutionHostId
) {
return true
}
return (
state.workspaceCleanupScan?.candidates.some((candidate) => {
const candidateHostId = resolveWorkspaceCleanupRemovalHostId(candidate)
return (
candidate.worktreeId === worktreeId &&
candidateHostId !== null &&
candidateHostId !== requiredExecutionHostId
)
}) ?? false
)
}
/** Drop only the confirmed host's row, leaving the surviving host's state whole. */
function dropConfirmedHostRow(
set: WorktreeSliceSet,
worktreeId: string,
requiredExecutionHostId: ExecutionHostId
): void {
requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]')
set((state) => {
const nextWorktreesByRepo = { ...state.worktreesByRepo }
for (const [candidateRepoId, worktrees] of Object.entries(nextWorktreesByRepo)) {
const matchOptions = worktreeHostMatchOptions(state, candidateRepoId, requiredExecutionHostId)
nextWorktreesByRepo[candidateRepoId] = worktrees.filter(
(worktree) =>
worktree.id !== worktreeId ||
!worktreeMatchesHost(worktree, requiredExecutionHostId, matchOptions)
)
}
const nextDeleteState = { ...state.deleteStateByWorktreeId }
delete nextDeleteState[worktreeId]
return {
worktreesByRepo: nextWorktreesByRepo,
deleteStateByWorktreeId: nextDeleteState,
sortEpoch: state.sortEpoch + 1
}
})
if (parseExecutionHostId(requiredExecutionHostId)?.kind === 'ssh') {
rememberAuthoritativelyRemovedWorktrees(requiredExecutionHostId, [worktreeId])
}
}
/**
* Finish a removal whose id still exists on another host: prune just the
* confirmed host's row and keep the preserved-branch follow-up pinned to it.
*/
export function completeSameIdHostScopedRemoval(args: {
set: WorktreeSliceSet
get: WorktreeSliceGet
worktreeId: string
requiredExecutionHostId: ExecutionHostId
removalResult: RemoveWorktreeResult | undefined
removalRoute: WorktreeOperationRoute | null
target: ReturnType<typeof getActiveRuntimeTarget>
worktreeBeforeRemoval: PreservedBranchWorktree
suppressPreservedBranchToast: boolean
}): RemoveWorktreeSliceResult {
const {
set,
get,
worktreeId,
requiredExecutionHostId,
removalResult,
removalRoute,
target,
worktreeBeforeRemoval,
suppressPreservedBranchToast
} = args
dropConfirmedHostRow(set, worktreeId, requiredExecutionHostId)
const preservedBranch = removalResult?.preservedBranch
if (!preservedBranch) {
return { ok: true as const }
}
const runtimeEnvironment = removalRoute?.runtimeEnvironmentId
? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId }
: {}
const cleanup = {
worktreeId,
branchName: preservedBranch.branchName,
expectedHead: preservedBranch.head,
hostId: requiredExecutionHostId,
...runtimeEnvironment
}
preservedBranchRuntimeTargetByCleanupKey.set(preservedBranchCleanupKey(cleanup), {
cleanup,
target
})
if (!suppressPreservedBranchToast) {
showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => {
void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, {
hostId: requiredExecutionHostId,
...runtimeEnvironment
})
})
}
return {
ok: true as const,
preservedBranch: { ...preservedBranch, hostId: requiredExecutionHostId, ...runtimeEnvironment }
}
}
@@ -15,18 +15,21 @@ import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-contro
import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
import {
resolveWorktreeOperationRoute,
resolveWorktreeOperationRouteResult,
resolveWorktreeOperationRouteResultForHost,
settingsForWorktreeOperationRoute
} from '@/lib/worktree-operation-route'
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
import {
beginHostQualifiedRemoval,
completeSameIdHostScopedRemoval,
findWorktreeOnConfirmedHost
} from './host-qualified-worktree-removal'
import {
classifyWorktreeForceDeleteReason,
getLockedWorktreeRemovalReason,
isLockedWorktreeRemovalError
} from '../../../../../../shared/worktree/removal'
import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup'
import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from '../listing/worktree-slice-constants'
import { detachedHeadAutoDerivedDisplayNames } from '../metadata/detached-head-display-name'
import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation'
import { rememberAuthoritativelyRemovedWorktrees } from '../listing/authoritative-worktree-removal-memory'
@@ -44,19 +47,19 @@ export function createRemoveWorktree(
): WorktreeSlice['removeWorktree'] {
return async (worktreeId, force, options) => {
const forgetLocalOnly = options?.mode === 'forget-local'
const removalRoute = resolveWorktreeOperationRoute(get(), worktreeId)
if (!forgetLocalOnly && !removalRoute) {
return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR }
// Why (STA-4343): a qualified caller confirmed ONE host's row; route at that
// host instead of the active workspace's, which owns the same id elsewhere.
const requiredExecutionHostId = options?.requiredExecutionHostId
const start = beginHostQualifiedRemoval(
get,
worktreeId,
requiredExecutionHostId,
forgetLocalOnly
)
if (!start.ok) {
return { ok: false, error: start.error }
}
const hostId = removalRoute?.executionHostId ?? undefined
const removalGenerationGuard = removalRoute
? captureWorktreeOperationGenerationGuard(
get,
worktreeId,
removalRoute,
() => new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR)
)
: null
const { removalRoute, hostId, removalGenerationGuard, sameIdSurvivesOnAnotherHost } = start
set((s) => ({
deleteStateByWorktreeId: {
...s.deleteStateByWorktreeId,
@@ -82,9 +85,11 @@ export function createRemoveWorktree(
removalRoute?.runtimeEnvironmentId
)) === 'skip'
const worktreeBeforeRemoval = get()
.allWorktrees()
.find((entry) => entry.id === worktreeId)
const worktreeBeforeRemoval = findWorktreeOnConfirmedHost(
get,
worktreeId,
requiredExecutionHostId
)
const terminalPtyIdsBeforeRemoval = (get().tabsByWorktree[worktreeId] ?? []).flatMap(
(tab) => get().ptyIdsByTabId[tab.id] ?? []
)
@@ -142,7 +147,9 @@ export function createRemoveWorktree(
(isRuntimeRepoNotFoundError(error) || isRuntimeSelectorNotFoundError(error))
) {
// Missing means stale mirror; ambiguous or changed ownership must fail closed.
const currentResolution = resolveWorktreeOperationRouteResult(get(), worktreeId)
const currentResolution = requiredExecutionHostId
? resolveWorktreeOperationRouteResultForHost(get(), worktreeId, requiredExecutionHostId)
: resolveWorktreeOperationRouteResult(get(), worktreeId)
if (currentResolution.kind === 'ambiguous') {
throw error
}
@@ -187,6 +194,23 @@ export function createRemoveWorktree(
}
}
// Why (STA-4343): another host still owns this id, so the shared renderer
// state (tabs, terminals, browsers) belongs to that live workspace — drop
// only the confirmed host's row instead of tearing all of it down.
if (sameIdSurvivesOnAnotherHost && requiredExecutionHostId) {
return completeSameIdHostScopedRemoval({
set,
get,
worktreeId,
requiredExecutionHostId,
removalResult,
removalRoute,
target,
worktreeBeforeRemoval,
suppressPreservedBranchToast: options?.suppressPreservedBranchToast === true
})
}
// Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement.
forgetHugeRepoWarningDismissalsForWorktrees([worktreeId])
// Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an
@@ -1,8 +1,10 @@
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from './execution-host'
import type { WorkspaceCleanupCandidate } from './workspace-cleanup'
/** Host evidence a cleanup row carries; `worktreeId` alone repeats across hosts. */
export type WorkspaceCleanupHostFacts = {
@@ -10,6 +12,22 @@ export type WorkspaceCleanupHostFacts = {
executionHostId?: ExecutionHostId
}
export type WorkspaceCleanupIdentityFacts = WorkspaceCleanupHostFacts &
Pick<WorkspaceCleanupCandidate, 'worktreeId'>
const WORKSPACE_CLEANUP_IDENTITY_SEPARATOR = '\0'
export function getWorkspaceCleanupCandidateHostId(
candidate: WorkspaceCleanupHostFacts
): ExecutionHostId {
return (
resolveWorkspaceCleanupRemovalHostId(candidate) ??
// Why: display/index surfaces still need a bucket for a pre-host row; only
// the destructive path may not guess (see resolveWorkspaceCleanupRemovalHostId).
LOCAL_EXECUTION_HOST_ID
)
}
/**
* The owner a destructive removal is allowed to target, or null when the row
* carries no host evidence at all (a snapshot or publisher that predates
@@ -26,3 +44,24 @@ export function resolveWorkspaceCleanupRemovalHostId(
const connectionId = candidate.connectionId?.trim()
return connectionId ? toSshExecutionHostId(connectionId) : null
}
export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string {
return `${hostId}${WORKSPACE_CLEANUP_IDENTITY_SEPARATOR}${id}`
}
/** Stable key for one row of one host — the identity every cleanup surface keys on. */
export function getWorkspaceCleanupCandidateIdentity(
candidate: WorkspaceCleanupIdentityFacts
): string {
return getWorkspaceCleanupHostIdentity(
getWorkspaceCleanupCandidateHostId(candidate),
candidate.worktreeId
)
}
export function getWorkspaceCleanupIdentityWorktreeId(identity: string): string {
const separatorIndex = identity.indexOf(WORKSPACE_CLEANUP_IDENTITY_SEPARATOR)
return separatorIndex === -1
? identity
: identity.slice(separatorIndex + WORKSPACE_CLEANUP_IDENTITY_SEPARATOR.length)
}
+14 -2
View File
@@ -1,6 +1,7 @@
// Type-only, so the cycle back through workspace-cleanup-filter-model erases at build.
import type { WorkspaceCleanupBrowseState } from './workspace-cleanup-browse-state'
import type { ExecutionHostId } from './execution-host'
import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity'
export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2
export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000
@@ -38,6 +39,10 @@ export type WorkspaceCleanupDismissal = {
dismissedAt: number
fingerprint: string
classifierVersion: number
// Why (STA-4343): `repoId::path` ids repeat across hosts, so ignoring one
// host's row must not hide another host's. Optional: a dismissal persisted
// before this field keeps its legacy id-only match.
executionHostId?: ExecutionHostId
}
export type WorkspaceCleanupUIState = {
@@ -266,12 +271,19 @@ export function isWorkspaceOldForCleanup(
}
export function shouldHideWorkspaceCleanupCandidate(
candidate: Pick<WorkspaceCleanupCandidate, 'worktreeId' | 'fingerprint'>,
candidate: Pick<
WorkspaceCleanupCandidate,
'worktreeId' | 'fingerprint' | 'connectionId' | 'executionHostId'
>,
dismissal: WorkspaceCleanupDismissal | undefined
): boolean {
return (
dismissal?.worktreeId === candidate.worktreeId &&
dismissal.fingerprint === candidate.fingerprint &&
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION &&
// A host-qualified dismissal hides only its own host's row; a legacy one
// (no host recorded) keeps hiding every row that matches the fingerprint.
(dismissal.executionHostId === undefined ||
dismissal.executionHostId === getWorkspaceCleanupCandidateHostId(candidate))
)
}