From a726bd73409352c3f522bcfbe6d37875e8d329d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 18:11:17 +0100 Subject: [PATCH] fix(qa): retry transient FTS settle races --- .../src/suite-runtime-agent-session.test.ts | 49 +++++++++++++++++++ .../qa-lab/src/suite-runtime-agent-session.ts | 43 +++++++++++++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/extensions/qa-lab/src/suite-runtime-agent-session.test.ts b/extensions/qa-lab/src/suite-runtime-agent-session.test.ts index 82d4d30b8c04..187c015218ca 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-session.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-session.test.ts @@ -183,6 +183,55 @@ describe("qa suite runtime agent session helpers", () => { }); }); + it("retries transient FTS integrity mismatches while child transcripts settle", async () => { + const readEntries = vi + .fn() + .mockImplementationOnce(() => { + throw new Error( + 'SQLite integrity_check failed for qa.sqlite: fts5: checksum mismatch for table "session_transcript_fts"', + ); + }) + .mockReturnValueOnce([ + { + sessionKey: "session-1", + entry: { sessionId: "session-1", updatedAt: 10 }, + }, + ]); + vi.useFakeTimers(); + + const pending = readRawQaSessionStore( + { gateway: { tempRoot: "/tmp/qa-fts-settle" } } as never, + { readEntries, retryDelaysMs: [1] }, + ); + await vi.advanceTimersByTimeAsync(1); + + await expect(pending).resolves.toEqual({ + "session-1": { sessionId: "session-1", updatedAt: 10 }, + }); + expect(readEntries).toHaveBeenCalledTimes(2); + }); + + it("fails closed when an FTS integrity mismatch does not settle", async () => { + const mismatch = new Error( + 'SQLite integrity_check failed for qa.sqlite: fts5: checksum mismatch for table "session_transcript_fts"', + ); + const readEntries = vi.fn(() => { + throw mismatch; + }); + vi.useFakeTimers(); + + const assertion = expect( + readRawQaSessionStore({ gateway: { tempRoot: "/tmp/qa-fts-persistent" } } as never, { + readEntries, + retryDelaysMs: [1], + }), + ).rejects.toThrow(mismatch.message); + await vi.runAllTimersAsync(); + + await assertion; + expect(readEntries).toHaveBeenCalledTimes(2); + }); + it("seeds QA session metadata and transcript messages in SQLite", async () => { const tempRoot = await makeTempDir("qa-session-seed-"); const sessionId = "seeded-session"; diff --git a/extensions/qa-lab/src/suite-runtime-agent-session.ts b/extensions/qa-lab/src/suite-runtime-agent-session.ts index 65796371f329..6545dbfaec74 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-session.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-session.ts @@ -43,6 +43,7 @@ type QaSessionTranscriptSeedParams = { }; const SESSION_STORE_LOCK_RETRY_DELAYS_MS = [1_000, 3_000, 5_000] as const; +const SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; type QaSessionTranscriptSummary = { assistantToolCallCounts: Record; @@ -67,6 +68,15 @@ function isSessionStoreLockTimeout(error: unknown) { ); } +function isSessionStoreFtsSettleRace(error: unknown) { + const text = formatErrorMessage(error); + return ( + text.includes("SQLite integrity_check failed") && + text.includes("fts5: checksum mismatch") && + text.includes("session_transcript_fts") + ); +} + function readSessionTranscriptEventMessage(event: unknown) { return isRecord(event) && isRecord(event.message) ? event.message : undefined; } @@ -287,12 +297,33 @@ async function seedQaSessionTranscript( } } -async function readRawQaSessionStore(env: Pick) { - return Object.fromEntries( - listSessionEntries({ agentId: "qa", env: qaSessionRuntimeEnv(env.gateway.tempRoot) }).map( - ({ sessionKey, entry }) => [sessionKey, entry as QaRawSessionStoreEntry], - ), - ); +async function readRawQaSessionStore( + env: Pick, + options: { + readEntries?: typeof listSessionEntries; + retryDelaysMs?: readonly number[]; + } = {}, +) { + const runtimeEnv = qaSessionRuntimeEnv(env.gateway.tempRoot); + const readEntries = options.readEntries ?? listSessionEntries; + const retryDelaysMs = options.retryDelaysMs ?? SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS; + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { + try { + return Object.fromEntries( + readEntries({ agentId: "qa", env: runtimeEnv }).map(({ sessionKey, entry }) => [ + sessionKey, + entry as QaRawSessionStoreEntry, + ]), + ); + } catch (error) { + if (!isSessionStoreFtsSettleRace(error) || attempt === retryDelaysMs.length) { + throw error; + } + // Child completion can publish before its transcript writer has settled the FTS state. + await sleep(retryDelaysMs[attempt]); + } + } + throw new Error("QA session store read failed after FTS settle retries"); } async function readSessionTranscriptSummary(