Files
Mert Köseoğlu f1878a703b fix(stats): v1.0.148 hotfix — schema migration + META-scope + strict-compression formula (#685)
* fix(stats): lazy schema migration in aggregator — recover legacy DB signal (#683 follow-up)

Symptom: post-v1.0.147 upgrade, ctx_stats Section 1 displays
"Without context-mode 158 KB / With context-mode 158 KB / 0% kept out"
— a degenerate identity bar instead of the historical savings ratio.

Root cause: three intertwined bugs.

BUG A — Schema migration only runs in SessionDB constructor.
db.ts L754-776 contains an ALTER TABLE migration for the post-v1.0.130
columns (project_dir, attribution_source, attribution_confidence,
bytes_avoided, bytes_returned). It only runs when SessionDB is
instantiated for a SPECIFIC active DB file. Historical session DBs
that never get opened through SessionDB (because the SQLite session
moved on) keep their pre-migration schema indefinitely. On my disk:
197 total DBs, only 28 fully migrated, 38 partially, 131 still legacy.

BUG C — Aggregator catch-all swallows missing-column errors.
analytics.ts getRealBytesStats() loops over every session DB file under
the storage root and runs a combined SUM query referencing the new
columns. When the column doesn't exist on a legacy DB, the prepare()
throws "no such column", the surrounding catch at the end of the loop
body skips the ENTIRE DB — not just the bytes_avoided field — so even
the LENGTH(data) signal is lost. 131 of 197 DBs contributed zero to
all columns.

BUG B — Display formula identity-collapse when bytesAvoided == 0.
analytics.ts L1991-1993:
  convBytesWithout = measuredAvoided + measuredReturned + measuredEvent;
  convBytesWith    = max(1, measuredReturned + measuredEvent);
With measuredAvoided == 0 (which Bug A + C effectively guaranteed for
every conversation that spanned the v1.0.130 upgrade boundary), both
sides equal identically → 0% displayed.

Fix: extract the schema migration from the SessionDB ctor into a shared
top-level helper, call it from the analytics aggregator before each
per-DB readonly open. Self-healing: every getRealBytesStats invocation
backfills any legacy DBs it scans, so the user's next ctx_stats call
silently fixes their historical data — no manual migration command
needed.

Empirically verified on my machine: 197 DBs → 196 migrated + 1 leftover
(probably a lock holder) after one stats call. Display went from
158/158/0% to 168/158/6% — the 6% is honest for this conversation
(heavy editing, few routing redirects). Pre-Bug-A baseline showed 98%
because the lifetime aggregator was reading historical data that no
longer falls into the same per-conversation rollup.

ADR-0001 compatible: no EXCLUSIVE pragma, no acquireDbLock — the
helper opens writable, runs idempotent PRAGMA-guarded ALTER, closes.
SQLite busy_timeout + WAL semantics from SQLiteBase already provide
the multi-writer concurrency contract per ADR-0001.

Shared `applyMissingSessionEventsColumns(db)` helper now used by both
SessionDB ctor AND the new `ensureSessionEventsSchema(dbPath, ctor)`
wrapper. Single column list at the top of db.ts — no drift between
ctor migration and aggregator backfill.

All 449 server tests pass (including the 3 previously-failing
ctx_doctor / ctx_index storage e2e tests that turn out to have been
casualties of the same schema-skip path — they pass now too).

* test(stats): regression coverage for schema-migration recovery (#683 follow-up)

Three behavioural tests in tests/session/real-bytes-stats.test.ts
pin the v1.0.148 hotfix through the public getRealBytesStats API:

1. **Recovery** — A pre-v1.0.130 legacy-schema session DB
   (no bytes_avoided / bytes_returned / project_dir columns) with two
   events on disk MUST contribute its LENGTH(data) signal. Pre-fix:
   prepare() threw "no such column", catch skipped the WHOLE DB,
   eventDataBytes == 0 even though LENGTH(data) > 0. Post-fix: signal
   recovered (assertion bounds 40 < n < 500 — guards against the
   identity-collapse failure mode without pinning fragile exact byte
   counts).

2. **In-place migration** — Calling getRealBytesStats against a legacy
   DB MUST add all five post-v1.0.130 columns (project_dir,
   attribution_source, attribution_confidence, bytes_avoided,
   bytes_returned) to the on-disk schema. Asserted via PRAGMA
   table_xinfo before/after.

3. **Idempotency** — Second aggregator call against an already-migrated
   DB MUST NOT throw and MUST NOT add new columns. Pins the
   PRAGMA-guarded early-return contract in
   applyMissingSessionEventsColumns.

RED-GREEN proven: with the ensureSessionEventsSchema call temporarily
commented out in analytics.ts, all 3 tests fail; re-enabling makes
them pass. Confirms the tests exercise the actual regression path,
not just shape.

Test helpers (createLegacySessionDb, readSessionEventsColumns) bypass
SessionDB's ctor migration via raw better-sqlite3 SQL so the seed DB
genuinely reproduces the broken-on-upgrade state observed in the wild.
Per CONTRIBUTING line 282, folded into the existing
real-bytes-stats.test.ts — no new test file.

14/14 tests in real-bytes-stats.test.ts pass (the new 3 + existing 11).

* fix(stats): Bug E+F — META-scoped per-conversation aggregation (#683 follow-up)

The v1.0.148 schema-migration fix (903ddd2) unblocked the SUM query
on legacy DBs but uncovered a second-order under-attribution bug.

Empirical evidence from the reporter's machine:
  Display Section 1:  Without 168 KB / With 158 KB / 6% kept out
  Real signal:        Without 5.1 MB / With 2.2 MB / 56% kept out
  → 49 percentage points of attribution loss

Root cause is two intertwined bugs nested inside the per-conversation
aggregator path.

BUG E — aggregator scopes by single session_id.
A Claude Code conversation routinely spans 80+ session_ids: resume
cycles, /compact rebirths, and most importantly the PID sub-process
sessions spawned by ctx_execute / ctx_fetch_and_index for each
sandboxed run. The renderer at server.ts:3454 was passing only the
top-level main session_id, so every sandbox burst's bytes_avoided
got dropped.

BUG F — sandbox-burst PID-session EVENTS write project_dir=''.
A naive project_dir filter on session_events would still miss them.
On the reporter's machine the 11 PID-* sessions active in the last
24h carried 475.7 KB of bytes_avoided in events whose
event-level project_dir was empty string — even though their META
row had the parent cwd. Event-level filtering would lose them.

Fix:

1. getRealBytesStats accepts a new `projectDir` option (mutually
   exclusive with `sessionId`). When set, the SQL uses a META
   subquery — `WHERE session_id IN (SELECT session_id FROM
   session_meta WHERE project_dir = ?)` — to pick sibling sessions
   by META, then sums ALL events for those sessions regardless of
   the events' own project_dir. Solves Bug E (broader session
   scope) AND Bug F (META-not-event scoping catches PID bursts
   whose events write empty project_dir) in one query change.

2. ctx_stats renderer (server.ts) looks up project_dir from the
   active session's META row, then calls getRealBytesStats with
   projectDir instead of sessionId. Best-effort: falls back to
   the old sessionId scope if the META lookup fails for any reason
   (corrupt DB, missing META row), so the bar never disappears.

ADR-0001 compatible: pure read path, no writes, no locks. The
META subquery runs against the already-open readonly handle.

Test coverage (real-bytes-stats.test.ts):
  - getRealBytesStats({ projectDir }) sums bytes from EVERY session
    whose META project_dir matches, including PID-burst sessions
    whose EVENTS have empty project_dir but whose META has the
    parent cwd. Excludes sessions from other projects entirely.
  - The fixture mirrors the real-world Bug F shape: session B has
    META.project_dir=target but events.project_dir=''. Pre-fix:
    excluded entirely. Post-fix: bytes attributed correctly.

15/15 real-bytes-stats tests pass.
449/449 server tests pass.

* fix(stats): Bug G — Section 1 strict-compression formula (#683 follow-up)

Empirical evidence converged the 7-agent EM ops audit on the strict-
compression formula for the per-conversation Section 1 bar. Replaces
the v1.0.134 SLICE B incidental fix (commit ce62275) that folded
eventDataBytes into both sides of the Without/With ratio.

ROOT CAUSE: eventDataBytes is hook payload metadata written to
SessionDB (496 duplicate CLAUDE.md copies, hundreds of tool_response
records, etc.). It NEVER enters the model context window — it is
analytics infrastructure. After PR #685's Bug A+C+D+E+F fixes let
real bytesAvoided flow into the formula, SLICE B crushed the display
from the literal compression ratio (~95% on real conversations) down
to 56%.

THE FORMULA (src/session/analytics.ts ~L2031-2074):
  if (bytesAvoided + bytesReturned == 0) {
    // honest empty-state hint, no degenerate bar
  } else {
    Without = bytesAvoided + bytesReturned
    With    = max(1, bytesReturned)
    pct     = (1 - With / Without) * 100
  }

EMPIRICAL (reporter's machine, project_dir scope):
  bytesAvoided   = 2,898 KB | bytesReturned = 140 KB | eventDataBytes = 2,136 KB (excluded)
  Without = 3,038 KB | With = 140 KB | pct = 95.4% | mult = 22x runtime
  (was 0% / 6% / 56% across the cascade)

Lifetime totals UNCHANGED — they use a different aggregation that
includes eventDataBytes correctly.

TESTS (RED→GREEN proven):
- tests/analytics/format-report.test.ts: SLICE B describe superseded
  by "Bug G — strict-compression formula" (empty-state, mixed 60%,
  only-avoided 100%) — 3 GREEN.
- tests/session/real-bytes-stats.test.ts: 15 pass (no regression).
- tests/core/server.test.ts: 525 pass / 3 pre-existing PR #617
  failures unchanged.

ADR-0001 compliance: pure read-side formula, no schema, no locks.
ADR-0004 (new) documents the formula + SLICE B archaeology +
empirical reporter data + consequence (display jumps 56% → 95%).
release-notes-v1.0.148.md walks the entire 7-bug cascade.
2026-05-24 16:33:10 +03:00
..