From c8bb56aa2c67d5d22ea8bbebc66067fecad7bedc Mon Sep 17 00:00:00 2001 From: Leonardo Prado Date: Fri, 5 Jun 2026 23:56:20 -0300 Subject: [PATCH 1/2] fix(session-end): skip enqueue for empty sessions to prevent FK violation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Claude Code exits without any messages (e.g. only running /config), the SessionEnd hook still fires with a session_id, but no session row is ever written to the DB. Calling enqueue() in that case hits the FOREIGN KEY constraint on analysis_queue.session_id → sessions(id) and throws SQLITE_CONSTRAINT_FOREIGNKEY. Guard the enqueue() call with sessionExists() so we return early when the session is absent from the DB. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cli/src/commands/session-end.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/session-end.ts b/cli/src/commands/session-end.ts index 618441bb..eb5d1b39 100644 --- a/cli/src/commands/session-end.ts +++ b/cli/src/commands/session-end.ts @@ -23,6 +23,7 @@ import { fileURLToPath } from 'url'; import { getConfigDir } from '../utils/config.js'; import { syncSingleFile } from './sync.js'; import { enqueue } from '../db/queue.js'; +import { sessionExists } from '../db/read.js'; /** Resolve the CLI entry point for spawning child processes. */ const CLI_ENTRY = resolve(fileURLToPath(import.meta.url), '../../index.js'); @@ -82,7 +83,11 @@ export async function sessionEndCommand(options: SessionEndOptions = {}): Promis } } - // Phase 2: Enqueue for async analysis + // Phase 2: Enqueue for async analysis — skip if session wasn't written to DB + // (e.g. user exited without sending any messages, like running /config then quitting) + if (!sessionExists(sessionId)) { + return; + } enqueue(sessionId, native ? 'native' : 'provider'); // Phase 3: Spawn detached worker to process the queue From 78ae4456e06800c500abf13cc677ae061f04ab62 Mon Sep 17 00:00:00 2001 From: Leonardo Prado Date: Sat, 6 Jun 2026 00:18:58 -0300 Subject: [PATCH 2/2] test(session-end): add tests for empty-session FK guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the four key paths: - ghost session (no DB row) → enqueue skipped - real session (DB row exists) → enqueue called - missing session_id → early exit before sessionExists check - CODE_INSIGHTS_HOOK_ACTIVE guard → early exit before sessionExists check Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../commands/__tests__/session-end.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 cli/src/commands/__tests__/session-end.test.ts diff --git a/cli/src/commands/__tests__/session-end.test.ts b/cli/src/commands/__tests__/session-end.test.ts new file mode 100644 index 00000000..e0a7d284 --- /dev/null +++ b/cli/src/commands/__tests__/session-end.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mocks must be declared before the module under test is imported. +vi.mock('child_process', () => ({ spawn: vi.fn(() => ({ unref: vi.fn() })) })); +vi.mock('fs', () => ({ + openSync: vi.fn(() => 3), + closeSync: vi.fn(), + mkdirSync: vi.fn(), + existsSync: vi.fn(() => true), +})); +vi.mock('url', () => ({ fileURLToPath: vi.fn(() => '/fake/path/session-end.js') })); +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { ...actual, resolve: vi.fn(() => '/fake/index.js') }; +}); + +const mockGetConfigDir = vi.fn(() => '/tmp/code-insights'); +vi.mock('../../utils/config.js', () => ({ getConfigDir: mockGetConfigDir })); + +const mockSyncSingleFile = vi.fn(); +vi.mock('../sync.js', () => ({ syncSingleFile: mockSyncSingleFile })); + +const mockEnqueue = vi.fn(); +vi.mock('../../db/queue.js', () => ({ enqueue: mockEnqueue })); + +const mockSessionExists = vi.fn(); +vi.mock('../../db/read.js', () => ({ sessionExists: mockSessionExists })); + +const { sessionEndCommand } = await import('../session-end.js'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeStdin(payload: Record): void { + const data = JSON.stringify(payload); + Object.defineProperty(process, 'stdin', { + value: { + isTTY: false, + setEncoding: vi.fn(), + on: vi.fn((event: string, cb: (chunk?: string) => void) => { + if (event === 'data') cb(data); + if (event === 'end') cb(); + }), + }, + writable: true, + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('sessionEndCommand — empty session guard', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.CODE_INSIGHTS_HOOK_ACTIVE; + }); + + it('does NOT call enqueue when session is absent from the DB', async () => { + mockSessionExists.mockReturnValue(false); + makeStdin({ session_id: 'ghost-session', transcript_path: null }); + + await sessionEndCommand({ quiet: true }); + + expect(mockSessionExists).toHaveBeenCalledWith('ghost-session'); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + + it('calls enqueue when session exists in the DB', async () => { + mockSessionExists.mockReturnValue(true); + makeStdin({ session_id: 'real-session', transcript_path: null }); + + await sessionEndCommand({ quiet: true, native: true }); + + expect(mockSessionExists).toHaveBeenCalledWith('real-session'); + expect(mockEnqueue).toHaveBeenCalledWith('real-session', 'native'); + }); + + it('returns early without calling sessionExists when session_id is missing', async () => { + makeStdin({}); + + await sessionEndCommand({ quiet: true }); + + expect(mockSessionExists).not.toHaveBeenCalled(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + + it('returns early without calling sessionExists when CODE_INSIGHTS_HOOK_ACTIVE is set', async () => { + process.env.CODE_INSIGHTS_HOOK_ACTIVE = '1'; + makeStdin({ session_id: 'any-session' }); + + await sessionEndCommand({ quiet: true }); + + expect(mockSessionExists).not.toHaveBeenCalled(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); +});