From c94e8fc440691b6cd9e6c7f7a6d89e265ef7ebe2 Mon Sep 17 00:00:00 2001 From: Mert Koseoglu Date: Sun, 19 Jul 2026 17:36:55 +0300 Subject: [PATCH 1/3] fix(forward): suppress platform forwards for 24h after HTTP 402 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A churned org's bridge kept firing a POST on every single event forever — the 402 ("Subscription required") response fell through the status handling (only 401 and 429 were handled) and the fire-and-forget caller never backed off. Now a 402 persists `suppressed_until` (epoch ms, now + 24h) into platform.json — the same config file the bridge already owns — and prints one concise stderr note. maybeForward gates on the marker BEFORE any network call; an expired marker is cleared and forwarding resumes automatically, and any 2xx response clears a lingering marker for immediate resume after reactivation. Marker survives restarts and is shared across concurrent sessions via the config file; in-flight 402 bursts dedupe to a single write and note. Co-Authored-By: Claude Fable 5 --- hooks/platform-bridge.mjs | 61 ++++++++++- tests/hooks/platform-bridge-wire.test.ts | 134 ++++++++++++++++++++++- 2 files changed, 192 insertions(+), 3 deletions(-) diff --git a/hooks/platform-bridge.mjs b/hooks/platform-bridge.mjs index 416fa395b..41bf02793 100644 --- a/hooks/platform-bridge.mjs +++ b/hooks/platform-bridge.mjs @@ -11,6 +11,7 @@ import { execSync } from "node:child_process"; const CACHE_TTL_MS = 60_000; const FETCH_TIMEOUT_MS = 2_000; +const SUPPRESS_TTL_MS = 24 * 60 * 60 * 1000; // 402 back-off window const MAX_FIELD_LEN = 200; const MAX_DEPTH = 4; @@ -54,7 +55,35 @@ function normalizeConfig(raw) { if (!platform_url && raw.events_url) platform_url = String(raw.events_url).replace(/\/events$/, ""); if (typeof api_key !== "string" || !api_key.startsWith("ctxm_")) return null; if (typeof platform_url !== "string" || !platform_url) return null; - return { api_key, platform_url: platform_url.replace(/\/$/, "") }; + const cfg = { api_key, platform_url: platform_url.replace(/\/$/, "") }; + // 402 suppress marker (churned-org back-off) rides the same file so it + // survives restarts and is shared across concurrent sessions. + if (typeof raw.suppressed_until === "number" && Number.isFinite(raw.suppressed_until)) { + cfg.suppressed_until = raw.suppressed_until; + } + return cfg; +} + +// === 402 suppress marker (churned-org back-off) === +// A 402 ("Subscription required") means the org churned — hammering the +// platform on every event forever is pure waste. Persist `suppressed_until` +// (epoch ms) INTO platform.json itself: same file, same read/write ownership, +// no sibling cache to invent. null → clear the marker. +function persistSuppressMarker(untilMs) { + // In-memory first — suppression must hold within this process even if the + // file write fails (read-only FS, concurrent uninstall). + if (_cache && _cache !== NO_CONFIG) { + if (untilMs != null) _cache.suppressed_until = untilMs; + else delete _cache.suppressed_until; + } + const cfgPath = configPath(); + try { + const raw = JSON.parse(fs.readFileSync(cfgPath, "utf8")); + if (untilMs != null) raw.suppressed_until = untilMs; + else if (raw.suppressed_until === undefined) return; // nothing to clear — skip the write + else delete raw.suppressed_until; + fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n"); + } catch { /* unreadable/unwritable — in-memory state already updated */ } } function readConfig() { @@ -269,6 +298,15 @@ export async function maybeForward(event, platform, opts = {}) { const cfg = readConfig(); if (!cfg) return; + // 402 suppression gate — BEFORE any allocation or network call. Active + // marker → the org's subscription is inactive; stay silent for 24h. + // Expired marker → clear it and proceed, so reactivated orgs resume + // automatically within 24h even without a fresh login. + if (typeof cfg.suppressed_until === "number") { + if (cfg.suppressed_until > Date.now()) return; + persistSuppressMarker(null); + } + // Project identity must be resolved from the RAW projectDir — the resolver // reads `git config` against the actual filesystem path. After sanitize, // $HOME-normalization would break the lookup. We overlay the resolved id @@ -306,7 +344,26 @@ export async function maybeForward(event, platform, opts = {}) { }), signal: ctrl.signal, }); - if (res.status === 401) { _cache = null; _cacheLoadedAt = 0; } + if (res.ok) { + // Reactivation: a successful forward clears any lingering suppress + // marker (e.g. written by a concurrent session) — immediate resume. + if (_cache !== null && _cache !== NO_CONFIG && _cache.suppressed_until !== undefined) { + persistSuppressMarker(null); + } + } + else if (res.status === 401) { _cache = null; _cacheLoadedAt = 0; } + else if (res.status === 402) { + // Subscription inactive (churned org) — pause ALL forwards for 24h. + // Dedupe: a burst of in-flight events all passed the gate before the + // first 402 landed; only the first response writes + notes. + const alreadySuppressed = _cache !== null && _cache !== NO_CONFIG + && typeof _cache.suppressed_until === "number" + && _cache.suppressed_until > Date.now(); + if (!alreadySuppressed) { + persistSuppressMarker(Date.now() + SUPPRESS_TTL_MS); + process.stderr.write("context-mode: platform subscription inactive — forwards paused for 24h\n"); + } + } else if (res.status === 429) { process.stderr.write(`[context-mode-platform] rate limited (retry after ${res.headers.get("Retry-After")}s)\n`); } diff --git a/tests/hooks/platform-bridge-wire.test.ts b/tests/hooks/platform-bridge-wire.test.ts index c969be5d0..3e0fc4043 100644 --- a/tests/hooks/platform-bridge-wire.test.ts +++ b/tests/hooks/platform-bridge-wire.test.ts @@ -11,7 +11,7 @@ */ import { describe, test, beforeEach, afterEach, expect, vi } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; @@ -613,3 +613,135 @@ describe("platform-bridge — project identity resolution", () => { }); }); }); + +// ───────────────────────────────────────────────────────── +// 402 subscription suppression — churned orgs must not be +// hammered with a forward on every event forever. A 402 +// persists `suppressed_until` (epoch ms) into platform.json +// and pauses ALL forwards for 24h; expiry or a 2xx resumes. +// ───────────────────────────────────────────────────────── +describe("platform-bridge — 402 subscription suppression", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const config = { + api_key: "ctxm_suppress_test", + platform_url: "https://example.test/api/v1", + }; + const event = { type: "tool_use", category: "edit", data: "x" }; + + let fakeHome: string; + let origHome: string | undefined; + let origXdg: string | undefined; + let origAppData: string | undefined; + let fetchSpy: ReturnType; + + beforeEach(() => { + fakeHome = mkdtempSync(join(tmpdir(), "ctx-bridge-402-")); + origHome = process.env.HOME; + origXdg = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.HOME = fakeHome; + delete process.env.XDG_CONFIG_HOME; + process.env.APPDATA = join(fakeHome, "AppData", "Roaming"); + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 200 }), + ); + }); + + afterEach(() => { + if (origHome !== undefined) process.env.HOME = origHome; + else delete process.env.HOME; + if (origXdg !== undefined) process.env.XDG_CONFIG_HOME = origXdg; + else delete process.env.XDG_CONFIG_HOME; + if (origAppData !== undefined) process.env.APPDATA = origAppData; + else delete process.env.APPDATA; + try { rmSync(fakeHome, { recursive: true, force: true }); } catch {} + vi.resetModules(); + vi.restoreAllMocks(); + }); + + test("402 writes suppressed_until (~now+24h) and pauses subsequent forwards — no fetch", async () => { + const cfgFile = writePlatformConfig(fakeHome, config); + fetchSpy.mockResolvedValue(new Response("Subscription required", { status: 402 })); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + const { bridge } = await importFresh(); + bridge._internal.resetState(); + + const before = Date.now(); + const res = await bridge.maybeForward(event, "claude-code"); + expect(res).toEqual({ ok: false, status: 402 }); + + // Marker persisted in the SAME config file the bridge already owns. + const persisted = JSON.parse(readFileSync(cfgFile, "utf8")); + expect(typeof persisted.suppressed_until).toBe("number"); + expect(persisted.suppressed_until).toBeGreaterThanOrEqual(before + DAY_MS); + expect(persisted.suppressed_until).toBeLessThanOrEqual(Date.now() + DAY_MS); + + // One concise operator note. + const pauseNotes = stderrSpy.mock.calls.filter((c) => + String(c[0]).includes("forwards paused for 24h")); + expect(pauseNotes.length).toBe(1); + + // Subsequent forward: early return BEFORE any network call. + fetchSpy.mockClear(); + const res2 = await bridge.maybeForward(event, "claude-code"); + expect(res2).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("active marker survives process restart (fresh import) — still no fetch", async () => { + writePlatformConfig(fakeHome, { + ...config, + suppressed_until: Date.now() + 60 * 60 * 1000, + }); + + const { bridge } = await importFresh(); + bridge._internal.resetState(); + + await bridge.maybeForward(event, "claude-code"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("expired marker: forward proceeds, 2xx clears the marker, forwarding resumes", async () => { + const cfgFile = writePlatformConfig(fakeHome, { + ...config, + suppressed_until: Date.now() - 1000, // reactivated org, stale pause + }); + + const { bridge } = await importFresh(); + bridge._internal.resetState(); + + const res = await bridge.maybeForward(event, "claude-code"); + expect(res).toEqual({ ok: true, status: 200 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Marker cleared from the config file — immediate durable resume. + const persisted = JSON.parse(readFileSync(cfgFile, "utf8")); + expect(persisted).not.toHaveProperty("suppressed_until"); + expect(persisted.api_key).toBe(config.api_key); // rest of config untouched + + await bridge.maybeForward(event, "claude-code"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + test("burst of in-flight 402s: marker written once, ONE stderr note total", async () => { + writePlatformConfig(fakeHome, config); + fetchSpy.mockResolvedValue(new Response("Subscription required", { status: 402 })); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + const { bridge } = await importFresh(); + bridge._internal.resetState(); + + // Fire-and-forget loop shape: all events pass the gate before the first + // 402 response lands. Dedupe must collapse the notes to exactly one. + await Promise.all([ + bridge.maybeForward(event, "claude-code"), + bridge.maybeForward(event, "claude-code"), + bridge.maybeForward(event, "claude-code"), + ]); + + const pauseNotes = stderrSpy.mock.calls.filter((c) => + String(c[0]).includes("forwards paused for 24h")); + expect(pauseNotes.length).toBe(1); + }); +}); From e1d9448050d55f89b5ef9e15f0836d23bb1fe025 Mon Sep 17 00:00:00 2001 From: Mert Koseoglu Date: Sun, 19 Jul 2026 17:38:45 +0300 Subject: [PATCH 2/3] Revert "fix(forward): suppress platform forwards for 24h after HTTP 402" Billing enforcement belongs to the platform side only. The server already rejects churned-org forwards with 402 before any handler or D1 write runs; the OSS bridge must stay billing-agnostic. This reverts commit c94e8fc4. Co-Authored-By: Claude Fable 5 --- hooks/platform-bridge.mjs | 61 +---------- tests/hooks/platform-bridge-wire.test.ts | 134 +---------------------- 2 files changed, 3 insertions(+), 192 deletions(-) diff --git a/hooks/platform-bridge.mjs b/hooks/platform-bridge.mjs index 41bf02793..416fa395b 100644 --- a/hooks/platform-bridge.mjs +++ b/hooks/platform-bridge.mjs @@ -11,7 +11,6 @@ import { execSync } from "node:child_process"; const CACHE_TTL_MS = 60_000; const FETCH_TIMEOUT_MS = 2_000; -const SUPPRESS_TTL_MS = 24 * 60 * 60 * 1000; // 402 back-off window const MAX_FIELD_LEN = 200; const MAX_DEPTH = 4; @@ -55,35 +54,7 @@ function normalizeConfig(raw) { if (!platform_url && raw.events_url) platform_url = String(raw.events_url).replace(/\/events$/, ""); if (typeof api_key !== "string" || !api_key.startsWith("ctxm_")) return null; if (typeof platform_url !== "string" || !platform_url) return null; - const cfg = { api_key, platform_url: platform_url.replace(/\/$/, "") }; - // 402 suppress marker (churned-org back-off) rides the same file so it - // survives restarts and is shared across concurrent sessions. - if (typeof raw.suppressed_until === "number" && Number.isFinite(raw.suppressed_until)) { - cfg.suppressed_until = raw.suppressed_until; - } - return cfg; -} - -// === 402 suppress marker (churned-org back-off) === -// A 402 ("Subscription required") means the org churned — hammering the -// platform on every event forever is pure waste. Persist `suppressed_until` -// (epoch ms) INTO platform.json itself: same file, same read/write ownership, -// no sibling cache to invent. null → clear the marker. -function persistSuppressMarker(untilMs) { - // In-memory first — suppression must hold within this process even if the - // file write fails (read-only FS, concurrent uninstall). - if (_cache && _cache !== NO_CONFIG) { - if (untilMs != null) _cache.suppressed_until = untilMs; - else delete _cache.suppressed_until; - } - const cfgPath = configPath(); - try { - const raw = JSON.parse(fs.readFileSync(cfgPath, "utf8")); - if (untilMs != null) raw.suppressed_until = untilMs; - else if (raw.suppressed_until === undefined) return; // nothing to clear — skip the write - else delete raw.suppressed_until; - fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n"); - } catch { /* unreadable/unwritable — in-memory state already updated */ } + return { api_key, platform_url: platform_url.replace(/\/$/, "") }; } function readConfig() { @@ -298,15 +269,6 @@ export async function maybeForward(event, platform, opts = {}) { const cfg = readConfig(); if (!cfg) return; - // 402 suppression gate — BEFORE any allocation or network call. Active - // marker → the org's subscription is inactive; stay silent for 24h. - // Expired marker → clear it and proceed, so reactivated orgs resume - // automatically within 24h even without a fresh login. - if (typeof cfg.suppressed_until === "number") { - if (cfg.suppressed_until > Date.now()) return; - persistSuppressMarker(null); - } - // Project identity must be resolved from the RAW projectDir — the resolver // reads `git config` against the actual filesystem path. After sanitize, // $HOME-normalization would break the lookup. We overlay the resolved id @@ -344,26 +306,7 @@ export async function maybeForward(event, platform, opts = {}) { }), signal: ctrl.signal, }); - if (res.ok) { - // Reactivation: a successful forward clears any lingering suppress - // marker (e.g. written by a concurrent session) — immediate resume. - if (_cache !== null && _cache !== NO_CONFIG && _cache.suppressed_until !== undefined) { - persistSuppressMarker(null); - } - } - else if (res.status === 401) { _cache = null; _cacheLoadedAt = 0; } - else if (res.status === 402) { - // Subscription inactive (churned org) — pause ALL forwards for 24h. - // Dedupe: a burst of in-flight events all passed the gate before the - // first 402 landed; only the first response writes + notes. - const alreadySuppressed = _cache !== null && _cache !== NO_CONFIG - && typeof _cache.suppressed_until === "number" - && _cache.suppressed_until > Date.now(); - if (!alreadySuppressed) { - persistSuppressMarker(Date.now() + SUPPRESS_TTL_MS); - process.stderr.write("context-mode: platform subscription inactive — forwards paused for 24h\n"); - } - } + if (res.status === 401) { _cache = null; _cacheLoadedAt = 0; } else if (res.status === 429) { process.stderr.write(`[context-mode-platform] rate limited (retry after ${res.headers.get("Retry-After")}s)\n`); } diff --git a/tests/hooks/platform-bridge-wire.test.ts b/tests/hooks/platform-bridge-wire.test.ts index 3e0fc4043..c969be5d0 100644 --- a/tests/hooks/platform-bridge-wire.test.ts +++ b/tests/hooks/platform-bridge-wire.test.ts @@ -11,7 +11,7 @@ */ import { describe, test, beforeEach, afterEach, expect, vi } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; @@ -613,135 +613,3 @@ describe("platform-bridge — project identity resolution", () => { }); }); }); - -// ───────────────────────────────────────────────────────── -// 402 subscription suppression — churned orgs must not be -// hammered with a forward on every event forever. A 402 -// persists `suppressed_until` (epoch ms) into platform.json -// and pauses ALL forwards for 24h; expiry or a 2xx resumes. -// ───────────────────────────────────────────────────────── -describe("platform-bridge — 402 subscription suppression", () => { - const DAY_MS = 24 * 60 * 60 * 1000; - const config = { - api_key: "ctxm_suppress_test", - platform_url: "https://example.test/api/v1", - }; - const event = { type: "tool_use", category: "edit", data: "x" }; - - let fakeHome: string; - let origHome: string | undefined; - let origXdg: string | undefined; - let origAppData: string | undefined; - let fetchSpy: ReturnType; - - beforeEach(() => { - fakeHome = mkdtempSync(join(tmpdir(), "ctx-bridge-402-")); - origHome = process.env.HOME; - origXdg = process.env.XDG_CONFIG_HOME; - origAppData = process.env.APPDATA; - process.env.HOME = fakeHome; - delete process.env.XDG_CONFIG_HOME; - process.env.APPDATA = join(fakeHome, "AppData", "Roaming"); - fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(null, { status: 200 }), - ); - }); - - afterEach(() => { - if (origHome !== undefined) process.env.HOME = origHome; - else delete process.env.HOME; - if (origXdg !== undefined) process.env.XDG_CONFIG_HOME = origXdg; - else delete process.env.XDG_CONFIG_HOME; - if (origAppData !== undefined) process.env.APPDATA = origAppData; - else delete process.env.APPDATA; - try { rmSync(fakeHome, { recursive: true, force: true }); } catch {} - vi.resetModules(); - vi.restoreAllMocks(); - }); - - test("402 writes suppressed_until (~now+24h) and pauses subsequent forwards — no fetch", async () => { - const cfgFile = writePlatformConfig(fakeHome, config); - fetchSpy.mockResolvedValue(new Response("Subscription required", { status: 402 })); - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - - const { bridge } = await importFresh(); - bridge._internal.resetState(); - - const before = Date.now(); - const res = await bridge.maybeForward(event, "claude-code"); - expect(res).toEqual({ ok: false, status: 402 }); - - // Marker persisted in the SAME config file the bridge already owns. - const persisted = JSON.parse(readFileSync(cfgFile, "utf8")); - expect(typeof persisted.suppressed_until).toBe("number"); - expect(persisted.suppressed_until).toBeGreaterThanOrEqual(before + DAY_MS); - expect(persisted.suppressed_until).toBeLessThanOrEqual(Date.now() + DAY_MS); - - // One concise operator note. - const pauseNotes = stderrSpy.mock.calls.filter((c) => - String(c[0]).includes("forwards paused for 24h")); - expect(pauseNotes.length).toBe(1); - - // Subsequent forward: early return BEFORE any network call. - fetchSpy.mockClear(); - const res2 = await bridge.maybeForward(event, "claude-code"); - expect(res2).toBeUndefined(); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - test("active marker survives process restart (fresh import) — still no fetch", async () => { - writePlatformConfig(fakeHome, { - ...config, - suppressed_until: Date.now() + 60 * 60 * 1000, - }); - - const { bridge } = await importFresh(); - bridge._internal.resetState(); - - await bridge.maybeForward(event, "claude-code"); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - test("expired marker: forward proceeds, 2xx clears the marker, forwarding resumes", async () => { - const cfgFile = writePlatformConfig(fakeHome, { - ...config, - suppressed_until: Date.now() - 1000, // reactivated org, stale pause - }); - - const { bridge } = await importFresh(); - bridge._internal.resetState(); - - const res = await bridge.maybeForward(event, "claude-code"); - expect(res).toEqual({ ok: true, status: 200 }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - // Marker cleared from the config file — immediate durable resume. - const persisted = JSON.parse(readFileSync(cfgFile, "utf8")); - expect(persisted).not.toHaveProperty("suppressed_until"); - expect(persisted.api_key).toBe(config.api_key); // rest of config untouched - - await bridge.maybeForward(event, "claude-code"); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - test("burst of in-flight 402s: marker written once, ONE stderr note total", async () => { - writePlatformConfig(fakeHome, config); - fetchSpy.mockResolvedValue(new Response("Subscription required", { status: 402 })); - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - - const { bridge } = await importFresh(); - bridge._internal.resetState(); - - // Fire-and-forget loop shape: all events pass the gate before the first - // 402 response lands. Dedupe must collapse the notes to exactly one. - await Promise.all([ - bridge.maybeForward(event, "claude-code"), - bridge.maybeForward(event, "claude-code"), - bridge.maybeForward(event, "claude-code"), - ]); - - const pauseNotes = stderrSpy.mock.calls.filter((c) => - String(c[0]).includes("forwards paused for 24h")); - expect(pauseNotes.length).toBe(1); - }); -}); From 98af4abd6f6a78b892e07f1bbcc54b4f5f4ee420 Mon Sep 17 00:00:00 2001 From: skoll43 Date: Fri, 24 Jul 2026 03:43:06 -0400 Subject: [PATCH 3/3] test: close SessionDB instances in tests to prevent Windows EPERM hang --- tests/adapters/omp-plugin.test.ts | 15 ++++++++++++- tests/core/search-project-filter.test.ts | 20 ++++++++++++++--- .../commit-message-symmetry.test.ts | 8 +++++++ .../cross-project-attribution.test.ts | 22 ++++++++++++++----- .../integration/seed-parity-coverage.test.ts | 14 ++++++++++++ 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/tests/adapters/omp-plugin.test.ts b/tests/adapters/omp-plugin.test.ts index 094490f62..7589028c1 100644 --- a/tests/adapters/omp-plugin.test.ts +++ b/tests/adapters/omp-plugin.test.ts @@ -20,6 +20,13 @@ import "../setup-home"; */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +const activeDBs: Array<{ close?: () => void }> = []; +afterEach(() => { + for (const db of activeDBs) { try { db.close?.(); } catch {} } + activeDBs.length = 0; +}); + import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -206,6 +213,7 @@ describe("OMP plugin", () => { sessionsDir: adapter.getSessionDir(), }), }); + activeDBs.push(db as any); const latest = db.getLatestSessionId(); expect(latest).not.toBeNull(); const events = db.getEvents(latest as string); @@ -252,6 +260,7 @@ describe("OMP plugin", () => { sessionsDir: adapter.getSessionDir(), }), }); + activeDBs.push(db as any); const latest = db.getLatestSessionId(); expect(latest).not.toBeNull(); }); @@ -309,6 +318,7 @@ describe("OMP plugin", () => { sessionsDir: adapter.getSessionDir(), }), }); + activeDBs.push(db as any); const resume = db.getResume(sid); expect(resume).not.toBeNull(); expect(resume?.snapshot.length).toBeGreaterThan(0); @@ -357,7 +367,10 @@ describe("OMP plugin", () => { expect(fileExists(canonicalPath)).toBe(true); // Verify the canonical file is the one with our session_start row. - const db = new SessionDB({ dbPath: canonicalPath }); + const db = new SessionDB({ dbPath: canonicalPath }); + activeDBs.push(db as any); + activeDBs.push(db as any); + activeDBs.push(db as any); try { const latest = db.getLatestSessionId(); expect(latest).not.toBeNull(); diff --git a/tests/core/search-project-filter.test.ts b/tests/core/search-project-filter.test.ts index a58498e58..c62d9617a 100644 --- a/tests/core/search-project-filter.test.ts +++ b/tests/core/search-project-filter.test.ts @@ -17,7 +17,7 @@ * null (no filter), explicit string → that string. */ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, afterEach } from "vitest"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; @@ -25,12 +25,24 @@ import { ContentStore } from "../../src/store.js"; import { SessionDB } from "../../src/session/db.js"; import { searchAllSources } from "../../src/search/unified.js"; +const activeDBs: Array<{ close?: () => void, cleanup?: () => void }> = []; + +afterEach(() => { + for (const db of activeDBs) { + try { db.cleanup?.(); } catch {} + try { db.close?.(); } catch {} + } + activeDBs.length = 0; +}); + function createStore(): ContentStore { const path = join( tmpdir(), `ctx-issue737-store-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, ); - return new ContentStore(path); + const store = new ContentStore(path); + activeDBs.push(store as any); + return store; } function createSessionDB(): SessionDB { @@ -38,7 +50,9 @@ function createSessionDB(): SessionDB { tmpdir(), `ctx-issue737-session-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, ); - return new SessionDB({ dbPath: path }); + const db = new SessionDB({ dbPath: path }); + activeDBs.push(db as any); + return db; } // ═══════════════════════════════════════════════════════════ diff --git a/tests/integration/commit-message-symmetry.test.ts b/tests/integration/commit-message-symmetry.test.ts index bc90c3b1d..568bccea0 100644 --- a/tests/integration/commit-message-symmetry.test.ts +++ b/tests/integration/commit-message-symmetry.test.ts @@ -12,6 +12,13 @@ */ import { describe, test, beforeEach, afterEach, expect, vi } from "vitest"; + +const activeDBs: Array<{ close?: () => void }> = []; +afterEach(() => { + for (const db of activeDBs) { try { db.close?.(); } catch {} } + activeDBs.length = 0; +}); + import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; @@ -78,6 +85,7 @@ describe("session-loaders — Bug 2 repro: commit_message symmetric stamp", () = test("session with 1 commit + 3 file edits — every body MUST carry commit_message alongside has_commit", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sid = "commit-msg-symmetry-" + Date.now(); db.ensureSession(sid, fakeHome); diff --git a/tests/integration/cross-project-attribution.test.ts b/tests/integration/cross-project-attribution.test.ts index 95f66c897..f2d9a9e89 100644 --- a/tests/integration/cross-project-attribution.test.ts +++ b/tests/integration/cross-project-attribution.test.ts @@ -17,6 +17,13 @@ */ import { describe, test, beforeEach, afterEach, expect, vi } from "vitest"; + +const activeDBs: Array<{ close?: () => void }> = []; +afterEach(() => { + for (const db of activeDBs) { try { db.close?.(); } catch {} } + activeDBs.length = 0; +}); + import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; @@ -102,7 +109,8 @@ describe("cross-project attribution — Bug 7 repro", () => { }); test("tracer: cwd=projA but file_edit on projB/foo.ts → body.project resolves to projB's canonical id", async () => { - const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + activeDBs.push(db as any); const sid = "cross-proj-" + Date.now(); db.ensureSession(sid, projA); @@ -145,7 +153,8 @@ describe("cross-project attribution — Bug 7 repro", () => { }); test("batched events split across projA + projB → each attributes to its own repo", async () => { - const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + activeDBs.push(db as any); const sid = "cross-proj-batch-" + Date.now(); db.ensureSession(sid, projA); @@ -172,7 +181,8 @@ describe("cross-project attribution — Bug 7 repro", () => { }); test("Bug 8 — Bash 'git -C /projB status' via real extractEvents → project=projB", async () => { - const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + activeDBs.push(db as any); const sid = "bash-c-" + Date.now(); db.ensureSession(sid, projA); @@ -212,7 +222,8 @@ describe("cross-project attribution — Bug 7 repro", () => { }); test("Bug 8 — Bash 'cd /projB && npm test' → project=projB", async () => { - const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + activeDBs.push(db as any); const sid = "bash-cd-" + Date.now(); db.ensureSession(sid, projA); @@ -242,7 +253,8 @@ describe("cross-project attribution — Bug 7 repro", () => { }); test("Bash without any path indicator → falls back to cwd (projA) — expected behavior", async () => { - const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + const db = new SessionDB({ dbPath: join(fakeHome, "test.db") }); + activeDBs.push(db as any); const sid = "bash-no-path-" + Date.now(); db.ensureSession(sid, projA); diff --git a/tests/integration/seed-parity-coverage.test.ts b/tests/integration/seed-parity-coverage.test.ts index 9ca4ff011..dc4b80ab1 100644 --- a/tests/integration/seed-parity-coverage.test.ts +++ b/tests/integration/seed-parity-coverage.test.ts @@ -12,6 +12,13 @@ */ import { describe, test, beforeEach, afterEach, expect, vi } from "vitest"; + +const activeDBs: Array<{ close?: () => void }> = []; +afterEach(() => { + for (const db of activeDBs) { try { db.close?.(); } catch {} } + activeDBs.length = 0; +}); + import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; @@ -132,6 +139,7 @@ describe("seed-parity coverage gate", () => { test("every outgoing event carries all 21 universal seed-parity columns", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sessionId = "parity-session-" + Date.now(); db.ensureSession(sessionId, fakeHome); @@ -181,6 +189,7 @@ describe("seed-parity coverage gate", () => { test("variant columns populate by category", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sessionId = "parity-variants-" + Date.now(); db.ensureSession(sessionId, fakeHome); @@ -224,6 +233,7 @@ describe("seed-parity coverage gate", () => { test("rollup snapshot reflects session-wide aggregates", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sessionId = "parity-rollup-" + Date.now(); db.ensureSession(sessionId, fakeHome); @@ -263,6 +273,7 @@ describe("seed-parity coverage gate", () => { test("Bash metadata: command_type/command_tool/exit_code derived algorithmically", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sid = "bash-meta-" + Date.now(); db.ensureSession(sid, fakeHome); @@ -301,6 +312,7 @@ describe("seed-parity coverage gate", () => { test("blocker_status: derived from canonical event type, not regex", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sid = "blocker-" + Date.now(); db.ensureSession(sid, fakeHome); @@ -328,6 +340,7 @@ describe("seed-parity coverage gate", () => { test("latency_ms: read from PreToolUse marker, duration_bucket derived", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sid = "latency-" + Date.now(); db.ensureSession(sid, fakeHome); @@ -358,6 +371,7 @@ describe("seed-parity coverage gate", () => { test("variant matrix — coverage report", async () => { const db = new SessionDB({ dbPath }); + activeDBs.push(db as any); const sessionId = "parity-matrix-" + Date.now(); db.ensureSession(sessionId, fakeHome);