From 05cb2a40a930af0ece63fc05bdf93e1704ecc48c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:12:14 -0700 Subject: [PATCH] fix(worktrees): walk up to a reachable ancestor, not just one level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reachability probe checked a single parent, which only disambiguates when ~/.claude exists but ~/.claude/projects does not. The far more common shapes have the ancestors missing too: a distro where Claude has never run has no ~/.claude at all, and a repo with no workspaces yet has neither the workspace root nor its parent. In both, the one-level probe also got ENOENT and called the route unreachable, which is exactly the 60s rescan loop it was added to prevent. Walk up until a listing succeeds, bounded so a pathological path cannot hold the scan slot. One reachable ancestor proves the route is up, so the ENOENT below it is real absence. The test that was supposed to guard this had ~/.claude resolving, so the real shape was never exercised — which is why the defect shipped green. Its fixture now leaves the whole chain absent up to the distro home, and reverting to the one-level probe turns it red. --- .../worktree-retirement-discovery-wsl.test.ts | 44 +++++++++---------- src/main/worktree-retirement-discovery.ts | 43 +++++++++++------- 2 files changed, 49 insertions(+), 38 deletions(-) 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