mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user