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.
@@ -25,14 +25,16 @@ The FTS5 indexes over ``conversation_messages`` (``conversation_fts``) and
25
25
  ``conversation_ai_titles`` (``conversation_title_fts``) are external-content and
26
26
  maintained by AFTER-DELETE triggers, which are logically correct on subset
27
27
  deletes. Whole groups are deleted so those triggers keep the index consistent.
28
- The kernel runs inside the caller's open transaction (the orchestrator owns the
29
- flocks, ``BEGIN IMMEDIATE``, and commit).
28
+ The kernel normally runs inside the caller's open transaction. The orchestrator
29
+ supplies a post-group boundary that commits each whole conversation separately
30
+ while retaining every flock for the full pass (#315), bounding WAL growth.
30
31
  """
31
32
  from __future__ import annotations
32
33
 
33
34
  import datetime as dt
34
35
  import fcntl
35
36
  import sqlite3
37
+ from collections.abc import Callable
36
38
  from dataclasses import dataclass
37
39
 
38
40
  import _cctally_core
@@ -71,12 +73,25 @@ def _cutoff_iso(cutoff_utc: dt.datetime) -> str:
71
73
 
72
74
 
73
75
  def prune_conversation_transcripts(
74
- conn: sqlite3.Connection, *, cutoff_utc: dt.datetime
76
+ conn: sqlite3.Connection,
77
+ *,
78
+ cutoff_utc: dt.datetime,
79
+ after_group: "Callable[[], None] | None" = None,
75
80
  ) -> PruneStats:
76
- """Prune every transcript group whose latest activity is before ``cutoff_utc``."""
81
+ """Prune every transcript group whose latest activity is before ``cutoff_utc``.
82
+
83
+ ``after_group`` runs only after every table and FTS posting owned by one
84
+ session/conversation has been deleted. Direct kernel callers leave it unset
85
+ and retain their caller-managed transaction; the orchestrator uses it for
86
+ #315's whole-conversation intermediate commits.
87
+ """
77
88
  cutoff = _cutoff_iso(cutoff_utc)
78
- claude_sessions, claude_messages = _prune_claude(conn, cutoff)
79
- codex_conversations, codex_events = _prune_codex(conn, cutoff)
89
+ claude_sessions, claude_messages = _prune_claude(
90
+ conn, cutoff, after_group=after_group
91
+ )
92
+ codex_conversations, codex_events = _prune_codex(
93
+ conn, cutoff, after_group=after_group
94
+ )
80
95
  return PruneStats(
81
96
  claude_sessions=claude_sessions,
82
97
  claude_messages=claude_messages,
@@ -116,7 +131,12 @@ def _prunable_null_identity_paths(
116
131
  return [row[0] for row in conn.execute(sql, (cutoff,))]
117
132
 
118
133
 
119
- def _prune_claude(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
134
+ def _prune_claude(
135
+ conn: sqlite3.Connection,
136
+ cutoff: str,
137
+ *,
138
+ after_group: "Callable[[], None] | None" = None,
139
+ ) -> tuple[int, int]:
120
140
  sessions = 0
121
141
  messages = 0
122
142
  for session_id in _prunable_groups(
@@ -140,6 +160,8 @@ def _prune_claude(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
140
160
  (session_id,),
141
161
  )
142
162
  sessions += 1
163
+ if after_group is not None:
164
+ after_group()
143
165
  for source_path in _prunable_null_identity_paths(
144
166
  conn, "conversation_messages", "session_id", cutoff
145
167
  ):
@@ -166,6 +188,8 @@ def _prune_claude(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
166
188
  )
167
189
  messages += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
168
190
  sessions += 1
191
+ if after_group is not None:
192
+ after_group()
169
193
  return sessions, messages
170
194
 
171
195
 
@@ -235,9 +259,11 @@ def _maybe_prune_conversation_retention(
235
259
  two provider flocks are taken in a FIXED order (Claude then Codex),
236
260
  non-blocking, so a rebuild/reingest mid-flight makes the prune skip this
237
261
  cycle rather than race between candidate selection and deletion. The prune of
238
- both providers and the throttle stamp run in ONE ``BEGIN IMMEDIATE``
239
- transaction, so the stamp is written only after both provider phases succeed;
240
- any failure rolls back the whole thing (no stamp retried next cycle).
262
+ each whole session/conversation runs in its own ``BEGIN IMMEDIATE``
263
+ transaction (#315), while every flock remains held for the full pass. The
264
+ throttle stamp is committed only after both provider phases succeed. A
265
+ failure rolls back the active group but preserves completed groups, writes no
266
+ stamp, and therefore retries the remainder next cycle.
241
267
 
242
268
  ``conn`` must hold no provider flock and no open transaction (the caller
243
269
  guarantees this — the dashboard opens a dedicated cache connection; the
@@ -251,7 +277,7 @@ def _maybe_prune_conversation_retention(
251
277
  core.APP_DIR.mkdir(parents=True, exist_ok=True)
252
278
  except OSError:
253
279
  return None
254
- maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
280
+ maint_fh = open(core.CONVERSATIONS_LOCK_MAINTENANCE_PATH, "w")
255
281
  try:
256
282
  try:
257
283
  fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -259,13 +285,13 @@ def _maybe_prune_conversation_retention(
259
285
  return None # another prune holds it; skip cleanly, do NOT stamp
260
286
  if not force and not _retention_due(conn, now_utc):
261
287
  return None
262
- claude_fh = open(core.CACHE_LOCK_PATH, "w")
288
+ claude_fh = open(core.CONVERSATIONS_LOCK_PATH, "w")
263
289
  try:
264
290
  try:
265
291
  fcntl.flock(claude_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
266
292
  except (BlockingIOError, OSError):
267
293
  return None # a Claude sync is mid-flight; retry next cycle
268
- codex_fh = open(core.CACHE_LOCK_CODEX_PATH, "w")
294
+ codex_fh = open(core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
269
295
  try:
270
296
  try:
271
297
  fcntl.flock(codex_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -274,7 +300,15 @@ def _maybe_prune_conversation_retention(
274
300
  cutoff = now_utc - dt.timedelta(days=int(retention_days))
275
301
  conn.execute("BEGIN IMMEDIATE")
276
302
  try:
277
- stats = prune_conversation_transcripts(conn, cutoff_utc=cutoff)
303
+ def commit_group() -> None:
304
+ conn.commit()
305
+ conn.execute("BEGIN IMMEDIATE")
306
+
307
+ stats = prune_conversation_transcripts(
308
+ conn,
309
+ cutoff_utc=cutoff,
310
+ after_group=commit_group,
311
+ )
278
312
  _stamp_retention_prune(conn, now_utc)
279
313
  conn.commit()
280
314
  except Exception:
@@ -354,7 +388,12 @@ def _delete_codex_conversation_derived(
354
388
  )
355
389
 
356
390
 
357
- def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
391
+ def _prune_codex(
392
+ conn: sqlite3.Connection,
393
+ cutoff: str,
394
+ *,
395
+ after_group: "Callable[[], None] | None" = None,
396
+ ) -> tuple[int, int]:
358
397
  conversations = 0
359
398
  events = 0
360
399
  for conversation_key in _prunable_groups(
@@ -369,6 +408,8 @@ def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
369
408
  )
370
409
  events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
371
410
  conversations += 1
411
+ if after_group is not None:
412
+ after_group()
372
413
  for source_path in _prunable_null_identity_paths(
373
414
  conn, "codex_conversation_events", "conversation_key", cutoff
374
415
  ):
@@ -381,4 +422,6 @@ def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
381
422
  )
382
423
  events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
383
424
  conversations += 1
425
+ if after_group is not None:
426
+ after_group()
384
427
  return conversations, events
@@ -200,6 +200,16 @@ class DoctorState:
200
200
  # DOCTOR_WAL_WARN_BYTES (2x the WAL cap) — only when the journal_size_limit
201
201
  # + forced-checkpoint machinery has genuinely failed to contain the WAL.
202
202
  cache_db_wal_bytes: Optional[int] = None
203
+ # #315: read-only PRAGMA page_count/freelist_count evidence. The pure
204
+ # db.reclaimable check warns when free pages reach 25% of cache.db and
205
+ # points at the already-guarded explicit vacuum command. None means the
206
+ # cache was absent or unreadable; the check degrades to OK.
207
+ cache_db_page_count: Optional[int] = None
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
203
213
  # #294 S2: root-qualified physical Codex quota freshness, per-root native
204
214
  # hook state, and lifecycle activity are gathered by _cctally_doctor.
205
215
  codex_quota_windows: Optional[list[dict]] = None
@@ -1229,21 +1239,22 @@ def _check_data_conversation_sessions_rollup(s: DoctorState) -> CheckResult:
1229
1239
  row per distinct ``conversation_messages.session_id`` (#217 S1 / U9).
1230
1240
 
1231
1241
  OK when the two counts are equal, when either is ``None`` (pre-rollup or an
1232
- unreadable cache.db — consistent with the kernel's graceful-degrade posture),
1242
+ unreadable conversations.db — consistent with the kernel's graceful-degrade
1243
+ posture),
1233
1244
  OR when a sync/reingest/backfill is in progress. WARN ONLY on a mismatch
1234
1245
  observed in a QUIESCENT cache.
1235
1246
 
1236
- False-WARN avoidance (Codex P2): ``sync_cache`` commits
1247
+ False-WARN avoidance (Codex P2): ``sync_claude_conversations`` commits
1237
1248
  ``conversation_messages`` per file *before* the ``conversation_sessions``
1238
1249
  recompute, and resumable reingest commits per file before its rollup
1239
1250
  completes — so an unsynchronized read can transiently mismatch. The
1240
1251
  in-progress signal (``conv_rollup_sync_in_progress``) is set by
1241
- ``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
1242
1253
  (lock held ⇒ a writer is mid-flight) AND the presence of any pending
1243
1254
  ``cache_meta`` reingest/split/backfill flag; if either says in-progress, this
1244
1255
  stays OK. Doctor remains read-only and never blocks on the lock.
1245
1256
 
1246
- Informational only (no remediation): the next full ``sync_cache`` re-derives
1257
+ Informational only (no remediation): the next conversation sync re-derives
1247
1258
  the rollup via its incremental DELETE+INSERT.
1248
1259
  """
1249
1260
  rollup = s.conv_sessions_rollup_count
@@ -1725,6 +1736,91 @@ def _check_db_wal_size(s: DoctorState) -> CheckResult:
1725
1736
  )
