cctally 1.70.0 → 1.72.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.
@@ -876,7 +876,16 @@ def _run_pending_migrations(
876
876
  # absent must still be treated as non-fresh so a flag-only
877
877
  # conversation migration's consumer actually runs (e.g. 011's
878
878
  # command-args promotion) instead of being stamped without it.
879
- "cache.db": ("session_entries", "conversation_messages"),
879
+ # codex_conversation_events joins them (#294 S6): a Codex-bearing
880
+ # cache written by S1's fused ingest but missing schema_migrations
881
+ # would otherwise be classified fresh and stamp 025 WITHOUT replaying
882
+ # normalization, leaving the read kernels authoritative over an empty
883
+ # normalized corpus. Probing it forces 025's handler to run and derive
884
+ # the normalized rows from the retained events.
885
+ "cache.db": (
886
+ "session_entries", "conversation_messages",
887
+ "codex_conversation_events",
888
+ ),
880
889
  }.get(db_label, ())
881
890
  for probe_table in probe_tables:
882
891
  # _probe_table_nonempty centralizes the "is there data here?"
@@ -2621,6 +2630,81 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2621
2630
  CREATE INDEX IF NOT EXISTS idx_codex_events_timestamp
2622
2631
  ON codex_conversation_events(timestamp_utc);
2623
2632
 
