diff --git a/src/main/ai-vault/session-scanner-codex-title-index.ts b/src/main/ai-vault/session-scanner-codex-title-index.ts index 635a215562a..8f72286c545 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.ts @@ -1,5 +1,6 @@ import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' @@ -18,9 +19,14 @@ type CodexSessionIndexTitleCacheEntry = { } const codexSessionIndexTitleCache = new Map>() +const inFlightTitleReads = new InFlightPromiseDedupe>( + 0, + CODEX_SESSION_INDEX_TITLE_CACHE_MAX +) export function resetCodexSessionIndexTitleCacheForTests(): void { codexSessionIndexTitleCache.clear() + inFlightTitleReads.clear() } export function _getCodexSessionIndexTitleCacheSizeForTest(): number { @@ -88,6 +94,22 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise + loadCodexSessionIndexTitles(codexHome, indexPath, signature) + ) +} + +async function loadCodexSessionIndexTitles( + codexHome: string, + indexPath: string, + signature: string +): Promise> { + const cachedTitles = await readCachedCodexSessionIndexTitles(codexHome, signature) + if (cachedTitles) { + return cachedTitles + } + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then(({ titles, refused }) => { // Why: a gate refusal is a stalled distro, not a title-less index — caching // it would pin "no titles" until the index's size or mtime changes. diff --git a/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts index 94d544121f2..96fafb28865 100644 --- a/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts +++ b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts @@ -86,6 +86,87 @@ afterEach(async () => { }) describe('memoized WSL session indexes under a stalled mount', () => { + it('shares one cold Codex index read across a concurrent parser batch', async () => { + const handles: ReturnType[] = [] + mocks.open.mockImplementation(async () => { + const handle = servingHandle( + `${JSON.stringify({ id: SESSION_ID, thread_name: 'Shared title' })}\n` + ) + handles.push(handle) + return handle + }) + + const titles = await Promise.all( + Array.from({ length: 8 }, () => + readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID) + ) + ) + + expect(titles).toEqual(Array.from({ length: 8 }, () => 'Shared title')) + expect(mocks.stat).toHaveBeenCalledTimes(1) + expect(mocks.open).toHaveBeenCalledTimes(1) + expect(handles).toHaveLength(1) + expect(handles[0].close).toHaveBeenCalledTimes(1) + }) + + it('keeps concurrent Codex homes separate even when their file signatures match', async () => { + const otherHome = `${CODEX_HOME}-other` + mocks.open.mockImplementation(async (path: string) => + servingHandle( + `${JSON.stringify({ + id: SESSION_ID, + thread_name: path.includes('-other') ? 'Other home' : 'First home' + })}\n` + ) + ) + + const titles = await Promise.all( + [CODEX_HOME, otherHome].flatMap((home) => + Array.from({ length: 8 }, () => + readCodexSessionIndexTitle(CODEX_SESSION_FILE, home, SESSION_ID) + ) + ) + ) + + expect(titles).toEqual([ + ...Array.from({ length: 8 }, () => 'First home'), + ...Array.from({ length: 8 }, () => 'Other home') + ]) + expect(mocks.open).toHaveBeenCalledTimes(2) + }) + + it('shares one changed Codex index read and preserves later title updates', async () => { + mocks.open.mockImplementation(async () => + servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Original title' })}\n`) + ) + expect(await readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)).toBe( + 'Original title' + ) + mocks.open.mockClear() + mocks.stat.mockResolvedValue({ ...INDEX_STATS, mtimeMs: 8 }) + mocks.open.mockImplementation(async () => + servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Updated title' })}\n`) + ) + + const titles = await Promise.all( + Array.from({ length: 8 }, () => + readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID) + ) + ) + + expect(titles).toEqual(Array.from({ length: 8 }, () => 'Updated title')) + expect(mocks.open).toHaveBeenCalledTimes(1) + + mocks.stat.mockResolvedValue({ ...INDEX_STATS, mtimeMs: 9 }) + mocks.open.mockImplementation(async () => + servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Latest title' })}\n`) + ) + expect(await readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)).toBe( + 'Latest title' + ) + expect(mocks.open).toHaveBeenCalledTimes(2) + }) + it('evicts the Codex title index instead of pinning "no titles"', async () => { mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) }) const refused = readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)