Skip to content
Merged
44 changes: 36 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,13 @@ jobs:
path: ~/.cache/huggingface
key: ${{ runner.os }}-hf-all-MiniLM-L6-v2

# The `codebase` extra is included even though this job is about the
# storage backend: without tree-sitter/leidenalg, 9 tests SKIP here that
# the PostgreSQL job runs, so the two gates would not be equal and a
# codebase-analysis regression could reach the default backend unseen.
# Measured 2026-07-28 locally: 8 skips from tree-sitter, 1 from leidenalg.
- name: Install dependencies (no postgresql extra)
run: pip install -e ".[dev,sqlite]"
run: pip install -e ".[dev,sqlite,codebase]"

# Retry-with-backoff, fail-loudly: see the `test` job's pre-download step
# for the root-cause rationale (CI run 28495801728, 2026-07-01).
Expand All @@ -235,13 +240,36 @@ jobs:
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
# Scope: the SQLite fallback is intentionally NOT at full feature parity
# with the mandatory PostgreSQL backend (some SqliteMemoryStore methods
# and PG-specific tests do not apply). Run the dedicated SQLite backend
# suite, which exercises CRUD, heat_base columns/indexes, FTS, and
# backend selection on the fallback path. Broadening to the full suite
# on SQLite is tracked separately (full-parity effort).
run: pytest tests_py/infrastructure/test_sqlite_backend.py --tb=short -q
# SQLite is the plugin's DEFAULT backend, so it gets the same gate
# PostgreSQL does. Explicit rather than inferred: this job has no PG
# service container, and conftest would select SQLite on its own, but
# a future runner with PG reachable must not silently turn this into
# a second PostgreSQL run.
CORTEX_MEMORY_STORE_BACKEND: sqlite
# No model download from a CI runner; degrade to first-stage scores.
CORTEX_RERANKER_OFFLINE: "1"
# Scope: THE FULL SUITE. This job previously ran one file
# (test_sqlite_backend.py) and deferred the rest to a "full-parity
# effort" that named no issue. That gap let three real defects ship on
# the default backend — missing acquire_interactive/acquire_batch, the
# PG-only lesson-promotion query, and a bare except that reported the
# resulting failure as an empty backlog (issue #220).
#
# Measured 2026-07-28 on this tree (rebased onto 575f2f1, so it
# includes the tests #229/#230 added), macOS 15 / Python 3.13,
# `CORTEX_MEMORY_STORE_BACKEND=sqlite`: 6103 passed, 96 skipped,
# exit 0 in 247s.
#
# Those 96 skips, counted (not estimated) from a `-rs` run:
# 84 PostgreSQL-only — the test's SUBJECT is the PG implementation
# (pg_store_* dialect modules, PG-only maintenance passes). These
# SHOULD skip here; making them "backend-agnostic" would mean
# testing PG code without PG.
# 12 optional deps absent from that local env (8 tree-sitter,
# 1 leidenalg, 3 sqlite-vec). All three ARE installed on this
# job, so it should report ~84 skips, not 96 — if it reports
# more, an extra is missing and the gate has silently narrowed.
run: pytest --tb=short -q -p no:randomly

test-windows:
name: Test (Windows, SQLite backend)
Expand Down
10 changes: 8 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ The default store is a local SQLite file — nothing to provision. PostgreSQL 17
git clone https://github.com/cdeust/Cortex.git
cd Cortex

# Install with dev + benchmark extras (add `postgresql` for the PG backend)
pip install -e ".[postgresql,benchmarks,dev]"
# Install every extra CI installs. Missing extras do not fail — they SKIP, so
# a local run looks green while covering less than CI does. Measured
# 2026-07-28 on a tree without them, 12 tests skipped [not-a-count-claim: tests]
# locally that CI runs (8 tree-sitter, 1 leidenalg — both `codebase`;
# 3 sqlite-vec — `sqlite`).
# CI's SQLite job installs ".[dev,sqlite,codebase]"; its PG job adds
# `postgresql`. Install all of them so your run is the stricter one.
pip install -e ".[postgresql,sqlite,codebase,benchmarks,dev]"