2633
+ -- #294 S6: normalized Codex conversation storage. Like the S1 tables
2634
+ -- above, these live in the UNCONDITIONAL base-schema script — before the
2635
+ -- legacy-FTS topology checks below — so an existing cache gains them on
2636
+ -- every open. The independent Codex FTS layer
2637
+ -- (_apply_codex_conversation_fts) stands up codex_conversation_fts + its
2638
+ -- own triggers separately, BEFORE the Claude legacy-FTS early-return, so
2639
+ -- a legacy-shape Claude cache still gets the Codex search index.
2640
+ --
2641
+ -- codex_conversation_messages: one normalized row per NORMALIZED physical
2642
+ -- event (§3.1). ``id`` is a PLAIN rowid alias (deliberately NOT
2643
+ -- AUTOINCREMENT): migration 025 replays inserts in deterministic
2644
+ -- (source_path ASC, line_offset ASC) order, so a repeated run is
2645
+ -- byte-idempotent (no sqlite_sequence drift), while fresh ingest inserts
2646
+ -- in discovery order — fresh-vs-migrated equality is asserted
2647
+ -- SEMANTICALLY (row content modulo id).
2648
+ CREATE TABLE IF NOT EXISTS codex_conversation_messages (
2649
+ id INTEGER PRIMARY KEY,
2650
+ conversation_key TEXT NOT NULL,
2651
+ source_root_key TEXT NOT NULL,
2652
+ source_path TEXT NOT NULL,
2653
+ line_offset INTEGER NOT NULL,
2654
+ timestamp_utc TEXT,
2655
+ turn_id TEXT,
2656
+ call_id TEXT,
2657
+ kind TEXT NOT NULL,
2658
+ event_type TEXT,
2659
+ record_family TEXT NOT NULL,
2660
+ model TEXT,
2661
+ text TEXT,
2662
+ content_digest TEXT NOT NULL,
2663
+ content_len INTEGER NOT NULL CHECK(content_len >= 0),
2664
+ detail_json TEXT,
2665
+ search_tool TEXT,
2666
+ search_thinking TEXT,
2667
+ UNIQUE(source_path, line_offset)
2668
+ );
2669
+ CREATE INDEX IF NOT EXISTS idx_codex_conv_msgs_conversation
2670
+ ON codex_conversation_messages(conversation_key, timestamp_utc, id);
2671
+ CREATE INDEX IF NOT EXISTS idx_codex_conv_msgs_source
2672
+ ON codex_conversation_messages(source_path);
2673
+
2674
+ -- codex_conversation_rollups: browse-rail materialization (§3.2). A pure
2675
+ -- function of surviving codex_conversation_messages (+ thread metadata),
2676
+ -- recomputed-affected-or-deleted after every write/delete path. item_count
2677
+ -- counts rendered LOGICAL items (mirror-paired), not physical rows.
2678
+ CREATE TABLE IF NOT EXISTS codex_conversation_rollups (
2679
+ conversation_key TEXT NOT NULL PRIMARY KEY,
2680
+ source_root_key TEXT NOT NULL,
2681
+ parent_thread_id TEXT,
2682
+ item_count INTEGER NOT NULL DEFAULT 0,
2683
+ started_utc TEXT,
2684
+ last_activity_utc TEXT,
2685
+ project_key TEXT,
2686
+ project_label TEXT,
2687
+ models_json TEXT,
2688
+ title TEXT
2689
+ );
2690
+ CREATE INDEX IF NOT EXISTS idx_codex_conv_rollups_recent
2691
+ ON codex_conversation_rollups(last_activity_utc DESC, conversation_key DESC);
2692
+
2693
+ -- codex_conversation_file_touches: write-class axis for the `files`
2694
+ -- search kind + outline file stats (§3.3). source_path gives explicit
2695
+ -- lineage so the per-file delete/truncate/prune paths scope deletions
2696
+ -- exactly as they do for the other Codex families.
2697
+ CREATE TABLE IF NOT EXISTS codex_conversation_file_touches (
2698
+ message_id INTEGER NOT NULL,
2699
+ conversation_key TEXT NOT NULL,
2700
+ source_path TEXT NOT NULL,
2701
+ file_path TEXT NOT NULL,
2702
+ tool TEXT NOT NULL,
2703
+ UNIQUE(message_id, file_path, tool)
2704
+ );
2705
+ CREATE INDEX IF NOT EXISTS idx_codex_conv_touches_source
2706
+ ON codex_conversation_file_touches(source_path);
2707
+
2624
2708
  CREATE TABLE IF NOT EXISTS cache_meta (
2625
2709
  key TEXT PRIMARY KEY,
2626
2710
  value TEXT
@@ -2662,6 +2746,10 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2662
2746
  add_column_if_missing(conn, "codex_session_files", "last_root_thread_id", "TEXT")
2663
2747
  add_column_if_missing(conn, "codex_session_files", "last_parent_thread_id", "TEXT")
2664
2748
  add_column_if_missing(conn, "codex_session_files", "last_conversation_key", "TEXT")
2749
+ # #294 S6: the terminal sticky-turn seed for delta resumes, alongside the
2750
+ # existing last_model/thread facts. A batch that ends after a turn_context
2751
+ # and resumes with a response_item stamps the correct effective turn.
2752
+ add_column_if_missing(conn, "codex_session_files", "last_turn_id", "TEXT")
2665
2753
  conn.execute(
2666
2754
  "CREATE INDEX IF NOT EXISTS idx_codex_files_source_root "
2667
2755
  "ON codex_session_files(source_root_key)"
@@ -2761,6 +2849,12 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2761
2849
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
2762
2850
  "ON session_files(session_id)"
2763
2851
  )
2852
+ # #294 S6: the independent Codex conversation FTS lifecycle. Run BEFORE (and
2853
+ # independent of) the Claude FTS branch below — the Claude branch early-returns
2854
+ # on a legacy conversation_fts(text) shape, so the Codex index must be stood up
2855
+ # here or a legacy-shape cache would never gain Codex search. Never touches any
2856
+ # Claude FTS object (conv_fts_* / conv_title_fts_*).
2857
+ _apply_codex_conversation_fts(conn)
2764
2858
  # FTS5 is optional in the sqlite build. Create the external-content index +
2765
2859
  # sync triggers as separate executes wrapped in one try; on failure create
2766
2860
  # NEITHER the table NOR the triggers (a trigger referencing a missing table
@@ -3188,6 +3282,142 @@ def clear_conversation_messages(conn: sqlite3.Connection) -> None:
3188
3282
  _create_conversation_fts_triggers(conn)
3189
3283
 
3190
3284
 
3285
+ # ── #294 S6: independent Codex conversation FTS lifecycle (§3.4) ──────────────
3286
+ # The Codex normalized search index owns its OWN external-content FTS5 table +
3287
+ # AI/AD/AU trigger trio, entirely separate from the Claude conversation_fts /
3288
+ # conversation_title_fts objects. _apply_codex_conversation_fts runs from
3289
+ # _apply_cache_schema BEFORE the Claude legacy-FTS early-return, so a legacy-shape
3290
+ # Claude cache still gains it. The Codex-scoped `codex_fts_unavailable` cache_meta
3291
+ # marker is INDEPENDENT of the Claude `fts5_unavailable` marker. Column names
3292
+ # match codex_conversation_messages BY NAME (external-content FTS5 rule).
3293
+ _CODEX_CONV_FTS_DDL = (
3294
+ "CREATE VIRTUAL TABLE IF NOT EXISTS codex_conversation_fts USING fts5("
3295
+ "text, search_tool, search_thinking, "
3296
+ "content='codex_conversation_messages', content_rowid='id')"
3297
+ )
3298
+ # Trigger DDL lives in ONE place so the initial create and the storm-free
3299
+ # full-clear (which drops + recreates the trigger set) can never drift. AD/AU use
3300
+ # the external-content `'delete'` idiom; AU fires AFTER UPDATE OF the three
3301
+ # indexed columns.
3302
+ _CODEX_CONV_FTS_TRIGGER_DDL = (
3303
+ "CREATE TRIGGER IF NOT EXISTS codex_conv_fts_ai AFTER INSERT ON codex_conversation_messages "
3304
+ "BEGIN INSERT INTO codex_conversation_fts(rowid, text, search_tool, search_thinking) "
3305
+ "VALUES (new.id, new.text, new.search_tool, new.search_thinking); END",
3306
+ "CREATE TRIGGER IF NOT EXISTS codex_conv_fts_ad AFTER DELETE ON codex_conversation_messages "
3307
+ "BEGIN INSERT INTO codex_conversation_fts(codex_conversation_fts, rowid, text, search_tool, search_thinking) "
3308
+ "VALUES('delete', old.id, old.text, old.search_tool, old.search_thinking); END",
3309
+ "CREATE TRIGGER IF NOT EXISTS codex_conv_fts_au "
3310
+ "AFTER UPDATE OF text, search_tool, search_thinking ON codex_conversation_messages "
3311
+ "BEGIN INSERT INTO codex_conversation_fts(codex_conversation_fts, rowid, text, search_tool, search_thinking) "
3312
+ "VALUES('delete', old.id, old.text, old.search_tool, old.search_thinking); "
3313
+ "INSERT INTO codex_conversation_fts(rowid, text, search_tool, search_thinking) "
3314
+ "VALUES (new.id, new.text, new.search_tool, new.search_thinking); END",
3315
+ )
3316
+ _CODEX_CONV_FTS_TRIGGER_NAMES = ("codex_conv_fts_au", "codex_conv_fts_ad", "codex_conv_fts_ai")
3317
+
3318
+
3319
+ def _create_codex_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
3320
+ """Create the Codex FTS sync trigger set — idempotent (each ``IF NOT EXISTS``).
3321
+ Single source of truth, shared by ``_apply_codex_conversation_fts`` and the
3322
+ storm-free full-clear. The caller must have already created the vtable."""
3323
+ for stmt in _CODEX_CONV_FTS_TRIGGER_DDL:
3324
+ conn.execute(stmt)
3325
+
3326
+
3327
+ def _drop_codex_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
3328
+ """Drop the Codex FTS sync trigger set — idempotent (``IF EXISTS``). Swallows
3329
+ ``OperationalError`` per statement so an absent set (FTS5-unavailable build) is
3330
+ tolerated. Dropping these on a no-FTS5 build is what keeps a normalized INSERT
3331
+ from firing a trigger against the missing vtable and rolling back the shared
3332
+ per-file ingest transaction. NEVER touches any Claude FTS trigger."""
3333
+ for name in _CODEX_CONV_FTS_TRIGGER_NAMES:
3334
+ try:
3335
+ conn.execute(f"DROP TRIGGER IF EXISTS {name}")
3336
+ except sqlite3.OperationalError:
3337
+ pass
3338
+
3339
+
3340
+ def _apply_codex_conversation_fts(conn: sqlite3.Connection) -> None:
3341
+ """Stand up / recover / degrade the independent Codex conversation FTS (§3.4).
3342
+
3343
+ Four states: create (FTS5 available, no marker) → codex_conversation_fts +
3344
+ triggers; unavailable-at-creation → set ``codex_fts_unavailable`` + skip DDL;
3345
+ capable→unavailable reopen → drop ONLY the Codex triggers + set the marker;
3346
+ recovery (FTS5 available again, marker set) → drop/recreate the vtable + its
3347
+ triggers, ``rebuild`` from the base rows, clear the marker. Never touches any
3348
+ Claude FTS object."""
3349
+ if _fts5_available(conn):
3350
+ recovering = conn.execute(
3351
+ "SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'"
3352
+ ).fetchone() is not None
3353
+ try:
3354
+ if recovering:
3355
+ # A prior FTS5-unavailable run ingested normalized rows without a
3356
+ # live trigger (or a capable→unavailable→capable cycle left a stale
3357
+ # vtable). Drop + recreate the index and its triggers, then rebuild
3358
+ # from the base rows.
3359
+ _drop_codex_conversation_fts_triggers(conn)
3360
+ conn.execute("DROP TABLE IF EXISTS codex_conversation_fts")
3361
+ conn.execute(_CODEX_CONV_FTS_DDL)
3362
+ _create_codex_conversation_fts_triggers(conn)
3363
+ if recovering:
3364
+ conn.execute(
3365
+ "INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('rebuild')")
3366
+ conn.execute("DELETE FROM cache_meta WHERE key='codex_fts_unavailable'")
3367
+ except sqlite3.OperationalError:
3368
+ # Partial create cleanup, then mark unavailable so search uses LIKE.
3369
+ _drop_codex_conversation_fts_triggers(conn)
3370
+ try:
3371
+ conn.execute("DROP TABLE IF EXISTS codex_conversation_fts")
3372
+ except sqlite3.OperationalError:
3373
+ pass
3374
+ _set_cache_meta(conn, "codex_fts_unavailable", "1")
3375
+ else:
3376
+ # FTS5 unavailable on THIS build. If a prior capable run left the Codex
3377
+ # triggers, they now reference an unusable vtable and every normalized
3378
+ # INSERT would fail and roll back the shared per-file transaction — so
3379
+ # drop ONLY the Codex triggers (the vtable can't be dropped without fts5,
3380
+ # but with no triggers nothing writes to it) and mark unavailable.
3381
+ _drop_codex_conversation_fts_triggers(conn)
3382
+ _set_cache_meta(conn, "codex_fts_unavailable", "1")
3383
+
3384
+
3385
+ def _codex_conversation_fts_full_clear(conn: sqlite3.Connection) -> None:
3386
+ """Storm-free FULL clear of every Codex normalized derived table (§3.4).
3387
+
3388
+ Drops the Codex FTS triggers, truncates codex_conversation_messages (the
3389
+ no-trigger fast path), resets the external-content index via ``'delete-all'``,
3390
+ recreates the triggers, then clears file-touches + rollups. ``'delete-all'`` is
3391
+ valid here precisely because the WHOLE normalized corpus empties; the sequence
3392
+ makes repeated runs byte-idempotent at the FTS shadow-table level (migration
3393
+ 025 re-run, cache-rebuild). Partial deletes (per-file truncation, root-set
3394
+ orphan prune) MUST NOT use this — they ride the per-row delete triggers so
3395
+ surviving conversations keep their postings. Falls back to a plain base DELETE
3396
+ when FTS5 is unavailable (no triggers, no usable vtable)."""
3397
+ try:
3398
+ fts_unavailable = conn.execute(
3399
+ "SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'"
3400
+ ).fetchone() is not None
3401
+ except sqlite3.OperationalError:
3402
+ fts_unavailable = True
3403
+ if fts_unavailable:
3404
+ conn.execute("DELETE FROM codex_conversation_messages")
3405
+ else:
3406
+ _drop_codex_conversation_fts_triggers(conn)
3407
+ conn.execute("DELETE FROM codex_conversation_messages")
3408
+ conn.execute(
3409
+ "INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('delete-all')")
3410
+ _create_codex_conversation_fts_triggers(conn)
3411
+ for stmt in (
3412
+ "DELETE FROM codex_conversation_file_touches",
3413
+ "DELETE FROM codex_conversation_rollups",
3414
+ ):
3415
+ try:
3416
+ conn.execute(stmt)
3417
+ except sqlite3.OperationalError:
3418
+ pass # table not yet created (pre-schema conn); nothing to clear
3419
+
3420
+
3191
3421
  def _eagerly_apply_cache_migrations() -> None:
3192
3422
  """Open cache.db so its pending migrations (notably
3193
3423
  ``001_dedup_highest_wins``) apply BEFORE stats migration 008's gate
@@ -4278,6 +4508,68 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
4278
4508
  conn.execute("DELETE FROM codex_conversation_threads")
4279
4509
  conn.execute("DELETE FROM codex_conversation_events")
4280
4510
  conn.execute("DELETE FROM codex_source_roots")
4511
+ # F3 (#313): clearing Codex quota state without invalidating the
4512
+ # quota-projection certificate would leave a stale-valid cert
4513
+ # (cache sequence unchanged) that lets the reconcile short-circuit
4514
+ # skip over now-deleted data. Delete it in the same transaction.
4515
+ conn.execute(
4516
+ "DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
4517
+ )
4518
+ conn.commit()
4519
+ except Exception:
4520
+ conn.rollback()
4521
+ raise
4522
+ finally:
4523
+ if lock_fh is not None:
4524
+ try:
4525
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
4526
+ except OSError:
4527
+ pass
4528
+ lock_fh.close()
4529
+
4530
+
4531
+ @cache_migration("025_codex_conversation_normalization")
4532
+ def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
4533
+ """Derive S6 normalized conversation state for caches whose Codex events were
4534
+ ingested before S6 (#294 S6, §3.5).
4535
+
4536
+ Clears the three derived tables (codex_conversation_messages,
4537
+ codex_conversation_file_touches, codex_conversation_rollups) via the §3.4
4538
+ storm-free full-clear helper, then replays the pure normalization kernel over
4539
+ the stored ``codex_conversation_events`` per file in
4540
+ ``(source_path ASC, line_offset ASC)`` order — rebuilding session_meta /
4541
+ turn_context sticky state as it goes. The physical event log and Codex
4542
+ accounting/thread/quota rows are the replay SOURCE and are never touched.
4543
+
4544
+ Takes ``cache.db.codex.lock`` with the same fcntl-then-``BEGIN IMMEDIATE``
4545
+ order as ``sync_codex_cache``, raising ``MigrationGateNotMet`` on contention
4546
+ (defer, never partially normalize) from both the ordinary and eager cache
4547
+ dispatch paths. Deterministic replay order + the non-AUTOINCREMENT ``id``
4548
+ rowid alias + the storm-free full-clear make a re-run (handler success, crash
4549
+ before the central stamp) byte-idempotent INCLUDING the FTS shadow tables.
4550
+ Never self-stamps; the dispatcher owns ``schema_migrations`` and
4551
+ ``user_version``.
4552
+ """
4553
+ lock_path = _cache_db_codex_lock_path_for_conn(conn)
4554
+ lock_fh = None
4555
+ if lock_path is not None:
4556
+ lock_fh = open(lock_path, "w")
4557
+ try:
4558
+ fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
4559
+ except BlockingIOError:
4560
+ lock_fh.close()
4561
+ raise MigrationGateNotMet(
4562
+ "cache.db.codex.lock held by a concurrent sync_codex_cache; "
4563
+ "deferring cache 025 conversation normalization (#294 S6)"
4564
+ )
4565
+ try:
4566
+ conn.execute("BEGIN IMMEDIATE")
4567
+ try:
4568
+ # Full clear of only the DERIVED normalized tables (the event log
4569
+ # remains as the replay source), then re-derive from it.
4570
+ _codex_conversation_fts_full_clear(conn)
4571
+ import _cctally_cache
4572
+ _cctally_cache._replay_codex_normalization(conn)
4281
4573
  conn.commit()
4282
4574
  except Exception:
4283
4575
  conn.rollback()
@@ -5802,3 +6094,155 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
5802
6094
  print(f"cctally: {result.db} WAL {mb_b:.1f} MB -> {mb_a:.1f} MB "
5803
6095
  f"({result.frames_checkpointed} frames; {state}).")
5804
6096
  return 0 if result.truncated else 3
6097
+
6098
+
6099
+ # VACUUM writes a full fresh copy of the database into a temporary file and then
6100
+ # swaps it in, so it transiently needs roughly the DB's own size on top of the
6101
+ # existing file, plus room for the drained WAL. A short busy_timeout keeps a
6102
+ # contended VACUUM from hanging (F13).
6103
+ _VACUUM_BUSY_TIMEOUT_MS = 250
6104
+
6105
+
6106
+ def _free_disk_bytes(directory) -> int:
6107
+ """Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
6108
+ import shutil
6109
+ return shutil.disk_usage(str(directory)).free
6110
+
6111
+
6112
+ def _vacuum_required_free_bytes(path) -> int:
6113
+ """Conservative free-space floor to VACUUM ``path``: ~2x the DB file plus its
6114
+ current WAL sidecar (F13 — the VACUUM temp copy + a WAL/temp margin)."""
6115
+ try:
6116
+ db_bytes = path.stat().st_size
6117
+ except OSError:
6118
+ db_bytes = 0
6119
+ wal = path.parent / (path.name + "-wal")
6120
+ try:
6121
+ wal_bytes = wal.stat().st_size
6122
+ except OSError:
6123
+ wal_bytes = 0
6124
+ return 2 * db_bytes + wal_bytes
6125
+
6126
+
6127
+ def _run_vacuum_exclusive(path, label: str) -> int:
6128
+ """Checkpoint + VACUUM ``path`` under a real SQLite EXCLUSIVE lock (F13).
6129
+
6130
+ ``locking_mode=EXCLUSIVE`` + a short ``busy_timeout`` make a concurrent
6131
+ reader/writer FAIL PROMPTLY (no TOCTOU gap — the exclusion is the DB's own
6132
+ lock, which the advisory flocks do not provide against dashboard readers).
6133
+ Exit 0 on success, 3 when the DB is in use."""
6134
+ conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
6135
+ try:
6136
+ conn.execute(f"PRAGMA busy_timeout={_VACUUM_BUSY_TIMEOUT_MS}")
6137
+ page_size = conn.execute("PRAGMA page_size").fetchone()[0]
6138
+ before = conn.execute("PRAGMA page_count").fetchone()[0]
6139
+ try:
6140
+ conn.execute("PRAGMA locking_mode=EXCLUSIVE")
6141
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
6142
+ conn.execute("VACUUM")
6143
+ except sqlite3.OperationalError as exc:
6144
+ if "lock" in str(exc).lower() or "busy" in str(exc).lower():
6145
+ eprint(
6146
+ f"cctally: {label} is in use — VACUUM needs exclusive access. "
6147
+ f"Stop the dashboard and any other cctally process holding "
6148
+ f"{label}, then retry."
6149
+ )
6150
+ return 3
6151
+ raise
6152
+ after = conn.execute("PRAGMA page_count").fetchone()[0]
6153
+ freed_mb = max(0, (before - after)) * page_size / (1024 * 1024)
6154
+ print(
6155
+ f"cctally: {label} reclaimed {freed_mb:.1f} MB "
6156
+ f"({before} -> {after} pages)."
6157
+ )
6158
+ return 0
6159
+ finally:
6160
+ conn.close()
6161
+
6162
+
6163
+ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
6164
+ if not path.exists():
6165
+ print(f"cctally: no {label} database file present; nothing to reclaim.")
6166
+ return 0
6167
+ needed = _vacuum_required_free_bytes(path)
6168
+ free = _free_disk_bytes(path.parent)
6169
+ if free < needed:
6170
+ eprint(
6171
+ f"cctally: not enough free disk to VACUUM {label}: need ~"
6172
+ f"{needed // (1024 * 1024)} MB free, have {free // (1024 * 1024)} MB. "
6173
+ f"Free up space and retry."
6174
+ )
6175
+ return 3
6176
+ core = _cctally_core
6177
+ try:
6178
+ core.APP_DIR.mkdir(parents=True, exist_ok=True)
6179
+ except OSError:
6180
+ pass
6181
+ # Serialize against the retention prune and concurrent vacuums via the
6182
+ # dedicated maintenance flock (F13/F7), then the provider flocks for
6183
+ # cache.db. All non-blocking: fail promptly rather than hang.
6184
+ maint_fh = open(core.CACHE_LOCK_MAINTENANCE_PATH, "w")
6185
+ try:
6186
+ try:
6187
+ fcntl.flock(maint_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
6188
+ except (BlockingIOError, OSError):
6189
+ eprint(
6190
+ f"cctally: {label} VACUUM skipped: another maintenance operation "
6191
+ f"is running. Retry shortly."
6192
+ )
6193
+ return 3
6194
+ held = []
6195
+ try:
6196
+ if provider_locked:
6197
+ for lock_path, lname in (
6198
+ (core.CACHE_LOCK_PATH, "claude"),
6199
+ (core.CACHE_LOCK_CODEX_PATH, "codex"),
6200
+ ):
6201
+ fh = open(lock_path, "w")
6202
+ try:
6203
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
6204
+ except (BlockingIOError, OSError):
6205
+ fh.close()
6206
+ eprint(
6207
+ f"cctally: {label} VACUUM skipped: a {lname} sync is in "
6208
+ f"progress. Stop the dashboard / other cctally "
6209
+ f"processes and retry."
6210
+ )
6211
+ return 3
6212
+ held.append(fh)
6213
+ return _run_vacuum_exclusive(path, label)
6214
+ finally:
6215
+ for fh in held:
6216
+ try:
6217
+ fcntl.flock(fh, fcntl.LOCK_UN)
6218
+ except OSError:
6219
+ pass
6220
+ fh.close()
6221
+ finally:
6222
+ try:
6223
+ fcntl.flock(maint_fh, fcntl.LOCK_UN)
6224
+ except OSError:
6225
+ pass
6226
+ maint_fh.close()
6227
+
6228
+
6229
+ def cmd_db_vacuum(args: argparse.Namespace) -> int:
6230
+ """Reclaim disk space via VACUUM after a transcript prune (#313 P3, F13).
6231
+
6232
+ NEVER automatic. Holds the maintenance flock + (for cache.db) the provider
6233
+ flocks, then runs a checkpoint + VACUUM under a real SQLite EXCLUSIVE lock so
6234
+ a concurrent dashboard reader fails promptly instead of racing. Refuses when
6235
+ free disk is below ~2x the file + WAL. Exit 0 on success, 3 when a target is
6236
+ in use or disk is short."""
6237
+ which = getattr(args, "db", "cache")
6238
+ targets = []
6239
+ if which in ("cache", "all"):
6240
+ targets.append((_cctally_core.CACHE_DB_PATH, "cache.db", True))
6241
+ if which in ("stats", "all"):
6242
+ targets.append((_cctally_core.DB_PATH, "stats.db", False))
6243
+ overall = 0
6244
+ for path, label, provider_locked in targets:
6245
+ rc = _vacuum_one_db(path, label, provider_locked)
6246
+ if rc != 0:
6247
+ overall = rc
6248
+ 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.