cctally 1.75.1 → 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.
@@ -60,8 +60,10 @@ def _init_paths_from_env() -> None:
60
60
  `import _cctally_core`).
61
61
  """
62
62
  global APP_DIR, LEGACY_APP_DIR, LOG_DIR, DEV_MODE
63
- global DB_PATH, CACHE_DB_PATH
63
+ global DB_PATH, CACHE_DB_PATH, CONVERSATIONS_DB_PATH
64
64
  global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CACHE_LOCK_MAINTENANCE_PATH
65
+ global CONVERSATIONS_LOCK_PATH, CONVERSATIONS_LOCK_CODEX_PATH
66
+ global CONVERSATIONS_LOCK_MAINTENANCE_PATH
65
67
  global CONFIG_LOCK_PATH
66
68
  global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
67
69
  global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
@@ -101,6 +103,7 @@ def _init_paths_from_env() -> None:
101
103
 
102
104
  DB_PATH = APP_DIR / "stats.db"
103
105
  CACHE_DB_PATH = APP_DIR / "cache.db"
106
+ CONVERSATIONS_DB_PATH = APP_DIR / "conversations.db"
104
107
 
105
108
  CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
106
109
  CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
@@ -108,6 +111,14 @@ def _init_paths_from_env() -> None:
108
111
  # retention prune across processes, held ABOVE the two provider flocks so a
109
112
  # rebuild/reingest cannot land between candidate selection and deletion.
110
113
  CACHE_LOCK_MAINTENANCE_PATH = APP_DIR / "cache.db.maintenance.lock"
114
+ # #320: transcript ingest and maintenance are physically independent from
115
+ # the latency-sensitive token/quota cache. Never reuse the cache.db flock
116
+ # namespace for conversations.db writes.
117
+ CONVERSATIONS_LOCK_PATH = APP_DIR / "conversations.db.lock"
118
+ CONVERSATIONS_LOCK_CODEX_PATH = APP_DIR / "conversations.db.codex.lock"
119
+ CONVERSATIONS_LOCK_MAINTENANCE_PATH = (
120
+ APP_DIR / "conversations.db.maintenance.lock"
121
+ )
111
122
  CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
112
123
 
113
124
  CONFIG_PATH = APP_DIR / "config.json"
@@ -359,7 +359,9 @@ import _lib_log
359
359
  from _cctally_config import save_config, _load_config_unlocked
360
360
  from _cctally_db import _render_migration_error_banner
361
361
  from _cctally_cache import (
362
- get_entries, iter_entries, iter_entries_with_id, open_cache_db, sync_cache,
362
+ get_entries, iter_entries, iter_entries_with_id, open_cache_db,
363
+ open_conversations_db, sync_cache, sync_claude_conversations,
364
+ sync_codex_conversations,
363
365
  _prune_orphaned_cache_entries,
364
366
  )
365
367
  from _lib_snapshot_cache import (
@@ -1175,8 +1177,7 @@ def _dashboard_sync_loop(
1175
1177
 
1176
1178
  def _dashboard_maybe_prune_retention() -> None:
1177
1179
  """#313 P3 (F7): throttled transcript retention prune driven from the
1178
- dashboard sync thread. Runs once per iteration INSIDE the measured cooldown
1179
- work (Task 6), so its cost paces the deadline. Opens a dedicated cache
1180
+ independent conversation sync thread. Opens a dedicated transcript
1180
1181
  connection (no provider flock, no open transaction) for the orchestrator.
