cctally 1.76.0 → 1.78.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.
@@ -20,9 +20,9 @@ What lives here (spec §6):
20
20
  (``_require_transcripts_allowed`` / ``_transcript_gate`` / …) STAY on the
21
21
  class and are reached via the ``handler`` parameter (spec §6).
22
22
 
23
- Late-binding (spec §6): the two bare ``open_cache_db`` calls (in
23
+ Late-binding (spec §6): the conversation DB opener calls (in
24
24
  ``_run_conversation_query_impl`` + the events handler) reach
25
- ``sys.modules["_cctally_dashboard"].open_cache_db(...)`` — patched on the
25
+ ``sys.modules["_cctally_dashboard"].open_conversations_db(...)`` — patched on the
26
26
  dashboard module object at ``tests/test_conversation_endpoints.py:674``.
27
27
 
28
28
  Cross-module reaches (spec §2.1 "fully-qualify cross-module refs"): the
@@ -38,13 +38,19 @@ edits — the forwarders hit ``sys.modules["_cctally_dashboard"]`` at call time
38
38
  """
39
39
  from __future__ import annotations
40
40
 
41
- import json
42
41
  import re
43
42
  import socket
44
43
  import sqlite3
45
44
  import sys
46
45
 
47
- from _cctally_cache import sync_cache, sync_codex_cache, _codex_provider_roots
46
+ from _cctally_cache import (
47
+ open_cache_db,
48
+ sync_codex_cache,
49
+ sync_claude_conversations,
50
+ sync_codex_conversations,
51
+ _codex_provider_roots,
52
+ )
53
+ from _lib_dashboard_json import encode_dashboard_json
48
54
 
49
55
  # Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
50
56
  # below, so moved here with the events handler (spec §4.1 / §6).
@@ -91,7 +97,7 @@ class _BadConversationFilter(Exception):
91
97
 
92
98
 
93
99
  def _cached_file_sigs(conn, paths):
94
- """{path: size_bytes} from session_files for the given paths — the cache's
100
+ """{path: size_bytes} from conversation_source_files for the given paths — the transcript cache's
95
101
  own view of how far each file is ingested. Size-only by design, matching the
96
102
  watch kernel's size-only signature (`file_sig`) and sync_cache's size-only
97
103
  delta signal: mtime is NOT consulted, because a size-unchanged ingest does
@@ -105,7 +111,7 @@ def _cached_file_sigs(conn, paths):
105
111
  placeholders = ",".join("?" for _ in paths)
106
112
  try:
107
113
  rows = conn.execute(
108
- f"SELECT path, size_bytes FROM session_files "
114
+ f"SELECT path, size_bytes FROM conversation_source_files "
109
115
  f"WHERE path IN ({placeholders})", list(paths)).fetchall()
110
116
  except sqlite3.OperationalError:
111
117
  return out
@@ -115,7 +121,7 @@ def _cached_file_sigs(conn, paths):
115
121
 
116
122
 
117
123
  def _codex_cached_file_sigs(conn, paths):
118
- """{path: size_bytes} from ``codex_session_files`` for the given paths — the
124
+ """{path: size_bytes} from ``codex_conversation_source_files`` for the given paths — the
119
125
  Codex analogue of ``_cached_file_sigs`` (spec §5.2). Size-only, matching the
120
126
  watch kernel's size-only signature and ``sync_codex_cache``'s size-only
121
127
  delta. Paths with no row are absent → treated as changed. Baselines the
@@ -129,7 +135,7 @@ def _codex_cached_file_sigs(conn, paths):
129
135
  placeholders = ",".join("?" for _ in paths)
130
136
  try:
131
137
  rows = conn.execute(
132
- f"SELECT path, size_bytes FROM codex_session_files "
138
+ f"SELECT path, size_bytes FROM codex_conversation_source_files "
133
139
  f"WHERE path IN ({placeholders})", list(paths)).fetchall()
134
140
  except sqlite3.OperationalError:
135
141
  return out
@@ -144,7 +150,7 @@ def _codex_all_committed_sizes(conn):
144
150
  baseline. A plain SELECT (no lock), so it never widens the SSE lock scope."""
145
151
  try:
146
152
  rows = conn.execute(
147
- "SELECT path, size_bytes FROM codex_session_files").fetchall()
153
+ "SELECT path, size_bytes FROM codex_conversation_source_files").fetchall()
148
154
  except sqlite3.OperationalError:
