From 80a08cf13961f014cc66c28326a1d364c4defaf0 Mon Sep 17 00:00:00 2001 From: rajkumarsakthivel Date: Thu, 11 Jun 2026 15:34:59 +0100 Subject: [PATCH 1/2] fix: backfill session row on out-of-order hooks, add SQLite busy_timeout Two fixes for #49: 1. FOREIGN KEY constraint failed: UserPromptSubmit and PostToolUse crash when SessionStart was missed (cce serve started mid-session, dropped POST, resumed session_id). All hook handlers now call _ensure_session() which does INSERT OR IGNORE to backfill the FK target before dependent inserts. 2. database is locked: auto-prune contends with the hot insert path. Added PRAGMA busy_timeout = 5000 so concurrent writers wait up to 5 seconds instead of failing immediately. Closes #49 --- src/context_engine/memory/db.py | 3 ++ src/context_engine/memory/hooks.py | 26 ++++++++++++ tests/memory/test_hooks.py | 64 ++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src/context_engine/memory/db.py b/src/context_engine/memory/db.py index ed81a87..6323346 100644 --- a/src/context_engine/memory/db.py +++ b/src/context_engine/memory/db.py @@ -309,6 +309,9 @@ def connect(db_path: str | Path) -> sqlite3.Connection: # WAL gives concurrent readers (the dashboard) decent isolation while the # MCP server writes; no impact on single-process use. conn.execute("PRAGMA journal_mode = WAL") + # Give concurrent writers (hooks + auto-prune) up to 5 seconds to acquire + # the write lock instead of failing immediately with "database is locked". + conn.execute("PRAGMA busy_timeout = 5000") has_vec = _try_load_vec(conn) _ensure_schema(conn, has_vec=has_vec) return conn diff --git a/src/context_engine/memory/hooks.py b/src/context_engine/memory/hooks.py index de384ad..f834390 100644 --- a/src/context_engine/memory/hooks.py +++ b/src/context_engine/memory/hooks.py @@ -47,6 +47,24 @@ def _conn(request: web.Request) -> sqlite3.Connection: _RESUME_DECISION_REASON_CHARS = 200 +def _ensure_session(conn: sqlite3.Connection, session_id: str) -> None: + """Ensure a sessions row exists for the given session_id. + + Hooks can arrive out of order (UserPromptSubmit before SessionStart) + when cce serve starts mid-session or SessionStart is dropped. Without + this, the FOREIGN KEY constraint on prompts/tool_events crashes every + subsequent insert for that session. INSERT OR IGNORE is a no-op if the + row already exists. + """ + epoch = _now_epoch() + conn.execute( + "INSERT OR IGNORE INTO sessions " + "(id, project, started_at_epoch, started_at, status) " + "VALUES (?, '', ?, ?, 'active')", + (session_id, epoch, _now_iso(epoch)), + ) + + def _build_savings_line(conn: sqlite3.Connection) -> str: """One-line savings summary from the savings_log table. @@ -213,6 +231,8 @@ async def handle_user_prompt_submit(request: web.Request) -> web.Response: conn = _conn(request) try: + _ensure_session(conn, session_id) + if prompt_number is None: row = conn.execute( "SELECT COALESCE(MAX(prompt_number), 0) + 1 AS next " @@ -264,6 +284,8 @@ async def handle_post_tool_use(request: web.Request) -> web.Response: conn = _conn(request) try: + _ensure_session(conn, session_id) + if prompt_number is None: row = conn.execute( "SELECT COALESCE(MAX(prompt_number), 0) AS cur FROM prompts " @@ -301,6 +323,8 @@ async def handle_stop(request: web.Request) -> web.Response: conn = _conn(request) try: + _ensure_session(conn, session_id) + if prompt_number is None: row = conn.execute( "SELECT COALESCE(MAX(prompt_number), 0) AS cur FROM prompts " @@ -329,6 +353,8 @@ async def handle_session_end(request: web.Request) -> web.Response: conn = _conn(request) try: + _ensure_session(conn, session_id) + epoch = _now_epoch() conn.execute( "UPDATE sessions SET status = 'completed', exit_reason = ?, " diff --git a/tests/memory/test_hooks.py b/tests/memory/test_hooks.py index cb10ac7..eb68f65 100644 --- a/tests/memory/test_hooks.py +++ b/tests/memory/test_hooks.py @@ -418,3 +418,67 @@ async def test_session_start_no_savings_no_line(hook_app, aiohttp_client): ) text = await resp.text() assert "CCE saved" not in text + + +# ── Out-of-order hooks (SessionStart missed) ──────────────────────────── + + +async def test_prompt_without_session_start_backfills(hook_app, aiohttp_client): + """UserPromptSubmit before SessionStart should backfill the sessions row + instead of crashing with FOREIGN KEY constraint failed.""" + app, conn = hook_app + client = await aiohttp_client(app) + # No SessionStart — go straight to prompt + resp = await client.post( + "/hooks/UserPromptSubmit", + json={"session_id": "orphan", "prompt_text": "hi"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["ok"] is True + # Session row was backfilled + row = conn.execute("SELECT id, status FROM sessions WHERE id = ?", ("orphan",)).fetchone() + assert row is not None + assert row["status"] == "active" + + +async def test_tool_use_without_session_start_backfills(hook_app, aiohttp_client): + """PostToolUse before SessionStart should backfill the sessions row.""" + app, conn = hook_app + client = await aiohttp_client(app) + resp = await client.post( + "/hooks/PostToolUse", + json={ + "session_id": "orphan2", + "tool_name": "Bash", + "tool_input": "ls", + "tool_output": "file.py", + }, + ) + assert resp.status == 200 + data = await resp.json() + assert data["ok"] is True + row = conn.execute("SELECT id FROM sessions WHERE id = ?", ("orphan2",)).fetchone() + assert row is not None + + +async def test_stop_without_session_start_does_not_crash(hook_app, aiohttp_client): + """Stop before SessionStart should not crash.""" + app, conn = hook_app + client = await aiohttp_client(app) + resp = await client.post( + "/hooks/Stop", + json={"session_id": "orphan3"}, + ) + assert resp.status == 200 + + +async def test_session_end_without_session_start_does_not_crash(hook_app, aiohttp_client): + """SessionEnd before SessionStart should not crash.""" + app, conn = hook_app + client = await aiohttp_client(app) + resp = await client.post( + "/hooks/SessionEnd", + json={"session_id": "orphan4", "exit_reason": "normal"}, + ) + assert resp.status == 200 From da90ed8057b13774cbf20dbb697393b3cd9693b8 Mon Sep 17 00:00:00 2001 From: rajkumarsakthivel Date: Mon, 15 Jun 2026 19:02:00 +0100 Subject: [PATCH 2/2] fix: pass project name to backfilled sessions, clarify busy_timeout - _ensure_session now accepts project param so backfilled rows have the correct project name for dashboard display - All call sites pass request.app["project_name"] - Tests assert backfilled rows include project name - Clarify busy_timeout comment (SQLite PRAGMA vs Python connect timeout) --- src/context_engine/memory/db.py | 6 ++++-- src/context_engine/memory/hooks.py | 16 +++++++++------- tests/memory/test_hooks.py | 8 +++++--- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/context_engine/memory/db.py b/src/context_engine/memory/db.py index 6323346..8da332e 100644 --- a/src/context_engine/memory/db.py +++ b/src/context_engine/memory/db.py @@ -309,8 +309,10 @@ def connect(db_path: str | Path) -> sqlite3.Connection: # WAL gives concurrent readers (the dashboard) decent isolation while the # MCP server writes; no impact on single-process use. conn.execute("PRAGMA journal_mode = WAL") - # Give concurrent writers (hooks + auto-prune) up to 5 seconds to acquire - # the write lock instead of failing immediately with "database is locked". + # Explicitly set the SQLite busy timeout so concurrent writers (hooks + + # auto-prune) wait up to 5s for the write lock before raising + # "database is locked". This is the SQLite-level PRAGMA, separate from + # Python's sqlite3.connect(timeout=...) parameter. conn.execute("PRAGMA busy_timeout = 5000") has_vec = _try_load_vec(conn) _ensure_schema(conn, has_vec=has_vec) diff --git a/src/context_engine/memory/hooks.py b/src/context_engine/memory/hooks.py index f834390..1306599 100644 --- a/src/context_engine/memory/hooks.py +++ b/src/context_engine/memory/hooks.py @@ -47,7 +47,9 @@ def _conn(request: web.Request) -> sqlite3.Connection: _RESUME_DECISION_REASON_CHARS = 200 -def _ensure_session(conn: sqlite3.Connection, session_id: str) -> None: +def _ensure_session( + conn: sqlite3.Connection, session_id: str, project: str = "" +) -> None: """Ensure a sessions row exists for the given session_id. Hooks can arrive out of order (UserPromptSubmit before SessionStart) @@ -60,8 +62,8 @@ def _ensure_session(conn: sqlite3.Connection, session_id: str) -> None: conn.execute( "INSERT OR IGNORE INTO sessions " "(id, project, started_at_epoch, started_at, status) " - "VALUES (?, '', ?, ?, 'active')", - (session_id, epoch, _now_iso(epoch)), + "VALUES (?, ?, ?, ?, 'active')", + (session_id, project, epoch, _now_iso(epoch)), ) @@ -231,7 +233,7 @@ async def handle_user_prompt_submit(request: web.Request) -> web.Response: conn = _conn(request) try: - _ensure_session(conn, session_id) + _ensure_session(conn, session_id, request.app.get("project_name", "")) if prompt_number is None: row = conn.execute( @@ -284,7 +286,7 @@ async def handle_post_tool_use(request: web.Request) -> web.Response: conn = _conn(request) try: - _ensure_session(conn, session_id) + _ensure_session(conn, session_id, request.app.get("project_name", "")) if prompt_number is None: row = conn.execute( @@ -323,7 +325,7 @@ async def handle_stop(request: web.Request) -> web.Response: conn = _conn(request) try: - _ensure_session(conn, session_id) + _ensure_session(conn, session_id, request.app.get("project_name", "")) if prompt_number is None: row = conn.execute( @@ -353,7 +355,7 @@ async def handle_session_end(request: web.Request) -> web.Response: conn = _conn(request) try: - _ensure_session(conn, session_id) + _ensure_session(conn, session_id, request.app.get("project_name", "")) epoch = _now_epoch() conn.execute( diff --git a/tests/memory/test_hooks.py b/tests/memory/test_hooks.py index eb68f65..4adac76 100644 --- a/tests/memory/test_hooks.py +++ b/tests/memory/test_hooks.py @@ -436,10 +436,11 @@ async def test_prompt_without_session_start_backfills(hook_app, aiohttp_client): assert resp.status == 200 data = await resp.json() assert data["ok"] is True - # Session row was backfilled - row = conn.execute("SELECT id, status FROM sessions WHERE id = ?", ("orphan",)).fetchone() + # Session row was backfilled with project name + row = conn.execute("SELECT id, status, project FROM sessions WHERE id = ?", ("orphan",)).fetchone() assert row is not None assert row["status"] == "active" + assert row["project"] == "demo" async def test_tool_use_without_session_start_backfills(hook_app, aiohttp_client): @@ -458,8 +459,9 @@ async def test_tool_use_without_session_start_backfills(hook_app, aiohttp_client assert resp.status == 200 data = await resp.json() assert data["ok"] is True - row = conn.execute("SELECT id FROM sessions WHERE id = ?", ("orphan2",)).fetchone() + row = conn.execute("SELECT id, project FROM sessions WHERE id = ?", ("orphan2",)).fetchone() assert row is not None + assert row["project"] == "demo" async def test_stop_without_session_start_does_not_crash(hook_app, aiohttp_client):