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.
This commit is contained in:
Brennan Benson
2026-08-13 17:42:54 -07:00
parent f336164715
commit 54afdb36ac
2 changed files with 372 additions and 221 deletions
+249 -82
View File
@@ -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<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 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('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<string, string[]>()
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<string, string[]>()
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<string, string[]>()
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<string>) => {
merged.push(...names)
mergeRetiredWorktreeNames: (key: string, names: Iterable<string>) => {
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<string>) => {
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([])
})
})
+123 -139
View File
@@ -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<string> = new Set(MARINE_CREATURES.map((name) => name.toLowerCase()))
const RETIREMENT_PROBE_NAME = 'orca-retirement-probe'
const backfillsByStore = new WeakMap<object, Map<string, Promise<void>>>()
type RetirementStore = {
getRetiredWorktreeNames?(repoId: string): string[]
mergeRetiredWorktreeNames?(repoId: string, names: Iterable<string>): boolean
type RetirementReadStore = {
getRetiredWorktreeNames(collisionKey: string): string[]
}
type RetirementWriteStore = {
addRetiredWorktreeName(collisionKey: string, name: string): void
}
type RetirementBackfillStore = {
mergeRetiredWorktreeNames(collisionKey: string, names: Iterable<string>): boolean
}
type RetirementPathSettings = Pick<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
/** 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<string>): Set<string> {
export function collectRetiredNamesFromLeafNames(leafNames: Iterable<string>): Set<string> {
const retired = new Set<string>()
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<string, string>()
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<string>()
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<string>,
repos: readonly Repo[],
settings: RetirementPathSettings
): Record<string, string[]> {
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<string, Set<string>>()
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<string, string[]> = {}
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<void> {
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<string[]> {
try {
const entries = await readdir(path, { withFileTypes: true })
@@ -226,24 +190,44 @@ async function readDirectoryNames(path: string): Promise<string[]> {
}
}
/** 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 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)
}