1181
1182
  No-op when retention is disabled; never raises (a prune failure must not
1182
1183
  crash the sync thread)."""
@@ -1187,7 +1188,7 @@ def _dashboard_maybe_prune_retention() -> None:
1187
1188
  retention_days = resolve_retention_days(c.load_config())
1188
1189
  if retention_days <= 0:
1189
1190
  return
1190
- conn = c.open_cache_db()
1191
+ conn = c.open_conversations_db(attach_cache=False)
1191
1192
  try:
1192
1193
  retention._maybe_prune_conversation_retention(
1193
1194
  conn,
@@ -4100,10 +4101,6 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
4100
4101
  _DEBUG_CACHE_TABLES = (
4101
4102
  "session_entries",
4102
4103
  "session_files",
4103
- "conversation_messages",
4104
- "conversation_sessions",
4105
- "conversation_ai_titles",
4106
- "conversation_file_touches",
4107
4104
  "codex_session_entries",
4108
4105
  "codex_session_files",
4109
4106
  )
@@ -6769,11 +6766,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6769
6766
  and _time.monotonic() - last_heal[0] >= 60.0):
6770
6767
  last_heal[0] = _time.monotonic()
6771
6768
  _dashboard_self_heal_orphans(skip_sync=self._skip_sync)
6772
- # #313 P3 (F7): throttled transcript retention prune, inside the
6773
- # measured iteration so its cost is in the cooldown work. Gated
6774
- # off under --no-sync (frozen mode makes no cache writes).
6775
- if not self._skip_sync:
6776
- _dashboard_maybe_prune_retention()
6777
6769
 
6778
6770
  # Work-proportional cooldown (F10): sleep to t0 + max(interval, work)
6779
6771
  # so a slow rebuild cannot peg a full core. The manual POST /api/sync
@@ -6797,6 +6789,47 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6797
6789
  if sync_thread is not None:
6798
6790
  sync_thread.start()
6799
6791
 
6792
+ # #320: transcript/search ingestion runs on its own thread and SQLite file.
6793
+ # A multi-GB first rebuild or a contended conversations.db therefore cannot
6794
+ # delay `_run_sync_now`, its `last_sync_at` stamp, or core SSE publication.
6795
+ conversation_sync_stop = threading.Event()
6796
+
6797
+ def _conversation_sync_loop() -> None:
6798
+ interval = max(5.0, float(args.sync_interval))
6799
+ while not conversation_sync_stop.is_set():
6800
+ conn = None
6801
+ try:
6802
+ conn = open_conversations_db()
6803
+ sync_claude_conversations(conn)
6804
+ sync_codex_conversations(conn)
6805
+ _dashboard_maybe_prune_retention()
6806
+ except (OSError, sqlite3.DatabaseError) as exc:
6807
+ eprint(f"[conversations] background sync unavailable: {exc}")
6808
+ except Exception as exc: # noqa: BLE001
6809
+ # Transcript parsing/normalization is deliberately outside the
6810
+ # core freshness loop. Keep this worker alive so a later clean
6811
+ # tick can self-heal instead of permanently stopping after one
6812
+ # malformed provider record.
6813
+ eprint(
6814
+ "[conversations] background sync failed: "
6815
+ f"{type(exc).__name__}: {exc}"
6816
+ )
6817
+ finally:
6818
+ if conn is not None:
6819
+ conn.close()
6820
+ conversation_sync_stop.wait(interval)
6821
+
6822
+ conversation_sync_thread = (
6823
+ None if args.no_sync
6824
+ else threading.Thread(
6825
+ target=_conversation_sync_loop,
6826
+ daemon=True,
6827
+ name="dashboard-conversations-sync",
6828
+ )
6829
+ )
6830
+ if conversation_sync_thread is not None:
6831
+ conversation_sync_thread.start()
6832
+
6800
6833
  # Spec §3.5 (codex review fix #5): update-check thread runs even
6801
6834
  # under --no-sync (frozen-data sessions still surface new versions).
6802
6835
  # Dedicated stop event so we can join cleanly on shutdown without
@@ -6879,6 +6912,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6879
6912
  finally:
6880
6913
  if sync_thread is not None:
6881
6914
  sync_thread.stop()
6915
+ conversation_sync_stop.set()
6916
+ if conversation_sync_thread is not None:
6917
+ conversation_sync_thread.join(timeout=2)
6882
6918
  update_check_stop.set()
6883
6919
  srv.shutdown()
6884
6920
  http_thread.join(timeout=2)
@@ -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
@@ -44,7 +44,13 @@ import socket
44
44
  import sqlite3
45
45
  import sys
46
46
 
47
- from _cctally_cache import sync_cache, sync_codex_cache, _codex_provider_roots
47
+ from _cctally_cache import (
48
+ open_cache_db,
49
+ sync_codex_cache,
50
+ sync_claude_conversations,
51
+ sync_codex_conversations,
52
+ _codex_provider_roots,
53
+ )
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
@@ -785,7 +791,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
785
791
  _send_sse_headers(handler)
786
792
  passive = bool(type(handler).no_sync)
787
793
  try:
788
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
794
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
789
795
  except (sqlite3.DatabaseError, OSError):
790
796
  # Cache unavailable — degrade to keep-alive only; client backstop
791
797
  # tick still surfaces turns. (Headers already sent; can't 500.)
@@ -796,7 +802,7 @@ def _bare_conversation_events(handler, session_id: str) -> None:
796
802
  return cq.session_source_paths(conn, session_id) if conn else []
797
803
 
798
804
  def _ingest(changed):
799
- return sync_cache(conn, only_paths=set(changed))
805
+ return sync_claude_conversations(conn, only_paths=set(changed))
800
806
 
801
807
  try:
802
808
  _run_conversation_events_stream(
@@ -840,7 +846,24 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
840
846
  if not to_ingest:
841
847
  return files, False
842
848
  try:
843
- sync_codex_cache(conn, only_paths=set(to_ingest))
849
+ # A newly discovered child has no compact thread row yet. Advance
850
+ # the independent core cursor first so transcript normalization can
851
+ # link the child through the read-only attached cache, then commit
852
+ # the transcript cursor. Neither lock is held across the other.
853
+ core = open_cache_db()
854
+ try:
855
+ core_stats = sync_codex_cache(
856
+ core, only_paths=set(to_ingest)
857
+ )
858
+ finally:
859
+ core.close()
860
+ if not core_stats.targeted_clean:
861
+ return files, False
862
+ transcript_stats = sync_codex_conversations(
863
+ conn, only_paths=set(to_ingest)
864
+ )
865
+ if not transcript_stats.targeted_clean:
866
+ return files, False
844
867
  except sqlite3.DatabaseError:
845
868
  return files, False
846
869
  # Reap the now-classified candidates (child → already widened; non-child
@@ -866,7 +889,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
866
889
  targeted ingest + the directory-frontier child discovery."""
