diff --git a/src/main/worktree-retirement-discovery-wsl.test.ts b/src/main/worktree-retirement-discovery-wsl.test.ts index fc63e4b949e..fbc90f48663 100644 --- a/src/main/worktree-retirement-discovery-wsl.test.ts +++ b/src/main/worktree-retirement-discovery-wsl.test.ts @@ -52,27 +52,18 @@ describe('retirement discovery on WSL', () => { resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada' }) - // Every UNC read is admitted, at the scan priority that leaves the exact-probe permit free. - // Neither directory exists here, so each listing is followed by the parent probe that tells a - // genuinely absent directory apart from an unreachable 9P route. - expect(runWslTranscriptFsTaskMock.mock.calls.map(([options]) => options)).toEqual([ - expect.objectContaining({ operation: 'readdir', path: DISTRO_ROOT, priority: 'scan' }), - expect.objectContaining({ - operation: 'readdir', - path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca', - priority: 'scan' - }), - expect.objectContaining({ - operation: 'readdir', - path: join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects'), - priority: 'scan' - }), - expect.objectContaining({ - operation: 'readdir', - path: join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude'), - priority: 'scan' - }) - ]) + // Both UNC reads — the workspace root and the distro's bucket directory — are admitted, and + // every listing goes at the scan priority that leaves the exact-probe permit free. Nothing + // exists under this fixture, so each is followed by the ancestor walk that tells a genuinely + // absent directory apart from an unreachable 9P route; those are gated too. + const calls = runWslTranscriptFsTaskMock.mock.calls.map(([options]) => options) + expect(calls.every((options) => options.priority === 'scan')).toBe(true) + expect(calls.map((options) => options.path)).toEqual( + expect.arrayContaining([ + DISTRO_ROOT, + join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects') + ]) + ) }) it('leaves no listing ungated when the workspace root is on the Windows side', async () => { @@ -126,8 +117,15 @@ describe('retirement discovery on WSL', () => { // one-time seed into a 60s rescan loop for the life of the process. const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) const distroHome = '\\\\wsl.localhost\\Ubuntu\\home\\ada' - // Only the two leaves are missing; every parent lists, which is what a live distro looks like. - const missing = new Set([DISTRO_ROOT, join(distroHome, '.claude', 'projects')]) + // A distro where Claude never ran has no `~/.claude` at all, and a repo with no workspaces yet + // has neither the workspace root nor its parent — so the whole chain up to the home is absent. + // Only the home itself lists, which is what proves the route is up. + const missing = new Set([ + DISTRO_ROOT, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca', + join(distroHome, '.claude', 'projects'), + join(distroHome, '.claude') + ]) runWslTranscriptFsTaskMock.mockImplementation((options: { path: string }) => missing.has(options.path) ? Promise.reject(enoent) : Promise.resolve([]) ) diff --git a/src/main/worktree-retirement-discovery.ts b/src/main/worktree-retirement-discovery.ts index 30cd87b440d..d438d65c73d 100644 --- a/src/main/worktree-retirement-discovery.ts +++ b/src/main/worktree-retirement-discovery.ts @@ -72,27 +72,40 @@ async function readDirectoryNames(path: string): Promise<{ names: string[]; refu if (code !== 'ENOENT' && code !== 'ENOTDIR') { return { names: [], refused: true } } - return { names: [], refused: isWslUncPath(path) && !(await uncParentLists(path)) } + return { names: [], refused: isWslUncPath(path) && !(await uncRouteIsReachable(path)) } } } /** Windows reports an unreachable 9P route as ENOENT, so a shut-down distro is indistinguishable * from one that simply never held buckets — `wsl.ts` refuses to trust a UNC ENOENT for the same - * reason. The parent settles it: if it lists, the child really is absent and the answer is - * complete; if it does not, the route is down and the scan has to stay retryable. Trusting ENOENT - * outright memoized the STA-4472 hole after `wsl --shutdown`; distrusting it outright made every - * distro without `~/.claude/projects` rescan on a 60s loop for the life of the process. */ -async function uncParentLists(path: string): Promise { - const parent = path.replace(/[/\\]+[^/\\]+[/\\]*$/, '') - if (!parent || parent === path || !isWslUncPath(parent)) { - return false - } - try { - await wslGatedReaddir(parent, 'scan') - return true - } catch { - return false + * reason. One reachable ancestor settles it: the route is up, so the ENOENT below it is real + * absence and the answer is complete. + * + * Walking rather than checking one level, because the ancestors are usually missing too: a distro + * where Claude never ran has no `~/.claude` either, and a repo with no workspaces yet has neither + * the workspace root nor its parent. Stopping at the first level called those complete-looking + * cases unreachable, which is the 60s rescan loop this exists to avoid. */ +async function uncRouteIsReachable(path: string): Promise { + let current = path + // Bounded so a pathological path cannot spend the gate's scan slot indefinitely; the distro root + // is only a few levels above anything discovery lists. + for (let depth = 0; depth < 8; depth += 1) { + const parent = current.replace(/[/\\]+[^/\\]+[/\\]*$/, '') + if (!parent || parent === current || !isWslUncPath(parent)) { + return false + } + try { + await wslGatedReaddir(parent, 'scan') + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + return false + } + current = parent + } } + return false } /** Claude buckets its per-conversation state under a directory derived from the workspace cwd. A