149
155
  return {}
150
156
  return {p: size for (p, size) in rows}
@@ -162,7 +168,7 @@ def _codex_classified_paths(conn, paths):
162
168
  placeholders = ",".join("?" for _ in paths)
163
169
  try:
164
170
  rows = conn.execute(
165
- f"SELECT path FROM codex_session_files "
171
+ f"SELECT path FROM codex_conversation_source_files "
166
172
  f"WHERE path IN ({placeholders}) AND last_conversation_key IS NOT NULL",
167
173
  list(paths)).fetchall()
168
174
  except sqlite3.OperationalError:
@@ -444,12 +450,12 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
444
450
  has ALREADY been sent and the caller must just ``return``; ``ok=True``
445
451
  carries the kernel result (which may itself be ``None`` — the reader's
446
452
  404 sentinel — so the explicit flag, not ``body is None``, signals
447
- failure). An ``open_cache_db`` failure is a ``cache unavailable:`` 500;
453
+ failure). An ``open_conversations_db`` failure is a ``cache unavailable:`` 500;
448
454
  a kernel exception is logged as ``<log_label> failed: %r`` and returned
449
455
  as a ``{type}: {msg}`` 500 — byte-identical to the inlined handlers.
450
456
  """
451
457
  try:
452
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
458
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
453
459
  except (sqlite3.DatabaseError, OSError) as exc:
454
460
  handler._respond_json(500, {"error": f"cache unavailable: {exc}"})
455
461
  return False, None
@@ -703,7 +709,8 @@ def _run_conversation_events_stream(
703
709
  import time as _time
704
710
  watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
705
711
  tail_frame = (
706
- "event: tail\ndata: " + json.dumps(tail_data) + "\n\n").encode("utf-8")
712
+ "event: tail\ndata: " + encode_dashboard_json(tail_data) + "\n\n"
713
+ ).encode("utf-8")
707
714
 
708
715
  if passive:
709
716
  # Frozen-data contract: no ingest, no emit. Keep-alive only.
@@ -785,7 +792,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
785
792
  _send_sse_headers(handler)
786
793
  passive = bool(type(handler).no_sync)
787
794
  try:
788
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
795
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
789
796
  except (sqlite3.DatabaseError, OSError):
790
797
  # Cache unavailable — degrade to keep-alive only; client backstop
791
798
  # tick still surfaces turns. (Headers already sent; can't 500.)
@@ -796,7 +803,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
796
803
  return cq.session_source_paths(conn, session_id) if conn else []
797
804
 
798
805
  def _ingest(changed):
799
- return sync_cache(conn, only_paths=set(changed))
806
+ return sync_claude_conversations(conn, only_paths=set(changed))
800
807
 
801
808
  try:
802
809
  _run_conversation_events_stream(
@@ -840,7 +847,24 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
840
847
  if not to_ingest:
841
848
  return files, False
842
849
  try:
843
- sync_codex_cache(conn, only_paths=set(to_ingest))
850
+ # A newly discovered child has no compact thread row yet. Advance
851
+ # the independent core cursor first so transcript normalization can
852
+ # link the child through the read-only attached cache, then commit
853
+ # the transcript cursor. Neither lock is held across the other.
854
+ core = open_cache_db()
855
+ try:
856
+ core_stats = sync_codex_cache(
857
+ core, only_paths=set(to_ingest)
858
+ )
859
+ finally:
860
+ core.close()
861
+ if not core_stats.targeted_clean:
862
+ return files, False
863
+ transcript_stats = sync_codex_conversations(
864
+ conn, only_paths=set(to_ingest)
865
+ )
866
+ if not transcript_stats.targeted_clean:
867
+ return files, False
844
868
  except sqlite3.DatabaseError:
845
869
  return files, False
846
870
  # Reap the now-classified candidates (child → already widened; non-child
@@ -866,7 +890,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
866
890
  targeted ingest + the directory-frontier child discovery."""