867
890
  disp = _conversation_dispatch_impl()
868
891
  try:
869
- conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
892
+ conn = sys.modules["_cctally_dashboard"].open_conversations_db()
870
893
  except (sqlite3.DatabaseError, OSError):
871
894
  conn = None
872
895
 
@@ -921,7 +944,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
921
944
  return cq_codex.codex_conversation_source_paths(conn, key)
922
945
 
923
946
  def _ingest(changed):
924
- return sync_codex_cache(conn, only_paths=set(changed))
947
+ return sync_codex_conversations(conn, only_paths=set(changed))
925
948
 
926
949
  cached = lambda paths: _codex_cached_file_sigs(conn, paths)
927
950
  discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
@@ -932,7 +955,7 @@ def _qualified_conversation_events(handler, key: str) -> None:
932
955
  return cq.session_source_paths(conn, native)
933
956
 
934
957
  def _ingest(changed):
935
- return sync_cache(conn, only_paths=set(changed))
958
+ return sync_claude_conversations(conn, only_paths=set(changed))
936
959
 
937
960
  cached = lambda paths: _cached_file_sigs(conn, paths)
938
961
  discovery = None
@@ -45,7 +45,7 @@ from _lib_quota import (
45
45
  quota_freshness,
46
46
  select_baseline,
47
47
  )
48
- from _lib_jsonl import CodexEntry
48
+ 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
@@ -103,6 +103,21 @@ class CodexWeeklyPeriod:
103
103
  used_percent: float | None = None
104
104
 
105
105
 
