From eabf17b1769b2d93361cd54d019a23a5d33e80f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:10:49 -0700 Subject: [PATCH] fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473) --- src/main/worktree-name-retirement.test.ts | 171 -------------- src/main/worktree-name-retirement.ts | 113 +-------- .../worktree-retirement-backfill-scan.test.ts | 93 ++++++++ src/main/worktree-retirement-backfill-scan.ts | 58 +++++ ...worktree-retirement-backfill-stall.test.ts | 73 ++++++ .../worktree-retirement-discovery-wsl.test.ts | 167 +++++++++++++ .../worktree-retirement-discovery.test.ts | 220 ++++++++++++++++++ src/main/worktree-retirement-discovery.ts | 165 +++++++++++++ 8 files changed, 782 insertions(+), 278 deletions(-) create mode 100644 src/main/worktree-retirement-backfill-scan.test.ts create mode 100644 src/main/worktree-retirement-backfill-scan.ts create mode 100644 src/main/worktree-retirement-backfill-stall.test.ts create mode 100644 src/main/worktree-retirement-discovery-wsl.test.ts create mode 100644 src/main/worktree-retirement-discovery.test.ts create mode 100644 src/main/worktree-retirement-discovery.ts diff --git a/src/main/worktree-name-retirement.test.ts b/src/main/worktree-name-retirement.test.ts index e7c1c249c56..1499a0aab46 100644 --- a/src/main/worktree-name-retirement.test.ts +++ b/src/main/worktree-name-retirement.test.ts @@ -6,10 +6,7 @@ import { MARINE_CREATURES } from '../shared/marine-creatures' import type { GlobalSettings } from '../shared/global-settings-types' import type { Repo } from '../shared/repo-types' import { - collectRetiredNamesFromLeafNames, - discoverRetiredWorktreeNames, ensureRetiredWorktreeNamesBackfilled, - extractBucketLeafCandidates, getRetiredNameRegistryForRepo, normalizeRetirableGeneratedName, resetRetirementCollisionKeyCacheForTests @@ -48,174 +45,6 @@ describe('normalizeRetirableGeneratedName', () => { }) }) -describe('extractBucketLeafCandidates', () => { - it('takes everything past the encoded parent as the leaf', () => { - expect(extractBucketLeafCandidates(`-w-orca-${FIRST}`, ['-w-orca'])).toEqual([FIRST]) - }) - - it('does not treat the parent directory as a leaf when the workspace name is numeric', () => { - // Real data: `-Users-x-orca-workspaces-orca-7474` must not retire `orca`, which is in the pool. - expect(extractBucketLeafCandidates('-w-workspaces-orca-7474', ['-w-workspaces-orca'])).toEqual([ - '7474' - ]) - }) - - it('offers the first segment too, so an agent run in a subdirectory still retires the leaf', () => { - expect(extractBucketLeafCandidates(`-w-orca-${FIRST}-packages-api`, ['-w-orca'])).toEqual([ - `${FIRST}-packages-api`, - FIRST - ]) - }) - - it('rejects a sibling directory that shares the parent prefix', () => { - expect(extractBucketLeafCandidates(`-w-orcadyne-${FIRST}`, ['-w-orca'])).toEqual([]) - expect(extractBucketLeafCandidates(`-w-orca-secret-${FIRST}`, ['-w-orca-fix'])).toEqual([]) - }) - - it('yields nothing for the parent bucket itself', () => { - expect(extractBucketLeafCandidates('-w-orca', ['-w-orca'])).toEqual([]) - }) -}) - -describe('collectRetiredNamesFromLeafNames', () => { - it('keeps pool names and drops everything else', () => { - expect(collectRetiredNamesFromLeafNames([FIRST, SECOND, 'fix-login'])).toEqual( - new Set([FIRST, SECOND]) - ) - }) - - it('is case-insensitive and skips non-string entries without throwing', () => { - const leaves = [undefined, null, '', MARINE_CREATURES[0].toUpperCase()] as unknown as string[] - expect(collectRetiredNamesFromLeafNames(leaves)).toEqual(new Set([FIRST])) - }) -}) - -describe('discoverRetiredWorktreeNames', () => { - /** Buckets are written with the REAL per-character encoding, because a helper that mirrors the - * implementation would pass against a broken encoder — which is how the Windows gap shipped. */ - async function withFakeHome( - buckets: readonly string[], - run: (home: string) => Promise - ): Promise { - const home = await mkdtemp(join(tmpdir(), 'orca-retirement-home-')) - try { - for (const bucket of buckets) { - await mkdir(join(home, '.claude', 'projects', bucket), { recursive: true }) - } - await run(home) - } finally { - await rm(home, { force: true, recursive: true }) - } - } - - it('matches a plain POSIX workspace root', async () => { - await withFakeHome([`-Users-ada-orca-workspaces-orca-${FIRST}`], async (home) => { - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: ['/Users/ada/orca/workspaces/orca'], - home, - env: {} - }) - expect(retired).toEqual(new Set([FIRST])) - }) - }) - - it('matches an NFC bucket for an NFD workspace root', async () => { - const nfdRoot = '/Users/ada/cafe\u0301' - await withFakeHome([`-Users-ada-caf--${FIRST}`], async (home) => { - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: [nfdRoot], - home, - env: {} - }) - expect(retired).toEqual(new Set([FIRST])) - }) - }) - - it('matches a dot-directory root, where the separator run encodes to two dashes', async () => { - await withFakeHome([`-Users-ada--orca-worktrees-${FIRST}`], async (home) => { - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: ['/Users/ada/.orca/worktrees'], - home, - env: {} - }) - expect(retired).toEqual(new Set([FIRST])) - }) - }) - - it('matches a Windows drive root', async () => { - // `getDefaultWorkspaceDir` returns `C:\Users\\orca\workspaces` on Windows, so an encoder - // that collapsed `:\` rejected every bucket on that platform by default. - await withFakeHome([`C--Users-ada-orca-workspaces-${FIRST}`], async (home) => { - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'], - home, - env: {} - }) - expect(retired).toEqual(new Set([FIRST])) - }) - }) - - it('matches a WSL UNC root', async () => { - await withFakeHome([`--wsl--Ubuntu-home-ada-orca-workspaces-${FIRST}`], async (home) => { - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: ['\\\\wsl$\\Ubuntu\\home\\ada\\orca\\workspaces'], - home, - env: {} - }) - expect(retired).toEqual(new Set([FIRST])) - }) - }) - - it('ignores buckets belonging to a sibling root with the same prefix', async () => { - await withFakeHome( - [`-Users-ada-orca-workspaces-orcadyne-${FIRST}`, `-Users-ada-orca-workspaces-orca-${SECOND}`], - async (home) => { - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: ['/Users/ada/orca/workspaces/orca'], - home, - env: {} - }) - expect(retired).toEqual(new Set([SECOND])) - } - ) - }) - - it('reads buckets from CLAUDE_CONFIG_DIR when it is set', async () => { - const configDir = await mkdtemp(join(tmpdir(), 'orca-retirement-config-')) - await withFakeHome([`-Users-ada-w-${SECOND}`], async (home) => { - try { - await mkdir(join(configDir, 'projects', `-Users-ada-w-${FIRST}`), { recursive: true }) - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: ['/Users/ada/w'], - home, - env: { CLAUDE_CONFIG_DIR: configDir } - }) - // The override relocates the whole state root, so the default home is not also scanned. - expect(retired).toEqual(new Set([FIRST])) - } finally { - await rm(configDir, { force: true, recursive: true }) - } - }) - }) - - it('retires live workspace directories alongside surviving buckets', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-retirement-roots-')) - await withFakeHome([], async (home) => { - try { - await mkdir(join(root, SECOND), { recursive: true }) - const retired = await discoverRetiredWorktreeNames({ - workspaceRoots: [root], - home, - env: {} - }) - expect(retired).toEqual(new Set([SECOND])) - } finally { - await rm(root, { force: true, recursive: true }) - } - }) - }) -}) - describe('getRetiredNameRegistryForRepo', () => { const settingsFor = (nestWorkspaces: boolean): GlobalSettings => ({ workspaceDir: '/workspaces', nestWorkspaces }) as GlobalSettings diff --git a/src/main/worktree-name-retirement.ts b/src/main/worktree-name-retirement.ts index d74a37e2939..d10a71618b7 100644 --- a/src/main/worktree-name-retirement.ts +++ b/src/main/worktree-name-retirement.ts @@ -17,10 +17,6 @@ import { import { isFolderRepo } from '../shared/repo-kind' import type { GlobalSettings } from '../shared/global-settings-types' import type { Repo } from '../shared/repo-types' -import { - encodeClaudeProjectPaths, - isClaudeProjectDirInScope -} from './ai-vault/claude-project-dir-encoding' import { computeRemoteWorktreePath, computeWorktreePathAsync, @@ -34,10 +30,11 @@ import { retirementNamespaceKeysToRead, type SshTargetLookup } from './worktree-retirement-namespace' +import { discoverRetiredWorktreeNames } from './worktree-retirement-discovery' +import { runRetirementBackfillScan } from './worktree-retirement-backfill-scan' import { hasCachedWslHome, parseWslPath } from './wsl' const RETIREMENT_PROBE_NAME = 'orca-retirement-probe' -const scansByStore = new WeakMap>>>() type RetirementReadStore = { getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry @@ -67,45 +64,6 @@ export function failedWorktreeCreationNeedsRetirement(error: unknown): boolean { return typeof error === 'object' && error !== null && Reflect.get(error, 'cleanupFailed') === true } -/** Why: over-retiring costs one name from a 552-entry pool; under-retiring reissues a path whose - * agent history is still on disk. So this matches generously and never tries to be exact. */ -export function collectRetiredNamesFromLeafNames(leafNames: Iterable): Set { - const retired = new Set() - for (const leafName of leafNames) { - if (typeof leafName !== 'string' || leafName.length === 0) { - continue - } - const normalized = normalizeRetirableGeneratedName(leafName) - if (normalized) { - retired.add(normalized) - } - } - return retired -} - -/** The workspace leaf is whatever the bucket has beyond its encoded parent. Deriving it from - * trailing dash segments instead makes a numerically named workspace retire its parent - * directory's name — and `orca` is in the pool. The first segment is also offered because an - * agent run from a subdirectory buckets the whole subpath. */ -export function extractBucketLeafCandidates( - bucketName: string, - encodedParents: readonly string[] -): string[] { - const bucket = bucketName.toLowerCase() - for (const parent of encodedParents) { - if (!parent || bucket === parent || !isClaudeProjectDirInScope(bucket, [parent])) { - continue - } - const remainder = bucket.slice(parent.length + 1) - if (!remainder) { - continue - } - const firstSegment = remainder.split('-')[0] - return remainder === firstSegment ? [remainder] : [remainder, firstSegment] - } - return [] -} - async function getRetirementProbePath( repo: Repo, settings: RetirementPathSettings @@ -308,70 +266,11 @@ export async function ensureRetiredWorktreeNamesBackfilled( getWorktreePathSettings(repo, settings) ) const scanKey = `${getRepoExecutionHostId(repo)}:${worktreePathComparisonKey(probePath)}` - let storeScans = scansByStore.get(store) - if (!storeScans) { - storeScans = new Map() - scansByStore.set(store, storeScans) - } - let scan = storeScans.get(scanKey) - if (!scan) { - scan = discoverRetiredWorktreeNames({ workspaceRoots: [parentPath(probePath)] }) - storeScans.set(scanKey, scan) - } + const names = await runRetirementBackfillScan(store, scanKey, () => + discoverRetiredWorktreeNames({ workspaceRoots: [parentPath(probePath)] }) + ) // Why the merge sits outside the cached scan: the scan is per cwd namespace but the registry is // per repo, so every repo that asks must receive it — not only the one that triggered it. - store.mergeRetiredWorktreeNames(repo.id, await scan) + store.mergeRetiredWorktreeNames(repo.id, names) return scanKey } - -async function readDirectoryNames(path: string): Promise { - try { - const entries = await readdir(path, { withFileTypes: true }) - return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) - } catch { - // Missing or unreadable roots are normal: the agent may never have run on this machine. - return [] - } -} - -/** Claude buckets its per-conversation state under a directory derived from the workspace cwd. A - * bucket surviving its workspace is exactly the evidence we need: the name was used, the - * directory is gone, and reissuing the name would hand the next occupant that conversation. - * CLAUDE_CONFIG_DIR relocates the whole state root, buckets included. - * - * Codex is deliberately not scanned: it records the cwd inside - * `sessions/YYYY/MM/DD/rollout-*.jsonl` rather than in a directory name, so seeding from it would - * mean parsing user conversation files. Names spent only under Codex before this feature shipped - * stay issuable; every name spent after it is recorded at create time regardless of agent. */ -function getClaudeProjectsDir(home: string, env: NodeJS.ProcessEnv): string { - const override = env.CLAUDE_CONFIG_DIR?.trim() - return override ? join(override, 'projects') : join(home, '.claude', 'projects') -} - -/** Discovers names already spent for a repo, for the one-time seed of the retirement registry. - * Local and best-effort by design — agent state for an SSH workspace lives on the execution host, - * so names used only there stay issuable until this host observes them. */ -export async function discoverRetiredWorktreeNames(args: { - workspaceRoots: readonly string[] - home?: string - env?: NodeJS.ProcessEnv -}): Promise> { - const leafNames: string[] = [] - for (const root of args.workspaceRoots) { - leafNames.push(...(await readDirectoryNames(root))) - } - - // Lowercased on both sides: the recorded cwd can differ in case from ours on case-insensitive - // filesystems, and a missed bucket reissues a live path while a spurious one costs one name. - const encodedParents = args.workspaceRoots - .filter((root) => root.length > 0) - .flatMap((root) => encodeClaudeProjectPaths(root).map((path) => path.toLowerCase())) - if (encodedParents.length > 0) { - const projectsDir = getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env) - for (const bucket of await readDirectoryNames(projectsDir)) { - leafNames.push(...extractBucketLeafCandidates(bucket, encodedParents)) - } - } - - return collectRetiredNamesFromLeafNames(leafNames) -} diff --git a/src/main/worktree-retirement-backfill-scan.test.ts b/src/main/worktree-retirement-backfill-scan.test.ts new file mode 100644 index 00000000000..9daa6dcd787 --- /dev/null +++ b/src/main/worktree-retirement-backfill-scan.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS, + RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS, + runRetirementBackfillScan +} from './worktree-retirement-backfill-scan' + +/** A scan the test settles by hand, standing in for a `readdir` on a stalled NFS/SMB/WSL mount. */ +function stallingScan(): { run: () => Promise>; finish: (names: string[]) => void } { + let settle: (names: Set) => void = () => {} + return { + run: () => new Promise>((resolve) => (settle = resolve)), + finish: (names) => settle(new Set(names)) + } +} + +describe('runRetirementBackfillScan', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('gives up on a listing that never returns instead of blocking create forever', async () => { + const scan = stallingScan() + + const names = runRetirementBackfillScan({}, 'ns', scan.run) + const settled = expect(names).rejects.toThrow(/exceeded/) + await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) + + await settled + }) + + it('retries a timed-out namespace once its backoff lapses, rather than wedging until restart', async () => { + const store = {} + const first = stallingScan() + const second = vi.fn(async () => new Set(['nautilus'])) + + const stalled = runRetirementBackfillScan(store, 'ns', first.run) + const settled = expect(stalled).rejects.toThrow(/exceeded/) + await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) + await settled + + // Why the backoff: an unabortable `readdir` still holds a libuv thread, so re-probing on every + // create would starve the pool. Until it lapses the failure is served from the memo. + await expect(runRetirementBackfillScan(store, 'ns', second)).rejects.toThrow(/exceeded/) + expect(second).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS) + await expect(runRetirementBackfillScan(store, 'ns', second)).resolves.toEqual( + new Set(['nautilus']) + ) + }) + + it('scans a namespace once and shares the answer with every later caller', async () => { + const store = {} + const scan = vi.fn(async () => new Set(['nautilus'])) + + await expect(runRetirementBackfillScan(store, 'ns', scan)).resolves.toEqual( + new Set(['nautilus']) + ) + await expect(runRetirementBackfillScan(store, 'ns', scan)).resolves.toEqual( + new Set(['nautilus']) + ) + + expect(scan).toHaveBeenCalledTimes(1) + }) + + it('keeps one namespace stall from failing another', async () => { + const store = {} + const stuck = stallingScan() + const healthy = vi.fn(async () => new Set(['nautilus'])) + + const stalled = runRetirementBackfillScan(store, 'stuck-ns', stuck.run) + const settled = expect(stalled).rejects.toThrow(/exceeded/) + await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) + await settled + + await expect(runRetirementBackfillScan(store, 'healthy-ns', healthy)).resolves.toEqual( + new Set(['nautilus']) + ) + }) + + it('does not let one store inherit another store scan memo', async () => { + const first = vi.fn(async () => new Set(['nautilus'])) + const second = vi.fn(async () => new Set(['orca'])) + + await runRetirementBackfillScan({}, 'ns', first) + await expect(runRetirementBackfillScan({}, 'ns', second)).resolves.toEqual(new Set(['orca'])) + }) +}) diff --git a/src/main/worktree-retirement-backfill-scan.ts b/src/main/worktree-retirement-backfill-scan.ts new file mode 100644 index 00000000000..e6acf98f8f0 --- /dev/null +++ b/src/main/worktree-retirement-backfill-scan.ts @@ -0,0 +1,58 @@ +/** Deadline for one retirement discovery scan. Generated create awaits it, so it has to be short + * enough that a stalled NFS/SMB/WSL listing degrades to "seed nothing" instead of a frozen create. */ +export const RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS = 15_000 + +/** How long a failed scan stays failed. Retrying immediately would let every create start another + * listing against the same stalled mount, and an unabortable `readdir` holds a libuv threadpool + * thread until the OS releases it — four of those and the whole process loses filesystem access. */ +export const RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS = 60_000 + +type BackfillScan = { + /** Resolves with the discovered names, or rejects once the deadline lapses. */ + names: Promise> + /** Epoch ms this scan may be retried after; 0 while it is in flight or has succeeded. */ + retryAfter: number +} + +const scansByStore = new WeakMap>() + +function withScanDeadline(scan: Promise>): Promise> { + return new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error(`retirement backfill scan exceeded ${RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS}ms`) + ) + }, RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) + timer.unref?.() + void scan.then(resolve, reject).finally(() => clearTimeout(timer)) + }) +} + +/** Memoizes the one-time retirement discovery scan per store and cwd namespace. + * + * A success is kept for the process lifetime — a name never un-spends, so rescanning could only + * lose entries. A failure is not: the original cache held the scan Promise forever and its + * `readdir` had no deadline, so one stalled listing wedged this create and every later + * generated-name create for the namespace until Orca restarted. */ +export function runRetirementBackfillScan( + store: object, + scanKey: string, + scan: () => Promise> +): Promise> { + let storeScans = scansByStore.get(store) + if (!storeScans) { + storeScans = new Map() + scansByStore.set(store, storeScans) + } + const cached = storeScans.get(scanKey) + if (cached && (cached.retryAfter === 0 || Date.now() < cached.retryAfter)) { + return cached.names + } + const entry: BackfillScan = { names: Promise.resolve(new Set()), retryAfter: 0 } + entry.names = withScanDeadline(scan()).catch((error: unknown) => { + entry.retryAfter = Date.now() + RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS + throw error + }) + storeScans.set(scanKey, entry) + return entry.names +} diff --git a/src/main/worktree-retirement-backfill-stall.test.ts b/src/main/worktree-retirement-backfill-stall.test.ts new file mode 100644 index 00000000000..86e588072ca --- /dev/null +++ b/src/main/worktree-retirement-backfill-stall.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../shared/repo-types' + +const { readdirMock } = vi.hoisted(() => ({ readdirMock: vi.fn() })) + +// Why the module-level mock rather than a temp directory: the defect is a listing that never +// returns — a stalled NFS/SMB share or WSL UNC mount — which no real filesystem reproduces. +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + readdir: readdirMock +})) + +const { ensureRetiredWorktreeNamesBackfilled } = await import('./worktree-name-retirement') +const { RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS, RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS } = + await import('./worktree-retirement-backfill-scan') + +const repo = { + id: 'repo-a', + path: '/repos/a', + displayName: 'a', + badgeColor: '', + addedAt: 0 +} as Repo +const settings = { workspaceDir: '/workspaces', nestWorkspaces: false } + +function backfillStore(): { merged: string[]; mergeRetiredWorktreeNames: () => boolean } { + const merged: string[] = [] + return { + merged, + mergeRetiredWorktreeNames: (...args: unknown[]) => { + merged.push(...(args[1] as Iterable)) + return true + } + } as never +} + +describe('retirement backfill against a stalled listing', () => { + beforeEach(() => { + vi.useFakeTimers() + readdirMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('gives up rather than hanging generated create on a listing that never returns', async () => { + readdirMock.mockImplementation(() => new Promise(() => {})) + const store = backfillStore() + + const backfill = ensureRetiredWorktreeNamesBackfilled(store, repo, settings) + const settled = expect(backfill).rejects.toThrow(/exceeded/) + await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) + + await settled + }) + + it('recovers the namespace once the mount comes back, instead of staying wedged until restart', async () => { + readdirMock.mockImplementation(() => new Promise(() => {})) + const store = backfillStore() + + const stalled = ensureRetiredWorktreeNamesBackfilled(store, repo, settings) + const settled = expect(stalled).rejects.toThrow(/exceeded/) + await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) + await settled + + readdirMock.mockResolvedValue([{ name: 'nautilus', isDirectory: () => true }]) + await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS) + + await ensureRetiredWorktreeNamesBackfilled(store, repo, settings) + expect(store.merged).toContain('nautilus') + }) +}) diff --git a/src/main/worktree-retirement-discovery-wsl.test.ts b/src/main/worktree-retirement-discovery-wsl.test.ts new file mode 100644 index 00000000000..fc3314f3908 --- /dev/null +++ b/src/main/worktree-retirement-discovery-wsl.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MARINE_CREATURES } from '../shared/marine-creatures' +import { createRetiredNameLookup } from '../shared/worktree/retired-name-registry' + +const { runWslTranscriptFsTaskMock, getWslHomeAsyncMock } = vi.hoisted(() => ({ + runWslTranscriptFsTaskMock: vi.fn(), + getWslHomeAsyncMock: vi.fn() +})) + +vi.mock('./native-chat/wsl-transcript-fs-gate', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + runWslTranscriptFsTask: runWslTranscriptFsTaskMock +})) + +// Why the whole module is not replaced: `worktree-name-retirement` reads `parseWslPath` and +// `hasCachedWslHome` from here on the same code path under test. +vi.mock('./wsl', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getWslHomeAsync: getWslHomeAsyncMock +})) + +const { discoverRetiredWorktreeNames } = await import('./worktree-retirement-discovery') +const { ensureRetiredWorktreeNamesBackfilled } = await import('./worktree-name-retirement') +const { WslTranscriptFsError } = await import('./native-chat/wsl-transcript-fs-gate') + +const FIRST = MARINE_CREATURES[0].toLowerCase() +const DISTRO_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca\\workspaces' + +describe('retirement discovery on WSL', () => { + beforeEach(() => { + runWslTranscriptFsTaskMock.mockReset() + getWslHomeAsyncMock.mockReset() + // Pass through by default: the gate's own admission logic has its own tests. + runWslTranscriptFsTaskMock.mockImplementation( + (_options: unknown, task: (signal: AbortSignal) => Promise) => + task(new AbortController().signal) + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('admits every WSL UNC listing through the bounded filesystem gate', async () => { + await discoverRetiredWorktreeNames({ + workspaceRoots: [DISTRO_ROOT], + home: '/nonexistent-home', + env: {}, + resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada' + }) + + // Both UNC reads — the workspace root and the distro's bucket directory — are admitted, at the + // scan priority that leaves the exact-probe permit free. + expect(runWslTranscriptFsTaskMock.mock.calls.map(([options]) => options)).toEqual([ + expect.objectContaining({ operation: 'readdir', path: DISTRO_ROOT, priority: 'scan' }), + expect.objectContaining({ + operation: 'readdir', + path: join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects'), + priority: 'scan' + }) + ]) + }) + + it('leaves no listing ungated when the workspace root is on the Windows side', async () => { + await discoverRetiredWorktreeNames({ + workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'], + home: '/nonexistent-home', + env: {} + }) + + expect(runWslTranscriptFsTaskMock).not.toHaveBeenCalled() + }) + + it('fails the scan when the gate refuses, rather than memoizing a half-read answer', async () => { + // A swallowed refusal would cache "nothing is retired" for the process lifetime — the one + // direction that reissues a cwd whose conversation history is still on the distro. + runWslTranscriptFsTaskMock.mockRejectedValue( + new WslTranscriptFsError('timeout', 'filesystem access is taking too long') + ) + + await expect( + discoverRetiredWorktreeNames({ + workspaceRoots: [DISTRO_ROOT], + home: '/nonexistent-home', + env: {}, + resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada' + }) + ).rejects.toBeInstanceOf(WslTranscriptFsError) + }) + + it('keeps a deleted WSL workspace name spent, so the next create cannot reuse its cwd', async () => { + // Delete/recreate: the workspace directory is gone, but the agent ran inside the distro and its + // bucket survives there. That bucket is the only remaining evidence the cwd is unsafe. + const distroHome = await mkdtemp(join(tmpdir(), 'orca-wsl-distro-home-')) + const workspaceRoot = await mkdtemp(join(tmpdir(), 'orca-wsl-workspaces-')) + try { + await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), { + recursive: true + }) + + const retired = await discoverRetiredWorktreeNames({ + // The root is listed under its real (empty) path; the UNC spelling supplies the distro. + workspaceRoots: [DISTRO_ROOT], + home: workspaceRoot, + env: {}, + resolveWslHome: async () => distroHome + }) + + expect(retired).toEqual(new Set([FIRST])) + expect(createRetiredNameLookup({ exhaustedTiers: 0, names: [...retired] })(FIRST)).toBe(true) + } finally { + await rm(distroHome, { force: true, recursive: true }) + await rm(workspaceRoot, { force: true, recursive: true }) + } + }) + + it('reaches distro discovery from the real backfill, not only when called directly', async () => { + // Wiring, not discovery: every test above calls the module directly, so all of them stay green + // if `ensureRetiredWorktreeNamesBackfilled` keeps scanning the Windows home alone. That is the + // shape STA-4472 shipped in, so the only caller production has is asserted here. + const distroHome = await mkdtemp(join(tmpdir(), 'orca-wsl-backfill-home-')) + try { + await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), { + recursive: true + }) + // Serves both the workspace-root mirror and the distro bucket lookup. + getWslHomeAsyncMock.mockResolvedValue('\\\\wsl.localhost\\Ubuntu\\home\\ada') + runWslTranscriptFsTaskMock.mockImplementation( + (options: { path: string }, task: (signal: AbortSignal) => Promise) => { + // Stands in for the distro's bucket directory, which only exists behind the UNC root. + const claudeRoot = join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects') + return options.path === claudeRoot + ? readdir(join(distroHome, '.claude', 'projects'), { withFileTypes: true }) + : task(new AbortController().signal) + } + ) + const merged: string[] = [] + + await ensureRetiredWorktreeNamesBackfilled( + { + mergeRetiredWorktreeNames: (_repoId: string, names: Iterable) => { + merged.push(...names) + return true + } + } as never, + { + id: 'repo-wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repos\\a', + displayName: 'a', + badgeColor: '', + addedAt: 0 + } as never, + // The workspace dir is the UNC root itself. `wsl.ts`'s `parseWslPath` short-circuits off + // win32, so routing through the repo path instead would make this assert nothing on CI's + // Linux and macOS runners — the distro half of discovery keys on the root string. + { workspaceDir: DISTRO_ROOT, nestWorkspaces: false } + ) + + expect(merged).toContain(FIRST) + } finally { + await rm(distroHome, { force: true, recursive: true }) + } + }) +}) diff --git a/src/main/worktree-retirement-discovery.test.ts b/src/main/worktree-retirement-discovery.test.ts new file mode 100644 index 00000000000..38876b4e257 --- /dev/null +++ b/src/main/worktree-retirement-discovery.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MARINE_CREATURES } from '../shared/marine-creatures' +import { + collectRetiredNamesFromLeafNames, + discoverRetiredWorktreeNames, + extractBucketLeafCandidates +} from './worktree-retirement-discovery' + +const FIRST = MARINE_CREATURES[0].toLowerCase() +const SECOND = MARINE_CREATURES[1].toLowerCase() + +describe('extractBucketLeafCandidates', () => { + it('takes everything past the encoded parent as the leaf', () => { + expect(extractBucketLeafCandidates(`-w-orca-${FIRST}`, ['-w-orca'])).toEqual([FIRST]) + }) + + it('does not treat the parent directory as a leaf when the workspace name is numeric', () => { + // Real data: `-Users-x-orca-workspaces-orca-7474` must not retire `orca`, which is in the pool. + expect(extractBucketLeafCandidates('-w-workspaces-orca-7474', ['-w-workspaces-orca'])).toEqual([ + '7474' + ]) + }) + + it('offers the first segment too, so an agent run in a subdirectory still retires the leaf', () => { + expect(extractBucketLeafCandidates(`-w-orca-${FIRST}-packages-api`, ['-w-orca'])).toEqual([ + `${FIRST}-packages-api`, + FIRST + ]) + }) + + it('rejects a sibling directory that shares the parent prefix', () => { + expect(extractBucketLeafCandidates(`-w-orcadyne-${FIRST}`, ['-w-orca'])).toEqual([]) + expect(extractBucketLeafCandidates(`-w-orca-secret-${FIRST}`, ['-w-orca-fix'])).toEqual([]) + }) + + it('yields nothing for the parent bucket itself', () => { + expect(extractBucketLeafCandidates('-w-orca', ['-w-orca'])).toEqual([]) + }) +}) + +describe('collectRetiredNamesFromLeafNames', () => { + it('keeps pool names and drops everything else', () => { + expect(collectRetiredNamesFromLeafNames([FIRST, SECOND, 'fix-login'])).toEqual( + new Set([FIRST, SECOND]) + ) + }) + + it('is case-insensitive and skips non-string entries without throwing', () => { + const leaves = [undefined, null, '', MARINE_CREATURES[0].toUpperCase()] as unknown as string[] + expect(collectRetiredNamesFromLeafNames(leaves)).toEqual(new Set([FIRST])) + }) +}) + +describe('discoverRetiredWorktreeNames', () => { + /** Buckets are written with the REAL per-character encoding, because a helper that mirrors the + * implementation would pass against a broken encoder — which is how the Windows gap shipped. */ + async function withFakeHome( + buckets: readonly string[], + run: (home: string) => Promise + ): Promise { + const home = await mkdtemp(join(tmpdir(), 'orca-retirement-home-')) + try { + for (const bucket of buckets) { + await mkdir(join(home, '.claude', 'projects', bucket), { recursive: true }) + } + await run(home) + } finally { + await rm(home, { force: true, recursive: true }) + } + } + + it('matches a plain POSIX workspace root', async () => { + await withFakeHome([`-Users-ada-orca-workspaces-orca-${FIRST}`], async (home) => { + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['/Users/ada/orca/workspaces/orca'], + home, + env: {} + }) + expect(retired).toEqual(new Set([FIRST])) + }) + }) + + it('matches an NFC bucket for an NFD workspace root', async () => { + const nfdRoot = '/Users/ada/cafe\u0301' + await withFakeHome([`-Users-ada-caf--${FIRST}`], async (home) => { + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: [nfdRoot], + home, + env: {} + }) + expect(retired).toEqual(new Set([FIRST])) + }) + }) + + it('matches a dot-directory root, where the separator run encodes to two dashes', async () => { + await withFakeHome([`-Users-ada--orca-worktrees-${FIRST}`], async (home) => { + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['/Users/ada/.orca/worktrees'], + home, + env: {} + }) + expect(retired).toEqual(new Set([FIRST])) + }) + }) + + it('matches a Windows drive root', async () => { + // `getDefaultWorkspaceDir` returns `C:\Users\\orca\workspaces` on Windows, so an encoder + // that collapsed `:\` rejected every bucket on that platform by default. + await withFakeHome([`C--Users-ada-orca-workspaces-${FIRST}`], async (home) => { + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'], + home, + env: {} + }) + expect(retired).toEqual(new Set([FIRST])) + }) + }) + + it('matches a WSL UNC root', async () => { + await withFakeHome([`--wsl--Ubuntu-home-ada-orca-workspaces-${FIRST}`], async (home) => { + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['\\\\wsl$\\Ubuntu\\home\\ada\\orca\\workspaces'], + home, + env: {} + }) + expect(retired).toEqual(new Set([FIRST])) + }) + }) + + it('ignores buckets belonging to a sibling root with the same prefix', async () => { + await withFakeHome( + [`-Users-ada-orca-workspaces-orcadyne-${FIRST}`, `-Users-ada-orca-workspaces-orca-${SECOND}`], + async (home) => { + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['/Users/ada/orca/workspaces/orca'], + home, + env: {} + }) + expect(retired).toEqual(new Set([SECOND])) + } + ) + }) + + it('reads buckets from CLAUDE_CONFIG_DIR when it is set', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'orca-retirement-config-')) + await withFakeHome([`-Users-ada-w-${SECOND}`], async (home) => { + try { + await mkdir(join(configDir, 'projects', `-Users-ada-w-${FIRST}`), { recursive: true }) + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['/Users/ada/w'], + home, + env: { CLAUDE_CONFIG_DIR: configDir } + }) + // The override relocates the whole state root, so the default home is not also scanned. + expect(retired).toEqual(new Set([FIRST])) + } finally { + await rm(configDir, { force: true, recursive: true }) + } + }) + }) + + it("matches a WSL workspace's bucket inside the distro, keyed by its Linux cwd", async () => { + // The agent for a WSL workspace is spawned through `wsl.exe`, so it runs inside the distro: + // its cwd is `/home/ada/...`, and its bucket lands in the distro's own home — not the + // Windows-side one. After the workspace directory is gone, that bucket is the only evidence. + const distroHome = await mkdtemp(join(tmpdir(), 'orca-retirement-distro-')) + await withFakeHome([], async (home) => { + try { + await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), { + recursive: true + }) + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca\\workspaces'], + home, + env: {}, + resolveWslHome: async (distro) => (distro === 'Ubuntu' ? distroHome : null) + }) + expect(retired).toEqual(new Set([FIRST])) + } finally { + await rm(distroHome, { force: true, recursive: true }) + } + }) + }) + + it('still reads the Windows-side home for a WSL root when the distro home cannot be resolved', async () => { + // A stopped distro must not cost the retirements the host can still see. + await withFakeHome( + [`--wsl-localhost-Ubuntu-home-ada-orca-workspaces-${FIRST}`], + async (home) => { + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca\\workspaces'], + home, + env: {}, + resolveWslHome: async () => null + }) + expect(retired).toEqual(new Set([FIRST])) + } + ) + }) + + it('retires live workspace directories alongside surviving buckets', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-retirement-roots-')) + await withFakeHome([], async (home) => { + try { + await mkdir(join(root, SECOND), { recursive: true }) + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: [root], + home, + env: {} + }) + expect(retired).toEqual(new Set([SECOND])) + } finally { + await rm(root, { force: true, recursive: true }) + } + }) + }) +}) diff --git a/src/main/worktree-retirement-discovery.ts b/src/main/worktree-retirement-discovery.ts new file mode 100644 index 00000000000..be5f863f4e3 --- /dev/null +++ b/src/main/worktree-retirement-discovery.ts @@ -0,0 +1,165 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { parseWslUncPath } from '../shared/wsl-paths' +import { + encodeClaudeProjectPaths, + isClaudeProjectDirInScope +} from './ai-vault/claude-project-dir-encoding' +import { wslGatedReaddir } from './native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from './native-chat/wsl-transcript-fs-gate' +import { normalizeRetirableGeneratedName } from './worktree-name-retirement' +import { getWslHomeAsync } from './wsl' + +/** Why: over-retiring costs one name from a 552-entry pool; under-retiring reissues a path whose + * agent history is still on disk. So this matches generously and never tries to be exact. */ +export function collectRetiredNamesFromLeafNames(leafNames: Iterable): Set { + const retired = new Set() + for (const leafName of leafNames) { + if (typeof leafName !== 'string' || leafName.length === 0) { + continue + } + const normalized = normalizeRetirableGeneratedName(leafName) + if (normalized) { + retired.add(normalized) + } + } + return retired +} + +/** The workspace leaf is whatever the bucket has beyond its encoded parent. Deriving it from + * trailing dash segments instead makes a numerically named workspace retire its parent + * directory's name — and `orca` is in the pool. The first segment is also offered because an + * agent run from a subdirectory buckets the whole subpath. */ +export function extractBucketLeafCandidates( + bucketName: string, + encodedParents: readonly string[] +): string[] { + const bucket = bucketName.toLowerCase() + for (const parent of encodedParents) { + if (!parent || bucket === parent || !isClaudeProjectDirInScope(bucket, [parent])) { + continue + } + const remainder = bucket.slice(parent.length + 1) + if (!remainder) { + continue + } + const firstSegment = remainder.split('-')[0] + return remainder === firstSegment ? [remainder] : [remainder, firstSegment] + } + return [] +} + +/** WSL UNC listings go through the shared 9P gate, which bounds concurrency and deadlines each + * call; off UNC it is a plain `readdir`. A gate refusal propagates on purpose — swallowing it + * would memoize a partial answer forever, which under-retires, the one direction that leaks. */ +async function readDirectoryNames(path: string): Promise { + try { + const entries = await wslGatedReaddir(path, 'scan') + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + // Missing or unreadable roots are normal: the agent may never have run on this machine. + return [] + } +} + +/** Claude buckets its per-conversation state under a directory derived from the workspace cwd. A + * bucket surviving its workspace is exactly the evidence we need: the name was used, the + * directory is gone, and reissuing the name would hand the next occupant that conversation. + * CLAUDE_CONFIG_DIR relocates the whole state root, buckets included. + * + * Codex is deliberately not scanned: it records the cwd inside + * `sessions/YYYY/MM/DD/rollout-*.jsonl` rather than in a directory name, so seeding from it would + * mean parsing user conversation files. Names spent only under Codex before this feature shipped + * stay issuable; every name spent after it is recorded at create time regardless of agent. */ +function getClaudeProjectsDir(home: string, env: NodeJS.ProcessEnv): string { + const override = env.CLAUDE_CONFIG_DIR?.trim() + return override ? join(override, 'projects') : join(home, '.claude', 'projects') +} + +/** A bucket directory to scan, paired with the encoded cwds whose workspaces it would hold. */ +type ClaudeProjectsSource = { + projectsDir: string + encodedParents: string[] +} + +// Lowercased on both sides: the recorded cwd can differ in case from ours on case-insensitive +// filesystems, and a missed bucket reissues a live path while a spurious one costs one name. +function encodeParents(paths: readonly string[]): string[] { + return paths + .filter((path) => path.length > 0) + .flatMap((path) => encodeClaudeProjectPaths(path).map((encoded) => encoded.toLowerCase())) +} + +/** Every bucket directory that could hold a workspace under these roots. + * + * The Windows-side home is not enough for WSL. A WSL workspace's agent is spawned through + * `wsl.exe`, so it runs inside the distro: its cwd is the Linux path, not the UNC spelling, and + * its buckets land in the distro's own `~/.claude/projects`. Once the workspace directory is + * deleted that distro-local bucket is the only durable evidence the name is spent, so a backfill + * that reads only the host home reissues the cwd and hands over the previous conversation. + * + * The distro's own `CLAUDE_CONFIG_DIR` is not readable from the Windows side, so only the default + * location is scanned there — the same best-effort contract as the rest of this discovery. */ +async function claudeProjectsSources(args: { + workspaceRoots: readonly string[] + home?: string + env?: NodeJS.ProcessEnv + resolveWslHome?: (distro: string) => Promise +}): Promise { + const sources: ClaudeProjectsSource[] = [] + const hostParents = encodeParents(args.workspaceRoots) + if (hostParents.length > 0) { + sources.push({ + projectsDir: getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env), + encodedParents: hostParents + }) + } + const linuxRootsByDistro = new Map() + for (const root of args.workspaceRoots) { + const wsl = parseWslUncPath(root) + if (wsl) { + linuxRootsByDistro.set(wsl.distro, [ + ...(linuxRootsByDistro.get(wsl.distro) ?? []), + wsl.linuxPath + ]) + } + } + const resolveWslHome = args.resolveWslHome ?? getWslHomeAsync + for (const [distro, linuxRoots] of linuxRootsByDistro) { + const distroHome = await resolveWslHome(distro) + if (distroHome) { + sources.push({ + projectsDir: join(distroHome, '.claude', 'projects'), + encodedParents: encodeParents(linuxRoots) + }) + } + } + return sources +} + +/** Discovers names already spent for a repo, for the one-time seed of the retirement registry. + * Local and best-effort by design — agent state for an SSH workspace lives on the execution host, + * so names used only there stay issuable until this host observes them. */ +export async function discoverRetiredWorktreeNames(args: { + workspaceRoots: readonly string[] + home?: string + env?: NodeJS.ProcessEnv + /** Injected in tests; production resolves the distro's `$HOME` as a UNC path. */ + resolveWslHome?: (distro: string) => Promise +}): Promise> { + const leafNames: string[] = [] + for (const root of args.workspaceRoots) { + leafNames.push(...(await readDirectoryNames(root))) + } + + for (const source of await claudeProjectsSources(args)) { + for (const bucket of await readDirectoryNames(source.projectsDir)) { + leafNames.push(...extractBucketLeafCandidates(bucket, source.encodedParents)) + } + } + + return collectRetiredNamesFromLeafNames(leafNames) +}