1726
1737
 
1727
1738
 
1739
+ # #315: conservative advisory threshold. A quarter of cache.db being free is
1740
+ # large enough to make an explicit, guarded VACUUM useful without nagging for
1741
+ # ordinary page churn. This is a ratio, so it remains page-size independent.
1742
+ DOCTOR_RECLAIMABLE_WARN_RATIO = 0.25
1743
+
1744
+
1745
+ def _check_db_reclaimable(s: DoctorState) -> CheckResult:
1746
+ """Surface cache free pages without mutating or auto-vacuuming the DB."""
1747
+ page_count = s.cache_db_page_count
1748
+ freelist_count = s.cache_db_freelist_count
1749
+ ratio = None
1750
+ if (
1751
+ isinstance(page_count, int)
1752
+ and not isinstance(page_count, bool)
1753
+ and page_count > 0
1754
+ and isinstance(freelist_count, int)
1755
+ and not isinstance(freelist_count, bool)
1756
+ and 0 <= freelist_count <= page_count
1757
+ ):
1758
+ ratio = freelist_count / page_count
1759
+ details = {
1760
+ "cache_db_page_count": page_count,
1761
+ "cache_db_freelist_count": freelist_count,
1762
+ "cache_db_free_ratio": ratio,
1763
+ "warn_ratio": DOCTOR_RECLAIMABLE_WARN_RATIO,
1764
+ }
1765
+ if ratio is not None and ratio >= DOCTOR_RECLAIMABLE_WARN_RATIO:
1766
+ return CheckResult(
1767
+ id="db.reclaimable", title="Reclaimable cache space",
1768
+ severity="warn",
1769
+ summary=f"high — {ratio * 100:.1f}% of cache.db pages are free",
1770
+ remediation=(
1771
+ "Run `cctally db vacuum --db cache` to reclaim disk space."
1772
+ ),
1773
+ details=details,
1774
+ )
1775
+ return CheckResult(
1776
+ id="db.reclaimable", title="Reclaimable cache space", severity="ok",
1777
+ summary="below threshold", remediation=None, details=details,
1778
+ )
1779
+
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
+
1728
1824
  # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
