cctally 1.79.1 → 1.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1112,7 +1112,19 @@ def _statusline_reduce_and_publish() -> "_candidates.ReductionDecision | None":
1112
1112
  five_hour_resets_at=None if plan.five_hour is None else plan.five_hour.raw_resets_at,
1113
1113
  source="statusline",
1114
1114
  )
1115
- if _cctally().cmd_record_usage(args) != 0:
1115
+ # 6f mode wiring (P2 exit-code fix): the statusline persist fork's publish
1116
+ # step ingests OPPORTUNISTICALLY. A render tick must NEVER crash on a
1117
+ # derivation error — the opportunistic path swallows a cycle exception
1118
+ # (loud log only) instead of propagating, whereas the authoritative default
1119
+ # would re-raise out of this unwrapped persist fork. In the uncontended
1120
+ # common case opportunistic still consumes the just-appended obs
1121
+ # synchronously (the ingest lock is free), so `after` reflects the write
1122
+ # exactly as before; only under a concurrent-ingester storm does it skip and
1123
+ # let the winner consume the line (the next tick then observes it). The
1124
+ # statusline's AUTHORITATIVE publication step (`_authoritative_record_usage`)
1125
+ # keeps the default authoritative mode — it is already try/except-wrapped to
1126
+ # "record_failed".
1127
+ if _cctally().cmd_record_usage(args, ingest_mode="opportunistic") != 0:
1116
1128
  return decision
1117
1129
  after = _read_db_projection_stable()
1118
1130
  if _projection_changed(projection, after):
