diff --git a/src/main/worktree-name-retirement.ts b/src/main/worktree-name-retirement.ts index d10a71618b7..e044110239e 100644 --- a/src/main/worktree-name-retirement.ts +++ b/src/main/worktree-name-retirement.ts @@ -267,7 +267,12 @@ export async function ensureRetiredWorktreeNamesBackfilled( ) const scanKey = `${getRepoExecutionHostId(repo)}:${worktreePathComparisonKey(probePath)}` const names = await runRetirementBackfillScan(store, scanKey, () => - discoverRetiredWorktreeNames({ workspaceRoots: [parentPath(probePath)] }) + discoverRetiredWorktreeNames({ + workspaceRoots: [parentPath(probePath)], + // The repo, not the workspace root, is what says which distro the agent runs in: a WSL repo + // whose workspaces fell back to the Windows side still buckets under the drvfs mirror there. + wslDistro: parseWslPath(repo.path)?.distro + }) ) // 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. diff --git a/src/main/worktree-retirement-discovery-wsl.test.ts b/src/main/worktree-retirement-discovery-wsl.test.ts index 835309493d8..e1ad513fcef 100644 --- a/src/main/worktree-retirement-discovery-wsl.test.ts +++ b/src/main/worktree-retirement-discovery-wsl.test.ts @@ -109,6 +109,33 @@ describe('retirement discovery on WSL', () => { } }) + it('traces a Windows-side workspace of a WSL repo into the distro through the drvfs mirror', async () => { + // `computeWorkspaceRootAsync` falls back to the drive path whenever the distro home will not + // resolve at create time, so a WSL repo can own workspaces under `C:\`. The agent is still + // spawned through `wsl.exe`, so its cwd is `/mnt/c/...` and its bucket lands in the distro. + const distroHome = await mkdtemp(join(tmpdir(), 'orca-drvfs-distro-')) + const hostHome = await mkdtemp(join(tmpdir(), 'orca-drvfs-host-')) + try { + await mkdir( + join(distroHome, '.claude', 'projects', `-mnt-c-users-ada-orca-workspaces-${FIRST}`), + { recursive: true } + ) + + const retired = await discoverRetiredWorktreeNames({ + workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'], + home: hostHome, + env: {}, + wslDistro: 'Ubuntu', + resolveWslHome: async (distro) => (distro === 'Ubuntu' ? distroHome : null) + }) + + expect(retired.names).toEqual(new Set([FIRST])) + } finally { + await rm(distroHome, { force: true, recursive: true }) + await rm(hostHome, { force: true, recursive: true }) + } + }) + it('stays retryable when the distro home cannot be resolved, rather than memoizing the hole', async () => { // `getWslHomeAsync` shells out to `wsl.exe` and returns null for a stopped or slow distro. // Memoizing that as a complete "nothing is retired" is the STA-4472 defect itself, since the diff --git a/src/main/worktree-retirement-discovery.ts b/src/main/worktree-retirement-discovery.ts index 730a8bd2630..e071795f3a6 100644 --- a/src/main/worktree-retirement-discovery.ts +++ b/src/main/worktree-retirement-discovery.ts @@ -93,6 +93,12 @@ type ClaudeProjectsSource = { encodedParents: string[] } +/** How a distro sees a Windows drive path through the drvfs automount, which is lowercase `/mnt`. */ +function drvfsPath(windowsPath: string): string | null { + const drive = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/) + return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null +} + // 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[] { @@ -116,6 +122,7 @@ async function claudeProjectsSources(args: { home?: string env?: NodeJS.ProcessEnv resolveWslHome?: (distro: string) => Promise + wslDistro?: string }): Promise<{ sources: ClaudeProjectsSource[]; complete: boolean }> { const sources: ClaudeProjectsSource[] = [] let complete = true @@ -127,13 +134,22 @@ async function claudeProjectsSources(args: { }) } const linuxRootsByDistro = new Map() + const addLinuxRoot = (distro: string, linuxPath: string): void => { + linuxRootsByDistro.set(distro, [...(linuxRootsByDistro.get(distro) ?? []), linuxPath]) + } for (const root of args.workspaceRoots) { const wsl = parseWslUncPath(root) if (wsl) { - linuxRootsByDistro.set(wsl.distro, [ - ...(linuxRootsByDistro.get(wsl.distro) ?? []), - wsl.linuxPath - ]) + addLinuxRoot(wsl.distro, wsl.linuxPath) + continue + } + // A WSL repo can still have its workspaces on the Windows side: `computeWorkspaceRootAsync` + // falls back to the drive path whenever the distro home will not resolve at create time. The + // agent is still spawned through `wsl.exe`, so its cwd is the drvfs mirror of that drive path + // and its bucket lands in the distro home under `-mnt-c-…`, out of reach of the host scan. + const mirrored = args.wslDistro ? drvfsPath(root) : null + if (mirrored && args.wslDistro) { + addLinuxRoot(args.wslDistro, mirrored) } } const resolveWslHome = args.resolveWslHome ?? getWslHomeAsync @@ -163,6 +179,8 @@ export async function discoverRetiredWorktreeNames(args: { env?: NodeJS.ProcessEnv /** Injected in tests; production resolves the distro's `$HOME` as a UNC path. */ resolveWslHome?: (distro: string) => Promise + /** The repo's distro, so workspaces that live on the Windows side are still traced into it. */ + wslDistro?: string }): Promise { const leafNames: string[] = [] let refused = false