1729
1825
  # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
1730
1826
  # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
@@ -1759,6 +1855,8 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1759
1855
  ("db.migrations.pending", "_check_db_migrations_pending"),
1760
1856
  ("db.lock_state", "_check_db_lock_state"),
1761
1857
  ("db.wal_size", "_check_db_wal_size"),
1858
+ ("db.reclaimable", "_check_db_reclaimable"),
1859
+ ("db.conversations_reclaimable", "_check_db_conversations_reclaimable"),
1762
1860
  )),
1763
1861
  ("data", "Data", (
1764
1862
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
package/bin/_lib_jsonl.py CHANGED
@@ -584,20 +584,37 @@ def _first_valid_reset_at(
584
584
 
585
585
  def _codex_logical_limit_key(
586
586
  source_root_key: str | None, limit_id: str | None, observed_slot: str,
587
- window_minutes: int,
587
+ window_minutes: int, model: str | None = None,
588
588
  ) -> str:
589
- return _codex_canonical_json({
589
+ payload = {
590
590
  "limitId": limit_id,
591
591
  "observedSlot": observed_slot,
592
592
  "source": "codex",
593
593
  "sourceRootKey": source_root_key,
594
594
  "windowMinutes": window_minutes,
595
- })
595
+ }
596
+ if (model_pool := codex_model_scoped_quota_pool(model)) is not None:
597
+ payload["modelPool"] = model_pool
598
+ return _codex_canonical_json(payload)
599
+
600
+
601
+ def codex_model_scoped_quota_pool(model: object) -> str | None:
602
+ """Return the native model pool when Codex documents it as separate.
603
+
604
+ GPT Codex Spark runs against its own allowance and does not consume the
605
+ standard Codex quota. Native payloads currently reuse ``limit_id=codex``
606
+ and the same slot/duration as the standard pool, so the sticky rollout
607
+ model is the only retained discriminator.
608
+ """
609
+ if not isinstance(model, str):
610
+ return None
611
+ normalized = model.strip().lower()
612
+ return normalized if "-codex-spark" in normalized else None
596
613
 
597
614
 
598
615
  def _codex_quota_observations(
599
616
  obj: dict[str, Any], payload: dict[str, Any], path_str: str, line_offset: int,
600
- source_root_key: str | None,
617
+ source_root_key: str | None, model: str | None,
601
618
  ) -> tuple[CodexQuotaObservation, ...]:
602
619
  captured_at = _parse_codex_timestamp(obj.get("timestamp"))
603
620
  if captured_at is None:
@@ -638,7 +655,7 @@ def _codex_quota_observations(
638
655
  captured_at_utc=_format_codex_timestamp(captured_at),
639
656
  observed_slot=slot,
640
657
  logical_limit_key=_codex_logical_limit_key(
641
- source_root_key, limit_id, slot, window_minutes
658
+ source_root_key, limit_id, slot, window_minutes, model
642
659
  ),
643
660
  limit_id=limit_id,
644
661
  limit_name=_first_valid_string(
@@ -867,7 +884,7 @@ def _iter_codex_fused_records_with_offsets(
867
884
  filename_session_id_warned,
868
885
  )
869
886
  quotas = _codex_quota_observations(
870
- obj, payload, path_str, line_offset, source_root_key
887
+ obj, payload, path_str, line_offset, source_root_key, state.model
871
888
  )
872
889
  yield CodexFusedEmission(
873
890
  line_offset=line_offset,
@@ -191,32 +191,6 @@ def parse_statusline_stdin(raw: "bytes | str") -> "StatuslineInput | ParseError"
191
191
  )
192
192
 
193
193
 
194
- # ---- Pool-identity guard (persist-only; spec 2026-07-17 #311 D1) ----------
195
-
196
-
197
- _ALTERNATE_POOL_MODEL_ID_RE = re.compile(r"\[[^\]]+\]$")
198
-
199
-
200
- def is_alternate_pool_model_id(model_id) -> bool:
201
- """True iff ``model_id`` is a bracketed variant id (e.g.
202
- ``claude-opus-4-8[1m]``) that reports a SEPARATE rate-limit pool.
203
-
204
- Such a session's stdin ``rate_limits`` describes a DIFFERENT usage pool
205
- on the same account; persisting it poisons the default-pool DB (the 7d
206
- HWM clamp latches the foreign high value and dedup then freezes tracking
207
- — see the #311 spec). The persist feeder skips it.
208
-
209
- Matches ANY trailing bracket suffix, not just the literal ``[1m]``: a
210
- future variant that turns out to share the default pool would merely lose
211
- one redundant writer (fail-safe toward data purity), whereas matching
212
- only ``[1m]`` would let the next variant poison the DB again. Missing /
213
- ``None`` / non-string / no-suffix ids return ``False`` (persist proceeds —
214
- only a positive variant match skips). Never raises."""
215
- if not isinstance(model_id, str) or not model_id:
216
- return False
217
- return _ALTERNATE_POOL_MODEL_ID_RE.search(model_id) is not None
218
-
219
-
220
194
  # ---- Segment 1: model -----------------------------------------------------
221
195
 
222
196
 
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