fix(worktrees): walk up to a reachable ancestor, not just one level

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.
This commit is contained in:
Brennan Benson
2026-08-20 00:40:24 -07:00
parent 24d1f7a013
commit 05cb2a40a9
2 changed files with 49 additions and 38 deletions
@@ -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([])
)
+28 -15
View File
@@ -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<boolean> {
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<boolean> {
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