cctally 1.76.0 → 1.77.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.
@@ -429,16 +429,35 @@ def doctor_gather_state(
429
429
  # Conversation-sessions rollup consistency (#217 S1 / U9). Two cheap COUNTs
430
430
  # (graceful None on a missing table / unreadable DB) + an in-progress signal
431
431
  # so a transient mid-sync mismatch never WARNs. The in-progress signal is a
432
- # NON-BLOCKING cache.db.lock flock probe (a writer mid-walk holds it) OR the
432
+ # NON-BLOCKING conversations flock probe (a writer mid-walk holds it) OR the
433
433
  # presence of any pending reingest/split/backfill cache_meta flag — doctor
434
434
  # stays read-only and never blocks on the lock.
435
435
  conv_sessions_rollup_count = None
436
436
  conv_messages_distinct_sessions = None
437
437
  conv_rollup_sync_in_progress = False
438
- try:
439
- if _cctally_core.CACHE_DB_PATH.exists():
440
- conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
438
+ conversations_db_page_count = None
439
+ conversations_db_freelist_count = None
440
+ try:
441
+ if _cctally_core.CONVERSATIONS_DB_PATH.exists():
442
+ # This gather also runs inside dashboard snapshot precompute. A
443
+ # transcript writer may hold an exclusive SQLite lock, so use a
444
+ # read-only zero-timeout probe: conversation health can degrade,
445
+ # but it must never delay core snapshot freshness (#320).
446
+ conv_uri = (
447
+ _cctally_core.CONVERSATIONS_DB_PATH.resolve().as_uri()
448
+ + "?mode=ro"
449
+ )
450
+ conn = sqlite3.connect(conv_uri, uri=True, timeout=0.0)
441
451
  try:
452
+ try:
453
+ row = conn.execute("PRAGMA page_count").fetchone()
454
+ if row and row[0] is not None:
455
+ conversations_db_page_count = int(row[0])
456
+ row = conn.execute("PRAGMA freelist_count").fetchone()
457
+ if row and row[0] is not None:
458
+ conversations_db_freelist_count = int(row[0])
459
+ except sqlite3.Error:
460
+ pass
442
461
  try:
443
462
  row = conn.execute(
444
463
  "SELECT COUNT(*) FROM conversation_sessions"
@@ -472,12 +491,12 @@ def doctor_gather_state(
472
491
  pass
473
492
  finally:
474
493
  conn.close()
475
- # Non-blocking flock probe: if a writer (sync_cache / a reingest) holds
476
- # the cache.db.lock, the rollup may be mid-recompute → in progress. We
494
+ # Non-blocking flock probe: if a transcript writer/reingest holds the
495
+ # conversations.db lock, the rollup may be mid-recompute → in progress. We
477
496
  # acquire LOCK_EX|LOCK_NB and immediately release; failure (held) is the
478
497
  # signal. Never blocks (LOCK_NB), so doctor stays read-only + prompt.
479
498
  if not conv_rollup_sync_in_progress:
480
- lock_path = _cctally_core.CACHE_LOCK_PATH
499
+ lock_path = _cctally_core.CONVERSATIONS_LOCK_PATH
481
500
  if lock_path is not None and pathlib.Path(lock_path).exists():
482
501
  import fcntl as _fcntl
483
502
  lock_fh = open(str(lock_path), "w")
@@ -669,6 +688,15 @@ def doctor_gather_state(
669
688
  for _name, _lp in (
670
689
  ("cache.db.lock", _cctally_core.CACHE_LOCK_PATH),
671
690
  ("cache.db.codex.lock", _cctally_core.CACHE_LOCK_CODEX_PATH),
691
+ ("conversations.db.lock", _cctally_core.CONVERSATIONS_LOCK_PATH),
692
+ (
693
+ "conversations.db.codex.lock",
694
+ _cctally_core.CONVERSATIONS_LOCK_CODEX_PATH,
695
+ ),
696
+ (
697
+ "conversations.db.maintenance.lock",
698
+ _cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
699
+ ),
672
700
  ):
673
701
  if not _lp.exists():
674
702
  locks_held[_name] = False
@@ -877,6 +905,8 @@ def doctor_gather_state(
877
905
  # #315: read-only cache free-page evidence for the reclaim hint.
878
906
  cache_db_page_count=cache_db_page_count,
879
907
  cache_db_freelist_count=cache_db_freelist_count,
908
+ conversations_db_page_count=conversations_db_page_count,
909
+ conversations_db_freelist_count=conversations_db_freelist_count,
880
910
  codex_quota_windows=codex_quota_windows,
881
911
  codex_hook_roots=codex_hook_roots,
882
912
  codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
@@ -2924,7 +2924,7 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
2924
2924
  )
2925
2925
  db_vacuum.add_argument(
2926
2926
  "--db",
2927
- choices=("cache", "stats", "all"),
2927
+ choices=("cache", "conversations", "stats", "all"),
2928
2928
  default="cache",
2929
2929
  help="Which DB to VACUUM (default: cache)",
2930
2930
  )
@@ -279,33 +279,32 @@ def load_codex_quota_observations(
279
279
  }
280
280
  return required <= columns
281
281
 
282
- has_conversation_events = has_columns(
283
- "codex_conversation_events",
284
- {"source_path", "line_offset", "record_type", "payload_json"},
285
- )
286
282
  has_session_entries = has_columns(
287
283
  "codex_session_entries", {"source_path", "line_offset", "model"},
288
284
  )
289
- if has_conversation_events:
290
- model_expr = """
291
- (SELECT json_extract(events.payload_json, '$.payload.model')
292
- FROM codex_conversation_events AS events
293
- WHERE events.source_path=quota_window_snapshots.source_path
294
- AND events.line_offset<=quota_window_snapshots.line_offset
295
- AND events.record_type IN ('turn_context','session_meta')
296
- AND json_type(events.payload_json, '$.payload.model')='text'
297
- ORDER BY events.line_offset DESC LIMIT 1) AS observed_model
298
- """
299
- elif has_session_entries:
300
- model_expr = """
301
- (SELECT entries.model
302
- FROM codex_session_entries AS entries
303
- WHERE entries.source_path=quota_window_snapshots.source_path
304
- AND entries.line_offset<=quota_window_snapshots.line_offset
305
- ORDER BY entries.line_offset DESC LIMIT 1) AS observed_model
306
- """
307
- else:
308
- model_expr = "NULL AS observed_model"
285
+ has_observed_model = has_columns(
286
+ "quota_window_snapshots", {"observed_model"},
287
+ )
288
+ entry_lookup = (
289
+ "(SELECT entries.model FROM codex_session_entries AS entries "
290
+ "WHERE entries.source_path=quota_window_snapshots.source_path "
291
+ "AND entries.line_offset<=quota_window_snapshots.line_offset "
292
+ "ORDER BY entries.line_offset DESC LIMIT 1)"
293
+ )
294
+ # A file's terminal model is not evidence for an earlier quota sample:
295
+ # the model may change later in the rollout. Prefer the compact stamp,
296
+ # then only a nearest-prior accounting context for legacy rows. If
297
+ # neither exists, leave the pool unscoped rather than fabricating it.
298
+ fallback_model = entry_lookup if has_session_entries else "NULL"
299
+ selected_model = (
300
+ f"COALESCE(quota_window_snapshots.observed_model,{fallback_model})"
301
+ if has_observed_model and fallback_model != "NULL"
302
+ else (
303
+ "quota_window_snapshots.observed_model"
304
+ if has_observed_model else fallback_model
305
+ )
306
+ )
307
+ model_expr = f"{selected_model} AS observed_model"
309
308
  sql = """
310
309
  SELECT source, source_root_key, source_path, line_offset,
311
310
  captured_at_utc, observed_slot, logical_limit_key, limit_id,
@@ -86,7 +86,7 @@ def _cmd_transcript_export(args) -> int:
86
86
  eprint(_SPEED_ONLY_CODEX_MSG)
87
87
  return 2
88
88
 
89
- conn = c.open_cache_db()
89
+ conn = c.open_conversations_db()
90
90
  try:
91
91
  cq = c._load_sibling("_lib_conversation_query")
92
92
  md = cq.get_conversation_export(conn, session_id, scope)
@@ -118,7 +118,7 @@ def _cmd_transcript_export_qualified(
118
118
  return 2
119
119
  speed = c._resolve_codex_speed(speed_arg or "auto")
120
120
 
121
- conn = c.open_cache_db()
121
+ conn = c.open_conversations_db()
122
122
  try:
123
123
  env = disp.neutral_export(
124
124
  conn, session_id, scope=scope, effective_speed=speed)
@@ -189,7 +189,7 @@ def _cmd_transcript_search_claude(args) -> int:
189
189
  eprint(f"transcript: {exc}")
190
190
  return 2
191
191
 
192
- conn = c.open_cache_db()
192
+ conn = c.open_conversations_db()
193
193
  try:
194
194
  cq = c._load_sibling("_lib_conversation_query")
195
195
  try:
@@ -264,7 +264,7 @@ def _cmd_transcript_search_codex(args) -> int:
264
264
  eprint("transcript: invalid --cursor")
265
265
  return 2
266
266
 
267
- conn = c.open_cache_db()
267
+ conn = c.open_conversations_db()
268
268
  try:
269
269
  result = disp.neutral_search(
270
270
  conn, query, source="codex", kind=kind,
@@ -1772,40 +1772,12 @@ def _tui_build_sessions(
1772
1772
  (), now_utc=now_utc, limit=limit, display_tz=None,
1773
1773
  aggregated_override=aggregated_override,
1774
1774
  )
1775
- rows = list(view.rows)
1776
- # Attach human-readable titles for the dashboard/TUI Sessions surface.
1777
- # Reuses the conversation viewer's title derivation (AI title wins, else
1778
- # first non-marker human line), which reads the CACHE db
1779
- # (conversation_messages / conversation_ai_titles). Fail-soft: any cache
1780
- # failure leaves titles ``None`` -> the client renders an em-dash. One
1781
- # bounded indexed query (<= `limit` session ids, rides idx_conv_session_ts)
1782
- # per snapshot build. Titles are stashed unconditionally on this
1783
- # server-internal row; the privacy gate is applied later, at envelope
1784
- # serialization (``snapshot_to_envelope(transcripts_visible=...)``).
1785
- if rows:
1786
- session_ids = [r.session_id for r in rows if r.session_id]
1787
- try:
1788
- conn = c.open_cache_db()
1789
- except (sqlite3.DatabaseError, OSError):
1790
- conn = None
1791
- if conn is not None:
1792
- try:
1793
- titles = c._load_sibling(
1794
- "_lib_conversation_query"
1795
- )._session_titles_map(conn, session_ids)
1796
- rows = [
1797
- dataclasses.replace(r, title=titles.get(r.session_id))
1798
- if r.session_id in titles else r
1799
- for r in rows
1800
- ]
1801
- except (sqlite3.DatabaseError, OSError):
1802
- pass # fail-soft: leave titles None
1803
- finally:
1804
- try:
1805
- conn.close()
1806
- except sqlite3.Error:
1807
- pass
1808
- return rows
1775
+ # #320: transcript-derived titles are optional decoration. The core
1776
+ # dashboard/TUI snapshot must never open conversations.db, because even a
1777
+ # fail-soft read pays SQLite's lock timeout before it can fail. Conversation
1778
+ # routes retain title derivation; the accounting Sessions panel renders its
1779
+ # existing em-dash fallback when the independent store is unavailable.
1780
+ return list(view.rows)
1809
1781
 
1810
1782
 
1811
1783
  def _tui_sessions_cached(
@@ -71,9 +71,25 @@ _SEARCH_BADGE = {
71
71
 
72
72
 
73
73
  def codex_normalization_authoritative(conn: sqlite3.Connection) -> bool:
74
- """True iff migration 025 is stamped applied i.e. the normalized corpus is
75
- authoritative (§3.5). A missing ``schema_migrations`` table (bare
76
- ``_apply_cache_schema`` conn, or a pre-migration cache) reads as pending."""
74
+ """True iff the normalized Codex corpus is authoritative (§3.5).
75
+
76
+ Split stores use their provider-local rebuild marker: current schema alone
77
+ is not authority while migration 028's byte-zero replay is pending. Legacy
78
+ monolithic/bare connections retain the migration-025 stamp contract.
79
+ """
80
+ try:
81
+ split = conn.execute(
82
+ "SELECT 1 FROM cache_meta "
83
+ "WHERE key='conversation_schema_version'"
84
+ ).fetchone() is not None
85
+ if split:
86
+ pending = conn.execute(
87
+ "SELECT 1 FROM cache_meta "
88
+ "WHERE key='conversation_rebuild_codex_pending'"
89
+ ).fetchone() is not None
90
+ return not pending
91
+ except sqlite3.OperationalError:
92
+ pass
77
93
  try:
78
94
  row = conn.execute(
79
95
  "SELECT 1 FROM schema_migrations WHERE name = ?",
@@ -2048,7 +2048,9 @@ def _assemble_memo_clear() -> None:
2048
2048
  def _assemble_memo_key(conn, session_id):
2049
2049
  """Return (session_id, msg_count, last_activity_utc, entry_mutation_seq), or
2050
2050
  None to signal 'bypass the memo' — a missing rollup row or an unreadable
2051
- cache_meta (bare/path-less conn). The caller has already confirmed the
2051
+ accounting ``cache_meta``. Split-store connections read that watermark
2052
+ from the read-only ``cache_db`` attachment; legacy/bare test connections
2053
+ retain the main-schema fallback. The caller has already confirmed the
2052
2054
  rollup is authoritative."""
2053
2055
  try:
2054
2056
  row = conn.execute(
@@ -2059,9 +2061,12 @@ def _assemble_memo_key(conn, session_id):
2059
2061
  if row is None:
2060
2062
  return None
2061
2063
  try:
2064
+ schemas = {row[1] for row in conn.execute("PRAGMA database_list")}
2065
+ meta_schema = "cache_db" if "cache_db" in schemas else "main"
2062
2066
  seq_row = conn.execute(
2063
- "SELECT value FROM cache_meta "
2064
- "WHERE key='session_entries_mutation_seq'").fetchone()
2067
+ f"SELECT value FROM {meta_schema}.cache_meta "
2068
+ "WHERE key='session_entries_mutation_seq'"
2069
+ ).fetchone()
2065
2070
  except sqlite3.OperationalError:
2066
2071
  return None
2067
2072
  seq = int(seq_row[0]) if seq_row and seq_row[0] is not None else 0
@@ -277,7 +277,7 @@ def _maybe_prune_conversation_retention(
277
277
  core.APP_DIR.mkdir(parents=True, exist_ok=True)
278
278
  except OSError:
279
279
  return None
280
- maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
280
+ maint_fh = open(core.CONVERSATIONS_LOCK_MAINTENANCE_PATH, "w")
281
281
  try:
282
282
  try:
283
283
  fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -285,13 +285,13 @@ def _maybe_prune_conversation_retention(
285
285
  return None # another prune holds it; skip cleanly, do NOT stamp
286
286
  if not force and not _retention_due(conn, now_utc):
287
287
  return None
288
- claude_fh = open(core.CACHE_LOCK_PATH, "w")
288
+ claude_fh = open(core.CONVERSATIONS_LOCK_PATH, "w")
289
289
  try:
290
290
  try:
291
291
  fcntl.flock(claude_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
292
292
  except (BlockingIOError, OSError):
293
293
  return None # a Claude sync is mid-flight; retry next cycle
294
- codex_fh = open(core.CACHE_LOCK_CODEX_PATH, "w")
294
+ codex_fh = open(core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
295
295
  try:
296
296
  try:
297
297
  fcntl.flock(codex_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -206,6 +206,10 @@ class DoctorState:
206
206
  # cache was absent or unreadable; the check degrades to OK.
207
207
  cache_db_page_count: Optional[int] = None
208
208
  cache_db_freelist_count: Optional[int] = None
209
+ # #320: the independently large transcript store needs the same reclaim
210
+ # visibility, with remediation targeted at its own VACUUM surface.
211
+ conversations_db_page_count: Optional[int] = None
212
+ conversations_db_freelist_count: Optional[int] = None
209
213
  # #294 S2: root-qualified physical Codex quota freshness, per-root native
210
214
  # hook state, and lifecycle activity are gathered by _cctally_doctor.
211
215
  codex_quota_windows: Optional[list[dict]] = None
@@ -1235,21 +1239,22 @@ def _check_data_conversation_sessions_rollup(s: DoctorState) -> CheckResult:
1235
1239
  row per distinct ``conversation_messages.session_id`` (#217 S1 / U9).
1236
1240
 
1237
1241
  OK when the two counts are equal, when either is ``None`` (pre-rollup or an
1238
- unreadable cache.db — consistent with the kernel's graceful-degrade posture),
1242
+ unreadable conversations.db — consistent with the kernel's graceful-degrade
1243
+ posture),
1239
1244
  OR when a sync/reingest/backfill is in progress. WARN ONLY on a mismatch
1240
1245
  observed in a QUIESCENT cache.
1241
1246
 
1242
- False-WARN avoidance (Codex P2): ``sync_cache`` commits
1247
+ False-WARN avoidance (Codex P2): ``sync_claude_conversations`` commits
1243
1248
  ``conversation_messages`` per file *before* the ``conversation_sessions``
1244
1249
  recompute, and resumable reingest commits per file before its rollup
1245
1250
  completes — so an unsynchronized read can transiently mismatch. The
1246
1251
  in-progress signal (``conv_rollup_sync_in_progress``) is set by
1247
- ``doctor_gather_state`` from a NON-BLOCKING ``cache.db.lock`` flock probe
1252
+ ``doctor_gather_state`` from a NON-BLOCKING ``conversations.db.lock`` flock probe
1248
1253
  (lock held ⇒ a writer is mid-flight) AND the presence of any pending
1249
1254
  ``cache_meta`` reingest/split/backfill flag; if either says in-progress, this
1250
1255
  stays OK. Doctor remains read-only and never blocks on the lock.
1251
1256
 
1252
- Informational only (no remediation): the next full ``sync_cache`` re-derives
1257
+ Informational only (no remediation): the next conversation sync re-derives
1253
1258
  the rollup via its incremental DELETE+INSERT.
1254
1259
  """
1255
1260
  rollup = s.conv_sessions_rollup_count
@@ -1773,6 +1778,49 @@ def _check_db_reclaimable(s: DoctorState) -> CheckResult:
1773
1778
  )
1774
1779
 
1775
1780
 
1781
+ def _check_db_conversations_reclaimable(s: DoctorState) -> CheckResult:
1782
+ """Surface transcript-store free pages without mutating the DB (#320)."""
1783
+ page_count = s.conversations_db_page_count
1784
+ freelist_count = s.conversations_db_freelist_count
1785
+ ratio = None
1786
+ if (
1787
+ isinstance(page_count, int)
1788
+ and not isinstance(page_count, bool)
1789
+ and page_count > 0
1790
+ and isinstance(freelist_count, int)
1791
+ and not isinstance(freelist_count, bool)
1792
+ and 0 <= freelist_count <= page_count
1793
+ ):
1794
+ ratio = freelist_count / page_count
1795
+ details = {
1796
+ "conversations_db_page_count": page_count,
1797
+ "conversations_db_freelist_count": freelist_count,
1798
+ "conversations_db_free_ratio": ratio,
1799
+ "warn_ratio": DOCTOR_RECLAIMABLE_WARN_RATIO,
1800
+ }
1801
+ if ratio is not None and ratio >= DOCTOR_RECLAIMABLE_WARN_RATIO:
1802
+ return CheckResult(
1803
+ id="db.conversations_reclaimable",
1804
+ title="Reclaimable transcript space",
1805
+ severity="warn",
1806
+ summary=(
1807
+ f"high — {ratio * 100:.1f}% of conversations.db pages are free"
1808
+ ),
1809
+ remediation=(
1810
+ "Run `cctally db vacuum --db conversations` to reclaim disk space."
1811
+ ),
1812
+ details=details,
1813
+ )
1814
+ return CheckResult(
1815
+ id="db.conversations_reclaimable",
1816
+ title="Reclaimable transcript space",
1817
+ severity="ok",
1818
+ summary="below threshold",
1819
+ remediation=None,
1820
+ details=details,
1821
+ )
1822
+
1823
+
1776
1824
  # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
1777
1825
  # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
1778
1826
  # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
@@ -1808,6 +1856,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1808
1856
  ("db.lock_state", "_check_db_lock_state"),
1809
1857
  ("db.wal_size", "_check_db_wal_size"),
1810
1858
  ("db.reclaimable", "_check_db_reclaimable"),
1859
+ ("db.conversations_reclaimable", "_check_db_conversations_reclaimable"),
1811
1860
  )),
1812
1861
  ("data", "Data", (
1813
1862
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
package/bin/cctally CHANGED
@@ -1008,6 +1008,9 @@ get_codex_entries = _cctally_cache.get_codex_entries
1008
1008
  _sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
1009
1009
  get_entries = _cctally_cache.get_entries
1010
1010
  open_cache_db = _cctally_cache.open_cache_db
1011
+ open_conversations_db = _cctally_cache.open_conversations_db
1012
+ sync_claude_conversations = _cctally_cache.sync_claude_conversations
1013
+ sync_codex_conversations = _cctally_cache.sync_codex_conversations
1011
1014
  _reset_orphan_warning_throttle = _cctally_cache._reset_orphan_warning_throttle
1012
1015
  cmd_cache_sync = _cctally_cache.cmd_cache_sync
1013
1016