fix(worktrees): prevent overtaken worktree scans from pruning mid-scan lineage (#41284)

Creating a child worktree while a runtime or fleet listing was in flight could delete its fresh lineage edge if the mutation landed after the scan's overtaken check (for example during metadata pruning or removal-checkout probes). Gate pruneLineageForMissingRepoWorktrees on the scan-start generation and pass { scanGeneration } from both runtime callers.

Fixes #16654

Co-authored-by: Skyler Shaw <skyler@jump.run>
This commit is contained in:
Neil
2026-11-11 15:18:36 -08:00
committed by GitHub
co-authored by Skyler Shaw
parent 846531cf2f
commit 32bebf99fa
5 changed files with 71 additions and 3 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { GitWorktreeInfo, Worktree } from '../../../../shared/worktree/types'
@@ -10,9 +11,11 @@ import { buildDetectedGitWorktrees } from '../../../ipc/worktrees/listing/ssh-wo
import {
resolveRepoWorktreeRows,
resolveScopedWorktreeIdRow,
scanRepoWorktreeRows,
type RepoWorktreeRowDeps
} from './repo-worktree-row-resolution'
import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
import { bumpLocalWorktreeScanGeneration } from '../../../workspaces/catalog/local-worktree-scan-generation'
function repo(
id: string,
@@ -160,6 +163,39 @@ describe('scoped worktree id resolution across path spellings (#16243)', () => {
})
})
describe('scanRepoWorktreeRows lineage pruning', () => {
// #16654: lineage written by a create that overtook the scan must survive its stale rows.
it('keeps lineage recorded by a worktree change that overtook the scan', async () => {
const owner = repo('repo-1', '/repo')
const deps = createDeps([owner])
const childId = 'repo-1::/repo-child'
const lineageById: Record<string, WorktreeLineage> = {}
const removeWorktreeLineage = vi.fn()
Object.assign(deps.store, {
getAllWorktreeLineage: () => lineageById,
getWorktreeMeta: () => undefined,
removeWorktreeLineage
})
deps.scanRepo.mockImplementationOnce(async () => {
bumpLocalWorktreeScanGeneration(owner.id)
lineageById[childId] = {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/repo',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
return { ok: true, worktrees: [gitWorktree('/repo')] }
})
await scanRepoWorktreeRows(deps, owner, new Map())
expect(removeWorktreeLineage).not.toHaveBeenCalled()
})
})
describe('folder-to-Git checkout identity', () => {
it.each([
['C:\\projects\\draft', 'C:/projects/draft'],
@@ -28,6 +28,7 @@ import { createRepoRowExecutionHostLookup } from '../../../../shared/execution-h
import { areWorktreePathsEqual } from '../../../git/worktrees/catalog/worktree-path-comparison'
import { mergeWorktree } from '../../../ipc/worktree-metadata-merge'
import { pruneLineageForMissingRepoWorktrees } from '../../../workspaces/catalog/worktree-lineage-pruning'
import { getLocalWorktreeScanGeneration } from '../../../workspaces/catalog/local-worktree-scan-generation'
import { getRepoOwnedWorktreeMeta } from '../../../workspaces/catalog/worktree-metadata-ownership'
import { resolveLocalProjectRuntimesForRepos } from '../../../workspaces/execution-host/local-project-runtime-resolution'
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
@@ -117,6 +118,7 @@ export async function scanRepoWorktreeRows(
repoOwnerCount = deps.store.getRepos().filter((candidate) => candidate.id === repo.id).length
): Promise<RepoWorktreeRowsScan> {
const { store } = deps
const scanGeneration = getLocalWorktreeScanGeneration(repo.id)
// Absorb rejection before withTimeout: rejection preserves shipped empty results, but timeout restores last-known rows.
const scan: RuntimeWorktreeScanResult = (await withTimeout<RuntimeWorktreeScanResult | null>(
deps
@@ -132,7 +134,7 @@ export async function scanRepoWorktreeRows(
}
const gitWorktrees = preserveFolderUpgradeWorktreePath(repo, scan.worktrees)
if (scan.ok) {
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees, { scanGeneration })
}
return { ok: scan.ok, worktrees: gitWorktrees }
}
@@ -38,6 +38,7 @@ import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-querie
import type { RuntimeStore } from '../../construction/runtime-store-contract'
import {
bumpLocalWorktreeScanGeneration,
getLocalWorktreeScanGeneration,
resetLocalWorktreeScanGenerationsForTests
} from '../../../workspaces/catalog/local-worktree-scan-generation'
@@ -287,6 +288,28 @@ describe('RuntimeManagedWorktreeQueries.listDetected overtaken scan', () => {
expect(pruneLineageMock.mock.calls[0]?.[2]).toEqual(rows([repo.path, created]))
})
// #16654: a create landing during the metadata probe writes lineage the scan never saw.
it('gates lineage pruning on the scan-start generation when a change lands during metadata pruning', async () => {
const store = gitStore({
pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(() => []),
getProfileStorageDirectory: () => '/profile',
captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(() => ({ repo, metadata: [] }))
})
const atScanStart = getLocalWorktreeScanGeneration(repo.id)
pruneMetadataMock.mockImplementationOnce(async () => {
bumpLocalWorktreeScanGeneration(repo.id)
return { scanGenerationCurrent: false, preservedMetadataCandidateIds: new Set() }
})
const scanRepo = vi.fn(async () => ({ ok: true as const, worktrees: rows([repo.path]) }))
await queries(store, { scanRepo }).listDetected(repo)
// The prune refuses a generation that has moved on; it must see the scan-start one.
expect(pruneLineageMock).toHaveBeenCalledWith(store, repo, rows([repo.path]), {
scanGeneration: atScanStart
})
})
it('publishes the last scan non-authoritatively once the re-scan bound is spent', async () => {
const scanRepo = vi.fn(overtakenScan([repo.path]))
@@ -173,7 +173,9 @@ export class RuntimeManagedWorktreeQueries {
scanGeneration: scan.scanGeneration
})
}
pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees)
pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees, {
scanGeneration: scan.scanGeneration
})
}
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
@@ -9,6 +9,7 @@ import { worktreeRetentionPathComparisonKey } from './worktree-retention-path-co
import type { Store } from '../../persistence/loading-store/store'
import { isWslMoveRepoBlocked } from '../../persistence/moving-wsl-projects/orchestration/wsl-move-background-backoff'
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
import { isLocalWorktreeScanGenerationCurrent } from './local-worktree-scan-generation'
type WorktreeLineagePruningStore = Pick<Store, 'getRepos' | 'getWorktreeMeta' | 'setWorktreeMeta'> &
Partial<
@@ -58,12 +59,16 @@ export function pruneLineageForMissingRepoWorktrees(
options: {
platform?: NodeJS.Platform
preservedMetadataCandidateIds?: ReadonlySet<string>
/** Scan-start generation; a worktree change since then may have recorded lineage these rows miss (#16654). */
scanGeneration?: number
} = {}
): void {
const { platform = process.platform, preservedMetadataCandidateIds } = options
const { platform = process.platform, preservedMetadataCandidateIds, scanGeneration } = options
// Mid-move a scan can miss paths that still exist; lineage waits for the move to settle.
if (
isWslMoveRepoBlocked(repo) ||
(scanGeneration !== undefined &&
!isLocalWorktreeScanGenerationCurrent(repo.id, scanGeneration)) ||
typeof store.getAllWorktreeLineage !== 'function' ||
typeof store.removeWorktreeLineage !== 'function'
) {