From 288117f948bcba40e4d3e5d31c2cb787ecb59c38 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 9 Jul 2026 14:09:00 -0700 Subject: [PATCH] Guard shared AI Vault inflight cleanup against concurrent key replacement The extracted cache module's .finally() cleared inflight tracking unconditionally, dropping the if (inflightKey === key) guard its sibling outer cache kept: an older scan resolving after a different-key scan replaced the tracking would null the newer scan's dedup slot, so a re-request started a duplicate transcript rescan. Mirrors the sibling guard; the regression test flushes a macrotask so a reverted guard fails fast on the call count instead of hanging. Co-authored-by: Orca --- src/main/ai-vault/cached-session-list.ts | 8 ++++-- src/main/runtime/rpc/methods/ai-vault.test.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 5eeb71995e3..5f5fc135819 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -70,8 +70,12 @@ export async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - inflightKey = null - inflightList = null + // Only clear tracking if it still refers to this request: a concurrent + // different-key scan may have replaced it and must stay dedupable. + if (inflightKey === key) { + inflightKey = null + inflightList = null + } }) return inflightList } diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 8fa5795aeed..b136c231d0d 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -129,6 +129,33 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) }) + it('keeps a newer different-key scan dedupable after an older scan resolves', async () => { + // Why: the resolving scan's cleanup must not clear tracking a concurrent + // different-key scan replaced, or re-requests start a duplicate rescan. + const deferreds: ((result: AiVaultListResult) => void)[] = [] + scanAiVaultSessions.mockImplementation( + () => new Promise((resolve) => deferreds.push(resolve)) + ) + // The scanner is invoked a microtask after the call (WSL-home await), so + // wait for each deferred to register before driving resolution order. + const scanA = listAiVaultSessions({ limit: 100 }) + await vi.waitFor(() => expect(deferreds).toHaveLength(1)) + const scanB = listAiVaultSessions({ limit: 200 }) + await vi.waitFor(() => expect(deferreds).toHaveLength(2)) + deferreds[0]?.(makeResult()) + await scanA + // Re-request key B while its first scan is still pending: must dedupe. + const scanBAgain = listAiVaultSessions({ limit: 200 }) + // Flush a macrotask so that a wrongly-started duplicate scan would reach the + // mock (it fires a microtask after the WSL-home await): guarded stays 2, a + // reverted guard reads 3, so this assertion — not a Promise.all hang — pins + // the fix. + await new Promise((resolve) => setTimeout(resolve)) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + deferreds[1]?.(makeResult()) + await Promise.all([scanB, scanBAgain]) + }) + it('restamps the shared cached result as the addressed runtime host', async () => { scanAiVaultSessions.mockResolvedValue({ sessions: [makeSession()],