mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user