diff --git a/src/cli/bgRegistry.test.ts b/src/cli/bgRegistry.test.ts index b9a6069a9..f7d67b7e9 100644 --- a/src/cli/bgRegistry.test.ts +++ b/src/cli/bgRegistry.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { createHash } from 'node:crypto' import { + chmod, mkdir, mkdtemp, readFile, + readdir, rm, + stat, symlink, writeFile, } from 'node:fs/promises' @@ -44,6 +47,17 @@ const { recordBackgroundSessionNaturalTerminationSync, } = backgroundSessionRegistry +type Assert = T +type IsEqual = (() => T extends A ? 1 : 2) extends < + T, +>() => T extends B ? 1 : 2 + ? true + : false + +type _ResolveBackgroundSessionParametersAreTargetOnly = Assert< + IsEqual, [target: string]> +> + describe('background session registry', () => { let configDir: string @@ -95,6 +109,47 @@ describe('background session registry', () => { ) } + async function snapshotRegistryTree( + root: string, + relative = '', + ): Promise { + const current = join(root, relative) + const entries = await readdir(current, { withFileTypes: true }) + const snapshot: string[] = [] + for (const entry of entries.toSorted((a, b) => + a.name.localeCompare(b.name), + )) { + const entryRelative = join(relative, entry.name) + if (entry.isDirectory()) { + snapshot.push(`${entryRelative}/`) + snapshot.push(...(await snapshotRegistryTree(root, entryRelative))) + continue + } + const path = join(root, entryRelative) + const metadata = await stat(path, { bigint: true }) + snapshot.push( + `${entryRelative}:${metadata.mtimeNs}:${await readFile(path, 'utf8')}`, + ) + } + return snapshot + } + + async function resolveWithListCallCount(target: string): Promise<{ + session: BackgroundSession + listCalls: number + }> { + let listCalls = 0 + const session = + await backgroundSessionRegistry.__test.resolveBackgroundSessionWithList( + target, + async () => { + listCalls += 1 + return await listBackgroundSessions() + }, + ) + return { session, listCalls } + } + beforeEach(async () => { configDir = await mkdtemp(join(tmpdir(), 'openclaude-bg-registry-')) _setBackgroundSessionsRootForTesting(join(configDir, 'bg-sessions')) @@ -105,6 +160,10 @@ describe('background session registry', () => { await rm(configDir, { force: true, recursive: true }) }) + it('keeps the exported resolver signature free of test dependencies', () => { + expect(resolveBackgroundSession.length).toBe(1) + }) + it('creates session metadata and log files under the OpenClaude config dir', async () => { const session = await createBackgroundSession({ id: 'bg-test-1', @@ -276,7 +335,9 @@ describe('background session registry', () => { sessionId: 'conversation-2', }) - expect((await resolveBackgroundSession('bg-abc')).id).toBe('bg-named') + const { session, listCalls } = await resolveWithListCallCount('bg-abc') + expect(session.id).toBe('bg-named') + expect(listCalls).toBe(1) }) it('resolves exact session ids before exact session names', async () => { @@ -296,7 +357,206 @@ describe('background session registry', () => { sessionId: 'conversation-name', }) - expect((await resolveBackgroundSession('bg-target')).id).toBe('bg-target') + const { session, listCalls } = await resolveWithListCallCount('bg-target') + expect(session.id).toBe('bg-target') + expect(listCalls).toBe(0) + }) + + it('resolves an exact session id without listing the metadata directory', async () => { + await createBackgroundSession({ + id: 'bg-direct', + pid: 111, + cwd: '/repo', + command: ['openclaude', '--print', 'direct'], + sessionId: 'conversation-direct', + }) + const metadataDir = join(configDir, 'bg-sessions', 'sessions') + await writeFile(join(metadataDir, 'unrelated-malformed.json'), '{') + + const { session, listCalls } = await resolveWithListCallCount('bg-direct') + expect(session.id).toBe('bg-direct') + expect(listCalls).toBe(0) + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'resolves an exact session id when directory enumeration is unavailable', + async () => { + await createBackgroundSession({ + id: 'bg-direct-unlisted', + pid: 112, + cwd: '/repo', + command: ['openclaude', '--print', 'direct unlisted'], + sessionId: 'conversation-direct-unlisted', + }) + const metadataDir = join(configDir, 'bg-sessions', 'sessions') + + await chmod(metadataDir, 0o100) + try { + expect( + (await resolveBackgroundSession('bg-direct-unlisted')).id, + ).toBe('bg-direct-unlisted') + } finally { + await chmod(metadataDir, 0o700) + } + }, + ) + + it('applies natural terminal facts on the direct exact-id path', async () => { + await createBackgroundSession({ + id: 'bg-direct-natural', + pid: 333, + cwd: '/repo', + command: ['openclaude', '--print', 'direct natural'], + sessionId: 'conversation-direct-natural', + }) + await writeTerminalFact('bg-direct-natural', 'natural', { + pid: 333, + status: 'failed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 23, + terminalReason: 'exit_code', + }) + const { session, listCalls } = await resolveWithListCallCount( + 'bg-direct-natural', + ) + + expect(session).toMatchObject({ + status: 'failed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 23, + terminalReason: 'exit_code', + }) + expect(listCalls).toBe(0) + }) + + it('keeps killed facts strongest on the direct exact-id path', async () => { + await createBackgroundSession({ + id: 'bg-direct-killed', + pid: 334, + cwd: '/repo', + command: ['openclaude', '--print', 'direct killed'], + sessionId: 'conversation-direct-killed', + }) + await writeTerminalFact('bg-direct-killed', 'natural', { + pid: 334, + status: 'failed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 17, + terminalReason: 'exit_code', + }) + await writeTerminalFact('bg-direct-killed', 'killed', { + pid: 334, + status: 'killed', + finishedAt: '2026-06-15T08:05:00.000Z', + terminalReason: 'explicit_kill', + }) + const { session, listCalls } = await resolveWithListCallCount( + 'bg-direct-killed', + ) + + expect(session).toMatchObject({ + status: 'killed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 17, + terminalReason: 'explicit_kill', + }) + expect(listCalls).toBe(0) + }) + + it('falls back to an exact live name when exact-id metadata is malformed', async () => { + await createBackgroundSession({ + id: 'bg-name-owner', + name: 'bg-malformed', + pid: 335, + cwd: '/repo', + command: ['openclaude', '--print', 'name owner'], + sessionId: 'conversation-name-owner', + }) + await writeFile( + join(configDir, 'bg-sessions', 'sessions', 'bg-malformed.json'), + '{', + ) + + const { session, listCalls } = await resolveWithListCallCount( + 'bg-malformed', + ) + expect(session.id).toBe('bg-name-owner') + expect(listCalls).toBe(1) + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'falls back to an exact live name when exact-id metadata is unreadable', + async () => { + await createBackgroundSession({ + id: 'bg-unreadable', + pid: 336, + cwd: '/repo', + command: ['openclaude', '--print', 'unreadable id'], + sessionId: 'conversation-unreadable-id', + }) + await createBackgroundSession({ + id: 'bg-unreadable-name-owner', + name: 'bg-unreadable', + pid: 337, + cwd: '/repo', + command: ['openclaude', '--print', 'unreadable name owner'], + sessionId: 'conversation-unreadable-name-owner', + }) + const exactPath = join( + configDir, + 'bg-sessions', + 'sessions', + 'bg-unreadable.json', + ) + + await chmod(exactPath, 0o000) + try { + const { session, listCalls } = await resolveWithListCallCount( + 'bg-unreadable', + ) + expect(session.id).toBe('bg-unreadable-name-owner') + expect(listCalls).toBe(1) + } finally { + await chmod(exactPath, 0o600) + } + }, + ) + + it('does not interpret unsafe exact-id candidates as metadata paths', async () => { + const named = await createBackgroundSession({ + id: 'bg-safe-name-owner', + name: '../outside', + pid: 338, + cwd: '/repo', + command: ['openclaude', '--print', 'safe name owner'], + sessionId: 'conversation-safe-name-owner', + }) + await writeFile( + join(configDir, 'bg-sessions', 'outside.json'), + JSON.stringify({ ...named, id: 'outside' }), + ) + + expect((await resolveBackgroundSession('../outside')).id).toBe( + 'bg-safe-name-owner', + ) + }) + + it('does not write registry state while resolving an exact id', async () => { + await createBackgroundSession({ + id: 'bg-read-only', + pid: 339, + cwd: '/repo', + command: ['openclaude', '--print', 'read only'], + sessionId: 'conversation-read-only', + }) + const root = join(configDir, 'bg-sessions') + const before = await snapshotRegistryTree(root) + + expect((await resolveBackgroundSession('bg-read-only')).id).toBe( + 'bg-read-only', + ) + + expect(await snapshotRegistryTree(root)).toEqual(before) }) it('resolves unique session id prefixes when no exact name matches', async () => { diff --git a/src/cli/bgRegistry.ts b/src/cli/bgRegistry.ts index 7a5962e20..804b93901 100644 --- a/src/cli/bgRegistry.ts +++ b/src/cli/bgRegistry.ts @@ -2538,10 +2538,16 @@ export async function createBackgroundSession( return session } -export async function resolveBackgroundSession( +async function resolveBackgroundSessionWithList( target: string, + listSessions: () => Promise, ): Promise { - const sessions = await listBackgroundSessions() + if (SAFE_ID_RE.test(target)) { + const exact = await readSessionFile(metadataPathForId(target)) + if (exact) return await applyAuthoritativeTerminalFacts(exact) + } + + const sessions = await listSessions() const exactId = sessions.filter(s => s.id === target) if (exactId.length === 1) return exactId[0] @@ -2566,6 +2572,16 @@ export async function resolveBackgroundSession( throw new Error(`No background session found for "${target}"`) } +export async function resolveBackgroundSession( + target: string, +): Promise { + return await resolveBackgroundSessionWithList(target, listBackgroundSessions) +} + +export const __test = { + resolveBackgroundSessionWithList, +} + export async function refreshBackgroundSessionStatuses(options?: { isProcessAlive?: (pid: number) => boolean getProcessCommand?: (pid: number) => string | null