867
891
  disp = _conversation_dispatch_impl()
868
892
  try:
869
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
893
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
870
894
  except (sqlite3.DatabaseError, OSError):
871
895
  conn = None
872
896
 
@@ -921,7 +945,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
921
945
  return cq_codex.codex_conversation_source_paths(conn, key)
922
946
 
923
947
  def _ingest(changed):
924
- return sync_codex_cache(conn, only_paths=set(changed))
948
+ return sync_codex_conversations(conn, only_paths=set(changed))
925
949
 
926
950
  cached = lambda paths: _codex_cached_file_sigs(conn, paths)
927
951
  discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
@@ -932,7 +956,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
932
956
  return cq.session_source_paths(conn, native)
933
957
 
934
958
  def _ingest(changed):
935
- return sync_cache(conn, only_paths=set(changed))
959
+ return sync_claude_conversations(conn, only_paths=set(changed))
936
960
 
937
961
  cached = lambda paths: _cached_file_sigs(conn, paths)
938
962
  discovery = None
@@ -49,7 +49,10 @@ from _lib_jsonl import CodexEntry, codex_model_scoped_quota_pool
49
49
  from _lib_fmt import stable_sum
50
50
  from _lib_aggregators import _aggregate_codex_buckets
51
51
  from _lib_five_hour import _FIVE_HOUR_JITTER_FLOOR_SECONDS
