From e1294360767e4af5e741eb362cffe38acf42fce8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:07:41 -0700 Subject: [PATCH] fix(worktrees): trace a WSL repo's Windows-side workspaces into the distro too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distro discovery keyed only on the workspace root being a UNC path, but a WSL repo can legitimately own workspaces under C:\. computeWorkspaceRootAsync mirrors the workspace dir into the distro only when the distro home resolves at create time; when that wsl.exe call fails it falls back to the drive path, and those workspaces stay on the Windows side. The agent is still spawned through wsl.exe, so its cwd is the drvfs mirror (/mnt/c/...) and its bucket lands in the distro's own ~/.claude/projects, where the host-home scan cannot see it. The scan then reported complete and memoized the empty answer, so the name was reissued and the next occupant inherited the previous conversation — the STA-4472 defect, in the one configuration the UNC check does not cover. Which distro to look in comes from the repo path rather than the workspace root, since that is what still identifies the distro once the root is a drive path. --- src/main/worktree-name-retirement.ts | 7 ++++- .../worktree-retirement-discovery-wsl.test.ts | 27 +++++++++++++++++++ src/main/worktree-retirement-discovery.ts | 26 +++++++++++++++--- 3 files changed, 55 insertions(+), 5 deletions(-) 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