fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473)

This commit is contained in:
Brennan Benson
2026-08-20 00:40:24 -07:00
parent 2e895da937
commit eabf17b176
8 changed files with 782 additions and 278 deletions
-171
View File
@@ -6,10 +6,7 @@ import { MARINE_CREATURES } from '../shared/marine-creatures'
import type { GlobalSettings } from '../shared/global-settings-types'
import type { Repo } from '../shared/repo-types'
import {
collectRetiredNamesFromLeafNames,
discoverRetiredWorktreeNames,
ensureRetiredWorktreeNamesBackfilled,
extractBucketLeafCandidates,
getRetiredNameRegistryForRepo,
normalizeRetirableGeneratedName,
resetRetirementCollisionKeyCacheForTests
@@ -48,174 +45,6 @@ describe('normalizeRetirableGeneratedName', () => {
})
})
describe('extractBucketLeafCandidates', () => {
it('takes everything past the encoded parent as the leaf', () => {
expect(extractBucketLeafCandidates(`-w-orca-${FIRST}`, ['-w-orca'])).toEqual([FIRST])
})
it('does not treat the parent directory as a leaf when the workspace name is numeric', () => {
// Real data: `-Users-x-orca-workspaces-orca-7474` must not retire `orca`, which is in the pool.
expect(extractBucketLeafCandidates('-w-workspaces-orca-7474', ['-w-workspaces-orca'])).toEqual([
'7474'
])
})
it('offers the first segment too, so an agent run in a subdirectory still retires the leaf', () => {
expect(extractBucketLeafCandidates(`-w-orca-${FIRST}-packages-api`, ['-w-orca'])).toEqual([
`${FIRST}-packages-api`,
FIRST
])
})
it('rejects a sibling directory that shares the parent prefix', () => {
expect(extractBucketLeafCandidates(`-w-orcadyne-${FIRST}`, ['-w-orca'])).toEqual([])
expect(extractBucketLeafCandidates(`-w-orca-secret-${FIRST}`, ['-w-orca-fix'])).toEqual([])
})
it('yields nothing for the parent bucket itself', () => {
expect(extractBucketLeafCandidates('-w-orca', ['-w-orca'])).toEqual([])
})
})
describe('collectRetiredNamesFromLeafNames', () => {
it('keeps pool names and drops everything else', () => {
expect(collectRetiredNamesFromLeafNames([FIRST, SECOND, 'fix-login'])).toEqual(
new Set([FIRST, SECOND])
)
})
it('is case-insensitive and skips non-string entries without throwing', () => {
const leaves = [undefined, null, '', MARINE_CREATURES[0].toUpperCase()] as unknown as string[]
expect(collectRetiredNamesFromLeafNames(leaves)).toEqual(new Set([FIRST]))
})
})
describe('discoverRetiredWorktreeNames', () => {
/** Buckets are written with the REAL per-character encoding, because a helper that mirrors the
* implementation would pass against a broken encoder — which is how the Windows gap shipped. */
async function withFakeHome(
buckets: readonly string[],
run: (home: string) => Promise<void>
): Promise<void> {
const home = await mkdtemp(join(tmpdir(), 'orca-retirement-home-'))
try {
for (const bucket of buckets) {
await mkdir(join(home, '.claude', 'projects', bucket), { recursive: true })
}
await run(home)
} finally {
await rm(home, { force: true, recursive: true })
}
}
it('matches a plain POSIX workspace root', async () => {
await withFakeHome([`-Users-ada-orca-workspaces-orca-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/orca/workspaces/orca'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches an NFC bucket for an NFD workspace root', async () => {
const nfdRoot = '/Users/ada/cafe\u0301'
await withFakeHome([`-Users-ada-caf--${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [nfdRoot],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches a dot-directory root, where the separator run encodes to two dashes', async () => {
await withFakeHome([`-Users-ada--orca-worktrees-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/.orca/worktrees'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches a Windows drive root', async () => {
// `getDefaultWorkspaceDir` returns `C:\Users\<user>\orca\workspaces` on Windows, so an encoder
// that collapsed `:\` rejected every bucket on that platform by default.
await withFakeHome([`C--Users-ada-orca-workspaces-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches a WSL UNC root', async () => {
await withFakeHome([`--wsl--Ubuntu-home-ada-orca-workspaces-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['\\\\wsl$\\Ubuntu\\home\\ada\\orca\\workspaces'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('ignores buckets belonging to a sibling root with the same prefix', async () => {
await withFakeHome(
[`-Users-ada-orca-workspaces-orcadyne-${FIRST}`, `-Users-ada-orca-workspaces-orca-${SECOND}`],
async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/orca/workspaces/orca'],
home,
env: {}
})
expect(retired).toEqual(new Set([SECOND]))
}
)
})
it('reads buckets from CLAUDE_CONFIG_DIR when it is set', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'orca-retirement-config-'))
await withFakeHome([`-Users-ada-w-${SECOND}`], async (home) => {
try {
await mkdir(join(configDir, 'projects', `-Users-ada-w-${FIRST}`), { recursive: true })
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/w'],
home,
env: { CLAUDE_CONFIG_DIR: configDir }
})
// The override relocates the whole state root, so the default home is not also scanned.
expect(retired).toEqual(new Set([FIRST]))
} finally {
await rm(configDir, { force: true, recursive: true })
}
})
})
it('retires live workspace directories alongside surviving buckets', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-retirement-roots-'))
await withFakeHome([], async (home) => {
try {
await mkdir(join(root, SECOND), { recursive: true })
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [root],
home,
env: {}
})
expect(retired).toEqual(new Set([SECOND]))
} finally {
await rm(root, { force: true, recursive: true })
}
})
})
})
describe('getRetiredNameRegistryForRepo', () => {
const settingsFor = (nestWorkspaces: boolean): GlobalSettings =>
({ workspaceDir: '/workspaces', nestWorkspaces }) as GlobalSettings
+6 -107
View File
@@ -17,10 +17,6 @@ import {
import { isFolderRepo } from '../shared/repo-kind'
import type { GlobalSettings } from '../shared/global-settings-types'
import type { Repo } from '../shared/repo-types'
import {
encodeClaudeProjectPaths,
isClaudeProjectDirInScope
} from './ai-vault/claude-project-dir-encoding'
import {
computeRemoteWorktreePath,
computeWorktreePathAsync,
@@ -34,10 +30,11 @@ import {
retirementNamespaceKeysToRead,
type SshTargetLookup
} from './worktree-retirement-namespace'
import { discoverRetiredWorktreeNames } from './worktree-retirement-discovery'
import { runRetirementBackfillScan } from './worktree-retirement-backfill-scan'
import { hasCachedWslHome, parseWslPath } from './wsl'
const RETIREMENT_PROBE_NAME = 'orca-retirement-probe'
const scansByStore = new WeakMap<object, Map<string, Promise<Set<string>>>>()
type RetirementReadStore = {
getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry
@@ -67,45 +64,6 @@ export function failedWorktreeCreationNeedsRetirement(error: unknown): boolean {
return typeof error === 'object' && error !== null && Reflect.get(error, 'cleanupFailed') === true
}
/** Why: over-retiring costs one name from a 552-entry pool; under-retiring reissues a path whose
* agent history is still on disk. So this matches generously and never tries to be exact. */
export function collectRetiredNamesFromLeafNames(leafNames: Iterable<string>): Set<string> {
const retired = new Set<string>()
for (const leafName of leafNames) {
if (typeof leafName !== 'string' || leafName.length === 0) {
continue
}
const normalized = normalizeRetirableGeneratedName(leafName)
if (normalized) {
retired.add(normalized)
}
}
return retired
}
/** The workspace leaf is whatever the bucket has beyond its encoded parent. Deriving it from
* trailing dash segments instead makes a numerically named workspace retire its parent
* directory's name — and `orca` is in the pool. The first segment is also offered because an
* agent run from a subdirectory buckets the whole subpath. */
export function extractBucketLeafCandidates(
bucketName: string,
encodedParents: readonly string[]
): string[] {
const bucket = bucketName.toLowerCase()
for (const parent of encodedParents) {
if (!parent || bucket === parent || !isClaudeProjectDirInScope(bucket, [parent])) {
continue
}
const remainder = bucket.slice(parent.length + 1)
if (!remainder) {
continue
}
const firstSegment = remainder.split('-')[0]
return remainder === firstSegment ? [remainder] : [remainder, firstSegment]
}
return []
}
async function getRetirementProbePath(
repo: Repo,
settings: RetirementPathSettings
@@ -308,70 +266,11 @@ export async function ensureRetiredWorktreeNamesBackfilled(
getWorktreePathSettings(repo, settings)
)
const scanKey = `${getRepoExecutionHostId(repo)}:${worktreePathComparisonKey(probePath)}`
let storeScans = scansByStore.get(store)
if (!storeScans) {
storeScans = new Map()
scansByStore.set(store, storeScans)
}
let scan = storeScans.get(scanKey)
if (!scan) {
scan = discoverRetiredWorktreeNames({ workspaceRoots: [parentPath(probePath)] })
storeScans.set(scanKey, scan)
}
const names = await runRetirementBackfillScan(store, scanKey, () =>
discoverRetiredWorktreeNames({ workspaceRoots: [parentPath(probePath)] })
)
// 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.
store.mergeRetiredWorktreeNames(repo.id, await scan)
store.mergeRetiredWorktreeNames(repo.id, names)
return scanKey
}
async function readDirectoryNames(path: string): Promise<string[]> {
try {
const entries = await readdir(path, { withFileTypes: true })
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
} catch {
// Missing or unreadable roots are normal: the agent may never have run on this machine.
return []
}
}
/** Claude buckets its per-conversation state under a directory derived from the workspace cwd. A
* bucket surviving its workspace is exactly the evidence we need: the name was used, the
* directory is gone, and reissuing the name would hand the next occupant that conversation.
* CLAUDE_CONFIG_DIR relocates the whole state root, buckets included.
*
* Codex is deliberately not scanned: it records the cwd inside
* `sessions/YYYY/MM/DD/rollout-*.jsonl` rather than in a directory name, so seeding from it would
* mean parsing user conversation files. Names spent only under Codex before this feature shipped
* stay issuable; every name spent after it is recorded at create time regardless of agent. */
function getClaudeProjectsDir(home: string, env: NodeJS.ProcessEnv): string {
const override = env.CLAUDE_CONFIG_DIR?.trim()
return override ? join(override, 'projects') : join(home, '.claude', 'projects')
}
/** Discovers names already spent for a repo, for the one-time seed of the retirement registry.
* Local and best-effort by design — agent state for an SSH workspace lives on the execution host,
* so names used only there stay issuable until this host observes them. */
export async function discoverRetiredWorktreeNames(args: {
workspaceRoots: readonly string[]
home?: string
env?: NodeJS.ProcessEnv
}): Promise<Set<string>> {
const leafNames: string[] = []
for (const root of args.workspaceRoots) {
leafNames.push(...(await readDirectoryNames(root)))
}
// 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.
const encodedParents = args.workspaceRoots
.filter((root) => root.length > 0)
.flatMap((root) => encodeClaudeProjectPaths(root).map((path) => path.toLowerCase()))
if (encodedParents.length > 0) {
const projectsDir = getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env)
for (const bucket of await readDirectoryNames(projectsDir)) {
leafNames.push(...extractBucketLeafCandidates(bucket, encodedParents))
}
}
return collectRetiredNamesFromLeafNames(leafNames)
}
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS,
RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS,
runRetirementBackfillScan
} from './worktree-retirement-backfill-scan'
/** A scan the test settles by hand, standing in for a `readdir` on a stalled NFS/SMB/WSL mount. */
function stallingScan(): { run: () => Promise<Set<string>>; finish: (names: string[]) => void } {
let settle: (names: Set<string>) => void = () => {}
return {
run: () => new Promise<Set<string>>((resolve) => (settle = resolve)),
finish: (names) => settle(new Set(names))
}
}
describe('runRetirementBackfillScan', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('gives up on a listing that never returns instead of blocking create forever', async () => {
const scan = stallingScan()
const names = runRetirementBackfillScan({}, 'ns', scan.run)
const settled = expect(names).rejects.toThrow(/exceeded/)
await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS)
await settled
})
it('retries a timed-out namespace once its backoff lapses, rather than wedging until restart', async () => {
const store = {}
const first = stallingScan()
const second = vi.fn(async () => new Set(['nautilus']))
const stalled = runRetirementBackfillScan(store, 'ns', first.run)
const settled = expect(stalled).rejects.toThrow(/exceeded/)
await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS)
await settled
// Why the backoff: an unabortable `readdir` still holds a libuv thread, so re-probing on every
// create would starve the pool. Until it lapses the failure is served from the memo.
await expect(runRetirementBackfillScan(store, 'ns', second)).rejects.toThrow(/exceeded/)
expect(second).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS)
await expect(runRetirementBackfillScan(store, 'ns', second)).resolves.toEqual(
new Set(['nautilus'])
)
})
it('scans a namespace once and shares the answer with every later caller', async () => {
const store = {}
const scan = vi.fn(async () => new Set(['nautilus']))
await expect(runRetirementBackfillScan(store, 'ns', scan)).resolves.toEqual(
new Set(['nautilus'])
)
await expect(runRetirementBackfillScan(store, 'ns', scan)).resolves.toEqual(
new Set(['nautilus'])
)
expect(scan).toHaveBeenCalledTimes(1)
})
it('keeps one namespace stall from failing another', async () => {
const store = {}
const stuck = stallingScan()
const healthy = vi.fn(async () => new Set(['nautilus']))
const stalled = runRetirementBackfillScan(store, 'stuck-ns', stuck.run)
const settled = expect(stalled).rejects.toThrow(/exceeded/)
await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS)
await settled
await expect(runRetirementBackfillScan(store, 'healthy-ns', healthy)).resolves.toEqual(
new Set(['nautilus'])
)
})
it('does not let one store inherit another store scan memo', async () => {
const first = vi.fn(async () => new Set(['nautilus']))
const second = vi.fn(async () => new Set(['orca']))
await runRetirementBackfillScan({}, 'ns', first)
await expect(runRetirementBackfillScan({}, 'ns', second)).resolves.toEqual(new Set(['orca']))
})
})
@@ -0,0 +1,58 @@
/** Deadline for one retirement discovery scan. Generated create awaits it, so it has to be short
* enough that a stalled NFS/SMB/WSL listing degrades to "seed nothing" instead of a frozen create. */
export const RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS = 15_000
/** How long a failed scan stays failed. Retrying immediately would let every create start another
* listing against the same stalled mount, and an unabortable `readdir` holds a libuv threadpool
* thread until the OS releases it — four of those and the whole process loses filesystem access. */
export const RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS = 60_000
type BackfillScan = {
/** Resolves with the discovered names, or rejects once the deadline lapses. */
names: Promise<Set<string>>
/** Epoch ms this scan may be retried after; 0 while it is in flight or has succeeded. */
retryAfter: number
}
const scansByStore = new WeakMap<object, Map<string, BackfillScan>>()
function withScanDeadline(scan: Promise<Set<string>>): Promise<Set<string>> {
return new Promise<Set<string>>((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(`retirement backfill scan exceeded ${RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS}ms`)
)
}, RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS)
timer.unref?.()
void scan.then(resolve, reject).finally(() => clearTimeout(timer))
})
}
/** Memoizes the one-time retirement discovery scan per store and cwd namespace.
*
* A success is kept for the process lifetime — a name never un-spends, so rescanning could only
* lose entries. A failure is not: the original cache held the scan Promise forever and its
* `readdir` had no deadline, so one stalled listing wedged this create and every later
* generated-name create for the namespace until Orca restarted. */
export function runRetirementBackfillScan(
store: object,
scanKey: string,
scan: () => Promise<Set<string>>
): Promise<Set<string>> {
let storeScans = scansByStore.get(store)
if (!storeScans) {
storeScans = new Map()
scansByStore.set(store, storeScans)
}
const cached = storeScans.get(scanKey)
if (cached && (cached.retryAfter === 0 || Date.now() < cached.retryAfter)) {
return cached.names
}
const entry: BackfillScan = { names: Promise.resolve(new Set()), retryAfter: 0 }
entry.names = withScanDeadline(scan()).catch((error: unknown) => {
entry.retryAfter = Date.now() + RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS
throw error
})
storeScans.set(scanKey, entry)
return entry.names
}
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../shared/repo-types'
const { readdirMock } = vi.hoisted(() => ({ readdirMock: vi.fn() }))
// Why the module-level mock rather than a temp directory: the defect is a listing that never
// returns — a stalled NFS/SMB share or WSL UNC mount — which no real filesystem reproduces.
vi.mock('node:fs/promises', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
readdir: readdirMock
}))
const { ensureRetiredWorktreeNamesBackfilled } = await import('./worktree-name-retirement')
const { RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS, RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS } =
await import('./worktree-retirement-backfill-scan')
const repo = {
id: 'repo-a',
path: '/repos/a',
displayName: 'a',
badgeColor: '',
addedAt: 0
} as Repo
const settings = { workspaceDir: '/workspaces', nestWorkspaces: false }
function backfillStore(): { merged: string[]; mergeRetiredWorktreeNames: () => boolean } {
const merged: string[] = []
return {
merged,
mergeRetiredWorktreeNames: (...args: unknown[]) => {
merged.push(...(args[1] as Iterable<string>))
return true
}
} as never
}
describe('retirement backfill against a stalled listing', () => {
beforeEach(() => {
vi.useFakeTimers()
readdirMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('gives up rather than hanging generated create on a listing that never returns', async () => {
readdirMock.mockImplementation(() => new Promise(() => {}))
const store = backfillStore()
const backfill = ensureRetiredWorktreeNamesBackfilled(store, repo, settings)
const settled = expect(backfill).rejects.toThrow(/exceeded/)
await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS)
await settled
})
it('recovers the namespace once the mount comes back, instead of staying wedged until restart', async () => {
readdirMock.mockImplementation(() => new Promise(() => {}))
const store = backfillStore()
const stalled = ensureRetiredWorktreeNamesBackfilled(store, repo, settings)
const settled = expect(stalled).rejects.toThrow(/exceeded/)
await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS)
await settled
readdirMock.mockResolvedValue([{ name: 'nautilus', isDirectory: () => true }])
await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS)
await ensureRetiredWorktreeNamesBackfilled(store, repo, settings)
expect(store.merged).toContain('nautilus')
})
})
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { MARINE_CREATURES } from '../shared/marine-creatures'
import { createRetiredNameLookup } from '../shared/worktree/retired-name-registry'
const { runWslTranscriptFsTaskMock, getWslHomeAsyncMock } = vi.hoisted(() => ({
runWslTranscriptFsTaskMock: vi.fn(),
getWslHomeAsyncMock: vi.fn()
}))
vi.mock('./native-chat/wsl-transcript-fs-gate', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
runWslTranscriptFsTask: runWslTranscriptFsTaskMock
}))
// Why the whole module is not replaced: `worktree-name-retirement` reads `parseWslPath` and
// `hasCachedWslHome` from here on the same code path under test.
vi.mock('./wsl', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
getWslHomeAsync: getWslHomeAsyncMock
}))
const { discoverRetiredWorktreeNames } = await import('./worktree-retirement-discovery')
const { ensureRetiredWorktreeNamesBackfilled } = await import('./worktree-name-retirement')
const { WslTranscriptFsError } = await import('./native-chat/wsl-transcript-fs-gate')
const FIRST = MARINE_CREATURES[0].toLowerCase()
const DISTRO_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca\\workspaces'
describe('retirement discovery on WSL', () => {
beforeEach(() => {
runWslTranscriptFsTaskMock.mockReset()
getWslHomeAsyncMock.mockReset()
// Pass through by default: the gate's own admission logic has its own tests.
runWslTranscriptFsTaskMock.mockImplementation(
(_options: unknown, task: (signal: AbortSignal) => Promise<unknown>) =>
task(new AbortController().signal)
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('admits every WSL UNC listing through the bounded filesystem gate', async () => {
await discoverRetiredWorktreeNames({
workspaceRoots: [DISTRO_ROOT],
home: '/nonexistent-home',
env: {},
resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'
})
// Both UNC reads — the workspace root and the distro's bucket directory — are admitted, at the
// scan priority that leaves the exact-probe permit free.
expect(runWslTranscriptFsTaskMock.mock.calls.map(([options]) => options)).toEqual([
expect.objectContaining({ operation: 'readdir', path: DISTRO_ROOT, priority: 'scan' }),
expect.objectContaining({
operation: 'readdir',
path: join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects'),
priority: 'scan'
})
])
})
it('leaves no listing ungated when the workspace root is on the Windows side', async () => {
await discoverRetiredWorktreeNames({
workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'],
home: '/nonexistent-home',
env: {}
})
expect(runWslTranscriptFsTaskMock).not.toHaveBeenCalled()
})
it('fails the scan when the gate refuses, rather than memoizing a half-read answer', async () => {
// A swallowed refusal would cache "nothing is retired" for the process lifetime — the one
// direction that reissues a cwd whose conversation history is still on the distro.
runWslTranscriptFsTaskMock.mockRejectedValue(
new WslTranscriptFsError('timeout', 'filesystem access is taking too long')
)
await expect(
discoverRetiredWorktreeNames({
workspaceRoots: [DISTRO_ROOT],
home: '/nonexistent-home',
env: {},
resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'
})
).rejects.toBeInstanceOf(WslTranscriptFsError)
})
it('keeps a deleted WSL workspace name spent, so the next create cannot reuse its cwd', async () => {
// Delete/recreate: the workspace directory is gone, but the agent ran inside the distro and its
// bucket survives there. That bucket is the only remaining evidence the cwd is unsafe.
const distroHome = await mkdtemp(join(tmpdir(), 'orca-wsl-distro-home-'))
const workspaceRoot = await mkdtemp(join(tmpdir(), 'orca-wsl-workspaces-'))
try {
await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), {
recursive: true
})
const retired = await discoverRetiredWorktreeNames({
// The root is listed under its real (empty) path; the UNC spelling supplies the distro.
workspaceRoots: [DISTRO_ROOT],
home: workspaceRoot,
env: {},
resolveWslHome: async () => distroHome
})
expect(retired).toEqual(new Set([FIRST]))
expect(createRetiredNameLookup({ exhaustedTiers: 0, names: [...retired] })(FIRST)).toBe(true)
} finally {
await rm(distroHome, { force: true, recursive: true })
await rm(workspaceRoot, { force: true, recursive: true })
}
})
it('reaches distro discovery from the real backfill, not only when called directly', async () => {
// Wiring, not discovery: every test above calls the module directly, so all of them stay green
// if `ensureRetiredWorktreeNamesBackfilled` keeps scanning the Windows home alone. That is the
// shape STA-4472 shipped in, so the only caller production has is asserted here.
const distroHome = await mkdtemp(join(tmpdir(), 'orca-wsl-backfill-home-'))
try {
await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), {
recursive: true
})
// Serves both the workspace-root mirror and the distro bucket lookup.
getWslHomeAsyncMock.mockResolvedValue('\\\\wsl.localhost\\Ubuntu\\home\\ada')
runWslTranscriptFsTaskMock.mockImplementation(
(options: { path: string }, task: (signal: AbortSignal) => Promise<unknown>) => {
// Stands in for the distro's bucket directory, which only exists behind the UNC root.
const claudeRoot = join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects')
return options.path === claudeRoot
? readdir(join(distroHome, '.claude', 'projects'), { withFileTypes: true })
: task(new AbortController().signal)
}
)
const merged: string[] = []
await ensureRetiredWorktreeNamesBackfilled(
{
mergeRetiredWorktreeNames: (_repoId: string, names: Iterable<string>) => {
merged.push(...names)
return true
}
} as never,
{
id: 'repo-wsl',
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repos\\a',
displayName: 'a',
badgeColor: '',
addedAt: 0
} as never,
// The workspace dir is the UNC root itself. `wsl.ts`'s `parseWslPath` short-circuits off
// win32, so routing through the repo path instead would make this assert nothing on CI's
// Linux and macOS runners — the distro half of discovery keys on the root string.
{ workspaceDir: DISTRO_ROOT, nestWorkspaces: false }
)
expect(merged).toContain(FIRST)
} finally {
await rm(distroHome, { force: true, recursive: true })
}
})
})
@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { MARINE_CREATURES } from '../shared/marine-creatures'
import {
collectRetiredNamesFromLeafNames,
discoverRetiredWorktreeNames,
extractBucketLeafCandidates
} from './worktree-retirement-discovery'
const FIRST = MARINE_CREATURES[0].toLowerCase()
const SECOND = MARINE_CREATURES[1].toLowerCase()
describe('extractBucketLeafCandidates', () => {
it('takes everything past the encoded parent as the leaf', () => {
expect(extractBucketLeafCandidates(`-w-orca-${FIRST}`, ['-w-orca'])).toEqual([FIRST])
})
it('does not treat the parent directory as a leaf when the workspace name is numeric', () => {
// Real data: `-Users-x-orca-workspaces-orca-7474` must not retire `orca`, which is in the pool.
expect(extractBucketLeafCandidates('-w-workspaces-orca-7474', ['-w-workspaces-orca'])).toEqual([
'7474'
])
})
it('offers the first segment too, so an agent run in a subdirectory still retires the leaf', () => {
expect(extractBucketLeafCandidates(`-w-orca-${FIRST}-packages-api`, ['-w-orca'])).toEqual([
`${FIRST}-packages-api`,
FIRST
])
})
it('rejects a sibling directory that shares the parent prefix', () => {
expect(extractBucketLeafCandidates(`-w-orcadyne-${FIRST}`, ['-w-orca'])).toEqual([])
expect(extractBucketLeafCandidates(`-w-orca-secret-${FIRST}`, ['-w-orca-fix'])).toEqual([])
})
it('yields nothing for the parent bucket itself', () => {
expect(extractBucketLeafCandidates('-w-orca', ['-w-orca'])).toEqual([])
})
})
describe('collectRetiredNamesFromLeafNames', () => {
it('keeps pool names and drops everything else', () => {
expect(collectRetiredNamesFromLeafNames([FIRST, SECOND, 'fix-login'])).toEqual(
new Set([FIRST, SECOND])
)
})
it('is case-insensitive and skips non-string entries without throwing', () => {
const leaves = [undefined, null, '', MARINE_CREATURES[0].toUpperCase()] as unknown as string[]
expect(collectRetiredNamesFromLeafNames(leaves)).toEqual(new Set([FIRST]))
})
})
describe('discoverRetiredWorktreeNames', () => {
/** Buckets are written with the REAL per-character encoding, because a helper that mirrors the
* implementation would pass against a broken encoder — which is how the Windows gap shipped. */
async function withFakeHome(
buckets: readonly string[],
run: (home: string) => Promise<void>
): Promise<void> {
const home = await mkdtemp(join(tmpdir(), 'orca-retirement-home-'))
try {
for (const bucket of buckets) {
await mkdir(join(home, '.claude', 'projects', bucket), { recursive: true })
}
await run(home)
} finally {
await rm(home, { force: true, recursive: true })
}
}
it('matches a plain POSIX workspace root', async () => {
await withFakeHome([`-Users-ada-orca-workspaces-orca-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/orca/workspaces/orca'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches an NFC bucket for an NFD workspace root', async () => {
const nfdRoot = '/Users/ada/cafe\u0301'
await withFakeHome([`-Users-ada-caf--${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [nfdRoot],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches a dot-directory root, where the separator run encodes to two dashes', async () => {
await withFakeHome([`-Users-ada--orca-worktrees-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/.orca/worktrees'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches a Windows drive root', async () => {
// `getDefaultWorkspaceDir` returns `C:\Users\<user>\orca\workspaces` on Windows, so an encoder
// that collapsed `:\` rejected every bucket on that platform by default.
await withFakeHome([`C--Users-ada-orca-workspaces-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('matches a WSL UNC root', async () => {
await withFakeHome([`--wsl--Ubuntu-home-ada-orca-workspaces-${FIRST}`], async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['\\\\wsl$\\Ubuntu\\home\\ada\\orca\\workspaces'],
home,
env: {}
})
expect(retired).toEqual(new Set([FIRST]))
})
})
it('ignores buckets belonging to a sibling root with the same prefix', async () => {
await withFakeHome(
[`-Users-ada-orca-workspaces-orcadyne-${FIRST}`, `-Users-ada-orca-workspaces-orca-${SECOND}`],
async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/orca/workspaces/orca'],
home,
env: {}
})
expect(retired).toEqual(new Set([SECOND]))
}
)
})
it('reads buckets from CLAUDE_CONFIG_DIR when it is set', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'orca-retirement-config-'))
await withFakeHome([`-Users-ada-w-${SECOND}`], async (home) => {
try {
await mkdir(join(configDir, 'projects', `-Users-ada-w-${FIRST}`), { recursive: true })
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['/Users/ada/w'],
home,
env: { CLAUDE_CONFIG_DIR: configDir }
})
// The override relocates the whole state root, so the default home is not also scanned.
expect(retired).toEqual(new Set([FIRST]))
} finally {
await rm(configDir, { force: true, recursive: true })
}
})
})
it("matches a WSL workspace's bucket inside the distro, keyed by its Linux cwd", async () => {
// The agent for a WSL workspace is spawned through `wsl.exe`, so it runs inside the distro:
// its cwd is `/home/ada/...`, and its bucket lands in the distro's own home — not the
// Windows-side one. After the workspace directory is gone, that bucket is the only evidence.
const distroHome = await mkdtemp(join(tmpdir(), 'orca-retirement-distro-'))
await withFakeHome([], async (home) => {
try {
await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), {
recursive: true
})
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca\\workspaces'],
home,
env: {},
resolveWslHome: async (distro) => (distro === 'Ubuntu' ? distroHome : null)
})
expect(retired).toEqual(new Set([FIRST]))
} finally {
await rm(distroHome, { force: true, recursive: true })
}
})
})
it('still reads the Windows-side home for a WSL root when the distro home cannot be resolved', async () => {
// A stopped distro must not cost the retirements the host can still see.
await withFakeHome(
[`--wsl-localhost-Ubuntu-home-ada-orca-workspaces-${FIRST}`],
async (home) => {
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca\\workspaces'],
home,
env: {},
resolveWslHome: async () => null
})
expect(retired).toEqual(new Set([FIRST]))
}
)
})
it('retires live workspace directories alongside surviving buckets', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-retirement-roots-'))
await withFakeHome([], async (home) => {
try {
await mkdir(join(root, SECOND), { recursive: true })
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [root],
home,
env: {}
})
expect(retired).toEqual(new Set([SECOND]))
} finally {
await rm(root, { force: true, recursive: true })
}
})
})
})
+165
View File
@@ -0,0 +1,165 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { parseWslUncPath } from '../shared/wsl-paths'
import {
encodeClaudeProjectPaths,
isClaudeProjectDirInScope
} from './ai-vault/claude-project-dir-encoding'
import { wslGatedReaddir } from './native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from './native-chat/wsl-transcript-fs-gate'
import { normalizeRetirableGeneratedName } from './worktree-name-retirement'
import { getWslHomeAsync } from './wsl'
/** Why: over-retiring costs one name from a 552-entry pool; under-retiring reissues a path whose
* agent history is still on disk. So this matches generously and never tries to be exact. */
export function collectRetiredNamesFromLeafNames(leafNames: Iterable<string>): Set<string> {
const retired = new Set<string>()
for (const leafName of leafNames) {
if (typeof leafName !== 'string' || leafName.length === 0) {
continue
}
const normalized = normalizeRetirableGeneratedName(leafName)
if (normalized) {
retired.add(normalized)
}
}
return retired
}
/** The workspace leaf is whatever the bucket has beyond its encoded parent. Deriving it from
* trailing dash segments instead makes a numerically named workspace retire its parent
* directory's name — and `orca` is in the pool. The first segment is also offered because an
* agent run from a subdirectory buckets the whole subpath. */
export function extractBucketLeafCandidates(
bucketName: string,
encodedParents: readonly string[]
): string[] {
const bucket = bucketName.toLowerCase()
for (const parent of encodedParents) {
if (!parent || bucket === parent || !isClaudeProjectDirInScope(bucket, [parent])) {
continue
}
const remainder = bucket.slice(parent.length + 1)
if (!remainder) {
continue
}
const firstSegment = remainder.split('-')[0]
return remainder === firstSegment ? [remainder] : [remainder, firstSegment]
}
return []
}
/** WSL UNC listings go through the shared 9P gate, which bounds concurrency and deadlines each
* call; off UNC it is a plain `readdir`. A gate refusal propagates on purpose — swallowing it
* would memoize a partial answer forever, which under-retires, the one direction that leaks. */
async function readDirectoryNames(path: string): Promise<string[]> {
try {
const entries = await wslGatedReaddir(path, 'scan')
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
// Missing or unreadable roots are normal: the agent may never have run on this machine.
return []
}
}
/** Claude buckets its per-conversation state under a directory derived from the workspace cwd. A
* bucket surviving its workspace is exactly the evidence we need: the name was used, the
* directory is gone, and reissuing the name would hand the next occupant that conversation.
* CLAUDE_CONFIG_DIR relocates the whole state root, buckets included.
*
* Codex is deliberately not scanned: it records the cwd inside
* `sessions/YYYY/MM/DD/rollout-*.jsonl` rather than in a directory name, so seeding from it would
* mean parsing user conversation files. Names spent only under Codex before this feature shipped
* stay issuable; every name spent after it is recorded at create time regardless of agent. */
function getClaudeProjectsDir(home: string, env: NodeJS.ProcessEnv): string {
const override = env.CLAUDE_CONFIG_DIR?.trim()
return override ? join(override, 'projects') : join(home, '.claude', 'projects')
}
/** A bucket directory to scan, paired with the encoded cwds whose workspaces it would hold. */
type ClaudeProjectsSource = {
projectsDir: string
encodedParents: string[]
}
// 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[] {
return paths
.filter((path) => path.length > 0)
.flatMap((path) => encodeClaudeProjectPaths(path).map((encoded) => encoded.toLowerCase()))
}
/** Every bucket directory that could hold a workspace under these roots.
*
* The Windows-side home is not enough for WSL. A WSL workspace's agent is spawned through
* `wsl.exe`, so it runs inside the distro: its cwd is the Linux path, not the UNC spelling, and
* its buckets land in the distro's own `~/.claude/projects`. Once the workspace directory is
* deleted that distro-local bucket is the only durable evidence the name is spent, so a backfill
* that reads only the host home reissues the cwd and hands over the previous conversation.
*
* The distro's own `CLAUDE_CONFIG_DIR` is not readable from the Windows side, so only the default
* location is scanned there — the same best-effort contract as the rest of this discovery. */
async function claudeProjectsSources(args: {
workspaceRoots: readonly string[]
home?: string
env?: NodeJS.ProcessEnv
resolveWslHome?: (distro: string) => Promise<string | null>
}): Promise<ClaudeProjectsSource[]> {
const sources: ClaudeProjectsSource[] = []
const hostParents = encodeParents(args.workspaceRoots)
if (hostParents.length > 0) {
sources.push({
projectsDir: getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env),
encodedParents: hostParents
})
}
const linuxRootsByDistro = new Map<string, string[]>()
for (const root of args.workspaceRoots) {
const wsl = parseWslUncPath(root)
if (wsl) {
linuxRootsByDistro.set(wsl.distro, [
...(linuxRootsByDistro.get(wsl.distro) ?? []),
wsl.linuxPath
])
}
}
const resolveWslHome = args.resolveWslHome ?? getWslHomeAsync
for (const [distro, linuxRoots] of linuxRootsByDistro) {
const distroHome = await resolveWslHome(distro)
if (distroHome) {
sources.push({
projectsDir: join(distroHome, '.claude', 'projects'),
encodedParents: encodeParents(linuxRoots)
})
}
}
return sources
}
/** Discovers names already spent for a repo, for the one-time seed of the retirement registry.
* Local and best-effort by design — agent state for an SSH workspace lives on the execution host,
* so names used only there stay issuable until this host observes them. */
export async function discoverRetiredWorktreeNames(args: {
workspaceRoots: readonly string[]
home?: string
env?: NodeJS.ProcessEnv
/** Injected in tests; production resolves the distro's `$HOME` as a UNC path. */
resolveWslHome?: (distro: string) => Promise<string | null>
}): Promise<Set<string>> {
const leafNames: string[] = []
for (const root of args.workspaceRoots) {
leafNames.push(...(await readDirectoryNames(root)))
}
for (const source of await claudeProjectsSources(args)) {
for (const bucket of await readDirectoryNames(source.projectsDir)) {
leafNames.push(...extractBucketLeafCandidates(bucket, source.encodedParents))
}
}
return collectRetiredNamesFromLeafNames(leafNames)
}