52
- from _lib_source_analytics import build_codex_project_result
52
+ from _lib_source_analytics import (
53
+ build_codex_project_result,
54
+ collision_safe_project_label_map,
55
+ )
53
56
  from _lib_view_models import (
54
57
  CodexWeeklyView,
55
58
  build_codex_daily_view,
@@ -733,22 +736,33 @@ def _codex_conversation_metadata(
733
736
 
734
737
  ``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
735
738
  Conversation rollup titles are derived from prompt text and therefore must
736
- never be substituted for that name on the dashboard. Accounting remains
737
- authoritative for totals; conversation rollups only decorate projects.
739
+ never be substituted for that name on the dashboard. Project attribution
740
+ is derived from the compact thread ``cwd``/``git_json`` retained in
741
+ ``cache.db`` so non-conversation panels never open ``conversations.db``.
738
742
  """
739
743
  metadata: dict[tuple[str, str], dict[str, object]] = {}
740
744
  try:
741
- rows = tuple(cache_conn.execute(
745
+ core_rows = tuple(cache_conn.execute(
742
746
  "SELECT t.source_root_key, t.source_path, t.native_thread_id, "
743
747
  "(SELECT e.session_id FROM codex_session_entries AS e "
744
748
  " WHERE e.source_root_key=t.source_root_key AND e.source_path=t.source_path "
745
749
  " ORDER BY e.id LIMIT 1) AS accounting_session_id, "
746
- "r.project_key, r.project_label, r.started_utc "
750
+ "t.cwd, t.git_json, t.first_seen_utc, t.last_seen_utc "
747
751
  "FROM codex_conversation_threads AS t "
748
- "LEFT JOIN codex_conversation_rollups AS r "
749
- "ON r.conversation_key=t.conversation_key "
750
- "ORDER BY r.last_activity_utc DESC, t.conversation_key DESC"
752
+ "ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
751
753
  ))
754
+ from _cctally_cache import _codex_conversation_project_attribution
755
+ rows = tuple(
756
+ (
757
+ root_key, source_path, native_thread_id, accounting_session_id,
758
+ *_codex_conversation_project_attribution(root_key, cwd, git_json),
759
+ first_seen_at,
760
+ )
761
+ for (
762
+ root_key, source_path, native_thread_id, accounting_session_id,
763
+ cwd, git_json, first_seen_at, _last_seen_at,
764
+ ) in core_rows
765
+ )
752
766
  file_aliases = tuple(cache_conn.execute(
753
767
  "SELECT source_root_key, path, last_native_thread_id, last_session_id "
754
768
  "FROM codex_session_files "
@@ -1772,12 +1786,17 @@ def _partial_projects_wire(
1772
1786
  model_totals[field] += value
1773
1787
  session_totals[field] += value
1774
1788
 
1789
+ label_map = collision_safe_project_label_map(
1790
+ (f"{root_key}\0{project_key}", str(group["label"]))
1791
+ for (root_key, project_key), group in groups.items()
1792
+ )
1775
1793
  rows = []
1776
1794
  for (root_key, _project_key), group in groups.items():
1795
+ internal_identity = f"{root_key}\0{group['project_key']}"
1777
1796
  rows.append({
1778
1797
  "key": dashboard_resource_key("project", "codex", root_key, group["project_key"]),
1779
1798
  "source": "codex",
1780
- "label": group["label"],
1799
+ "label": label_map[internal_identity],
1781
1800
  "session_count": len(group["sessions"]),
1782
1801
  "first_seen": group["first_seen"].astimezone(UTC).isoformat(),
1783
1802
  "last_seen": group["last_seen"].astimezone(UTC).isoformat(),
@@ -2648,6 +2648,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2648
2648
  plan_type TEXT,
2649
2649
  individual_limit_json TEXT,
2650
2650
  reached_type TEXT,
2651
+ observed_model TEXT,
2651
2652
  UNIQUE(source, source_path, line_offset, logical_limit_key),
2652
2653
  CHECK(source != 'codex' OR source_root_key IS NOT NULL)
2653
2654
  );
@@ -2893,6 +2894,12 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2893
2894
  add_column_if_missing(conn, "conversation_sessions", "git_branch", "TEXT")
2894
2895
  add_column_if_missing(conn, "conversation_sessions", "models_json", "TEXT")
2895
2896
  add_column_if_missing(conn, "conversation_sessions", "title", "TEXT")
2897
+ # #320: quota pool identity cannot depend on transcript events after the
2898
+ # store split. Stamp the active model directly on each compact physical
2899
+ # quota observation. Existing caches receive the nullable column here; 028
2900
+ # backfills it from the still-present legacy event corpus before dropping
2901
+ # that corpus.
2902
+ add_column_if_missing(conn, "quota_window_snapshots", "observed_model", "TEXT")
2896
2903
  conn.execute(
2897
2904
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
2898
2905
  "ON session_files(session_id)"
@@ -3014,6 +3021,72 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3014
3021
  conn.commit()
3015
3022
 
3016
3023
 
3024
+ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
3025
+ """Create the #320 transcript/search database schema.
3026
+
3027
+ The historical cache schema remains the migration-fixture source of truth.
3028
+ Reuse it here, then remove the compact accounting families so conversation
3029
+ queries can resolve those names through the read-only ``cache_db``
3030
+ attachment. Empty compatibility tables may still exist in cache.db for old
3031
+ migration handlers, but all live transcript rows belong here.
3032
+ """
3033
+ # The first open projects the historical monolithic schema into the new
3034
+ # store. Avoid repeating that create-then-drop work on every conversation
3035
+ # endpoint: it writes sqlite_schema and WAL pages even when no transcript
3036
+ # data changed. Future conversation schema revisions must bump this marker.
3037
+ try:
3038
+ current = conn.execute(
3039
+ "SELECT value FROM cache_meta "
3040
+ "WHERE key='conversation_schema_version'"
3041
+ ).fetchone()
3042
+ except sqlite3.OperationalError:
3043
+ current = None
3044
+ if current is not None and current[0] == "1":
3045
+ return
3046
+
3047
+ _apply_cache_schema(conn)
3048
+ conn.executescript(
3049
+ """
3050
+ DROP TABLE IF EXISTS session_entries;
3051
+ DROP TABLE IF EXISTS session_files;
3052
+ DROP TABLE IF EXISTS codex_session_entries;
3053
+ DROP TABLE IF EXISTS codex_session_files;
3054
+ DROP TABLE IF EXISTS quota_window_snapshots;
3055
+ DROP TABLE IF EXISTS codex_conversation_threads;
3056
+ DROP TABLE IF EXISTS codex_source_roots;
3057
+
3058
+ CREATE TABLE IF NOT EXISTS conversation_source_files (
3059
+ path TEXT PRIMARY KEY,
3060
+ size_bytes INTEGER NOT NULL,
3061
+ mtime_ns INTEGER NOT NULL,
3062
+ last_byte_offset INTEGER NOT NULL,
3063
+ last_ingested_at TEXT NOT NULL
3064
+ );
3065
+ CREATE TABLE IF NOT EXISTS codex_conversation_source_files (
3066
+ path TEXT PRIMARY KEY,
3067
+ size_bytes INTEGER NOT NULL,
3068
+ mtime_ns INTEGER NOT NULL,
3069
+ last_byte_offset INTEGER NOT NULL,
3070
+ last_ingested_at TEXT NOT NULL,
3071
+ source_root_key TEXT,
3072
+ last_session_id TEXT,
3073
+ last_model TEXT,
3074
+ last_total_tokens INTEGER,
3075
+ last_native_thread_id TEXT,
3076
+ last_root_thread_id TEXT,
3077
+ last_parent_thread_id TEXT,
3078
+ last_conversation_key TEXT,
3079
+ last_turn_id TEXT
3080
+ );
3081
+ """
3082
+ )
3083
+ conn.execute(
3084
+ "INSERT INTO cache_meta(key,value) VALUES "
3085
+ "('conversation_schema_version','1') "
3086
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value"
3087
+ )
3088
+
3089
+
3017
3090
  def _fts5_available(conn: sqlite3.Connection) -> bool:
3018
3091
  """True if this sqlite build can create an FTS5 table. Cheap probe on a
3019
3092
  temp table that is created then dropped. Hidden test seam: tests monkeypatch
@@ -4730,6 +4803,118 @@ def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
4730
4803
  lock_fh.close()
4731
4804
 
4732
4805
 
4806
+ @cache_migration("028_split_conversation_store")
4807
+ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
4808
+ """Move the re-derivable transcript corpus out of cache.db (#320).
4809
+
4810
+ The upgrade deliberately does not copy gigabytes of prose/events. It creates
4811
+ the independent current-schema store, arms provider-local byte-zero replay,
4812
+ then drops the legacy transcript tables from the hot cache. Compact Codex
4813
+ thread metadata stays in cache.db because accounting/project attribution
4814
+ joins it directly.
4815
+ """
4816
+ core = _cctally_core
4817
+ lock_paths = (
4818
+ core.CACHE_LOCK_MAINTENANCE_PATH,
4819
+ core.CACHE_LOCK_PATH,
4820
+ core.CACHE_LOCK_CODEX_PATH,
4821
+ core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
4822
+ core.CONVERSATIONS_LOCK_PATH,
4823
+ core.CONVERSATIONS_LOCK_CODEX_PATH,
4824
+ )
4825
+ held = []
4826
+ try:
4827
+ core.APP_DIR.mkdir(parents=True, exist_ok=True)
4828
+ for path in lock_paths:
4829
+ fh = open(path, "w")
4830
+ try:
4831
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
4832
+ except BlockingIOError:
4833
+ fh.close()
4834
+ raise MigrationGateNotMet(
4835
+ "cache/conversation sync lock held; deferring cache 028 split"
4836
+ )
4837
+ held.append(fh)
4838
+
4839
+ conv = sqlite3.connect(core.CONVERSATIONS_DB_PATH)
4840
+ try:
4841
+ conv.execute("PRAGMA auto_vacuum=INCREMENTAL")
4842
+ conv.execute("PRAGMA journal_mode=WAL")
4843
+ _apply_conversations_schema(conv)
4844
+ _set_cache_meta(conv, "conversation_rebuild_claude_pending", "1")
4845
+ _set_cache_meta(conv, "conversation_rebuild_codex_pending", "1")
4846
+ conv.commit()
4847
+ finally:
4848
+ conv.close()
4849
+
4850
+ conn.execute("BEGIN IMMEDIATE")
4851
+ try:
4852
+ # Preserve exact model-scoped quota-pool identity before discarding
4853
+ # the legacy physical event corpus. The correlated lookup is the
4854
+ # same nearest-prior context rule the quota reader used pre-split.
4855
+ if conn.execute(
4856
+ "SELECT 1 FROM sqlite_master "
4857
+ "WHERE type='table' AND name='codex_conversation_events'"
4858
+ ).fetchone() is not None:
4859
+ conn.execute(
4860
+ "UPDATE quota_window_snapshots AS q SET observed_model=("
4861
+ " SELECT json_extract(e.payload_json, '$.payload.model')"
4862
+ " FROM codex_conversation_events AS e"
4863
+ " WHERE e.source_path=q.source_path"
4864
+ " AND e.line_offset<=q.line_offset"
4865
+ " AND e.record_type IN ('turn_context','session_meta')"
4866
+ " AND json_valid(e.payload_json)"
4867
+ " AND json_type(e.payload_json, '$.payload.model')='text'"
4868
+ " ORDER BY e.line_offset DESC LIMIT 1)"
4869
+ " WHERE q.source='codex' AND q.observed_model IS NULL"
4870
+ )
4871
+ for drop in (
4872
+ "DROP TRIGGER IF EXISTS conv_fts_ai",
4873
+ "DROP TRIGGER IF EXISTS conv_fts_ad",
4874
+ "DROP TRIGGER IF EXISTS conv_fts_au",
4875
+ "DROP TRIGGER IF EXISTS conv_fts_aux_ai",
4876
+ "DROP TRIGGER IF EXISTS conv_fts_aux_ad",
4877
+ "DROP TRIGGER IF EXISTS conv_fts_aux_au",
4878
+ "DROP TRIGGER IF EXISTS conv_title_fts_ai",
4879
+ "DROP TRIGGER IF EXISTS conv_title_fts_ad",
4880
+ "DROP TRIGGER IF EXISTS conv_title_fts_au",
4881
+ "DROP TRIGGER IF EXISTS codex_conv_fts_ai",
4882
+ "DROP TRIGGER IF EXISTS codex_conv_fts_ad",
4883
+ "DROP TRIGGER IF EXISTS codex_conv_fts_au",
4884
+ "DROP TABLE IF EXISTS conversation_fts_aux",
4885
+ "DROP TABLE IF EXISTS conversation_fts",
4886
+ "DROP TABLE IF EXISTS conversation_title_fts",
4887
+ "DROP TABLE IF EXISTS codex_conversation_fts",
4888
+ "DROP TABLE IF EXISTS conversation_file_touches",
4889
+ "DROP TABLE IF EXISTS conversation_sessions",
4890
+ "DROP TABLE IF EXISTS conversation_ai_titles",
4891
+ "DROP TABLE IF EXISTS conversation_messages",
4892
+ "DROP TABLE IF EXISTS codex_conversation_file_touches",
4893
+ "DROP TABLE IF EXISTS codex_conversation_rollups",
4894
+ "DROP TABLE IF EXISTS codex_conversation_messages",
4895
+ "DROP TABLE IF EXISTS codex_conversation_events",
4896
+ ):
4897
+ conn.execute(drop)
4898
+ conn.commit()
4899
+ except Exception:
4900
+ conn.rollback()
4901
+ raise
4902
+ try:
4903
+ if conn.execute("PRAGMA auto_vacuum").fetchone()[0] == 2:
4904
+ # Python 3.11/Linux may defer this zero-column PRAGMA until the
4905
+ # cursor is exhausted; fetchall drives it to completion.
4906
+ conn.execute("PRAGMA incremental_vacuum").fetchall()
4907
+ except sqlite3.DatabaseError:
4908
+ pass
4909
+ finally:
4910
+ for fh in reversed(held):
4911
+ try:
4912
+ fcntl.flock(fh, fcntl.LOCK_UN)
4913
+ except OSError:
4914
+ pass
4915
+ fh.close()
4916
+
4917
+
4733
4918
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
4734
4919
 
4735
4920
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -7006,7 +7191,12 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
7006
7191
  # Serialize against the retention prune and concurrent vacuums via the
7007
7192
  # dedicated maintenance flock (F13/F7), then the provider flocks for
7008
7193
  # cache.db. All non-blocking: fail promptly rather than hang.
7009
- maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
7194
+ conversation_store = path == core.CONVERSATIONS_DB_PATH
7195
+ maintenance_path = (
7196
+ core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
7197
+ if conversation_store else core.CACHE_LOCK_MAINTENANCE_PATH
7198
+ )
7199
+ maint_fh = open(maintenance_path, "w")
7010
7200
  try:
7011
7201
  try:
7012
7202
  fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -7019,10 +7209,18 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
7019
7209
  held = []
7020
7210
  try:
7021
7211
  if provider_locked:
7022
- for lock_path, lname in (
7023
- (core.CACHE_LOCK_PATH, "claude"),
7024
- (core.CACHE_LOCK_CODEX_PATH, "codex"),
7025
- ):
7212
+ provider_locks = (
7213
+ (
7214
+ (core.CONVERSATIONS_LOCK_PATH, "claude conversations"),
7215
+ (core.CONVERSATIONS_LOCK_CODEX_PATH, "codex conversations"),
7216
+ )
7217
+ if conversation_store else
7218
+ (
7219
+ (core.CACHE_LOCK_PATH, "claude"),
7220
+ (core.CACHE_LOCK_CODEX_PATH, "codex"),
7221
+ )
7222
+ )
7223
+ for lock_path, lname in provider_locks:
7026
7224
  fh = open(lock_path, "w")
7027
7225
  try:
7028
7226
  fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -7063,6 +7261,10 @@ def cmd_db_vacuum(args: argparse.Namespace) -> int:
7063
7261
  targets = []
7064
7262
  if which in ("cache", "all"):
7065
7263
  targets.append((_cctally_core.CACHE_DB_PATH, "cache.db", True))
7264
+ if which in ("conversations", "all"):
7265
+ targets.append((
7266
+ _cctally_core.CONVERSATIONS_DB_PATH, "conversations.db", True,
7267
+ ))
7066
7268
  if which in ("stats", "all"):
7067
7269
  targets.append((_cctally_core.DB_PATH, "stats.db", False))
7068
7270
  overall = 0
@@ -27,6 +27,7 @@ import argparse
27
27
  import datetime as dt
28
28
  import fcntl
29
29
  import json
30
+ import math
30
31
  import pathlib
31
32
  import shutil
32
33
  import sqlite3
@@ -35,6 +36,7 @@ import sys
35
36
  import _cctally_core
36
37
  import _lib_changelog
37
38
  from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
39
+ from _lib_dashboard_json import encode_dashboard_json
38
40
 
39
41
 
40
42
  def _cctally():
@@ -53,11 +55,25 @@ def _gather_statusline_pipeline(c, *, now_utc: dt.datetime) -> dict:
53
55
  "tombstones": {"fiveHour": "absent", "sevenDay": "absent"},
54
56
  }
55
57
  try:
56
- result["transport_age_seconds"] = c._statusline_transport_age_seconds()
58
+ age = c._statusline_transport_age_seconds()
59
+ result["transport_age_seconds"] = (
60
+ age
61
+ if isinstance(age, (int, float))
62
+ and not isinstance(age, bool)
63
+ and math.isfinite(float(age))
64
+ else None
65
+ )
57
66
  except Exception:
58
67
  pass
59
68
  try:
60
- result["selected_age_seconds"] = c._statusline_observe_age_seconds()
69
+ age = c._statusline_observe_age_seconds()
70
+ result["selected_age_seconds"] = (
71
+ age
72
+ if isinstance(age, (int, float))
73
+ and not isinstance(age, bool)
74
+ and math.isfinite(float(age))
75
+ else None
76
+ )
61
77
  except Exception:
62
78
  pass
63
79
  try:
@@ -429,16 +445,35 @@ def doctor_gather_state(
429
445
  # Conversation-sessions rollup consistency (#217 S1 / U9). Two cheap COUNTs
430
446
  # (graceful None on a missing table / unreadable DB) + an in-progress signal
431
447
  # 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
448
+ # NON-BLOCKING conversations flock probe (a writer mid-walk holds it) OR the
433
449
  # presence of any pending reingest/split/backfill cache_meta flag — doctor
434
450
  # stays read-only and never blocks on the lock.
435
451
  conv_sessions_rollup_count = None
436
452
  conv_messages_distinct_sessions = None
437
453
  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))
