cctally 1.70.0 → 1.71.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.
@@ -4278,6 +4278,13 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
4278
4278
  conn.execute("DELETE FROM codex_conversation_threads")
4279
4279
  conn.execute("DELETE FROM codex_conversation_events")
4280
4280
  conn.execute("DELETE FROM codex_source_roots")
4281
+ # F3 (#313): clearing Codex quota state without invalidating the
4282
+ # quota-projection certificate would leave a stale-valid cert
4283
+ # (cache sequence unchanged) that lets the reconcile short-circuit
4284
+ # skip over now-deleted data. Delete it in the same transaction.
4285
+ conn.execute(
4286
+ "DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
4287
+ )
4281
4288
  conn.commit()
4282
4289
  except Exception:
4283
4290
  conn.rollback()
@@ -5802,3 +5809,155 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
5802
5809
  print(f"cctally: {result.db} WAL {mb_b:.1f} MB -> {mb_a:.1f} MB "
5803
5810
  f"({result.frames_checkpointed} frames; {state}).")
5804
5811
  return 0 if result.truncated else 3
5812
+
5813
+
5814
+ # VACUUM writes a full fresh copy of the database into a temporary file and then
5815
+ # swaps it in, so it transiently needs roughly the DB's own size on top of the
5816
+ # existing file, plus room for the drained WAL. A short busy_timeout keeps a
5817
+ # contended VACUUM from hanging (F13).
5818
+ _VACUUM_BUSY_TIMEOUT_MS = 250
5819
+
5820
+
5821
+ def _free_disk_bytes(directory) -> int:
5822
+ """Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
5823
+ import shutil
5824
+ return shutil.disk_usage(str(directory)).free
5825
+
5826
+
5827
+ def _vacuum_required_free_bytes(path) -> int:
5828
+ """Conservative free-space floor to VACUUM ``path``: ~2x the DB file plus its
5829
+ current WAL sidecar (F13 — the VACUUM temp copy + a WAL/temp margin)."""
5830
+ try:
5831
+ db_bytes = path.stat().st_size
5832
+ except OSError:
5833
+ db_bytes = 0
5834
+ wal = path.parent / (path.name + "-wal")
5835
+ try:
5836
+ wal_bytes = wal.stat().st_size
5837
+ except OSError:
5838
+ wal_bytes = 0
5839
+ return 2 * db_bytes + wal_bytes
5840
+
5841
+
5842
+ def _run_vacuum_exclusive(path, label: str) -> int:
5843
+ """Checkpoint + VACUUM ``path`` under a real SQLite EXCLUSIVE lock (F13).
5844
+
5845
+ ``locking_mode=EXCLUSIVE`` + a short ``busy_timeout`` make a concurrent
5846
+ reader/writer FAIL PROMPTLY (no TOCTOU gap — the exclusion is the DB's own
5847
+ lock, which the advisory flocks do not provide against dashboard readers).
5848
+ Exit 0 on success, 3 when the DB is in use."""
5849
+ conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
5850
+ try:
5851
+ conn.execute(f"PRAGMA busy_timeout={_VACUUM_BUSY_TIMEOUT_MS}")
5852
+ page_size = conn.execute("PRAGMA page_size").fetchone()[0]
5853
+ before = conn.execute("PRAGMA page_count").fetchone()[0]
5854
+ try:
5855
+ conn.execute("PRAGMA locking_mode=EXCLUSIVE")
5856
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
5857
+ conn.execute("VACUUM")
5858
+ except sqlite3.OperationalError as exc:
5859
+ if "lock" in str(exc).lower() or "busy" in str(exc).lower():
5860
+ eprint(
5861
+ f"cctally: {label} is in use — VACUUM needs exclusive access. "
5862
+ f"Stop the dashboard and any other cctally process holding "
5863
+ f"{label}, then retry."
5864
+ )
5865
+ return 3
5866
+ raise
5867
+ after = conn.execute("PRAGMA page_count").fetchone()[0]
5868
+ freed_mb = max(0, (before - after)) * page_size / (1024 * 1024)
5869
+ print(
5870
+ f"cctally: {label} reclaimed {freed_mb:.1f} MB "
5871
+ f"({before} -> {after} pages)."
5872
+ )
5873
+ return 0
5874
+ finally:
5875
+ conn.close()
5876
+
5877
+
5878
+ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
5879
+ if not path.exists():
5880
+ print(f"cctally: no {label} database file present; nothing to reclaim.")
5881
+ return 0
5882
+ needed = _vacuum_required_free_bytes(path)
5883
+ free = _free_disk_bytes(path.parent)
5884
+ if free < needed:
5885
+ eprint(
5886
+ f"cctally: not enough free disk to VACUUM {label}: need ~"
5887
+ f"{needed // (1024 * 1024)} MB free, have {free // (1024 * 1024)} MB. "
5888
+ f"Free up space and retry."
5889
+ )
5890
+ return 3
5891
+ core = _cctally_core
5892
+ try:
5893
+ core.APP_DIR.mkdir(parents=True, exist_ok=True)
5894
+ except OSError:
5895
+ pass
5896
+ # Serialize against the retention prune and concurrent vacuums via the
5897
+ # dedicated maintenance flock (F13/F7), then the provider flocks for
5898
+ # cache.db. All non-blocking: fail promptly rather than hang.
5899
+ maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
5900
+ try:
5901
+ try:
5902
+ fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
5903
+ except (BlockingIOError, OSError):
5904
+ eprint(
5905
+ f"cctally: {label} VACUUM skipped: another maintenance operation "
5906
+ f"is running. Retry shortly."
5907
+ )
5908
+ return 3
5909
+ held = []
5910
+ try:
5911
+ if provider_locked:
5912
+ for lock_path, lname in (
5913
+ (core.CACHE_LOCK_PATH, "claude"),
5914
+ (core.CACHE_LOCK_CODEX_PATH, "codex"),
5915
+ ):
5916
+ fh = open(lock_path, "w")
5917
+ try:
5918
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
5919
+ except (BlockingIOError, OSError):
5920
+ fh.close()
5921
+ eprint(
5922
+ f"cctally: {label} VACUUM skipped: a {lname} sync is in "
5923
+ f"progress. Stop the dashboard / other cctally "
5924
+ f"processes and retry."
5925
+ )
5926
+ return 3
5927
+ held.append(fh)
5928
+ return _run_vacuum_exclusive(path, label)
5929
+ finally:
5930
+ for fh in held:
5931
+ try:
5932
+ fcntl.flock(fh, fcntl.LOCK_UN)
5933
+ except OSError:
5934
+ pass
5935
+ fh.close()
5936
+ finally:
5937
+ try:
5938
+ fcntl.flock(maint_fh, fcntl.LOCK_UN)
5939
+ except OSError:
5940
+ pass
5941
+ maint_fh.close()
5942
+
5943
+
5944
+ def cmd_db_vacuum(args: argparse.Namespace) -> int:
5945
+ """Reclaim disk space via VACUUM after a transcript prune (#313 P3, F13).
5946
+
5947
+ NEVER automatic. Holds the maintenance flock + (for cache.db) the provider
5948
+ flocks, then runs a checkpoint + VACUUM under a real SQLite EXCLUSIVE lock so
5949
+ a concurrent dashboard reader fails promptly instead of racing. Refuses when
5950
+ free disk is below ~2x the file + WAL. Exit 0 on success, 3 when a target is
5951
+ in use or disk is short."""
5952
+ which = getattr(args, "db", "cache")
5953
+ targets = []
5954
+ if which in ("cache", "all"):
5955
+ targets.append((_cctally_core.CACHE_DB_PATH, "cache.db", True))
5956
+ if which in ("stats", "all"):
5957
+ targets.append((_cctally_core.DB_PATH, "stats.db", False))
5958
+ overall = 0
5959
+ for path, label, provider_locked in targets:
5960
+ rc = _vacuum_one_db(path, label, provider_locked)
5961
+ if rc != 0:
5962
+ overall = rc
5963
+ return overall
@@ -2134,6 +2134,14 @@ def _build_cache_sync_parser(subparsers, name, *, help_text, xref=None):
2134
2134
  help="Prune cache rows for source files removed from disk "
2135
2135
  "(e.g. a deleted git worktree) without a full rebuild.",
2136
2136
  )
2137
+ p_cache_sync.add_argument(
2138
+ "--prune-conversations",
2139
+ action="store_true",
2140
+ help="Prune conversation transcripts older than "
2141
+ "conversation.retention_days (default 180) now, without a full "
2142
+ "rebuild. Re-derivable from JSONL; run `cctally db vacuum` to "
2143
+ "reclaim the freed disk space.",
2144
+ )
2137
2145
  p_cache_sync.set_defaults(func=c.cmd_cache_sync)
2138
2146
 
2139
2147
  def _build_project_parser(subparsers, name, *, help_text, xref=None, fixed_source=None):
@@ -2802,6 +2810,18 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
2802
2810
  help=argparse.SUPPRESS,
2803
2811
  )
2804
2812
  db_checkpoint.set_defaults(func=c.cmd_db_checkpoint)
2813
+ db_vacuum = db_sub.add_parser(
2814
+ "vacuum",
2815
+ help="Reclaim disk space after a transcript prune (VACUUM) — "
2816
+ "exclusive, never automatic",
2817
+ )
2818
+ db_vacuum.add_argument(
2819
+ "--db",
2820
+ choices=("cache", "stats", "all"),
2821
+ default="cache",
2822
+ help="Which DB to VACUUM (default: cache)",
2823
+ )
2824
+ db_vacuum.set_defaults(func=c.cmd_db_vacuum)
2805
2825
 
2806
2826
  def _build_doctor_parser(subparsers, name, *, help_text, xref=None):
2807
2827
  """Build the `doctor` parser (registered via _REGISTRATION; #279 S6 W3).
@@ -183,6 +183,32 @@ def _store_codex_quota_projection_certificate(
183
183
  return
184
184
 
185
185
 
186
+ def _stats_projection_signatures_match(
187
+ stats_conn: sqlite3.Connection,
188
+ active_roots: set[str],
189
+ cert_sigs: Mapping[str, str],
190
+ ) -> bool:
191
+ """True iff stats.db's projection signature matches the certificate for every root.
192
+
193
+ The cache certificate alone does not prove stats.db still holds the
194
+ projection: stats.db can be independently wiped/recovered while cache.db
195
+ persists (F1). Require an exact ``quota_projection_state.physical_signature``
196
+ match for every active root before the reconcile is allowed to short-circuit.
197
+ A missing row, a mismatch, or any ``sqlite3.Error`` degrades to False, which
198
+ forces the full reconcile (fail-safe).
199
+ """
200
+ try:
201
+ rows = stats_conn.execute(
202
+ "SELECT source_root_key, physical_signature FROM quota_projection_state"
203
+ ).fetchall()
204
+ except sqlite3.Error:
205
+ return False
206
+ projection = {str(row[0]): str(row[1]) for row in rows}
207
+ return all(
208
+ projection.get(root) == cert_sigs.get(root) for root in active_roots
209
+ )
210
+
211
+
186
212
  def load_codex_quota_observations(
187
213
  *,
188
214
  source_root_keys: Iterable[str] | None = None,
@@ -754,22 +780,48 @@ def reconcile_codex_quota_projection(
754
780
  raise ValueError("now must be timezone-aware")
755
781
  now_iso = _utc_iso(now)
756
782
 
783
+ alert_eligible_roots = {str(key) for key in alert_eligible_root_keys}
784
+
757
785
  try:
758
786
  cache = _cache_connection()
759
787
  except (FileNotFoundError, sqlite3.Error):
760
788
  return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
761
789
  try:
762
- active_roots = (
763
- _cache_root_keys(cache)
764
- if source_root_keys is None else {str(key) for key in source_root_keys}
765
- )
766
- physical_sequence = codex_physical_mutation_seq(cache)
790
+ # F2: read active_roots, the physical sequence, and the certificate
791
+ # inside ONE WAL read snapshot so a concurrent commit cannot interleave
792
+ # a stale sequence with a fresh certificate.
793
+ cache.execute("BEGIN")
794
+ try:
795
+ active_roots = (
796
+ _cache_root_keys(cache)
797
+ if source_root_keys is None else {str(key) for key in source_root_keys}
798
+ )
799
+ physical_sequence = codex_physical_mutation_seq(cache)
800
+ certificate = load_codex_quota_projection_certificate(cache)
801
+ finally:
802
+ cache.commit()
803
+ # Short-circuit: when nothing is alert-eligible and the certificate
804
+ # proves the cache physical state is current AND the stats-side
805
+ # projection still matches it (F1), the ~2.9 s observation load and the
806
+ # whole reconcile are provably a no-op. Any missed concurrent write
807
+ # leaves cur_seq != cert_seq (or a stats-signature mismatch) on the next
808
+ # call, so the scheme is self-healing.
809
+ if not alert_eligible_roots and certificate is not None:
810
+ cert_seq, cert_sigs = certificate
811
+ if physical_sequence == cert_seq and active_roots <= set(cert_sigs):
812
+ stats_conn = _cctally_core.open_db()
813
+ try:
814
+ if _stats_projection_signatures_match(
815
+ stats_conn, active_roots, cert_sigs
816
+ ):
817
+ return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
818
+ finally:
819
+ stats_conn.close()
767
820
  observations = load_codex_quota_observations(
768
821
  source_root_keys=active_roots, cache_conn=cache,
769
822
  )
770
823
  finally:
771
824
  cache.close()
772
- alert_eligible_roots = {str(key) for key in alert_eligible_root_keys}
773
825
 
774
826
  # No configured roots and no existing interpreted history means there is no
775
827
  # stats work. This preserves the existing empty-Codex sync fast path.
package/bin/cctally CHANGED
@@ -952,6 +952,7 @@ cmd_db_skip = _cctally_db.cmd_db_skip
952
952
  cmd_db_unskip = _cctally_db.cmd_db_unskip
953
953
  cmd_db_recover = _cctally_db.cmd_db_recover
954
954
  cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
955
+ cmd_db_vacuum = _cctally_db.cmd_db_vacuum
955
956
 
956
957
 
957
958
  # Session-entry cache subsystem (Claude + Codex) — read-through delta