fix(qa): retry transient FTS settle races

This commit is contained in:
Peter Steinberger
2026-07-16 18:11:17 +01:00
parent e71ef76e8e
commit a726bd7340
2 changed files with 86 additions and 6 deletions

View File

@@ -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";

View File

@@ -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<string, number>;
@@ -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<QaSuiteRuntimeEnv, "gateway">) {
return Object.fromEntries(
listSessionEntries({ agentId: "qa", env: qaSessionRuntimeEnv(env.gateway.tempRoot) }).map(
({ sessionKey, entry }) => [sessionKey, entry as QaRawSessionStoreEntry],
),
);
async function readRawQaSessionStore(
env: Pick<QaSuiteRuntimeEnv, "gateway">,
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(