diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 4bebc2866d..9518758974 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -452,6 +452,12 @@ function getLegacyInlineDetail(id: string) { async function saveCallLogOperation(entry: any): Promise { try { + // Bind the DB instance up front, before any await (resolveAccountName, + // writeCallArtifactAsync). If the singleton is reset/closed while this + // operation awaits, the insert must target the instance this request + // started against — a closed handle fails into the catch below instead of + // silently writing into whatever database opened afterwards (#12780). + const db = getDbInstance(); const apiKeyContext = getCallLogApiKeyContext(); // `||` (not `??`): an empty-string apiKeyId/apiKeyName is "unattributed", // same as before this fallback existed — it must not be persisted verbatim @@ -591,7 +597,6 @@ async function saveCallLogOperation(entry: any): Promise { } } - const db = getDbInstance(); db.prepare( ` INSERT INTO call_logs ( diff --git a/tests/integration/_chatPipelineHarness.ts b/tests/integration/_chatPipelineHarness.ts index c154255914..6c69d83c07 100644 --- a/tests/integration/_chatPipelineHarness.ts +++ b/tests/integration/_chatPipelineHarness.ts @@ -285,6 +285,16 @@ export async function createChatPipelineHarness(prefix) { invalidateMemorySettingsCache(); clearSkillState(); await new Promise((resolve) => setTimeout(resolve, 20)); + // Call-log persistence is fire-and-forget and the first cold artifact-worker + // spawn can take ~2.4s, so the previous test's saves may still be in flight. + // Drain before the DB reset so they land in the DB being torn down, not in the + // next test's fresh database (#12780). + const drained = await callLogsDb.waitForCallLogSaves(10_000); + if (!drained) { + console.warn( + `[chat-pipeline-harness:${prefix}] call-log saves did not drain within 10s; resetting anyway` + ); + } core.resetDbInstance(); fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); fs.mkdirSync(testDataDir, { recursive: true }); @@ -299,6 +309,7 @@ export async function createChatPipelineHarness(prefix) { semanticCacheModule.clearCache(); clearSkillState(); resetAllCircuitBreakers(); + await callLogsDb.waitForCallLogSaves(10_000); core.resetDbInstance(); fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 3620f1ebba..03b3e9e8a1 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -16,6 +16,7 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const readCacheDb = await import("../../src/lib/db/readCache.ts"); const { getLatestCallLog, getResponsesCallLogs } = await import("./_chatPipelineCallLogs.ts"); +const { waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts"); const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts"); const { skillRegistry } = await import("../../src/lib/skills/registry.ts"); const { skillExecutor } = await import("../../src/lib/skills/executor.ts"); @@ -373,6 +374,15 @@ async function resetStorage() { readCacheDb.invalidateDbCache(); invalidateMemorySettingsCache(); await new Promise((resolve) => setTimeout(resolve, 20)); + // Call-log persistence is fire-and-forget (persistAttemptLogs → saveCallLog with + // a .catch(() => {})), and the first cold artifact-worker spawn can take ~2.4s, so + // the previous test's saves may still be in flight here. Draining before the DB + // reset keeps those rows in the DB being torn down instead of letting them land + // in the next test's fresh database (#12780). + const drained = await waitForCallLogSaves(10_000); + if (!drained) { + console.warn("[chat-pipeline] call-log saves did not drain within 10s; resetting anyway"); + } core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); @@ -663,7 +673,13 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call ); const json = (await response.json()) as any; - const callLog = await waitFor(() => getLatestCallLog()); + // Wait specifically for THIS request's Codex /v1/responses row instead of taking + // whatever the latest row happens to be: an unfiltered read can surface a row from + // a previous test that landed late in this database (#12780). + const callLog = await waitFor(async () => { + const rows = await getResponsesCallLogs(); + return rows.find((row) => row.provider === "codex") ?? null; + }); assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); diff --git a/tests/unit/provider-request-failure-pipeline.test.ts b/tests/unit/provider-request-failure-pipeline.test.ts index d27e2f361b..3f0b1a3000 100644 --- a/tests/unit/provider-request-failure-pipeline.test.ts +++ b/tests/unit/provider-request-failure-pipeline.test.ts @@ -21,7 +21,8 @@ const { clearInflight } = await import("../../open-sse/services/requestDedup.ts" const { resetAll: resetAccountSemaphores } = await import("../../open-sse/services/accountSemaphore.ts"); const { clearModelLock } = await import("../../open-sse/services/accountFallback.ts"); -const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLogs.ts"); +const { getCallLogs, getCallLogById, waitForCallLogSaves } = + await import("../../src/lib/usage/callLogs.ts"); const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); const { resetPayloadRulesConfigForTests } = await import("../../open-sse/services/payloadRules.ts"); const { CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA, CONTEXT_1M_BETA_HEADER } = @@ -56,6 +57,11 @@ async function resetStorage() { clearIdempotency(); clearInflight(); clearModelLock(); + // Call-log persistence is fire-and-forget and the first cold artifact-worker + // spawn can take ~2.4s, so this test's saves may still be in flight when the + // next test resets the DB. Drain so a late row cannot land in the next test's + // fresh database and get picked up by its waitFor(getLatestCallLog()) (#12780). + await waitForCallLogSaves(10_000); core.resetDbInstance(); // A full reset must also drop the settings read-cache. Otherwise the cached // value (e.g. call_log_pipeline_enabled=true seeded earlier) survives the DB