106
+ def _is_model_scoped_codex_quota(logical_limit_key: object) -> bool:
107
+ """Whether an interpreted native identity belongs outside standard quota."""
108
+ if not isinstance(logical_limit_key, str):
109
+ return False
110
+ try:
111
+ payload = json.loads(logical_limit_key)
112
+ except (json.JSONDecodeError, TypeError):
113
+ return False
114
+ return (
115
+ isinstance(payload, dict)
116
+ and isinstance(payload.get("modelPool"), str)
117
+ and bool(payload["modelPool"].strip())
118
+ )
119
+
120
+
106
121
  def _resolve_codex_weekly_cycle(
107
122
  observations: Iterable[object],
108
123
  now_utc: dt.datetime,
@@ -113,6 +128,8 @@ def _resolve_codex_weekly_cycle(
113
128
  for history in build_history(tuple(observations)):
114
129
  if history.identity.window_minutes != 10_080:
115
130
  continue
131
+ if _is_model_scoped_codex_quota(history.identity.logical_limit_key):
132
+ continue
116
133
  baseline = select_baseline(history.observations, now_utc)
117
134
  if baseline is None or baseline.resets_at <= now_utc:
118
135
  continue
@@ -161,7 +178,8 @@ def _codex_weekly_periods(
161
178
  placeholders = ",".join("?" for _ in roots)
162
179
  try:
163
180
  rows = stats_conn.execute(
164
- "SELECT source_root_key, resets_at_utc, nominal_start_at_utc, current_percent "
181
+ "SELECT source_root_key, logical_limit_key, resets_at_utc, "
182
+ "nominal_start_at_utc, current_percent "
165
183
  "FROM quota_window_blocks "
166
184
  "WHERE source='codex' AND window_minutes=10080 "
167
185
  f"AND source_root_key IN ({placeholders}) AND orphaned_at IS NULL "
@@ -174,7 +192,9 @@ def _codex_weekly_periods(
174
192
 
175
193
  raw_boundaries: list[tuple[dt.datetime, dt.datetime, set[str], list[float]]] = []
176
194
 
177
- for root_key, resets_at_raw, start_at_raw, current_percent in rows:
195
+ for root_key, logical_limit_key, resets_at_raw, start_at_raw, current_percent in rows:
196
+ if _is_model_scoped_codex_quota(logical_limit_key):
197
+ continue
178
198
  try:
179
199
  start_at = dt.datetime.fromisoformat(str(start_at_raw).replace("Z", "+00:00"))
180
200
  resets_at = dt.datetime.fromisoformat(str(resets_at_raw).replace("Z", "+00:00"))
@@ -713,22 +733,33 @@ def _codex_conversation_metadata(
713
733
 
714
734
  ``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
715
735
  Conversation rollup titles are derived from prompt text and therefore must
716
- never be substituted for that name on the dashboard. Accounting remains
717
- authoritative for totals; conversation rollups only decorate projects.
736
+ never be substituted for that name on the dashboard. Project attribution
737
+ is derived from the compact thread ``cwd``/``git_json`` retained in
738
+ ``cache.db`` so non-conversation panels never open ``conversations.db``.
718
739
  """
719
740
  metadata: dict[tuple[str, str], dict[str, object]] = {}
720
741
  try:
721
- rows = tuple(cache_conn.execute(
742
+ core_rows = tuple(cache_conn.execute(
722
743
  "SELECT t.source_root_key, t.source_path, t.native_thread_id, "
723
744
  "(SELECT e.session_id FROM codex_session_entries AS e "
724
745
  " WHERE e.source_root_key=t.source_root_key AND e.source_path=t.source_path "
725
746
  " ORDER BY e.id LIMIT 1) AS accounting_session_id, "
726
- "r.project_key, r.project_label, r.started_utc "
747
+ "t.cwd, t.git_json, t.first_seen_utc, t.last_seen_utc "
727
748
  "FROM codex_conversation_threads AS t "
728
- "LEFT JOIN codex_conversation_rollups AS r "
729
- "ON r.conversation_key=t.conversation_key "
730
- "ORDER BY r.last_activity_utc DESC, t.conversation_key DESC"
749
+ "ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
731
750
  ))
751
+ from _cctally_cache import _codex_conversation_project_attribution
752
+ rows = tuple(
753
+ (
754
+ root_key, source_path, native_thread_id, accounting_session_id,
755
+ *_codex_conversation_project_attribution(root_key, cwd, git_json),
756
+ first_seen_at,
757
+ )
758
+ for (
759
+ root_key, source_path, native_thread_id, accounting_session_id,
760
+ cwd, git_json, first_seen_at, _last_seen_at,
761
+ ) in core_rows
762
+ )
732
763
  file_aliases = tuple(cache_conn.execute(
733
764
  "SELECT source_root_key, path, last_native_thread_id, last_session_id "
734
765
  "FROM codex_session_files "
@@ -1833,6 +1864,8 @@ def _build_codex_native_weekly_view(
1833
1864
  labels: dict[str, str] = {}
1834
1865
  periods_by_bucket: dict[str, CodexWeeklyPeriod] = {}
1835
1866
  for entry in entries:
1867
+ if codex_model_scoped_quota_pool(getattr(entry, "model", None)) is not None:
1868
+ continue
1836
1869
  timestamp = getattr(entry, "timestamp").astimezone(UTC)
1837
1870
  root_key = str(getattr(entry, "source_root_key", "") or "")
1838
1871
  period = next((