Files
orca/src/main/worktree-retirement-discovery-wsl.test.ts
T
mirror 34fa2cd5ca
Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
source snapshot 2026-09-02
2026-09-02 02:03:16 +08:00

250 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { MARINE_CREATURES } from '../shared/marine-creatures'
import { createRetiredNameLookup } from '../shared/worktree/retired-name-registry'
const { runWslTranscriptFsTaskMock, getWslHomeAsyncMock } = vi.hoisted(() => ({
runWslTranscriptFsTaskMock: vi.fn(),
getWslHomeAsyncMock: vi.fn()
}))
vi.mock('./native-chat/wsl-transcript-fs-gate', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
runWslTranscriptFsTask: runWslTranscriptFsTaskMock
}))
// Why the whole module is not replaced: `worktree-name-retirement` reads `parseWslPath` and
// `hasCachedWslHome` from here on the same code path under test.
vi.mock('./wsl', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
getWslHomeAsync: getWslHomeAsyncMock
}))
const { discoverRetiredWorktreeNames } = await import('./worktree-retirement-discovery')
const { ensureRetiredWorktreeNamesBackfilled } = await import('./worktree-name-retirement')
const { WslTranscriptFsError } = await import('./native-chat/wsl-transcript-fs-gate')
const FIRST = MARINE_CREATURES[0].toLowerCase()
const DISTRO_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca\\workspaces'
describe('retirement discovery on WSL', () => {
beforeEach(() => {
runWslTranscriptFsTaskMock.mockReset()
getWslHomeAsyncMock.mockReset()
// Pass through by default: the gate's own admission logic has its own tests.
runWslTranscriptFsTaskMock.mockImplementation(
(_options: unknown, task: (signal: AbortSignal) => Promise<unknown>) =>
task(new AbortController().signal)
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('admits every WSL UNC listing through the bounded filesystem gate', async () => {
await discoverRetiredWorktreeNames({
workspaceRoots: [DISTRO_ROOT],
home: '/nonexistent-home',
env: {},
resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'
})
// Both UNC reads — the workspace root and the distro's bucket directory — are admitted, and
// every listing goes at the scan priority that leaves the exact-probe permit free. Nothing
// exists under this fixture, so each is followed by the ancestor walk that tells a genuinely
// absent directory apart from an unreachable 9P route; those are gated too.
const calls = runWslTranscriptFsTaskMock.mock.calls.map(([options]) => options)
expect(calls.every((options) => options.priority === 'scan')).toBe(true)
expect(calls.map((options) => options.path)).toEqual(
expect.arrayContaining([
DISTRO_ROOT,
join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects')
])
)
})
it('leaves no listing ungated when the workspace root is on the Windows side', async () => {
await discoverRetiredWorktreeNames({
workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'],
home: '/nonexistent-home',
env: {}
})
expect(runWslTranscriptFsTaskMock).not.toHaveBeenCalled()
})
it('reports a gate refusal as incomplete but still returns what the host side could read', async () => {
// Marking it incomplete is what stops "nothing is retired" being cached for the process
// lifetime — the one direction that reissues a cwd whose history is still on the distro.
// Throwing instead would be worse than the pre-split behaviour: for a WSL repo the UNC
// workspace root is listed first, so a stuck 9P route would also discard the plain, readable
// Windows-side bucket scan that needs no distro access at all.
runWslTranscriptFsTaskMock.mockRejectedValue(
new WslTranscriptFsError('timeout', 'filesystem access is taking too long')
)
const hostHome = await mkdtemp(join(tmpdir(), 'orca-wsl-host-home-'))
try {
await mkdir(
join(
hostHome,
'.claude',
'projects',
`--wsl-localhost-ubuntu-home-ada-orca-workspaces-${FIRST}`
),
{ recursive: true }
)
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [DISTRO_ROOT],
home: hostHome,
env: {},
resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'
})
expect(retired.complete).toBe(false)
expect(retired.names).toEqual(new Set([FIRST]))
} finally {
await rm(hostHome, { force: true, recursive: true })
}
})
it('accepts a UNC ENOENT as absence once the parent directory still lists', async () => {
// The common WSL shape: the distro is up, but nobody has ever run Claude inside it, so
// `~/.claude/projects` genuinely does not exist. Treating that as a hole would turn the
// one-time seed into a 60s rescan loop for the life of the process.
const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
const distroHome = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
// A distro where Claude never ran has no `~/.claude` at all, and a repo with no workspaces yet
// has neither the workspace root nor its parent — so the whole chain up to the home is absent.
// Only the home itself lists, which is what proves the route is up.
const missing = new Set([
DISTRO_ROOT,
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca',
join(distroHome, '.claude', 'projects'),
join(distroHome, '.claude')
])
runWslTranscriptFsTaskMock.mockImplementation((options: { path: string }) =>
missing.has(options.path) ? Promise.reject(enoent) : Promise.resolve([])
)
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [DISTRO_ROOT],
home: '/nonexistent-home',
env: {},
resolveWslHome: async () => distroHome
})
expect(retired.complete).toBe(true)
})
it('does not trust a UNC ENOENT, which is what a shut-down distro looks like', async () => {
// Windows maps an unreachable 9P route to ENOENT, so `wsl --shutdown` is indistinguishable
// from a distro that never held buckets. The home stays cached and still resolves, so nothing
// else marks the scan incomplete — believing ENOENT here memoizes the STA-4472 hole for good.
const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
runWslTranscriptFsTaskMock.mockRejectedValue(enoent)
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [DISTRO_ROOT],
home: '/nonexistent-home',
env: {},
resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'
})
expect(retired.complete).toBe(false)
})
it('stays retryable when the distro home cannot be resolved, rather than memoizing the hole', async () => {
// `getWslHomeAsync` shells out to `wsl.exe` and returns null for a stopped or slow distro.
// Memoizing that as a complete "nothing is retired" is the STA-4472 defect itself, since the
// distro is precisely where a WSL workspace's history lives.
const retired = await discoverRetiredWorktreeNames({
workspaceRoots: [DISTRO_ROOT],
home: '/nonexistent-home',
env: {},
resolveWslHome: async () => null
})
expect(retired.complete).toBe(false)
})
it('keeps a deleted WSL workspace name spent, so the next create cannot reuse its cwd', async () => {
// Delete/recreate: the workspace directory is gone, but the agent ran inside the distro and its
// bucket survives there. That bucket is the only remaining evidence the cwd is unsafe.
const distroHome = await mkdtemp(join(tmpdir(), 'orca-wsl-distro-home-'))
const workspaceRoot = await mkdtemp(join(tmpdir(), 'orca-wsl-workspaces-'))
try {
await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), {
recursive: true
})
const retired = await discoverRetiredWorktreeNames({
// The root is listed under its real (empty) path; the UNC spelling supplies the distro.
workspaceRoots: [DISTRO_ROOT],
home: workspaceRoot,
env: {},
resolveWslHome: async () => distroHome
})
expect(retired.names).toEqual(new Set([FIRST]))
expect(createRetiredNameLookup({ exhaustedTiers: 0, names: [...retired.names] })(FIRST)).toBe(
true
)
} finally {
await rm(distroHome, { force: true, recursive: true })
await rm(workspaceRoot, { force: true, recursive: true })
}
})
it('reaches distro discovery from the real backfill, not only when called directly', async () => {
// Wiring, not discovery: every test above calls the module directly, so all of them stay green
// if `ensureRetiredWorktreeNamesBackfilled` keeps scanning the Windows home alone. That is the
// shape STA-4472 shipped in, so the only caller production has is asserted here.
const distroHome = await mkdtemp(join(tmpdir(), 'orca-wsl-backfill-home-'))
try {
await mkdir(join(distroHome, '.claude', 'projects', `-home-ada-orca-workspaces-${FIRST}`), {
recursive: true
})
// Serves both the workspace-root mirror and the distro bucket lookup.
getWslHomeAsyncMock.mockResolvedValue('\\\\wsl.localhost\\Ubuntu\\home\\ada')
runWslTranscriptFsTaskMock.mockImplementation(
(options: { path: string }, task: (signal: AbortSignal) => Promise<unknown>) => {
// Stands in for the distro's bucket directory, which only exists behind the UNC root.
const claudeRoot = join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects')
return options.path === claudeRoot
? readdir(join(distroHome, '.claude', 'projects'), { withFileTypes: true })
: task(new AbortController().signal)
}
)
const merged: string[] = []
await ensureRetiredWorktreeNamesBackfilled(
{
mergeRetiredWorktreeNames: (_repoId: string, names: Iterable<string>) => {
merged.push(...names)
return true
}
} as never,
{
id: 'repo-wsl',
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repos\\a',
displayName: 'a',
badgeColor: '',
addedAt: 0
} as never,
// The workspace dir is the UNC root itself. `wsl.ts`'s `parseWslPath` short-circuits off
// win32, so routing through the repo path instead would make this assert nothing on CI's
// Linux and macOS runners — the distro half of discovery keys on the root string.
{ workspaceDir: DISTRO_ROOT, nestWorkspaces: false }
)
expect(merged).toContain(FIRST)
} finally {
await rm(distroHome, { force: true, recursive: true })
}
})
})