@@ -0,0 +1,505 @@
1
+ """Unified SQLite store opener — one connect+PRAGMA policy, one version gate.
2
+
3
+ Design spec docs/superpowers/specs/2026-07-22-db-journal-redesign-design.md
4
+ §6 (access layer). This module is the single chokepoint every SQLite
5
+ connection for the three disposable indexes (stats.db / cache.db /
6
+ conversations.db) routes through for its **connection policy** and its
7
+ **schema-apply version gate**. ``open_db`` / ``open_cache_db`` /
8
+ ``open_conversations_db`` stay thin wrappers whose public signatures are
9
+ unchanged; they call in here for PRAGMAs and ask ``schema_current`` whether the
10
+ full DDL/migration apply may be skipped.
11
+
12
+ Two responsibilities live here (spec §6.1 / §6.2):
13
+
14
+ - ``open_index(store)`` — connect to the store's DB path (URI mode per policy),
15
+ install the row factory, and (test-only) install a trace hook. Corruption
16
+ handling stays in each opener (Task 8 moves it to the classifier-gated
17
+ ``HEAL_HOOK`` seam below), so ``open_index`` never probes or recreates.
18
+ - ``apply_policy(conn, store)`` — apply the §6.1 PRAGMA policy
19
+ (journal_mode / synchronous / busy_timeout / journal_size_limit /
20
+ auto_vacuum). ``auto_vacuum`` is emitted **before** ``journal_mode`` because
21
+ it only takes on a DB whose first page has not been written yet.
22
+ - ``schema_current(conn, store)`` — the §6.2 version gate: one
23
+ ``PRAGMA user_version`` read compared to the store's registry head. When it
24
+ returns ``True`` the opener skips the full schema executescript +
25
+ ``add_column_if_missing`` probes + FTS branch, so the steady-state open is
26
+ connect → PRAGMAs → one ``user_version`` read → done.
27
+
28
+ **Contract change (Task 2, spec §6.2):** cache/conversations schema changes must
29
+ ride a migration-registry bump; a bare ``add_column_if_missing`` in
30
+ ``_apply_cache_schema`` (or ``_apply_conversations_schema``) no longer reaches
31
+ version-current DBs, because the whole schema apply is version-gated and skipped
32
+ once ``user_version == registry head``. Add the column via a registered
33
+ migration (which bumps the head and re-triggers the apply) instead.
34
+
35
+ **Lock-order law** (spec §5.2 / §6.4; asserted here as documentation, exercised
36
+ by the storm test): maintenance flocks → ``journal.ingest.lock`` → provider
37
+ flocks (cache Claude → cache Codex → conversations Claude → conversations
38
+ Codex) → SQLite transactions → ``journal.lock`` (leaf). Never acquire the
39
+ ingest lock while holding a provider flock; no SQLite write transaction ever
40
+ spans a flock acquisition.
41
+
42
+ **Raw-connect escape hatches stay OUT of this module by design** (spec §6.1):
43
+ ``db checkpoint``'s ``mode=rw`` connect, ``db vacuum``'s exclusive connect, and
44
+ doctor's read-only gather deliberately bypass the opener so they carry no
45
+ schema-apply / migration side effects on maintenance/diagnostic paths.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import fcntl
50
+ import os
51
+ import sqlite3
52
+ import sys
53
+ import time
54
+ from dataclasses import dataclass
55
+
56
+ import _cctally_core
57
+ import _cctally_db
58
+
59
+
60
+ # --------------------------------------------------------------------------
61
+ # §6.1 policy table
62
+ # --------------------------------------------------------------------------
63
+
64
+ # WAL size caps (spec §6.1). Mirror the long-standing per-DB constants
65
+ # (_cctally_core.STATS_WAL_SIZE_LIMIT_BYTES, _cctally_cache.CACHE_WAL_SIZE_LIMIT_BYTES);
66
+ # duplicated here so the store module does not import _cctally_cache (which
67
+ # loads this module — that would be a cycle).
68
+ _STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16 MiB
69
+ _CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 128 MiB
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class StorePolicy:
74
+ """The §6.1 connection policy for one store."""
75
+
76
+ journal_mode: str # "WAL"
77
+ synchronous: str # "NORMAL"
78
+ busy_timeout: int # milliseconds
79
+ journal_size_limit: int # bytes
80
+ auto_vacuum: str | None # "INCREMENTAL", or None to leave unset
81
+ row_factory: str # "row" (sqlite3.Row) | "tuple"
82
+ uri: bool # connect with uri=True (RO ATTACH support)
83
+
84
+
85
+ STORE_POLICY: dict[str, StorePolicy] = {
86
+ # stats.db: auto_vacuum stays unset on normal opens. §6.1: INCREMENTAL only
87
+ # from the first epoch rebuild — a populated DB needs a full VACUUM to
88
+ # change modes, so it is deliberately NOT applied at in-place cutover.
89
+ "stats": StorePolicy(
90
+ journal_mode="WAL", synchronous="NORMAL", busy_timeout=15000,
91
+ journal_size_limit=_STATS_WAL_SIZE_LIMIT_BYTES, auto_vacuum=None,
92
+ row_factory="row", uri=False,
93
+ ),
94
+ "cache": StorePolicy(
95
+ journal_mode="WAL", synchronous="NORMAL", busy_timeout=15000,
96
+ journal_size_limit=_CACHE_WAL_SIZE_LIMIT_BYTES, auto_vacuum="INCREMENTAL",
97
+ row_factory="tuple", uri=False,
98
+ ),
99
+ "conversations": StorePolicy(
100
+ journal_mode="WAL", synchronous="NORMAL", busy_timeout=15000,
101
+ journal_size_limit=_CACHE_WAL_SIZE_LIMIT_BYTES, auto_vacuum="INCREMENTAL",
102
+ row_factory="tuple", uri=True,
103
+ ),
104
+ }
105
+
106
+
107
+ # Test-only tracing seam (spec §10 / §6.5 hot-path guards). When non-None it is
108
+ # installed via ``conn.set_trace_callback`` on every connection ``open_index``
109
+ # hands out, so a test can count the DDL statements a steady-state open runs
110
+ # (must be zero once the version gate holds). Production leaves it None.
111
+ _TRACE_HOOK = None
112
+
113
+ # Task 8 seam (spec §6.3): classifier-gated corruption auto-heal. Wired to
114
+ # ``_stats_heal_hook`` at the bottom of the module. ``open_db`` calls
115
+ # ``HEAL_HOOK("stats", exc)`` from its corruption boundary; a True return means
116
+ # "healed — retry the open once".
117
+ HEAL_HOOK = None
118
+
119
+
120
+ def _store_path(store: str):
121
+ if store == "stats":
122
+ return _cctally_core.DB_PATH
123
+ if store == "cache":
124
+ return _cctally_core.CACHE_DB_PATH
125
+ if store == "conversations":
126
+ return _cctally_core.CONVERSATIONS_DB_PATH
127
+ raise ValueError(f"unknown store {store!r}")
128
+
129
+
130
+ def _apply_row_factory(conn: sqlite3.Connection, policy: StorePolicy) -> None:
131
+ conn.row_factory = sqlite3.Row if policy.row_factory == "row" else None
132
+
133
+
134
+ def open_index(store: str) -> sqlite3.Connection:
135
+ """Connect to ``store``'s DB (URI mode per policy) + install the row factory.
136
+
137
+ Does NOT apply PRAGMAs, probe, or recreate — corruption handling is the
138
+ opener's job (Task 8 moves it to ``HEAL_HOOK``). The test trace hook, when
139
+ armed, is installed here so it captures every statement the opener then runs
140
+ (the gated schema apply included). Callers apply the PRAGMA policy with
141
+ ``apply_policy`` once the connection is confirmed usable.
142
+ """
143
+ policy = STORE_POLICY[store]
144
+ conn = sqlite3.connect(_store_path(store), uri=policy.uri)
145
+ if _TRACE_HOOK is not None:
146
+ conn.set_trace_callback(_TRACE_HOOK)
147
+ _apply_row_factory(conn, policy)
148
+ return conn
149
+
150
+
151
+ def apply_policy(conn: sqlite3.Connection, store: str) -> None:
152
+ """Apply the §6.1 PRAGMA policy for ``store`` to an open connection.
153
+
154
+ ``auto_vacuum`` (when set) is emitted first: it only takes effect before the
155
+ first page is written, so it must precede ``journal_mode=WAL`` / any DDL.
156
+ """
157
+ policy = STORE_POLICY[store]
158
+ if policy.auto_vacuum is not None:
159
+ conn.execute(f"PRAGMA auto_vacuum={policy.auto_vacuum}")
160
+ conn.execute(f"PRAGMA journal_mode={policy.journal_mode}")
161
+ conn.execute(f"PRAGMA synchronous={policy.synchronous}")
162
+ conn.execute(f"PRAGMA busy_timeout={policy.busy_timeout}")
163
+ conn.execute(f"PRAGMA journal_size_limit={policy.journal_size_limit}")
164
+
165
+
166
+ # --------------------------------------------------------------------------
167
+ # §6.2 version gate
168
+ # --------------------------------------------------------------------------
169
+
170
+ def _expected_head(store: str) -> int:
171
+ """The store's schema head to compare ``user_version`` against.
172
+
173
+ cache/conversations gate on their migration-registry length (read live off
174
+ ``_cctally_db`` so a test that mutates the registry is honored). stats
175
+ returns -1 — a value ``user_version`` can never equal — so ``schema_current``
176
+ is always False for it; ``open_db`` keeps its own schema-apply until Task 9
177
+ flips it to the ``STATS_INDEX_EPOCH`` gate.
178
+
179
+ conversations joined the framework in Task 10 (spec §7.2): it gates on
180
+ ``len(_CONVERSATIONS_MIGRATIONS)`` (head 1), so an up-to-date conversations.db
181
+ (``user_version == 1``) skips the schema apply on the steady-state open.
182
+ """
183
+ if store == "cache":
184
+ return len(_cctally_db._CACHE_MIGRATIONS)
185
+ if store == "conversations":
186
+ return len(_cctally_db._CONVERSATIONS_MIGRATIONS)
187
+ return -1 # stats: never gated in Task 2
188
+
189
+
190
+ def schema_current(conn: sqlite3.Connection, store: str) -> bool:
191
+ """True when ``store``'s stamped ``user_version`` equals its registry head.
192
+
193
+ When True the opener may skip the full DDL executescript +
194
+ ``add_column_if_missing`` probes + FTS branch entirely (spec §6.2). A head
195
+ of ``<= 0`` (a store with no registry yet, e.g. conversations pre-Task-10)
196
+ always returns False so the schema keeps being applied every open.
197
+ """
198
+ head = _expected_head(store)
199
+ if head <= 0:
200
+ return False
201
+ user_version = conn.execute("PRAGMA user_version").fetchone()[0]
202
+ return user_version == head
203
+
204
+
205
+ # --------------------------------------------------------------------------
206
+ # §7.1 stats.db epoch gate (Task 9)
207
+ # --------------------------------------------------------------------------
208
+
209
+ def stats_epoch_enabled() -> bool:
210
+ """True when the stats epoch/cutover machinery should engage (spec §7.1/§8).
211
+
212
+ It engages ONLY against the FROZEN production stats registry
213
+ (``len(_STATS_MIGRATIONS) == LEGACY_STATS_HEAD``). Under
214
+ ``CCTALLY_MIGRATION_TEST_MODE`` the injected 14th stats migration lifts the
215
+ count, so this returns False and ``open_db`` keeps the legacy dispatcher
216
+ behavior (no epoch gate, no cutover) — which is exactly what the
217
+ migration-framework golden harness exercises."""
218
+ return len(_cctally_db._STATS_MIGRATIONS) == _cctally_core.LEGACY_STATS_HEAD
219
+
220
+
221
+ def would_block_prod_stats_cutover(path) -> bool:
222
+ """A dev/worktree binary must refuse to CUT OVER a stats.db physically in the
223
+ real prod dir (spec §8, mirrors #146): the epoch stamp would brick the
224
+ installed release via ``DowngradeDetected``. Reuses the path-based prod-guard
225
+ predicate; ``CCTALLY_ALLOW_PROD_MIGRATION=1`` is the escape hatch."""
226
+ return _cctally_db._would_block_prod_stats(path)
227
+
228
+
229
+ # --------------------------------------------------------------------------
230
+ # §6.2 one-shot gate for open_db's three open-time backfills (Task 8)
231
+ # --------------------------------------------------------------------------
232
+ #
233
+ # stats.db is NOT epoch-gated until Task 9 (which flips the whole schema+
234
+ # migration+backfill region onto ``user_version == STATS_INDEX_EPOCH``). Until
235
+ # then, three self-extinguishing open-time writers still run — the
236
+ # five_hour_window_key backfill probe, the durable quota-projection schema apply,
237
+ # and the historical five_hour_blocks rollup backfill (+ its migration-003
238
+ # re-invocation). Each is cheap-but-nonzero per open (probe SELECTs / an
239
+ # executescript), and under a multi-agent hook storm that cost recurs many times
240
+ # a second (spec §1.3). §6.2 requires the steady-state open to do ZERO
241
+ # probe/DDL/backfill work.
242
+ #
243
+ # We gate all three on a single DB-RESIDENT marker (``stats_open_fixups``) — a
244
+ # marker must travel WITH the DB, not a file in APP_DIR, because a fresh index
245
+ # built by ``rebuild_stats_index`` (which quarantines the old file) needs the
246
+ # quota-projection SCHEMA applied; a stale file marker would leave the fresh DB
247
+ # missing those tables. A single-row table is the same framework-untracked
248
+ # additive posture as ``journal_cursor`` / ``weekly_credit_floors``. Steady state:
249
+ # one guarded SELECT, no DDL. Task 9's epoch gate SUBSUMES this (the whole region
250
+ # is skipped once the DB is epoch-current); the marker + these helpers then become
251
+ # a harmless inner short-circuit that Task 9 may retire.
252
+ #
253
+ # Bump ``_STATS_OPEN_FIXUPS_VERSION`` when a NEW open-time backfill is added, so
254
+ # existing installs re-run the fixups once to pick it up.
255
+ _STATS_OPEN_FIXUPS_VERSION = 1
256
+
257
+
258
+ def stats_open_fixups_current(conn: sqlite3.Connection) -> bool:
259
+ """True when open_db's three open-time backfills have already run for this
260
+ stats.db and need not run (or probe) again (spec §6.2, Task 8).
261
+
262
+ A missing ``stats_open_fixups`` table (fresh / pre-gate DB) or a stamped
263
+ version below the binary's expectation returns False, so the fixups run once
264
+ and re-stamp. Guarded so the read is safe before any schema exists — the only
265
+ steady-state cost is this one SELECT."""
266
+ try:
267
+ row = conn.execute(
268
+ "SELECT version FROM stats_open_fixups WHERE id = 1"
269
+ ).fetchone()
270
+ except sqlite3.OperationalError:
271
+ return False
272
+ return row is not None and int(row[0]) >= _STATS_OPEN_FIXUPS_VERSION
273
+
274
+
275
+ def mark_stats_open_fixups_done(conn: sqlite3.Connection) -> None:
276
+ """Stamp the ``stats_open_fixups`` marker after the three open-time backfills
277
+ ran (spec §6.2). Creates the single-row marker table on demand — this DDL
278
+ runs ONLY when the fixups run (first open / upgrade / rebuild), never on the
279
+ steady-state open — then upserts the current version."""
280
+ conn.execute(
281
+ "CREATE TABLE IF NOT EXISTS stats_open_fixups ("
282
+ "id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)"
283
+ )
284
+ conn.execute(
285
+ "INSERT INTO stats_open_fixups (id, version) VALUES (1, ?) "
286
+ "ON CONFLICT(id) DO UPDATE SET version = excluded.version",
287
+ (_STATS_OPEN_FIXUPS_VERSION,),
288
+ )
289
+
290
+
291
+ # --------------------------------------------------------------------------
292
+ # §6.3 classifier-gated corruption auto-heal (Task 8 Item 3)
293
+ # --------------------------------------------------------------------------
294
+ #
295
+ # The sequence on a POSITIVELY classified stats.db corruption (spec §6.3):
296
+ # classify (SQLITE_CORRUPT / NOTADB / "malformed" — NEVER busy/locked/perm)
297
+ # → dev-checkout-on-prod guard → acquire the MAINTENANCE lock (top of the
298
+ # lock-order law) → locked RE-CHECK (a sibling process may have healed already)
299
+ # → FORENSICS-FIRST (bundle written before evidence is disturbed) → acquire the
300
+ # ingest lock (bounded — the serialized stats writer) → QUARANTINE the damaged
301
+ # family into a timestamped incident dir (never delete evidence) → REBUILD a
302
+ # fresh index from the journal → return True so ``open_db`` retries the open
303
+ # once. A second failure surfaces loudly.
304
+ #
305
+ # The ingest-lock acquire is BOUNDED (not indefinite): a corruption surfacing
306
+ # from *inside* a ``run_stats_ingest`` cycle already holds the ingest lock, so an
307
+ # indefinite wait would self-deadlock. The bounded wait proceeds without the lock
308
+ # on timeout — correctness does not depend on it, because the rebuild writes a
309
+ # scratch index and ATOMICALLY swaps it in while the damaged family is already
310
+ # quarantined out of the way (a concurrent writer's writes land on the
311
+ # quarantined inode and are discarded).
312
+ #
313
+ # ``_HEAL_ACTIVE`` is a re-entrancy guard: the rebuild's own ``open_db`` calls use
314
+ # ``_target_path`` (auto-heal disarmed) and the post-heal retry opens the FRESH
315
+ # index, so this is belt-and-suspenders against any nested corruption looping.
316
+
317
+ _HEAL_ACTIVE = False
318
+
319
+
320
+ def _heal_flock_blocking(path) -> int:
321
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
322
+ fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
323
+ try:
324
+ fcntl.flock(fd, fcntl.LOCK_EX)
325
+ except BaseException:
326
+ os.close(fd)
327
+ raise
328
+ return fd
329
+
330
+
331
+ def _heal_flock_bounded(path, timeout_s: float) -> int:
332
+ """Bounded EX flock: return the held fd, or (on timeout) the OPEN fd WITHOUT
333
+ the lock held — the caller proceeds best-effort (see the module note)."""
334
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
335
+ fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
336
+ deadline = time.monotonic() + timeout_s
337
+ try:
338
+ while True:
339
+ try:
340
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
341
+ return fd
342
+ except (BlockingIOError, OSError):
343
+ if time.monotonic() >= deadline:
344
+ return fd
345
+ time.sleep(0.02)
346
+ except BaseException:
347
+ os.close(fd)
348
+ raise
349
+
350
+
351
+ def _heal_release_flock(fd: int) -> None:
352
+ try:
353
+ fcntl.flock(fd, fcntl.LOCK_UN)
354
+ finally:
355
+ os.close(fd)
356
+
357
+
358
+ def _probe_stats_ok(path) -> bool:
359
+ """Raw read-only probe (NEVER ``open_db`` — no schema/heal side effects) of
360
+ whether stats.db opens cleanly. Used for the locked re-check."""
361
+ if not path.exists():
362
+ return False
363
+ try:
364
+ c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
365
+ try:
366
+ c.execute("SELECT 1").fetchone()
367
+ finally:
368
+ c.close()
369
+ return True
370
+ except sqlite3.DatabaseError:
371
+ return False
372
+
373
+
374
+ def _stats_heal_hook(store: str, exc: Exception) -> bool:
375
+ """Classifier-gated corruption auto-heal for stats.db (spec §6.3). Returns
376
+ True when it healed (quarantined + rebuilt) OR a sibling already healed under
377
+ the maintenance lock; False when it DECLINES — a non-corruption
378
+ ``DatabaseError`` (BUSY / disk-full / permission), the dev-checkout-on-prod
379
+ guard, or re-entrancy. A False return leaves ``open_db`` to raise its guided
380
+ ``StatsDbCorruptError``."""
381
+ global _HEAL_ACTIVE
382
+ if store != "stats":
383
+ return False
384
+ if not _cctally_db._is_sqlite_corruption_error(exc):
385
+ return False # BUSY / disk-full / permission / SQL error — never heal
386
+ if _HEAL_ACTIVE:
387
+ return False
388
+ path = _cctally_core.DB_PATH
389
+ if _cctally_db._would_block_prod_stats(path):
390
+ print(
391
+ "[heal] refusing to auto-heal the prod stats.db from a dev checkout; "
392
+ "run the installed binary or `cctally db repair --db stats --yes`.",
393
+ file=sys.stderr,
394
+ )
395
+ return False
396
+ # No journal content ⇒ nothing to rebuild FROM. A rebuild would produce an
397
+ # EMPTY index — data loss for a pre-cutover corrupt DB whose legacy history
398
+ # was never journaled (spec §9: `db repair` is the transitional path there).
399
+ # Decline so open_db raises its guided StatsDbCorruptError → `db repair`.
400
+ import _cctally_journal
401
+ hw = _cctally_journal.journal_high_water()
402
+ if hw is None or hw[1] == 0:
403
+ return False
404
+ _HEAL_ACTIVE = True
405
+ try:
406
+ maint_fd = _heal_flock_blocking(_cctally_core.STATS_LOCK_MAINTENANCE_PATH)
407
+ try:
408
+ if _probe_stats_ok(path):
409
+ return True # a sibling process already healed it — retry the open
410
+ _cctally_db.write_corruption_forensics(path, db_label="stats")
411
+ ingest_fd = _heal_flock_bounded(
412
+ _cctally_core.JOURNAL_INGEST_LOCK_PATH, 5.0)
413
+ try:
414
+ _cctally_db.quarantine_db_family(path)
415
+ import _cctally_journal
416
+ _cctally_journal.rebuild_stats_index()
417
+ finally:
418
+ _heal_release_flock(ingest_fd)
419
+ print(
420
+ f"[heal] stats.db was corrupt ({exc}); quarantined its file family "
421
+ "under quarantine/ (forensics in logs/) and rebuilt a fresh index "
422
+ "from the journal.",
423
+ file=sys.stderr,
424
+ )
425
+ return True
426
+ finally:
427
+ _heal_release_flock(maint_fd)
428
+ except Exception as heal_exc:
429
+ print(f"[heal] stats.db auto-heal failed: {heal_exc}", file=sys.stderr)
430
+ return False
431
+ finally:
432
+ _HEAL_ACTIVE = False
433
+
434
+
435
+ HEAL_HOOK = _stats_heal_hook
436
+
437
+
438
+ # --------------------------------------------------------------------------
439
+ # §7.1 stats.db epoch-mismatch resolution (Task 9)
440
+ # --------------------------------------------------------------------------
441
+ #
442
+ # A stats.db whose ``user_version`` is neither legacy (<= LEGACY_STATS_HEAD) nor
443
+ # the current ``STATS_INDEX_EPOCH`` — a future epoch written by a newer binary,
444
+ # or any stray value > 13 — resolves by journal REBUILD (spec §7.1). This is
445
+ # DISJOINT from the corruption heal path: the DB is readable, only its version is
446
+ # wrong. The version-ahead DB is quarantined (nothing deleted), then a fresh
447
+ # index is rebuilt from the journal and swapped in. A mismatch with NO journal is
448
+ # a HARD ERROR (``StatsEpochMismatchError``) — never a silent rebuild-to-empty.
449
+
450
+ _EPOCH_MISMATCH_ACTIVE = False
451
+
452
+
453
+ def _raw_user_version(path) -> int:
454
+ """Read ``PRAGMA user_version`` via a raw RO connect (no open_db side
455
+ effects). -1 when the file cannot be read."""
456
+ try:
457
+ c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
458
+ try:
459
+ return c.execute("PRAGMA user_version").fetchone()[0]
460
+ finally:
461
+ c.close()
462
+ except sqlite3.DatabaseError:
463
+ return -1
464
+
465
+
466
+ def resolve_stats_epoch_mismatch():
467
+ """Resolve a version-mismatched stats.db by journal rebuild (spec §7.1).
468
+ Called by ``open_db`` after ``conn.close()`` — returns a fresh steady-state
469
+ ``open_db()`` connection over the rebuilt index."""
470
+ global _EPOCH_MISMATCH_ACTIVE
471
+ import _cctally_journal
472
+
473
+ path = _cctally_core.DB_PATH
474
+ if _cctally_db._would_block_prod_stats(path):
475
+ # A dev/worktree binary must not rebuild the real prod stats.db (#146).
476
+ raise _cctally_db.ProdMigrationRefused("stats.db", "epoch-rebuild")
477
+ if _EPOCH_MISMATCH_ACTIVE:
478
+ raise _cctally_db.StatsEpochMismatchError(
479
+ "stats.db epoch rebuild did not converge to epoch "
480
+ f"{_cctally_core.STATS_INDEX_EPOCH}; refusing to loop. Inspect the "
481
+ "journal and run `cctally db rebuild --db stats`.")
482
+ _EPOCH_MISMATCH_ACTIVE = True
483
+ try:
484
+ maint_fd = _heal_flock_blocking(_cctally_core.STATS_LOCK_MAINTENANCE_PATH)
485
+ try:
486
+ # Locked re-check: a sibling process may have already rebuilt it.
487
+ if _raw_user_version(path) != _cctally_core.STATS_INDEX_EPOCH:
488
+ hw = _cctally_journal.journal_high_water()
489
+ if hw is None or hw[1] == 0:
490
+ raise _cctally_db.StatsEpochMismatchError(
491
+ f"stats.db is at index epoch {_raw_user_version(path)}, "
492
+ f"but this cctally builds epoch "
493
+ f"{_cctally_core.STATS_INDEX_EPOCH} and no journal is "
494
+ "present to rebuild from. The journal/ directory is the "
495
+ "durable source — restore it from backup, then run "
496
+ "`cctally db rebuild --db stats`.")
497
+ # Preserve the version-ahead DB (nothing deleted), then rebuild a
498
+ # fresh epoch index into the now-absent destination.
499
+ _cctally_db.quarantine_db_family(path)
500
+ _cctally_journal.rebuild_stats_index()
501
+ finally:
502
+ _heal_release_flock(maint_fd)
503
+ return _cctally_core.open_db()
504
+ finally:
505
+ _EPOCH_MISMATCH_ACTIVE = False