cctally 1.75.0 → 1.76.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.76.0] - 2026-07-20
9
+
10
+ ### Added
11
+ - `cctally doctor` now reports `db.reclaimable` when at least 25% of `cache.db` pages are free, with a direct `cctally db vacuum --db cache` remediation. (#315)
12
+
13
+ ### Fixed
14
+ - Codex dashboard Weekly cycles and `$ / 1%` accounting now keep the separately-metered GPT-5.3-Codex-Spark quota pool distinct from the standard seven-day Codex pool, preventing brief Spark sessions from creating phantom standard weeks.
15
+ - Dashboard Codex Weekly cost deltas now use the shared fractional-ratio contract instead of rendering 100× too large, and the Codex **$/1% Trend** table once again aligns Used%, $/1%, and vs-prior values under their matching columns.
16
+ - Claude dashboard quota now keeps updating automatically while Claude Code uses a bracketed context variant such as `opus[1m]`; context-window metadata no longer suppresses the valid account-wide 5-hour and 7-day observations supplied to the status line.
17
+ - Conversation-retention pruning now commits each whole Claude session or Codex conversation separately while retaining its maintenance locks, bounding first-prune WAL growth without exposing partially deleted conversations; a 9.37 GB production-shaped benchmark reduced peak WAL from 889.7 MiB to 28.8 MiB with identical deleted-row counts and FTS integrity. (#315)
18
+
19
+ ## [1.75.1] - 2026-07-20
20
+
21
+ ### Fixed
22
+ - Database repair now rejects an SQLite shell without functional `.recover` support before creating a forensic copy, explains the required `SQLITE_ENABLE_DBPAGE_VTAB` capability, and runs its public Linux recovery tests against a checksum-pinned official SQLite shell instead of Ubuntu's incompatible distro build.
23
+ - Automatic transcript-space reclamation now drives zero-column `incremental_vacuum` pragmas through SQLite's completion path in bounded, progress-checked chunks, fixing the Python 3.11/Linux case that could leave freed pages on the freelist after retention pruning.
24
+
8
25
  ## [1.75.0] - 2026-07-20
9
26
 
10
27
  ### Added
@@ -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"))
@@ -1833,6 +1853,8 @@ def _build_codex_native_weekly_view(
1833
1853
  labels: dict[str, str] = {}
1834
1854
  periods_by_bucket: dict[str, CodexWeeklyPeriod] = {}
1835
1855
  for entry in entries:
1856
+ if codex_model_scoped_quota_pool(getattr(entry, "model", None)) is not None:
1857
+ continue
1836
1858
  timestamp = getattr(entry, "timestamp").astimezone(UTC)
1837
1859
  root_key = str(getattr(entry, "source_root_key", "") or "")
1838
1860
  period = next((
@@ -6411,6 +6411,25 @@ def _run_sqlite_recover(
6411
6411
  return True, ""
6412
6412
 
6413
6413
 
6414
+ def _probe_sqlite_recover(sqlite_binary: str) -> "tuple[bool, str]":
6415
+ """Prove the selected shell has a functional ``.recover`` command."""
6416
+ try:
6417
+ probe = subprocess.run(
6418
+ [sqlite_binary, ":memory:", ".recover"],
6419
+ stdout=subprocess.PIPE,
6420
+ stderr=subprocess.PIPE,
6421
+ check=False,
6422
+ )
6423
+ except OSError as exc:
6424
+ return False, str(exc)
6425
+ stderr = probe.stderr.decode("utf-8", "replace").strip()
6426
+ if probe.returncode != 0:
6427
+ return False, stderr or f"sqlite3 .recover probe exited {probe.returncode}"
6428
+ if b"BEGIN;" not in probe.stdout or b"COMMIT;" not in probe.stdout:
6429
+ return False, stderr or "sqlite3 .recover probe produced incomplete SQL"
6430
+ return True, ""
6431
+
6432
+
6414
6433
  def _repair_preflight_and_copy(
6415
6434
  path: pathlib.Path,
6416
6435
  backup: pathlib.Path,
@@ -6549,6 +6568,28 @@ def _cmd_db_repair_claimed(args: argparse.Namespace, path: pathlib.Path) -> int:
6549
6568
  """Repair body; caller owns the marker and has proved no old handles."""
6550
6569
 
6551
6570
  timeout_ms = int(getattr(args, "busy_timeout_ms", 250) or 250)
6571
+ sqlite_binary = (
6572
+ getattr(args, "sqlite3_binary", None) or shutil.which("sqlite3")
6573
+ )
6574
+ if not sqlite_binary:
6575
+ eprint(
6576
+ "cctally: db repair requires the sqlite3 command-line tool for "
6577
+ 'its corruption-tolerant ".recover" operation. The live DB is '
6578
+ "untouched and no corrupt backup was created."
6579
+ )
6580
+ return 3
6581
+ recover_supported, recover_reason = _probe_sqlite_recover(
6582
+ str(sqlite_binary)
6583
+ )
6584
+ if not recover_supported:
6585
+ eprint(
6586
+ "cctally: the selected sqlite3 build does not support SQLite "
6587
+ f".recover ({recover_reason}). Install the official sqlite.org "
6588
+ "CLI built with SQLITE_ENABLE_DBPAGE_VTAB. The live DB is "
6589
+ "untouched and no corrupt backup was created."
6590
+ )
6591
+ return 3
6592
+
6552
6593
  stamp = _db_backup_timestamp()
6553
6594
  backup = _unique_sibling_path(
6554
6595
  path.with_name(f"{path.name}.bak-corrupt-malformed-{stamp}")
@@ -6623,18 +6664,6 @@ def _cmd_db_repair_claimed(args: argparse.Namespace, path: pathlib.Path) -> int:
6623
6664
  close_guard()
6624
6665
  return 3
6625
6666
 
6626
- sqlite_binary = (
6627
- getattr(args, "sqlite3_binary", None) or shutil.which("sqlite3")
6628
- )
6629
- if not sqlite_binary:
6630
- eprint(
6631
- "cctally: db repair requires the sqlite3 command-line tool for "
6632
- 'its corruption-tolerant ".recover" operation. The live DB is '
6633
- f"untouched and the corrupt backup is {backup}."
6634
- )
6635
- close_guard()
6636
- return 3
6637
-
6638
6667
  ok, reason = _run_sqlite_recover(
6639
6668
  str(sqlite_binary), snapshot, recovered_path, scratch
6640
6669
  )
@@ -385,10 +385,21 @@ def doctor_gather_state(
385
385
 
386
386
  cache_entries_count = None
387
387
  cache_last_entry_at = None
388
+ cache_db_page_count = None
389
+ cache_db_freelist_count = None
388
390
  try:
389
391
  if _cctally_core.CACHE_DB_PATH.exists():
390
392
  conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
391
393
  try:
394
+ try:
395
+ row = conn.execute("PRAGMA page_count").fetchone()
396
+ if row and row[0] is not None:
397
+ cache_db_page_count = int(row[0])
398
+ row = conn.execute("PRAGMA freelist_count").fetchone()
399
+ if row and row[0] is not None:
400
+ cache_db_freelist_count = int(row[0])
401
+ except sqlite3.Error:
402
+ pass
392
403
  row = conn.execute(
393
404
  "SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
394
405
  ).fetchone()
@@ -863,6 +874,9 @@ def doctor_gather_state(
863
874
  locks_held=locks_held,
864
875
  # #297: cache.db WAL size backstop (gathered outside the deep branch).
865
876
  cache_db_wal_bytes=cache_db_wal_bytes,
877
+ # #315: read-only cache free-page evidence for the reclaim hint.
878
+ cache_db_page_count=cache_db_page_count,
879
+ cache_db_freelist_count=cache_db_freelist_count,
866
880
  codex_quota_windows=codex_quota_windows,
867
881
  codex_hook_roots=codex_hook_roots,
868
882
  codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
@@ -41,10 +41,12 @@ from _lib_quota import (
41
41
  source_path_key,
42
42
  )
43
43
  from _lib_json_envelope import stamp_schema_version
44
+ from _lib_jsonl import _codex_logical_limit_key, codex_model_scoped_quota_pool
44
45
 
45
46
 
46
47
  UTC = dt.timezone.utc
47
48
  _DASHBOARD_PROJECTION_CERTIFICATE_KEY = "codex_quota_projection_certificate"
49
+ _CODEX_QUOTA_INTERPRETATION_VERSION = 2
48
50
 
49
51
 
50
52
  @dataclass(frozen=True)
@@ -134,6 +136,11 @@ def load_codex_quota_projection_certificate(
134
136
  if row is None:
135
137
  return None
136
138
  payload = json.loads(str(row[0]))
139
+ if (
140
+ int(payload["interpretationVersion"])
141
+ != _CODEX_QUOTA_INTERPRETATION_VERSION
142
+ ):
143
+ return None
137
144
  sequence = int(payload["sequence"])
138
145
  signatures = {
139
146
  str(root_key): str(signature)
@@ -169,6 +176,7 @@ def _store_codex_quota_projection_certificate(
169
176
  conn.rollback()
170
177
  return
171
178
  payload = json.dumps({
179
+ "interpretationVersion": _CODEX_QUOTA_INTERPRETATION_VERSION,
172
180
  "sequence": sequence,
173
181
  "signatures": dict(sorted(signatures.items())),
174
182
  }, sort_keys=True, separators=(",", ":"))
@@ -262,14 +270,51 @@ def load_codex_quota_observations(
262
270
  previous_row_factory = conn.row_factory
263
271
  try:
264
272
  conn.row_factory = sqlite3.Row
273
+
274
+ def has_columns(table: str, required: set[str]) -> bool:
275
+ columns = {
276
+ str(row[1]) for row in conn.execute(
277
+ f"PRAGMA table_info({table})"
278
+ )
279
+ }
280
+ return required <= columns
281
+
282
+ has_conversation_events = has_columns(
283
+ "codex_conversation_events",
284
+ {"source_path", "line_offset", "record_type", "payload_json"},
285
+ )
286
+ has_session_entries = has_columns(
287
+ "codex_session_entries", {"source_path", "line_offset", "model"},
288
+ )
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"
265
309
  sql = """
266
310
  SELECT source, source_root_key, source_path, line_offset,
267
311
  captured_at_utc, observed_slot, logical_limit_key, limit_id,
268
312
  limit_name, window_minutes, used_percent, resets_at_utc,
269
- plan_type, individual_limit_json, reached_type
313
+ plan_type, individual_limit_json, reached_type,
314
+ {model_expr}
270
315
  FROM quota_window_snapshots
271
316
  WHERE source='codex' AND source_root_key IS NOT NULL
272
- """
317
+ """.format(model_expr=model_expr)
273
318
  params: list[object] = []
274
319
  if requested is not None:
275
320
  if not requested:
@@ -317,10 +362,17 @@ def load_codex_quota_observations(
317
362
  if any(row[name] is None or not str(row[name]).strip() for name in required_text):
318
363
  continue
319
364
  try:
365
+ logical_limit_key = str(row["logical_limit_key"])
366
+ if codex_model_scoped_quota_pool(row["observed_model"]) is not None:
367
+ logical_limit_key = _codex_logical_limit_key(
368
+ str(row["source_root_key"]), row["limit_id"],
369
+ str(row["observed_slot"]), int(row["window_minutes"]),
370
+ str(row["observed_model"]),
371
+ )
320
372
  identity = QuotaWindowIdentity(
321
373
  source=str(row["source"]),
322
374
  source_root_key=str(row["source_root_key"]),
323
- logical_limit_key=str(row["logical_limit_key"]),
375
+ logical_limit_key=logical_limit_key,
324
376
  observed_slot=str(row["observed_slot"]),
325
377
  window_minutes=int(row["window_minutes"]),
326
378
  limit_id=row["limit_id"],
@@ -1205,8 +1205,6 @@ def _fork_persist(parent_lock_fd: int) -> None:
1205
1205
 
1206
1206
  def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
1207
1207
  """Spool an eligible session candidate then reduce it opportunistically."""
1208
- if _lib_statusline.is_alternate_pool_model_id(parsed.model_id):
1209
- return
1210
1208
  candidate = _candidate_from_input(parsed, received_at=int(time.time()))
1211
1209
  if candidate is None:
1212
1210
  return
@@ -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
 
@@ -197,6 +221,27 @@ def _stamp_retention_prune(conn: sqlite3.Connection, now_utc: dt.datetime) -> No
197
221
  )
198
222
 
199
223
 
224
+ def _reclaim_incremental_vacuum(conn: sqlite3.Connection) -> None:
225
+ """Drive zero-column incremental-vacuum rows to completion portably."""
226
+ remaining = int(conn.execute("PRAGMA freelist_count").fetchone()[0])
227
+ if remaining <= 0:
228
+ return
229
+ chunk_pages = 4096
230
+ max_passes = (remaining + chunk_pages - 1) // chunk_pages + 1
231
+ for _ in range(max_passes):
232
+ requested = min(remaining, chunk_pages)
233
+ # executescript() routes through sqlite3_exec(), which steps zero-column
234
+ # pragma rows through SQLITE_DONE on Python/SQLite combinations where a
235
+ # Cursor.fetchall() can stop after the first row (public Linux 3.11).
236
+ conn.executescript(f"PRAGMA incremental_vacuum({requested});")
237
+ after = int(conn.execute("PRAGMA freelist_count").fetchone()[0])
238
+ if after <= 0:
239
+ return
240
+ if after >= remaining:
241
+ return
242
+ remaining = after
243
+
244
+
200
245
  def _maybe_prune_conversation_retention(
201
246
  conn: sqlite3.Connection,
202
247
  *,
@@ -214,9 +259,11 @@ def _maybe_prune_conversation_retention(
214
259
  two provider flocks are taken in a FIXED order (Claude then Codex),
215
260
  non-blocking, so a rebuild/reingest mid-flight makes the prune skip this
216
261
  cycle rather than race between candidate selection and deletion. The prune of
217
- both providers and the throttle stamp run in ONE ``BEGIN IMMEDIATE``
218
- transaction, so the stamp is written only after both provider phases succeed;
219
- 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.
220
267
 
221
268
  ``conn`` must hold no provider flock and no open transaction (the caller
222
269
  guarantees this — the dashboard opens a dedicated cache connection; the
@@ -253,7 +300,15 @@ def _maybe_prune_conversation_retention(
253
300
  cutoff = now_utc - dt.timedelta(days=int(retention_days))
254
301
  conn.execute("BEGIN IMMEDIATE")
255
302
  try:
256
- 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
+ )
257
312
  _stamp_retention_prune(conn, now_utc)
258
313
  conn.commit()
259
314
  except Exception:
@@ -271,12 +326,11 @@ def _maybe_prune_conversation_retention(
271
326
  # already-durable prune.
272
327
  if stats.total_rows > 0:
273
328
  try:
274
- # `.fetchall()` drives the pragma to completion a bare
275
- # execute() steps it once and reclaims a single page. This
276
- # clears the freelist and drops page_count; the physical
277
- # file shrinks on the next `wal_checkpoint(TRUNCATE)` the
278
- # sync loop already forces (#297).
279
- conn.execute("PRAGMA incremental_vacuum").fetchall()
329
+ # Use the sqlite3_exec path and verify progress between
330
+ # bounded chunks. This clears the freelist and drops
331
+ # page_count; the physical file shrinks on the next
332
+ # `wal_checkpoint(TRUNCATE)` the sync loop forces (#297).
333
+ _reclaim_incremental_vacuum(conn)
280
334
  except sqlite3.Error:
281
335
  pass
282
336
  return stats
@@ -334,7 +388,12 @@ def _delete_codex_conversation_derived(
334
388
  )
335
389
 
336
390
 
337
- 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]:
338
397
  conversations = 0
339
398
  events = 0
340
399
  for conversation_key in _prunable_groups(
@@ -349,6 +408,8 @@ def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
349
408
  )
350
409
  events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
351
410
  conversations += 1
411
+ if after_group is not None:
412
+ after_group()
352
413
  for source_path in _prunable_null_identity_paths(
353
414
  conn, "codex_conversation_events", "conversation_key", cutoff
354
415
  ):
@@ -361,4 +422,6 @@ def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
361
422
  )
362
423
  events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
363
424
  conversations += 1
425
+ if after_group is not None:
426
+ after_group()
364
427
  return conversations, events
@@ -200,6 +200,12 @@ 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
203
209
  # #294 S2: root-qualified physical Codex quota freshness, per-root native
204
210
  # hook state, and lifecycle activity are gathered by _cctally_doctor.
205
211
  codex_quota_windows: Optional[list[dict]] = None
@@ -1725,6 +1731,48 @@ def _check_db_wal_size(s: DoctorState) -> CheckResult:
1725
1731
  )
1726
1732
 
1727
1733
 
1734
+ # #315: conservative advisory threshold. A quarter of cache.db being free is
1735
+ # large enough to make an explicit, guarded VACUUM useful without nagging for
1736
+ # ordinary page churn. This is a ratio, so it remains page-size independent.
1737
+ DOCTOR_RECLAIMABLE_WARN_RATIO = 0.25
1738
+
1739
+
1740
+ def _check_db_reclaimable(s: DoctorState) -> CheckResult:
1741
+ """Surface cache free pages without mutating or auto-vacuuming the DB."""
1742
+ page_count = s.cache_db_page_count
1743
+ freelist_count = s.cache_db_freelist_count
1744
+ ratio = None
1745
+ if (
1746
+ isinstance(page_count, int)
1747
+ and not isinstance(page_count, bool)
1748
+ and page_count > 0
1749
+ and isinstance(freelist_count, int)
1750
+ and not isinstance(freelist_count, bool)
1751
+ and 0 <= freelist_count <= page_count
1752
+ ):
1753
+ ratio = freelist_count / page_count
1754
+ details = {
1755
+ "cache_db_page_count": page_count,
1756
+ "cache_db_freelist_count": freelist_count,
1757
+ "cache_db_free_ratio": ratio,
1758
+ "warn_ratio": DOCTOR_RECLAIMABLE_WARN_RATIO,
1759
+ }
1760
+ if ratio is not None and ratio >= DOCTOR_RECLAIMABLE_WARN_RATIO:
1761
+ return CheckResult(
1762
+ id="db.reclaimable", title="Reclaimable cache space",
1763
+ severity="warn",
1764
+ summary=f"high — {ratio * 100:.1f}% of cache.db pages are free",
1765
+ remediation=(
1766
+ "Run `cctally db vacuum --db cache` to reclaim disk space."
1767
+ ),
1768
+ details=details,
1769
+ )
1770
+ return CheckResult(
1771
+ id="db.reclaimable", title="Reclaimable cache space", severity="ok",
1772
+ summary="below threshold", remediation=None, details=details,
1773
+ )
1774
+
1775
+
1728
1776
  # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
1729
1777
  # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
1730
1778
  # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
@@ -1759,6 +1807,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1759
1807
  ("db.migrations.pending", "_check_db_migrations_pending"),
1760
1808
  ("db.lock_state", "_check_db_lock_state"),
1761
1809
  ("db.wal_size", "_check_db_wal_size"),
1810
+ ("db.reclaimable", "_check_db_reclaimable"),
1762
1811
  )),
1763
1812
  ("data", "Data", (
1764
1813
  ("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