mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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'
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user