# Optional: the setup script provisions PostgreSQL + pgvector and inits the DB
bash scripts/setup.sh # macOS / Linux
Expand Down
46 changes: 44 additions & 2 deletions mcp_server/handlers/lesson_promotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from __future__ import annotations

import logging
from typing import Any

from mcp_server.core.lesson_promotion import (
Expand All @@ -30,8 +31,31 @@
from mcp_server.infrastructure.pg_store_lesson_promotion import (
list_lesson_promotion_candidates,
)
from mcp_server.infrastructure.sqlite_store import SqliteMemoryStore
from mcp_server.infrastructure.sqlite_store_lesson_promotion import (
list_lesson_promotion_candidates as list_candidates_sqlite,
)
from mcp_server.observability import silent_failure

logger = logging.getLogger(__name__)


def _list_candidates(store: Any, limit: int) -> list[dict[str, Any]]:
"""Backend dispatch for the candidate query.

Same composition-root concern, and the same shape, as
`get_grooming_health._count_promotion_candidates`: the eligibility
query exists in two SQL dialects because `@>` and
`jsonb_array_elements_text` have no SQLite translation, and the
psycopg-compat wrapper translates lexical conventions only, never
jsonb operators. Dispatching on the store type is what keeps this
handler working on the plugin's default backend (issue #220).
"""
if isinstance(store, SqliteMemoryStore):
return list_candidates_sqlite(store._conn, limit=limit)
return list_lesson_promotion_candidates(store._conn, limit=limit)


schema = {
"title": "Lesson promotion",
"annotations": READ_ONLY,
Expand Down Expand Up @@ -78,11 +102,29 @@ async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]:
args = args or {}
limit = int(args.get("limit") or 10)

store: Any = None
try:
store = get_shared_store()
candidates = list_lesson_promotion_candidates(store._conn, limit=limit)
except Exception as exc: # noqa: BLE001 — mechanism boundary; failure is observable via silent_failure
candidates = _list_candidates(store, limit)
except Exception as exc: # noqa: BLE001 — mechanism boundary; failure is observable via silent_failure + the log below
# Was a bare swallow. The SQLite backend (the plugin DEFAULT) raised
# `unrecognized token: "@"` here on every call because the PG dialect
# ran unconditionally, and this handler reported an empty backlog
# instead of an error — the failure was invisible for as long as
# nobody compared it against the store (issue #220). The fallback
# stays (callers rely on the empty-list contract) but it is no longer
# silent: §13.1 F1 — every failure mode emits an actionable signal.
# Two signals, deliberately: `silent_failure` is the repo-wide
# machine-readable channel (#197 family 2), while the log names the
# BACKEND, which is the one fact that distinguishes this dialect bug
# from a genuinely empty store.
silent_failure.note("lesson_promotion.candidates", exc)
logger.warning(
"lesson-promotion candidate query failed on %s; reporting an "
"empty backlog. This is a degraded result, not an empty store.",
type(store).__name__ if store is not None else "<unresolved store>",
exc_info=True,
)
candidates = []

jobs = build_promotion_jobs(candidates)
Expand Down
7 changes: 3 additions & 4 deletions mcp_server/handlers/wiki_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

from __future__ import annotations

from mcp_server.infrastructure.row_factory import DICT_ROW

from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeGuard, cast

Expand Down Expand Up @@ -134,10 +136,7 @@ def _execute_view(
postcondition: returns {view, table, row_count, rows, sql} on success,
or {view, error, sql} on execution failure
"""
# psycopg import deferred to here — only reachable on the PG path
from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working

with store._conn.cursor(row_factory=dict_row) as cur:
with store._conn.cursor(row_factory=DICT_ROW) as cur:
try:
# compiled.sql comes from the safe view compiler: table/column
# whitelists, values as bound params (wiki_view_executor) — the
Expand Down
29 changes: 25 additions & 4 deletions mcp_server/infrastructure/pg_store_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,31 @@

from typing import TYPE_CHECKING, Any, Iterator

import psycopg
from psycopg import sql
# This module is reachable by import from the SQLite-default install (hooks ->
# injection_receipts -> pg_store_receipts -> here), and psycopg ships only in
# the optional [postgresql] extra. A module-scope `import psycopg` therefore
# made `import mcp_server.hooks.session_lifecycle` raise ModuleNotFoundError on
# every launch surface in PRIVACY.md lines 26-38. Only ProgrammingError is
# needed at RUNTIME (the rest are annotations, stringified by
# `from __future__ import annotations`), so the PG-only paths that raise it are
# unreachable without psycopg and the stand-in below is never matched.
# Surfaced by #220 broadening the SQLite CI job to the full suite.
_ProgrammingError: type[Exception]
try: # PostgreSQL backend — the optional [postgresql] extra is installed.
from psycopg import ProgrammingError as _PsycopgProgrammingError

_ProgrammingError = _PsycopgProgrammingError
except ModuleNotFoundError: # SQLite-default install — no psycopg present.

class _MissingPsycopgProgrammingError(Exception):
"""Stand-in so this module imports without the [postgresql] extra."""

_ProgrammingError = _MissingPsycopgProgrammingError


if TYPE_CHECKING:
import psycopg
from psycopg import sql
from psycopg.rows import DictRow
from psycopg_pool import ConnectionPool

Expand All @@ -49,7 +70,7 @@ def __init__(self, cursor: psycopg.Cursor[DictRow]) -> None:
self._rowcount = cursor.rowcount
try:
self._rows: list[DictRow] = cursor.fetchall()
except (psycopg.ProgrammingError, TypeError):
except (_ProgrammingError, TypeError):
# DDL / DML statements without a result set — fetchall raises.
self._rows = []
self._idx = 0
Expand All @@ -72,7 +93,7 @@ def one(self) -> DictRow:
"""
row = self.fetchone()
if row is None:
raise psycopg.ProgrammingError(
raise _ProgrammingError(
"statement guaranteed a row (RETURNING/aggregate) but produced none"
)
return row
Expand Down
9 changes: 4 additions & 5 deletions mcp_server/infrastructure/pg_store_lesson_promotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from __future__ import annotations

from mcp_server.infrastructure.row_factory import DICT_ROW

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
Expand Down Expand Up @@ -49,8 +51,6 @@ def list_lesson_promotion_candidates(
first. Read-only: never mutates memory_rules, prospective_memories,
wiki.citations, or the memories table itself.
"""
from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working

sql = f"""
SELECT m.id, LEFT(m.content, 500) AS content_preview, m.domain,
m.tags, m.useful_count, m.access_count, m.created_at
Expand All @@ -59,7 +59,7 @@ def list_lesson_promotion_candidates(
ORDER BY m.useful_count DESC, m.access_count DESC, m.created_at DESC
LIMIT %s;
""" # noqa: S608 — interpolated fragment is the module-level literal _ELIGIBLE_WHERE; values are bound parameters (docs/ASSURANCE-CASE.md §5)
with conn.cursor(row_factory=dict_row) as cur:
with conn.cursor(row_factory=DICT_ROW) as cur:
cur.execute(sql, (limit,))
return list(cur.fetchall())

Expand All @@ -80,10 +80,9 @@ def count_lesson_promotion_candidates(conn: StoreConnection) -> int:
~2.6s on the same corpus; see wiki_backlog_pass.py's docstring for
why that one is NOT wired into the recurring cycle).
"""
from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working

sql = f"SELECT count(*) AS n FROM current_memories m WHERE {_ELIGIBLE_WHERE};" # noqa: S608 — interpolated fragment is the module-level literal _ELIGIBLE_WHERE; values are bound parameters (docs/ASSURANCE-CASE.md §5)
with conn.cursor(row_factory=dict_row) as cur:
with conn.cursor(row_factory=DICT_ROW) as cur:
cur.execute(sql)
row = cur.fetchone()
return int(row["n"]) if row else 0
6 changes: 3 additions & 3 deletions mcp_server/infrastructure/pg_store_memory_dedup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

from __future__ import annotations

from mcp_server.infrastructure.row_factory import DICT_ROW

from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
Expand Down Expand Up @@ -72,8 +74,6 @@ def list_exact_duplicate_groups(conn: StoreConnection, limit: int) -> list[dict]
group consecutive rows by a single pass
(``itertools.groupby``) without an extra sort.
"""
from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working

# candidates: re-selects `m.*` into its own CTE before calling
# effective_heat(). Required, not stylistic — current_memories is a
# VIEW with its own composite row type, which PostgreSQL will NOT
Expand Down Expand Up @@ -116,7 +116,7 @@ def list_exact_duplicate_groups(conn: StoreConnection, limit: int) -> list[dict]
ORDER BY dk.dup_key, c.id
LIMIT %(limit)s
""" # noqa: S608 — expression from hardcoded identifiers only (documented contract at _dup_key_expr); values are bound parameters (docs/ASSURANCE-CASE.md §5)
with conn.cursor(row_factory=dict_row) as cur:
with conn.cursor(row_factory=DICT_ROW) as cur:
cur.execute(cast("LiteralString", sql), {"limit": limit})
return list(cur.fetchall())

Expand Down
6 changes: 3 additions & 3 deletions mcp_server/infrastructure/pg_store_memory_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from __future__ import annotations

from mcp_server.infrastructure.row_factory import DICT_ROW

from typing import TYPE_CHECKING

if TYPE_CHECKING:
Expand Down Expand Up @@ -55,8 +57,6 @@ def list_domainless_memories(
successful re-resolution, so a rescanned-and-resolved
row will not be re-selected as an orphan again.
"""
from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working

orphan_filter = (
"" if include_orphans else "AND NOT tags @> '[\"domain-orphan\"]'::jsonb"
)
Expand All @@ -68,7 +68,7 @@ def list_domainless_memories(
" ORDER BY id\n"
" LIMIT %(limit)s"
)
with conn.cursor(row_factory=dict_row) as cur:
with conn.cursor(row_factory=DICT_ROW) as cur:
cur.execute(sql, {"limit": limit})
return list(cur.fetchall())

Expand Down
6 changes: 3 additions & 3 deletions mcp_server/infrastructure/pg_store_memory_reheat.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

from __future__ import annotations

from mcp_server.infrastructure.row_factory import DICT_ROW

from typing import TYPE_CHECKING

if TYPE_CHECKING:
Expand Down Expand Up @@ -91,8 +93,6 @@ def list_deliberate_below_target(
memories`` is a VIEW with its own composite type that
Postgres will not implicitly cast to ``memories``.
"""
from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working

sql = """
WITH candidates AS (
SELECT m.*
Expand Down Expand Up @@ -126,7 +126,7 @@ def list_deliberate_below_target(
ORDER BY id
LIMIT %(limit)s
"""
with conn.cursor(row_factory=dict_row) as cur:
with conn.cursor(row_factory=DICT_ROW) as cur:
cur.execute(
sql,
{
Expand Down
6 changes: 3 additions & 3 deletions mcp_server/infrastructure/pg_store_memory_write_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@

from __future__ import annotations

from mcp_server.infrastructure.row_factory import DICT_ROW

from typing import TYPE_CHECKING

if TYPE_CHECKING:
Expand All @@ -60,8 +62,6 @@ def list_source_groups_at_default(conn: StoreConnection, limit: int) -> list[dic
a full apply finds nothing left to reclassify
(idempotence).
"""
from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working

sql = (
"SELECT COALESCE(source, '') AS source, COUNT(*) AS row_count\n"
" FROM current_memories\n"
Expand All @@ -71,7 +71,7 @@ def list_source_groups_at_default(conn: StoreConnection, limit: int) -> list[dic
" ORDER BY source\n"
" LIMIT %(limit)s"
)
with conn.cursor(row_factory=dict_row) as cur:
with conn.cursor(row_factory=DICT_ROW) as cur:
cur.execute(sql, {"sentinel": _DEFAULT_SENTINEL, "limit": limit})
return list(cur.fetchall())

Expand Down
Loading
Loading