From 54afdb36acaf2f6e47b0bc6a944f9c61d2627de4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:42:54 -0700 Subject: [PATCH] fix(worktree): make the retirement seed scan actually match buckets The bucket encoder collapsed runs of non-alphanumerics while the real one emits a dash per character, so every dot-path bucket missed and the Windows default workspace root (C:\...) matched nothing at all. Reuse the shared encoder and its boundary check, which also stops a repo absorbing a sibling whose path merely shares its prefix. Also: - Derive the workspace leaf by stripping the known encoded parent instead of guessing from trailing dash segments, which retired the parent directory's name whenever a workspace was named numerically. - Reuse isAutoGeneratedCreatureBranchName so the -10 and -100 tiers retire. - Drop the .codex/sessions root: Codex keeps the cwd inside the transcript rather than in a directory name, so the scan could only ever see a year folder. Reading transcript contents is not a trade this feature justifies, so the gap is documented instead. - Honor CLAUDE_CONFIG_DIR, which relocates the bucket root. - Delete the unused retirableLeafName export. Tests write buckets with the real per-character encoding against a fake home, covering POSIX, dot-directory, Windows drive and WSL UNC roots; all three platform cases fail against the previous encoder. --- src/main/worktree-name-retirement.test.ts | 331 ++++++++++++++++------ src/main/worktree-name-retirement.ts | 262 ++++++++--------- 2 files changed, 372 insertions(+), 221 deletions(-) diff --git a/src/main/worktree-name-retirement.test.ts b/src/main/worktree-name-retirement.test.ts index 726adc6f70d..52b23b35a4e 100644 --- a/src/main/worktree-name-retirement.test.ts +++ b/src/main/worktree-name-retirement.test.ts @@ -1,110 +1,262 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, 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 type { GlobalSettings, Repo } from '../shared/types' import { - collectRetiredNamesFromPaths, + collectRetiredNamesFromLeafNames, + discoverRetiredWorktreeNames, ensureRetiredWorktreeNamesBackfilled, - extractCandidateLeafNames, + extractBucketLeafCandidates, getRetiredWorktreeNamesForRepo, - normalizeRetirableGeneratedName + normalizeRetirableGeneratedName, + resetRetirementCollisionKeyCacheForTests, + retireGeneratedWorktreeName } from './worktree-name-retirement' const FIRST = MARINE_CREATURES[0].toLowerCase() const SECOND = MARINE_CREATURES[1].toLowerCase() -describe('extractCandidateLeafNames', () => { - it('takes the trailing segment of a real path', () => { - expect(extractCandidateLeafNames(`/Users/ada/orca/workspaces/orca/${FIRST}`)).toEqual([FIRST]) +const makeRepo = (id: string, path: string): Repo => + ({ id, path, displayName: id, badgeColor: '', addedAt: 0 }) as Repo + +beforeEach(() => { + resetRetirementCollisionKeyCacheForTests() +}) + +describe('normalizeRetirableGeneratedName', () => { + it('accepts pool names and every numbered tier, not just single digits', () => { + expect(normalizeRetirableGeneratedName(` ${FIRST} `)).toBe(FIRST) + expect(normalizeRetirableGeneratedName(`${FIRST}-2`)).toBe(`${FIRST}-2`) + // Why: the previous `-([2-9]\d*)` never matched these, so a path whose agent history was + // still on disk got reissued — the exact bug this module exists to prevent. + expect(normalizeRetirableGeneratedName(`${FIRST}-10`)).toBe(`${FIRST}-10`) + expect(normalizeRetirableGeneratedName(`${FIRST}-100`)).toBe(`${FIRST}-100`) }) - it('takes the trailing segment of a dash-encoded transcript bucket', () => { - expect(extractCandidateLeafNames(`-Users-ada-orca-workspaces-orca-${FIRST}`)).toEqual([FIRST]) + it('rejects names outside the pool and absurdly long input', () => { + expect(normalizeRetirableGeneratedName('fix-login')).toBeNull() + expect(normalizeRetirableGeneratedName('')).toBeNull() + expect(normalizeRetirableGeneratedName(`${FIRST}-${'9'.repeat(300)}`)).toBeNull() + }) +}) + +describe('extractBucketLeafCandidates', () => { + it('takes everything past the encoded parent as the leaf', () => { + expect(extractBucketLeafCandidates(`-w-orca-${FIRST}`, ['-w-orca'])).toEqual([FIRST]) }) - it('keeps a numeric tail attached so suffixed variants retire as themselves', () => { - // Why: returning only the base would retire "gar" and leave "gar-2" issuable. - expect(extractCandidateLeafNames(`-Users-ada-worktrees-${FIRST}-2`)).toEqual([ - `${FIRST}-2`, + 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('handles Windows separators and trailing separators', () => { - expect(extractCandidateLeafNames(`C:\\worktrees\\${FIRST}\\`)).toEqual([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('returns nothing for an empty or separator-only input', () => { - expect(extractCandidateLeafNames('')).toEqual([]) - expect(extractCandidateLeafNames('---')).toEqual([]) + it('yields nothing for the parent bucket itself', () => { + expect(extractBucketLeafCandidates('-w-orca', ['-w-orca'])).toEqual([]) }) }) -describe('collectRetiredNamesFromPaths', () => { - it('retires pool names found in live workspace directories', () => { - expect(collectRetiredNamesFromPaths([FIRST, SECOND])).toEqual(new Set([FIRST, SECOND])) - }) - - it('retires a name whose directory is gone but whose transcript bucket survives', () => { - // The core case: this is the evidence that a deleted workspace left agent state behind. - expect(collectRetiredNamesFromPaths([`-Users-ada-orca-workspaces-orca-${FIRST}`])).toEqual( - new Set([FIRST]) +describe('collectRetiredNamesFromLeafNames', () => { + it('keeps pool names and drops everything else', () => { + expect(collectRetiredNamesFromLeafNames([FIRST, SECOND, 'fix-login'])).toEqual( + new Set([FIRST, SECOND]) ) }) - it('retires a suffixed variant without also freeing it', () => { - const retired = collectRetiredNamesFromPaths([`-Users-ada-worktrees-${FIRST}-2`]) - expect(retired.has(`${FIRST}-2`)).toBe(true) - }) - - it('ignores paths that contain no pool name', () => { - expect( - collectRetiredNamesFromPaths(['-Users-ada-orca-workspaces-orca-fix-login-redirect']) - ).toEqual(new Set()) - }) - - it('is case-insensitive', () => { - expect(collectRetiredNamesFromPaths([MARINE_CREATURES[0].toUpperCase()])).toEqual( - new Set([FIRST]) - ) - }) - - it('skips non-string and empty entries without throwing', () => { - const paths = [undefined, null, '', FIRST] as unknown as string[] - expect(collectRetiredNamesFromPaths(paths)).toEqual(new Set([FIRST])) + 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('normalizeRetirableGeneratedName', () => { - it('accepts only generated pool names and their numbered variants', () => { - expect(normalizeRetirableGeneratedName(` ${FIRST} `)).toBe(FIRST) - expect(normalizeRetirableGeneratedName(`${FIRST}-2`)).toBe(`${FIRST}-2`) - expect(normalizeRetirableGeneratedName('fix-login')).toBeNull() - expect(normalizeRetirableGeneratedName(`${FIRST}-1`)).toBeNull() +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 + ): Promise { + 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 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\\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('getRetiredWorktreeNamesForRepo', () => { - const makeRepo = (id: string, path: string): Repo => - ({ id, path, displayName: id, badgeColor: '', addedAt: 0 }) as Repo - const store = { - getRetiredWorktreeNames: (repoId: string) => (repoId === 'repo-a' ? [FIRST] : [SECOND]) - } + const settingsFor = (nestWorkspaces: boolean): GlobalSettings => + ({ workspaceDir: '/workspaces', nestWorkspaces }) as GlobalSettings - it('shares retirements when non-nested repos create into the same cwd namespace', () => { - const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')] - const settings = { workspaceDir: '/workspaces', nestWorkspaces: false } as GlobalSettings - expect(getRetiredWorktreeNamesForRepo(store, repos[1], repos, settings).sort()).toEqual( - [FIRST, SECOND].sort() - ) + it('shares retirements when two repos create into the same cwd namespace', () => { + const byKey = new Map() + const store = { + getRetiredWorktreeNames: (key: string) => byKey.get(key) ?? [], + addRetiredWorktreeName: (key: string, name: string) => + byKey.set(key, [...(byKey.get(key) ?? []), name]) + } + const settings = settingsFor(false) + const repoA = makeRepo('repo-a', '/repos/a') + const repoB = makeRepo('repo-b', '/repos/b') + + retireGeneratedWorktreeName(store, repoA, settings, FIRST) + + expect(getRetiredWorktreeNamesForRepo(store, repoB, settings)).toEqual([FIRST]) }) it('keeps independent nested repo paths in separate retirement domains', () => { - const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')] - const settings = { workspaceDir: '/workspaces', nestWorkspaces: true } as GlobalSettings - expect(getRetiredWorktreeNamesForRepo(store, repos[1], repos, settings)).toEqual([SECOND]) + const byKey = new Map() + const store = { + getRetiredWorktreeNames: (key: string) => byKey.get(key) ?? [], + addRetiredWorktreeName: (key: string, name: string) => + byKey.set(key, [...(byKey.get(key) ?? []), name]) + } + const settings = settingsFor(true) + const repoA = makeRepo('repo-a', '/repos/a') + const repoB = makeRepo('repo-b', '/repos/b') + + retireGeneratedWorktreeName(store, repoA, settings, FIRST) + + expect(getRetiredWorktreeNamesForRepo(store, repoB, settings)).toEqual([]) + expect(getRetiredWorktreeNamesForRepo(store, repoA, settings)).toEqual([FIRST]) + }) + + it('survives a repo being removed and re-added under a new id', () => { + // Why: repo ids are regenerated on re-add. Keying the registry by repo id silently dropped + // every retirement for a path that had not changed at all. + const byKey = new Map() + const store = { + getRetiredWorktreeNames: (key: string) => byKey.get(key) ?? [], + addRetiredWorktreeName: (key: string, name: string) => + byKey.set(key, [...(byKey.get(key) ?? []), name]) + } + const settings = settingsFor(true) + + retireGeneratedWorktreeName(store, makeRepo('repo-old', '/repos/a'), settings, FIRST) + + expect( + getRetiredWorktreeNamesForRepo(store, makeRepo('repo-new', '/repos/a'), settings) + ).toEqual([FIRST]) + }) + + it('reports nothing for a folder workspace, which has no generated worktree names', () => { + const store = { getRetiredWorktreeNames: () => [FIRST] } + const folderRepo = { ...makeRepo('folder', '/repos/folder'), kind: 'folder' as const } + expect(getRetiredWorktreeNamesForRepo(store, folderRepo, settingsFor(false))).toEqual([]) }) }) @@ -113,29 +265,44 @@ describe('ensureRetiredWorktreeNamesBackfilled', () => { const root = await mkdtemp(join(tmpdir(), 'orca-retirement-backfill-')) const workspaceRoot = join(root, 'workspaces') await mkdir(join(workspaceRoot, FIRST), { recursive: true }) - const merged: string[] = [] + const merged: { key: string; names: string[] }[] = [] const store = { - mergeRetiredWorktreeNames: (_repoId: string, names: Iterable) => { - merged.push(...names) + mergeRetiredWorktreeNames: (key: string, names: Iterable) => { + merged.push({ key, names: [...names] }) return true } } - const repo = { - id: 'repo-a', - path: join(root, 'repos', 'a'), - displayName: 'repo-a', - badgeColor: '', - addedAt: 0 - } as Repo + const repo = makeRepo('repo-a', join(root, 'repos', 'a')) + const settings = { workspaceDir: workspaceRoot, nestWorkspaces: false } try { - await ensureRetiredWorktreeNamesBackfilled(store, repo, { - workspaceDir: workspaceRoot, - nestWorkspaces: false - }) - expect(merged).toContain(FIRST) + await ensureRetiredWorktreeNamesBackfilled(store, repo, settings) + expect(merged).toHaveLength(1) + expect(merged[0].names).toContain(FIRST) + + // A second repo in the same namespace reuses the one in-flight scan. + await ensureRetiredWorktreeNamesBackfilled(store, makeRepo('repo-b', '/repos/b'), settings) + expect(merged).toHaveLength(1) } finally { await rm(root, { force: true, recursive: true }) } }) + + it('skips repos whose agent state lives on another host', async () => { + const merged: string[] = [] + const store = { + mergeRetiredWorktreeNames: (_key: string, names: Iterable) => { + merged.push(...names) + return true + } + } + const sshRepo = { ...makeRepo('repo-ssh', '/remote/repo'), connectionId: 'ssh-1' } + + await ensureRetiredWorktreeNamesBackfilled(store, sshRepo, { + workspaceDir: '/workspaces', + nestWorkspaces: false + }) + + expect(merged).toEqual([]) + }) }) diff --git a/src/main/worktree-name-retirement.ts b/src/main/worktree-name-retirement.ts index 04ee3d253ea..7698270bc37 100644 --- a/src/main/worktree-name-retirement.ts +++ b/src/main/worktree-name-retirement.ts @@ -1,10 +1,14 @@ import { readdir } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' -import { MARINE_CREATURES } from '../shared/marine-creatures' +import { isAutoGeneratedCreatureBranchName } from '../shared/branch-name-from-work' import { getRepoExecutionHostId } from '../shared/execution-host' import { isFolderRepo } from '../shared/repo-kind' import type { GlobalSettings, Repo } from '../shared/types' +import { + encodeClaudeProjectPath, + isClaudeProjectDirInScope +} from './ai-vault/claude-project-dir-encoding' import { computeRemoteWorktreePath, computeWorktreePath, @@ -13,74 +17,69 @@ import { } from './ipc/worktree-logic' import { worktreePathComparisonKey } from './ipc/worktree-path-comparison' -/** Agent CLIs bucket their 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. */ -const AGENT_TRANSCRIPT_ROOTS = ['.claude/projects', '.codex/sessions'] as const - -const POOL_NAMES: ReadonlySet = new Set(MARINE_CREATURES.map((name) => name.toLowerCase())) const RETIREMENT_PROBE_NAME = 'orca-retirement-probe' const backfillsByStore = new WeakMap>>() -type RetirementStore = { - getRetiredWorktreeNames?(repoId: string): string[] - mergeRetiredWorktreeNames?(repoId: string, names: Iterable): boolean +type RetirementReadStore = { + getRetiredWorktreeNames(collisionKey: string): string[] +} +type RetirementWriteStore = { + addRetiredWorktreeName(collisionKey: string, name: string): void +} +type RetirementBackfillStore = { + mergeRetiredWorktreeNames(collisionKey: string, names: Iterable): boolean } type RetirementPathSettings = Pick +/** Why reuse the auto-rename predicate: it already defines "a name Orca picked, not the user", is + * already tested, and a second definition here drifted — its `-([2-9]\d*)` never matched `-10`. */ export function normalizeRetirableGeneratedName(name: string): string | null { const normalized = name.trim().toLowerCase() if (normalized.length > 256) { return null } - const match = normalized.match(/^(.+?)(?:-([2-9]\d*))?$/) - return match && POOL_NAMES.has(match[1]) ? normalized : null -} - -/** Why: worktree paths reaching retirement can be native, WSL, or SSH-host paths, so the leaf is - * taken separator-agnostically rather than through a platform-bound basename. */ -export function retirableLeafName(path: string): string { - const segments = path.split(/[/\\]/).filter((segment) => segment.trim().length > 0) - return segments.at(-1) ?? '' -} - -/** Bucket names encode a path with separators and dots flattened to dashes, so the workspace leaf - * is the trailing segment. A numeric tail is a suffixed variant ("gar-2") and must be kept whole, - * otherwise the base name alone gets retired and the variant stays issuable. */ -export function extractCandidateLeafNames(encodedPath: string): string[] { - const segments = encodedPath - .split(/[-/\\]/) - .map((segment) => segment.trim().toLowerCase()) - .filter((segment) => segment.length > 0) - const last = segments.at(-1) - if (last === undefined) { - return [] - } - const previous = segments.at(-2) - if (previous && /^\d+$/.test(last)) { - return [`${previous}-${last}`, previous] - } - return [last] + return isAutoGeneratedCreatureBranchName(normalized) ? normalized : null } /** 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 collectRetiredNamesFromPaths(paths: Iterable): Set { +export function collectRetiredNamesFromLeafNames(leafNames: Iterable): Set { const retired = new Set() - for (const path of paths) { - if (typeof path !== 'string' || path.length === 0) { + for (const leafName of leafNames) { + if (typeof leafName !== 'string' || leafName.length === 0) { continue } - for (const candidate of extractCandidateLeafNames(path)) { - const normalized = normalizeRetirableGeneratedName(candidate) - if (normalized) { - retired.add(normalized) - } + 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 [] +} + function getRetirementProbePath(repo: Repo, settings: RetirementPathSettings): string { const pathSettings = getWorktreePathSettings(repo, settings) return repo.connectionId @@ -90,82 +89,61 @@ function getRetirementProbePath(repo: Repo, settings: RetirementPathSettings): s : computeWorktreePath(RETIREMENT_PROBE_NAME, repo.path, pathSettings) } -function getRetirementCollisionKey(repo: Repo, settings: RetirementPathSettings): string { - return `${getRepoExecutionHostId(repo)}:${worktreePathComparisonKey( - getRetirementProbePath(repo, settings) - )}` +// Why: for a WSL repo the probe path costs a blocking `wsl.exe` call, and the create path plus +// every suggestion refresh asks for the same key. Inputs are all the key derives from. +const COLLISION_KEY_CACHE_MAX = 512 +const collisionKeyCache = new Map() + +export function resetRetirementCollisionKeyCacheForTests(): void { + collisionKeyCache.clear() +} + +/** Identifies the cwd namespace a repo creates workspaces into. Two repos that would place a + * workspace of the same name at the same path share retirements; independent paths do not. */ +export function getRetirementCollisionKey(repo: Repo, settings: RetirementPathSettings): string { + const hostId = getRepoExecutionHostId(repo) + const cacheKey = [ + hostId, + repo.path, + repo.worktreeBasePath ?? '', + settings.workspaceDir, + settings.nestWorkspaces ? 'nested' : 'flat' + ].join('\u0000') + const cached = collisionKeyCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + const key = `${hostId}:${worktreePathComparisonKey(getRetirementProbePath(repo, settings))}` + if (collisionKeyCache.size >= COLLISION_KEY_CACHE_MAX) { + collisionKeyCache.clear() + } + collisionKeyCache.set(cacheKey, key) + return key } export function getRetiredWorktreeNamesForRepo( - store: RetirementStore, + store: RetirementReadStore, repo: Repo, - repos: readonly Repo[], settings: RetirementPathSettings ): string[] { - const getRetiredNames = store.getRetiredWorktreeNames?.bind(store) - if (!getRetiredNames) { + if (isFolderRepo(repo)) { return [] } - const collisionKey = getRetirementCollisionKey(repo, settings) - const names = new Set() - for (const candidate of repos) { - if ( - isFolderRepo(candidate) || - getRetirementCollisionKey(candidate, settings) !== collisionKey - ) { - continue - } - for (const name of getRetiredNames(candidate.id)) { - names.add(name) - } - } - return [...names] + return store.getRetiredWorktreeNames(getRetirementCollisionKey(repo, settings)) } -export function getRetiredWorktreeNamesByRepo( - store: RetirementStore, - repoIds: Iterable, - repos: readonly Repo[], - settings: RetirementPathSettings -): Record { - const getRetiredNames = store.getRetiredWorktreeNames?.bind(store) - if (!getRetiredNames) { - return {} +/** Records the name main actually used, not the one the client proposed — the create path can + * advance past a taken candidate. Call only for names Orca generated. */ +export function retireGeneratedWorktreeName( + store: RetirementWriteStore, + repo: Repo, + settings: RetirementPathSettings, + name: string +): void { + if (isFolderRepo(repo)) { + return } - const namesByCollisionKey = new Map>() - for (const candidate of repos) { - if (isFolderRepo(candidate)) { - continue - } - const retiredNames = getRetiredNames(candidate.id) - if (retiredNames.length === 0) { - continue - } - const collisionKey = getRetirementCollisionKey(candidate, settings) - let names = namesByCollisionKey.get(collisionKey) - if (!names) { - names = new Set() - namesByCollisionKey.set(collisionKey, names) - } - for (const name of retiredNames) { - names.add(name) - } - } - if (namesByCollisionKey.size === 0) { - return {} - } - const requestedRepoIds = new Set(repoIds) - const byRepo: Record = {} - for (const repo of repos) { - if (!requestedRepoIds.has(repo.id) || isFolderRepo(repo)) { - continue - } - const names = namesByCollisionKey.get(getRetirementCollisionKey(repo, settings)) - if (names?.size) { - byRepo[repo.id] = [...names] - } - } - return byRepo + store.addRetiredWorktreeName(getRetirementCollisionKey(repo, settings), name) } function parentPath(path: string): string { @@ -175,12 +153,12 @@ function parentPath(path: string): string { } export function ensureRetiredWorktreeNamesBackfilled( - store: RetirementStore, + store: RetirementBackfillStore, repo: Repo, settings: RetirementPathSettings ): Promise { - const mergeRetiredNames = store.mergeRetiredWorktreeNames?.bind(store) - if (isFolderRepo(repo) || repo.connectionId || !mergeRetiredNames) { + // Remote workspaces keep their agent state on the execution host, which this scan cannot see. + if (isFolderRepo(repo) || repo.connectionId) { return Promise.resolve() } let storeBackfills = backfillsByStore.get(store) @@ -196,26 +174,12 @@ export function ensureRetiredWorktreeNamesBackfilled( const backfill = discoverRetiredWorktreeNames({ workspaceRoots: [parentPath(getRetirementProbePath(repo, settings))] }).then((names) => { - mergeRetiredNames(repo.id, names) + store.mergeRetiredWorktreeNames(collisionKey, names) }) storeBackfills.set(collisionKey, backfill) return backfill } -/** Transcript bucket names are the workspace cwd with every non-alphanumeric run flattened to a - * dash. Encoding the worktree parent keeps retirement scoped to the cwd collision domain. */ -export function encodePathForBucketMatch(path: string): string { - return path.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase() -} - -function bucketBelongsToParent(bucketName: string, encodedParents: readonly string[]): boolean { - if (encodedParents.length === 0) { - return true - } - const bucket = bucketName.toLowerCase() - return encodedParents.some((parent) => parent.length > 0 && bucket.startsWith(parent)) -} - async function readDirectoryNames(path: string): Promise { try { const entries = await readdir(path, { withFileTypes: true }) @@ -226,24 +190,44 @@ async function readDirectoryNames(path: string): Promise { } } +/** 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> { - const home = args.home ?? homedir() - const observed: string[] = [] - const encodedParents = args.workspaceRoots.map(encodePathForBucketMatch) - + const leafNames: string[] = [] for (const root of args.workspaceRoots) { - observed.push(...(await readDirectoryNames(root))) - } - for (const relativeRoot of AGENT_TRANSCRIPT_ROOTS) { - const buckets = await readDirectoryNames(join(home, ...relativeRoot.split('/'))) - observed.push(...buckets.filter((bucket) => bucketBelongsToParent(bucket, encodedParents))) + leafNames.push(...(await readDirectoryNames(root))) } - return collectRetiredNamesFromPaths(observed) + // 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) + .map((root) => encodeClaudeProjectPath(root).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) }