454
+ conversations_db_page_count = None
455
+ conversations_db_freelist_count = None
456
+ try:
457
+ if _cctally_core.CONVERSATIONS_DB_PATH.exists():
458
+ # This gather also runs inside dashboard snapshot precompute. A
459
+ # transcript writer may hold an exclusive SQLite lock, so use a
460
+ # read-only zero-timeout probe: conversation health can degrade,
461
+ # but it must never delay core snapshot freshness (#320).
462
+ conv_uri = (
463
+ _cctally_core.CONVERSATIONS_DB_PATH.resolve().as_uri()
464
+ + "?mode=ro"
465
+ )
466
+ conn = sqlite3.connect(conv_uri, uri=True, timeout=0.0)
441
467
  try:
468
+ try:
469
+ row = conn.execute("PRAGMA page_count").fetchone()
470
+ if row and row[0] is not None:
471
+ conversations_db_page_count = int(row[0])
472
+ row = conn.execute("PRAGMA freelist_count").fetchone()
473
+ if row and row[0] is not None:
474
+ conversations_db_freelist_count = int(row[0])
475
+ except sqlite3.Error:
476
+ pass
442
477
  try:
443
478
  row = conn.execute(
444
479
  "SELECT COUNT(*) FROM conversation_sessions"
@@ -472,12 +507,12 @@ def doctor_gather_state(
472
507
  pass
473
508
  finally:
474
509
  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
510
+ # Non-blocking flock probe: if a transcript writer/reingest holds the
511
+ # conversations.db lock, the rollup may be mid-recompute → in progress. We
477
512
  # acquire LOCK_EX|LOCK_NB and immediately release; failure (held) is the
478
513
  # signal. Never blocks (LOCK_NB), so doctor stays read-only + prompt.
479
514
  if not conv_rollup_sync_in_progress:
480
- lock_path = _cctally_core.CACHE_LOCK_PATH
515
+ lock_path = _cctally_core.CONVERSATIONS_LOCK_PATH
481
516
  if lock_path is not None and pathlib.Path(lock_path).exists():
482
517
  import fcntl as _fcntl
483
518
  lock_fh = open(str(lock_path), "w")
@@ -669,6 +704,15 @@ def doctor_gather_state(
669
704
  for _name, _lp in (
670
705
  ("cache.db.lock", _cctally_core.CACHE_LOCK_PATH),
671
706
  ("cache.db.codex.lock", _cctally_core.CACHE_LOCK_CODEX_PATH),
707
+ ("conversations.db.lock", _cctally_core.CONVERSATIONS_LOCK_PATH),
708
+ (
709
+ "conversations.db.codex.lock",
710
+ _cctally_core.CONVERSATIONS_LOCK_CODEX_PATH,
711
+ ),
712
+ (
713
+ "conversations.db.maintenance.lock",
714
+ _cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
715
+ ),
672
716
  ):
673
717
  if not _lp.exists():
674
718
  locks_held[_name] = False
@@ -877,6 +921,8 @@ def doctor_gather_state(
877
921
  # #315: read-only cache free-page evidence for the reclaim hint.
878
922
  cache_db_page_count=cache_db_page_count,
879
923
  cache_db_freelist_count=cache_db_freelist_count,
924
+ conversations_db_page_count=conversations_db_page_count,
925
+ conversations_db_freelist_count=conversations_db_freelist_count,
880
926
  codex_quota_windows=codex_quota_windows,
881
927
  codex_hook_roots=codex_hook_roots,
882
928
  codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
@@ -913,7 +959,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
913
959
  state = c.doctor_gather_state(deep=True)
914
960
  report = _lib_doctor.run_checks(state)
915
961
  if getattr(args, "json", False):
916
- print(json.dumps(
962
+ print(encode_dashboard_json(
917
963
  _lib_doctor.serialize_json(report), indent=2, sort_keys=True,
918
964
  ))
919
965
  else:
@@ -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
  )