cctally 1.67.1 → 1.69.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +187 -15
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +238 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +325 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -107,7 +107,7 @@ import sqlite3
107
107
  import sys
108
108
  import time
109
109
  from dataclasses import dataclass, field
110
- from typing import Any, Callable, Iterator
110
+ from typing import Any, Callable, Iterator, NamedTuple
111
111
 
112
112
 
113
113
  def _cctally():
@@ -121,6 +121,7 @@ def _cctally():
121
121
  # for ``eprint`` is deleted.
122
122
  import _cctally_core
123
123
  from _cctally_core import eprint
124
+ from _lib_source_identity import source_root_key
124
125
 
125
126
 
126
127
  # Module-level back-ref shims for the three out-of-scope JSONL/project
@@ -165,6 +166,7 @@ CodexEntry = _lib_jsonl.CodexEntry
165
166
  _CodexIterState = _lib_jsonl._CodexIterState
166
167
  _iter_jsonl_entries_with_offsets = _lib_jsonl._iter_jsonl_entries_with_offsets
167
168
  _iter_codex_jsonl_entries_with_offsets = _lib_jsonl._iter_codex_jsonl_entries_with_offsets
169
+ _iter_codex_fused_records_with_offsets = _lib_jsonl._iter_codex_fused_records_with_offsets
168
170
  _parse_usage_entries = _lib_jsonl._parse_usage_entries
169
171
  _should_replace = _lib_jsonl._should_replace
170
172
 
@@ -182,6 +184,13 @@ _iter_message_rows = _lib_conversation.iter_message_rows
182
184
  # the default path.
183
185
  _perf = _load_lib("_lib_perf")
184
186
 
187
+ # #302: the single embedded-pricing version knob (bumped on every pricing sync),
188
+ # used to auto-invalidate the rollup's materialized cost when pricing changes.
189
+ # _lib_pricing is a pure stdlib leaf (no sibling imports), so binding it at
190
+ # module-load is circular-safe. Referenced as a module global by
191
+ # _arm_rollup_backfill_on_pricing_change so a test may monkeypatch it.
192
+ PRICING_SNAPSHOT_DATE = _load_lib("_lib_pricing").PRICING_SNAPSHOT_DATE
193
+
185
194
  # Shared by the fused per-file walk AND backfill_conversation_messages so the
186
195
  # column list, placeholders, and tuple order live in ONE place — a column
187
196
  # add/reorder can't silently desync the two ingest paths (which would land
@@ -316,6 +325,96 @@ clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
316
325
  _set_cache_meta = _cctally_db_sib._set_cache_meta
317
326
 
318
327
 
328
+ # cache.db WAL hardening (#297). See
329
+ # docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
330
+ # `journal_size_limit` bounds the *persistent* WAL file so a checkpoint that
331
+ # actually resets the WAL truncates the file back down to this cap — it is
332
+ # containment/recovery under transient reader contention, NOT a reader-proof
333
+ # hard cap (a pinned long-lived reader still defeats it until it releases).
334
+ CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 134217728
335
+ # End-of-sync physical-size shrink trigger: only force a TRUNCATE checkpoint
336
+ # once the -wal file has grown past this, so normal small syncs stay cheap.
337
+ CACHE_WAL_CHECKPOINT_TRIGGER_BYTES = 64 * 1024 * 1024 # 67108864
338
+ # Near-nonblocking busy_timeout for the auto end-of-sync checkpoint. It runs
339
+ # BEFORE the ingest flock is released, so a long wait here would stall every
340
+ # above-threshold sync's flock under the heavy-reader contention that motivates
341
+ # the fix — fail fast and rely on journal_size_limit + the next checkpoint.
342
+ CHECKPOINT_AUTO_BUSY_TIMEOUT_MS = 100
343
+ # The explicit `cctally db checkpoint` command may wait this long.
344
+ CHECKPOINT_CMD_BUSY_TIMEOUT_MS = 15000
345
+
346
+
347
+ class CheckpointResult(NamedTuple):
348
+ """Outcome of a single ``PRAGMA wal_checkpoint(TRUNCATE)`` (#297).
349
+
350
+ ``busy`` is the checkpoint's own busy flag (a reader/writer held it off);
351
+ ``truncated`` means the WAL was actually reset AND the -wal file is now
352
+ zero-length/absent — a checkpoint can copy some frames yet still report
353
+ ``busy`` (partial), which is NOT ``truncated``.
354
+ """
355
+
356
+ db: str
357
+ wal_bytes_before: int
358
+ wal_bytes_after: int
359
+ frames_checkpointed: int
360
+ busy: bool
361
+ truncated: bool
362
+
363
+
364
+ def _wal_file_size(db_path) -> int:
365
+ """Best-effort size of the -wal sidecar in bytes; 0 if absent/unreadable."""
366
+ try:
367
+ return os.path.getsize(f"{db_path}-wal")
368
+ except OSError:
369
+ return 0
370
+
371
+
372
+ def _run_wal_truncate(conn, db_path, *, db_label: str) -> "CheckpointResult":
373
+ """Run a best-effort TRUNCATE checkpoint on an already-open connection.
374
+
375
+ PRECONDITION: ``conn`` has NO active transaction (autocommit). The
376
+ ``db checkpoint`` command passes a fresh raw connection; the end-of-sync
377
+ caller has committed all ingest work first. ``PRAGMA
378
+ wal_checkpoint(TRUNCATE)`` returns ``(busy, log, checkpointed)``: ``busy=0``
379
+ means the WAL was reset. Measures the -wal size before and after so callers
380
+ can report the shrink without re-deriving it.
381
+ """
382
+ before = _wal_file_size(db_path)
383
+ row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
384
+ busy = bool(row[0]) if row else True
385
+ frames = int(row[2]) if (row and row[2] is not None) else 0
386
+ after = _wal_file_size(db_path)
387
+ truncated = (not busy) and after == 0
388
+ return CheckpointResult(db_label, before, after, frames, busy, truncated)
389
+
390
+
391
+ def _maybe_truncate_wal(conn, db_path) -> None:
392
+ """End-of-sync best-effort WAL drain (#297).
393
+
394
+ Threshold-gated (only once the -wal file has grown past
395
+ ``CACHE_WAL_CHECKPOINT_TRIGGER_BYTES``) and run under a near-nonblocking
396
+ short busy_timeout so it NEVER stalls the held sync flock — the checkpoint
397
+ runs BEFORE the flock is released, so a long busy wait here would stall
398
+ every above-threshold sync under exactly the heavy-reader contention that
399
+ motivates the fix. Restores the prior busy_timeout. Fail-soft: a checkpoint
400
+ error must never fail the sync (observability/hygiene, not correctness).
401
+
402
+ PRECONDITION: ``conn`` has no active transaction — the caller has committed
403
+ all ingest work by this point.
404
+ """
405
+ try:
406
+ if _wal_file_size(db_path) <= CACHE_WAL_CHECKPOINT_TRIGGER_BYTES:
407
+ return
408
+ prior = conn.execute("PRAGMA busy_timeout").fetchone()[0]
409
+ try:
410
+ conn.execute(f"PRAGMA busy_timeout={CHECKPOINT_AUTO_BUSY_TIMEOUT_MS}")
411
+ _run_wal_truncate(conn, db_path, db_label="cache.db")
412
+ finally:
413
+ conn.execute(f"PRAGMA busy_timeout={prior}")
414
+ except sqlite3.DatabaseError:
415
+ pass # best-effort; observability/hygiene must never fail a sync
416
+
417
+
319
418
  _PARSE_HEALTH_SCHEMA = 1
320
419
 
321
420
 
@@ -499,6 +598,361 @@ def _resolve_project_key(
499
598
  # === Region 2: Codex sessions-dir helpers (was bin/cctally:2072-2099) ===
500
599
 
501
600
 
601
+ @dataclass(frozen=True)
602
+ class CodexProviderRoot:
603
+ """One configured Codex provider boundary and its JSONL walk directory."""
604
+
605
+ provider_root: pathlib.Path
606
+ walk_root: pathlib.Path
607
+ source_root_key: str
608
+
609
+
610
+ @dataclass(frozen=True)
611
+ class CodexDiscoveredFile:
612
+ """One physical rollout paired with its first matching provider root.
613
+
614
+ ``physical_path`` is only the de-duplication identity. ``source_path``
615
+ keeps the configured walk spelling because reporting resolves it against
616
+ the configured ``$CODEX_HOME`` roots.
617
+ """
618
+
619
+ source_path: pathlib.Path
620
+ physical_path: pathlib.Path
621
+ provider_root: pathlib.Path
622
+ walk_root: pathlib.Path
623
+ source_root_key: str
624
+
625
+
626
+ def _canonical_codex_path(path: pathlib.Path) -> pathlib.Path:
627
+ """Resolve an absolute Codex path, retaining a safe absolute spelling on I/O failure."""
628
+ try:
629
+ return path.resolve()
630
+ except OSError:
631
+ return path.absolute()
632
+
633
+
634
+ def _codex_provider_roots() -> list[CodexProviderRoot]:
635
+ """Return configured provider roots with their sessions/direct walk roots.
636
+
637
+ Provider identity follows the configured root, not necessarily the walked
638
+ ``sessions/`` child. Canonical duplicate provider aliases collapse here;
639
+ overlapping *distinct* configured roots remain ordered so discovery can
640
+ honor the first configured match.
641
+ """
642
+ roots: list[CodexProviderRoot] = []
643
+ seen: set[pathlib.Path] = set()
644
+ for configured in _cctally()._codex_home_roots():
645
+ provider_root = _canonical_codex_path(configured)
646
+ if provider_root in seen:
647
+ continue
648
+ sessions = configured / "sessions"
649
+ if sessions.is_dir():
650
+ walk_root = sessions
651
+ elif configured.is_dir():
652
+ walk_root = configured
653
+ else:
654
+ continue
655
+ seen.add(provider_root)
656
+ roots.append(CodexProviderRoot(
657
+ provider_root=provider_root,
658
+ walk_root=walk_root,
659
+ source_root_key=source_root_key(str(provider_root)),
660
+ ))
661
+ return roots
662
+
663
+
664
+ def _discover_codex_files_with_roots() -> list[CodexDiscoveredFile]:
665
+ """Discover each physical rollout once with the first matching root facts."""
666
+ discovered: list[CodexDiscoveredFile] = []
667
+ seen: set[pathlib.Path] = set()
668
+ for root in _codex_provider_roots():
669
+ for candidate in root.walk_root.glob("**/*.jsonl"):
670
+ if not candidate.is_file():
671
+ continue
672
+ physical_path = _canonical_codex_path(candidate)
673
+ if physical_path in seen:
674
+ continue
675
+ seen.add(physical_path)
676
+ discovered.append(CodexDiscoveredFile(
677
+ source_path=candidate,
678
+ physical_path=physical_path,
679
+ provider_root=root.provider_root,
680
+ walk_root=root.walk_root,
681
+ source_root_key=root.source_root_key,
682
+ ))
683
+ return discovered
684
+
685
+
686
+ def _delete_codex_file_derived_rows(
687
+ conn: sqlite3.Connection,
688
+ path_str: str,
689
+ *,
690
+ source_root_key: str | None = None,
691
+ match_source_root: bool = False,
692
+ ) -> None:
693
+ """Drop Codex rows for one file, optionally qualified to one source root."""
694
+ root_clause = " AND source_root_key IS ?" if match_source_root else ""
695
+ params: tuple[str, ...] | tuple[str, str | None]
696
+ params = (path_str, source_root_key) if match_source_root else (path_str,)
697
+ conn.execute(
698
+ "DELETE FROM codex_session_entries WHERE source_path = ?" + root_clause,
699
+ params,
700
+ )
701
+ conn.execute(
702
+ "DELETE FROM quota_window_snapshots WHERE source = 'codex' "
703
+ "AND source_path = ?" + root_clause,
704
+ params,
705
+ )
706
+ conn.execute(
707
+ "DELETE FROM codex_conversation_events WHERE source_path = ?" + root_clause,
708
+ params,
709
+ )
710
+ conn.execute(
711
+ "DELETE FROM codex_conversation_threads WHERE source_path = ?" + root_clause,
712
+ params,
713
+ )
714
+ conn.execute(
715
+ "DELETE FROM codex_session_files WHERE path = ?" + root_clause,
716
+ params,
717
+ )
718
+
719
+
720
+ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
721
+ """Clear every re-derivable Codex row family in child-before-root order."""
722
+ conn.execute("DELETE FROM codex_session_entries")
723
+ conn.execute("DELETE FROM quota_window_snapshots WHERE source = 'codex'")
724
+ conn.execute("DELETE FROM codex_conversation_threads")
725
+ conn.execute("DELETE FROM codex_conversation_events")
726
+ conn.execute("DELETE FROM codex_session_files")
727
+ conn.execute("DELETE FROM codex_source_roots")
728
+
729
+
730
+ def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
731
+ """Advance the dashboard's Codex physical-identity sequence in this txn."""
732
+ conn.execute(
733
+ "INSERT INTO cache_meta(key, value) VALUES "
734
+ "('codex_physical_mutation_seq', '1') "
735
+ "ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER) + 1"
736
+ )
737
+
738
+
739
+ def _collect_inactive_codex_paths_and_roots(
740
+ conn: sqlite3.Connection,
741
+ current_file_identities: set[tuple[str, str]],
742
+ active_root_keys: set[str],
743
+ ) -> tuple[list[tuple[str, str | None]], set[str]]:
744
+ """Return stale real source identities and their candidate root keys.
745
+
746
+ A failed/partial prior write can leave any S1 child family without its
747
+ terminal ``codex_session_files`` row. Scope pruning must therefore use
748
+ every physical family, compare each row's path AND provider root, and leave
749
+ relative fixture rows alone.
750
+ """
751
+ stale_identities: set[tuple[str, str | None]] = set()
752
+ stale_root_keys: set[str] = set()
753
+ current_paths = {path for path, _root_key in current_file_identities}
754
+ terminal_file_identities = {
755
+ (path, root_key)
756
+ for path, root_key in conn.execute(
757
+ "SELECT path, source_root_key FROM codex_session_files"
758
+ )
759
+ }
760
+ family_queries = (
761
+ "SELECT path, source_root_key FROM codex_session_files",
762
+ "SELECT source_path, source_root_key FROM codex_session_entries",
763
+ "SELECT source_path, source_root_key FROM quota_window_snapshots "
764
+ "WHERE source = 'codex'",
765
+ "SELECT source_path, source_root_key FROM codex_conversation_threads",
766
+ "SELECT source_path, source_root_key FROM codex_conversation_events",
767
+ )
768
+ for query in family_queries:
769
+ for source_path, root_key in conn.execute(query):
770
+ identity = (source_path, root_key)
771
+ if (
772
+ not os.path.isabs(source_path)
773
+ or identity in current_file_identities
774
+ # An old terminal file at a currently discovered path must
775
+ # reach the normal requalification loop, which resets every
776
+ # family as one file transaction and records the reset stat.
777
+ or (
778
+ source_path in current_paths
779
+ and identity in terminal_file_identities
780
+ )
781
+ ):
782
+ continue
783
+ stale_identities.add(identity)
784
+ if root_key is not None:
785
+ stale_root_keys.add(root_key)
786
+ stale_root_keys.update(
787
+ root_key
788
+ for (root_key,) in conn.execute(
789
+ "SELECT source_root_key FROM codex_source_roots"
790
+ )
791
+ if root_key not in active_root_keys
792
+ )
793
+ return sorted(stale_identities, key=lambda item: (item[0], item[1] or "")), stale_root_keys
794
+
795
+
796
+ def _prune_inactive_codex_source_roots(
797
+ conn: sqlite3.Connection,
798
+ active_root_keys: set[str],
799
+ *,
800
+ candidate_root_keys: set[str] | None = None,
801
+ ) -> None:
802
+ """Remove inactive roots only after every child family has been pruned."""
803
+ if candidate_root_keys is not None and not candidate_root_keys:
804
+ return
805
+ predicates: list[str] = []
806
+ params: list[str] = []
807
+ if active_root_keys:
808
+ placeholders = ",".join("?" for _ in active_root_keys)
809
+ predicates.append("roots.source_root_key NOT IN (" + placeholders + ")")
810
+ params.extend(active_root_keys)
811
+ if candidate_root_keys is not None:
812
+ placeholders = ",".join("?" for _ in candidate_root_keys)
813
+ predicates.append("roots.source_root_key IN (" + placeholders + ")")
814
+ params.extend(candidate_root_keys)
815
+ inactive = " AND ".join(predicates) if predicates else "1"
816
+ conn.execute(
817
+ f"""DELETE FROM codex_source_roots AS roots
818
+ WHERE {inactive}
819
+ AND NOT EXISTS (
820
+ SELECT 1 FROM codex_session_files AS files
821
+ WHERE files.source_root_key = roots.source_root_key
822
+ )
823
+ AND NOT EXISTS (
824
+ SELECT 1 FROM codex_session_entries AS entries
825
+ WHERE entries.source_root_key = roots.source_root_key
826
+ )
827
+ AND NOT EXISTS (
828
+ SELECT 1 FROM quota_window_snapshots AS quotas
829
+ WHERE quotas.source = 'codex'
830
+ AND quotas.source_root_key = roots.source_root_key
831
+ )
832
+ AND NOT EXISTS (
833
+ SELECT 1 FROM codex_conversation_threads AS threads
834
+ WHERE threads.source_root_key = roots.source_root_key
835
+ )
836
+ AND NOT EXISTS (
837
+ SELECT 1 FROM codex_conversation_events AS events
838
+ WHERE events.source_root_key = roots.source_root_key
839
+ )""",
840
+ tuple(params),
841
+ )
842
+
843
+
844
+ def _write_codex_file_batch(
845
+ conn: sqlite3.Connection,
846
+ *,
847
+ discovered: CodexDiscoveredFile,
848
+ path_str: str,
849
+ size: int,
850
+ mtime_ns: int,
851
+ final_offset: int,
852
+ last_session_id: str | None,
853
+ last_model: str | None,
854
+ last_total_tokens: int | None,
855
+ last_native_thread_id: str | None,
856
+ last_root_thread_id: str | None,
857
+ last_parent_thread_id: str | None,
858
+ last_conversation_key: str | None,
859
+ reset_file: bool,
860
+ accounting_rows: list[tuple[Any, ...]],
861
+ quota_rows: list[tuple[Any, ...]],
862
+ thread_rows: list[tuple[Any, ...]],
863
+ event_rows: list[tuple[Any, ...]],
864
+ active_root_keys: set[str],
865
+ ) -> int:
866
+ """Write one fully-buffered Codex file atomically and return entry changes."""
867
+ now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
868
+ if reset_file:
869
+ _delete_codex_file_derived_rows(conn, path_str)
870
+ conn.execute(
871
+ """INSERT INTO codex_source_roots
872
+ (source_root_key, canonical_root_path, first_seen_utc, last_seen_utc)
873
+ VALUES (?,?,?,?)
874
+ ON CONFLICT(source_root_key) DO UPDATE SET
875
+ canonical_root_path=excluded.canonical_root_path,
876
+ last_seen_utc=excluded.last_seen_utc""",
877
+ (discovered.source_root_key, str(discovered.provider_root), now_iso, now_iso),
878
+ )
879
+ rows_changed = 0
880
+ if accounting_rows:
881
+ before = conn.total_changes
882
+ conn.executemany(
883
+ """INSERT OR IGNORE INTO codex_session_entries
884
+ (source_path, line_offset, timestamp_utc, session_id, model,
885
+ input_tokens, cached_input_tokens, output_tokens,
886
+ reasoning_output_tokens, total_tokens, source_root_key,
887
+ conversation_key)
888
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
889
+ accounting_rows,
890
+ )
891
+ rows_changed = conn.total_changes - before
892
+ if quota_rows:
893
+ conn.executemany(
894
+ """INSERT OR IGNORE INTO quota_window_snapshots
895
+ (source, source_root_key, source_path, line_offset,
896
+ captured_at_utc, observed_slot, logical_limit_key, limit_id,
897
+ limit_name, window_minutes, used_percent, resets_at_utc,
898
+ plan_type, individual_limit_json, reached_type)
899
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
900
+ quota_rows,
901
+ )
902
+ if thread_rows:
903
+ conn.executemany(
904
+ """INSERT INTO codex_conversation_threads
905
+ (conversation_key, source_root_key, native_thread_id,
906
+ root_thread_id, parent_thread_id, source_path, cwd, git_json,
907
+ source_kind, thread_source_json, model_provider, context_window,
908
+ first_seen_utc, last_seen_utc)
909
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
910
+ ON CONFLICT(conversation_key) DO UPDATE SET
911
+ source_root_key=excluded.source_root_key,
912
+ native_thread_id=excluded.native_thread_id,
913
+ root_thread_id=excluded.root_thread_id,
914
+ parent_thread_id=excluded.parent_thread_id,
915
+ source_path=excluded.source_path, cwd=excluded.cwd,
916
+ git_json=excluded.git_json, source_kind=excluded.source_kind,
917
+ thread_source_json=excluded.thread_source_json,
918
+ model_provider=excluded.model_provider,
919
+ context_window=excluded.context_window,
920
+ last_seen_utc=excluded.last_seen_utc""",
921
+ [(*row, now_iso, now_iso) for row in thread_rows],
922
+ )
923
+ if event_rows:
924
+ conn.executemany(
925
+ """INSERT OR IGNORE INTO codex_conversation_events
926
+ (source_path, line_offset, source_root_key, conversation_key,
927
+ native_thread_id, root_thread_id, parent_thread_id,
928
+ timestamp_utc, record_type, event_type, turn_id, call_id,
929
+ payload_json)
930
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
931
+ event_rows,
932
+ )
933
+ conn.execute(
934
+ """INSERT OR REPLACE INTO codex_session_files
935
+ (path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
936
+ last_session_id, last_model, last_total_tokens, source_root_key,
937
+ last_native_thread_id, last_root_thread_id, last_parent_thread_id,
938
+ last_conversation_key)
939
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
940
+ (
941
+ path_str, size, mtime_ns, final_offset, now_iso, last_session_id,
942
+ last_model, last_total_tokens, discovered.source_root_key,
943
+ last_native_thread_id, last_root_thread_id, last_parent_thread_id,
944
+ last_conversation_key,
945
+ ),
946
+ )
947
+ _prune_inactive_codex_source_roots(conn, active_root_keys)
948
+ # A file batch owns accounting, quota, thread, event, root, and cursor
949
+ # facts as one physical unit. Keep the version bump in that same commit so
950
+ # a rolled-back batch never appears newer to the dashboard signature.
951
+ _bump_codex_physical_mutation_seq(conn)
952
+ conn.commit()
953
+ return rows_changed
954
+
955
+
502
956
  def _iter_codex_jsonl_paths(roots: list[pathlib.Path]) -> Iterator[pathlib.Path]:
503
957
  """Yield each existing *.jsonl under the given roots, de-duped by RESOLVED
504
958
  path (first occurrence wins — collapses overlapping/prefix roots and
@@ -1832,6 +2286,12 @@ def sync_cache(
1832
2286
  # rows the rail's old live aggregate read, so the rollup stays
1833
2287
  # byte-identical to that aggregate.
1834
2288
  with _perf.phase("recompute.conversation_sessions"):
2289
+ # #302: auto-invalidate the rollup's MATERIALIZED cost when the
2290
+ # embedded pricing snapshot changed since it was last derived. Runs
2291
+ # BEFORE the pending check so a mismatch arms the same durable flag
2292
+ # the full-recompute path already consumes below (self-heal on a
2293
+ # pricing sync / cctally upgrade, no manual `cache-sync --rebuild`).
2294
+ _arm_rollup_backfill_on_pricing_change(conn)
1835
2295
  if _conversation_sessions_backfill_pending(conn):
1836
2296
  _recompute_conversation_sessions(conn)
1837
2297
  conn.execute(
@@ -1875,6 +2335,11 @@ def sync_cache(
1875
2335
  # write has materialized the -wal/-shm sidecars. open_cache_db hardens
1876
2336
  # cache.db + the data dir; this finishes the job for the sidecars.
1877
2337
  _harden_cache_sidecars()
2338
+ # #297: forced end-of-sync WAL drain. Threshold-gated + short-timeout +
2339
+ # best-effort. Runs here — all ingest work is committed (no active txn)
2340
+ # and the flock is still held, so the short busy_timeout keeps it from
2341
+ # stalling the lock under heavy-reader contention.
2342
+ _maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
1878
2343
  return stats
1879
2344
  finally:
1880
2345
  try:
@@ -2120,6 +2585,37 @@ def _conversation_sessions_backfill_pending(conn) -> bool:
2120
2585
  return False
2121
2586
 
2122
2587
 
2588
+ def _arm_rollup_backfill_on_pricing_change(conn) -> None:
2589
+ """Arm the conversation_sessions full backfill when the embedded pricing
2590
+ snapshot changed since the rollup's stored cost was last derived (#302). The
2591
+ rail now reads MATERIALIZED cost off the rollup, so a pricing sync / cctally
2592
+ upgrade would otherwise leave untouched sessions' cost (and the cost
2593
+ filter/sort axis) stale until a manual `cache-sync --rebuild`. This self-heals
2594
+ it: compares a stored cache_meta fingerprint against the current
2595
+ PRICING_SNAPSHOT_DATE and, on mismatch, arms
2596
+ conversation_sessions_backfill_pending + advances the stored fingerprint (one
2597
+ committed txn). The existing full-recompute-then-drop-flag-last machinery then
2598
+ re-derives every session's cost + enrichment.
2599
+
2600
+ Crash-safety is unchanged: the DURABLE backfill flag remains the recompute
2601
+ signal, so advancing the fingerprint here cannot strand stale cost (a crash
2602
+ after arming leaves the flag set -> next sync recomputes regardless of the
2603
+ fingerprint). No-op when cache_meta is unavailable (path-less / degraded
2604
+ conn). Caller path holds the cache.db.lock flock."""
2605
+ try:
2606
+ row = conn.execute(
2607
+ "SELECT value FROM cache_meta "
2608
+ "WHERE key='conversation_sessions_pricing_fp'"
2609
+ ).fetchone()
2610
+ except sqlite3.OperationalError:
2611
+ return
2612
+ if row is not None and row[0] == PRICING_SNAPSHOT_DATE:
2613
+ return
2614
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
2615
+ _set_cache_meta(conn, "conversation_sessions_pricing_fp", PRICING_SNAPSHOT_DATE)
2616
+ conn.commit()
2617
+
2618
+
2123
2619
  def _recompute_conversation_sessions(conn, session_ids=None) -> None:
2124
2620
  """Recompute the ``conversation_sessions`` browse-rail rollup from
2125
2621
  ``conversation_messages``. The caller holds the cache.db.lock flock and owns
@@ -2170,26 +2666,34 @@ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
2170
2666
 
2171
2667
  def _fill_conversation_sessions_filter_columns(conn, session_ids):
2172
2668
  """Fill the rollup's browse-FILTER columns (project_label / cost_usd /
2173
- cache_rebuild_count, migration 015) for the given sessions, or ALL when
2669
+ cache_rebuild_count, migration 015) AND the #302 DISPLAYED-enrichment columns
2670
+ (git_branch / models_json / title) for the given sessions, or ALL when
2174
2671
  ``session_ids is None``. The structural COUNT/MIN/MAX columns are filled by
2175
2672
  the INSERT in _recompute_conversation_sessions; this is the second pass that
2176
- materializes the three filter axes so the rail's date/project/cost/rebuild
2177
- filters are pure-SQL predicates.
2178
-
2179
- project_label + cost reuse the query kernel's batch maps (the SAME
2180
- _project_label / _session_cost_map the rail's per-page Python path used), so
2181
- a filtered/displayed value equals what the live rail produced. cost is
2182
- rounded to 6dp to match list_conversations' per-row rounding.
2183
- cache_rebuild_count is a per-session lightweight rebuild-count via the
2184
- query kernel's single-source-of-truth helper (no full assembly U1; the
2185
- rule is shared with the reader path) a whole-session property
2186
- (recompute, never increment).
2187
-
2188
- No-op when the columns are absent (a pre-migration-015 cache.db being
2189
- re-derived before its 015 ALTER lands), so an early/partial sync never
2190
- raises ``no such column``. The CALLER owns the commit (this never commits)."""
2673
+ materializes the filter axes (pure-SQL predicates) AND the displayed
2674
+ enrichment the rail reads straight off the rollup instead of re-scanning
2675
+ conversation_messages per session on every cold page.
2676
+
2677
+ Every value reuses the query kernel's batch maps the SAME
2678
+ _project_label / _session_cost_map / _session_latest_meta_map /
2679
+ _session_models_map / _session_first_prompt_titles_map the rail's live path
2680
+ uses so a materialized value equals what the live rail produces for that
2681
+ session (byte-identity by construction; #302 Section 1). cost is rounded to
2682
+ 6dp to match list_conversations' per-row rounding. cache_rebuild_count is a
2683
+ per-session lightweight rebuild-count via the query kernel's
2684
+ single-source-of-truth helper (no full assembly — U1). git_branch is the
2685
+ latest non-null branch (already computed in ``meta`` for project_label, so
2686
+ zero extra query). models_json stores the ordered raw model-ID list
2687
+ (_models_main_first order) as ``json.dumps(models) if models else None`` ->
2688
+ NULL when the session used no non-null model (read back ``[]`` on NULL). title
2689
+ stores ONLY the stable first-prompt title; the volatile AI title is overlaid
2690
+ live by list_conversations (#302 Q2-B), so it is NOT stored here.
2691
+
2692
+ No-op when any of the columns is absent (a pre-015 / pre-023 cache.db being
2693
+ re-derived before _apply_cache_schema adds them), so an early/partial sync
2694
+ never raises ``no such column``. The CALLER owns the commit (never commits)."""
2191
2695
  cols = {r[1] for r in conn.execute("PRAGMA table_info(conversation_sessions)")}
2192
- if "cache_rebuild_count" not in cols:
2696
+ if not {"cache_rebuild_count", "git_branch", "models_json", "title"} <= cols:
2193
2697
  return
2194
2698
  lq = _load_lib("_lib_conversation_query")
2195
2699
  if session_ids is None:
@@ -2201,13 +2705,21 @@ def _fill_conversation_sessions_filter_columns(conn, session_ids):
2201
2705
  return
2202
2706
  cost = lq._session_cost_map(conn, ids)
2203
2707
  meta = lq._session_latest_meta_map(conn, ids)
2708
+ models = lq._session_models_map(conn, ids)
2709
+ first_titles = lq._session_first_prompt_titles_map(conn, ids)
2204
2710
  for sid in ids:
2205
2711
  proj = lq._project_label(meta.get(sid, (None, None))[0])
2712
+ branch = meta.get(sid, (None, None))[1]
2206
2713
  rebuilds = lq.session_cache_rebuild_count(conn, sid)
2714
+ m = models.get(sid) or []
2715
+ models_json = json.dumps(m) if m else None
2716
+ title = first_titles.get(sid)
2207
2717
  conn.execute(
2208
2718
  "UPDATE conversation_sessions SET project_label=?, cost_usd=?, "
2209
- "cache_rebuild_count=? WHERE session_id=?",
2210
- (proj, round(cost.get(sid, 0.0), 6), rebuilds, sid),
2719
+ "cache_rebuild_count=?, git_branch=?, models_json=?, title=? "
2720
+ "WHERE session_id=?",
2721
+ (proj, round(cost.get(sid, 0.0), 6), rebuilds, branch, models_json,
2722
+ title, sid),
2211
2723
  )
2212
2724
 
2213
2725
 
@@ -2964,6 +3476,7 @@ def sync_codex_cache(
2964
3476
  progress: Callable[[CodexIngestStats], None] | None = None,
2965
3477
  rebuild: bool = False,
2966
3478
  lock_timeout: "float | None" = None,
3479
+ _on_first_file_rollback: Callable[[], None] | None = None,
2967
3480
  ) -> CodexIngestStats:
2968
3481
  """Read-through delta ingest of ~/.codex/sessions/**/*.jsonl.
2969
3482
 
@@ -2977,6 +3490,7 @@ def sync_codex_cache(
2977
3490
  untouched and the caller sees `lock_contended=True`.
2978
3491
  """
2979
3492
  stats = CodexIngestStats()
3493
+ project_after_unlock = False
2980
3494
  c = _cctally()
2981
3495
  _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
2982
3496
  _cctally_core.CACHE_LOCK_CODEX_PATH.touch()
@@ -2995,18 +3509,19 @@ def sync_codex_cache(
2995
3509
  # Clear INSIDE the lock — see sync_cache() for the full
2996
3510
  # rationale. Done before the existing SELECT so delta
2997
3511
  # detection sees an empty baseline.
2998
- conn.execute("DELETE FROM codex_session_entries")
2999
- conn.execute("DELETE FROM codex_session_files")
3512
+ before_clear = conn.total_changes
3513
+ _clear_codex_derived_rows(conn)
3514
+ if conn.total_changes != before_clear:
3515
+ _bump_codex_physical_mutation_seq(conn)
3000
3516
  conn.commit()
3001
3517
  eprint("[cache-sync] rebuild: cleared Codex cached entries")
3002
3518
 
3003
- roots = _cctally()._codex_session_roots()
3004
3519
  # Pure read (glob + is_file only); safe to run before the SELECT and
3005
3520
  # the per-file loop, where no cache.db write lock may be held.
3006
3521
  with _perf.phase("discover") as _p_disc:
3007
- paths: list[pathlib.Path] = list(_iter_codex_jsonl_paths(roots))
3008
- stats.files_total = len(paths)
3009
- _p_disc.set_count(len(paths))
3522
+ files = _discover_codex_files_with_roots()
3523
+ stats.files_total = len(files)
3524
+ _p_disc.set_count(len(files))
3010
3525
 
3011
3526
  # Scope the cache to the CURRENT root set: drop rows ingested under a
3012
3527
  # prior $CODEX_HOME (issue #108). iter_codex_entries() has NO root
@@ -3022,7 +3537,9 @@ def sync_codex_cache(
3022
3537
  # processes with different $CODEX_HOME would prune each other; the
3023
3538
  # flock serializes them and that is a pathological configuration.
3024
3539
  if not rebuild: # --rebuild already cleared both tables above
3025
- current_paths = {str(p) for p in paths}
3540
+ current_file_identities = {
3541
+ (str(item.source_path), item.source_root_key) for item in files
3542
+ }
3026
3543
  # Only prune ABSOLUTE source_paths. _codex_home_roots() makes
3027
3544
  # every real root absolute (via .absolute()), so a real ingested
3028
3545
  # row always stores an absolute str(jp) — INCLUDING a relative
@@ -3031,22 +3548,27 @@ def sync_codex_cache(
3031
3548
  # synthetic baked-cache fixture row (e.g. build-speed-fixtures.py)
3032
3549
  # with no on-disk JSONL to scope against; pruning it would wipe a
3033
3550
  # cache meant to be read as-is (issue #108).
3034
- orphan_paths = [
3035
- row[0]
3036
- for row in conn.execute("SELECT path FROM codex_session_files")
3037
- if row[0] not in current_paths and os.path.isabs(row[0])
3038
- ]
3039
- if orphan_paths:
3040
- conn.executemany(
3041
- "DELETE FROM codex_session_entries WHERE source_path = ?",
3042
- [(p,) for p in orphan_paths],
3043
- )
3044
- conn.executemany(
3045
- "DELETE FROM codex_session_files WHERE path = ?",
3046
- [(p,) for p in orphan_paths],
3551
+ active_root_keys = {item.source_root_key for item in files}
3552
+ orphan_sources, orphan_root_keys = _collect_inactive_codex_paths_and_roots(
3553
+ conn, current_file_identities, active_root_keys,
3554
+ )
3555
+ if orphan_sources or orphan_root_keys:
3556
+ before_prune = conn.total_changes
3557
+ for orphan_path, orphan_root_key in orphan_sources:
3558
+ _delete_codex_file_derived_rows(
3559
+ conn,
3560
+ orphan_path,
3561
+ source_root_key=orphan_root_key,
3562
+ match_source_root=True,
3563
+ )
3564
+ _prune_inactive_codex_source_roots(
3565
+ conn, active_root_keys,
3566
+ candidate_root_keys=orphan_root_keys,
3047
3567
  )
3568
+ if conn.total_changes != before_prune:
3569
+ _bump_codex_physical_mutation_seq(conn)
3048
3570
  conn.commit()
3049
- stats.files_pruned = len(orphan_paths)
3571
+ stats.files_pruned = len({path for path, _root in orphan_sources})
3050
3572
 
3051
3573
  # This SELECT does NOT open an implicit transaction (Python's
3052
3574
  # sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
@@ -3059,10 +3581,15 @@ def sync_codex_cache(
3059
3581
  # append-only, so a size change is a sufficient signal and mtime
3060
3582
  # is prone to clock-skew false-positives).
3061
3583
  existing = {
3062
- row[0]: (row[1], row[2], row[3], row[4], row[5], row[6])
3584
+ row[0]: (
3585
+ row[1], row[2], row[3], row[4], row[5], row[6], row[7],
3586
+ row[8], row[9], row[10], row[11],
3587
+ )
3063
3588
  for row in conn.execute(
3064
3589
  "SELECT path, size_bytes, mtime_ns, last_byte_offset, "
3065
- "last_session_id, last_model, last_total_tokens "
3590
+ "last_session_id, last_model, last_total_tokens, source_root_key, "
3591
+ "last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
3592
+ "last_conversation_key "
3066
3593
  "FROM codex_session_files"
3067
3594
  )
3068
3595
  }
@@ -3072,7 +3599,8 @@ def sync_codex_cache(
3072
3599
  # the loop stays flat, mirroring sync_cache's walk seam.
3073
3600
  _p_walk = _perf.phase("walk")
3074
3601
  _p_walk.__enter__()
3075
- for jp in paths:
3602
+ for discovered in files:
3603
+ jp = discovered.source_path
3076
3604
  path_str = str(jp)
3077
3605
  try:
3078
3606
  st = jp.stat()
@@ -3089,17 +3617,25 @@ def sync_codex_cache(
3089
3617
  initial_model: str | None = None
3090
3618
  initial_total_tokens = 0
3091
3619
  prev_total_tokens: int | None = None
3620
+ prev_native_thread_id: str | None = None
3621
+ prev_root_thread_id: str | None = None
3622
+ prev_parent_thread_id: str | None = None
3623
+ prev_conversation_key: str | None = None
3624
+ requalified = False
3092
3625
  if prev is not None:
3093
3626
  (
3094
3627
  prev_size, _, prev_offset, prev_sid, prev_model, prev_ttot,
3628
+ prev_root_key, prev_native_thread_id, prev_root_thread_id,
3629
+ prev_parent_thread_id, prev_conversation_key,
3095
3630
  ) = prev
3096
3631
  prev_total_tokens = (
3097
3632
  int(prev_ttot) if prev_ttot is not None else None
3098
3633
  )
3099
- if size == prev_size:
3634
+ requalified = prev_root_key != discovered.source_root_key
3635
+ if not requalified and size == prev_size:
3100
3636
  stats.files_skipped_unchanged += 1
3101
3637
  continue
3102
- if size > prev_size:
3638
+ if not requalified and size > prev_size:
3103
3639
  start_offset = prev_offset
3104
3640
  initial_session_id = prev_sid
3105
3641
  initial_model = prev_model
@@ -3112,7 +3648,10 @@ def sync_codex_cache(
3112
3648
  initial_total_tokens = 0
3113
3649
  prev_total_tokens = None
3114
3650
 
3115
- rows: list[tuple[Any, ...]] = []
3651
+ accounting_rows: list[tuple[Any, ...]] = []
3652
+ quota_rows: list[tuple[Any, ...]] = []
3653
+ thread_rows: list[tuple[Any, ...]] = []
3654
+ event_rows: list[tuple[Any, ...]] = []
3116
3655
  final_offset = start_offset
3117
3656
  # Mutable tracker that the iterator updates on every
3118
3657
  # session_meta / turn_context record, regardless of whether a
@@ -3133,21 +3672,76 @@ def sync_codex_cache(
3133
3672
  model=initial_model,
3134
3673
  total_tokens=initial_total_tokens,
3135
3674
  )
3675
+ if (
3676
+ prev is not None and not truncated and not requalified
3677
+ and prev_native_thread_id is not None
3678
+ and prev_root_thread_id is not None
3679
+ ):
3680
+ iter_state.thread = _lib_jsonl.CodexThreadMetadata(
3681
+ source_root_key=discovered.source_root_key,
3682
+ source_path=path_str,
3683
+ native_thread_id=prev_native_thread_id,
3684
+ root_thread_id=prev_root_thread_id,
3685
+ parent_thread_id=prev_parent_thread_id,
3686
+ conversation_key=prev_conversation_key,
3687
+ cwd=None,
3688
+ git_json=None,
3689
+ source_kind=None,
3690
+ thread_source_json=None,
3691
+ model_provider=None,
3692
+ context_window=None,
3693
+ )
3136
3694
  yielded_count = 0
3137
3695
  try:
3138
- with open(jp, "r", encoding="utf-8", errors="replace") as fh:
3696
+ with open(jp, "rb") as fh:
3139
3697
  fh.seek(start_offset)
3140
- for offset, entry in _iter_codex_jsonl_entries_with_offsets(
3698
+ for emission in _iter_codex_fused_records_with_offsets(
3141
3699
  fh,
3142
3700
  path_str,
3143
3701
  initial_session_id=initial_session_id,
3144
3702
  initial_model=initial_model,
3145
3703
  initial_total_tokens=initial_total_tokens,
3704
+ source_root_key=discovered.source_root_key,
3146
3705
  state=iter_state,
3147
3706
  ):
3148
- rows.append((
3707
+ event = emission.event
3708
+ event_rows.append((
3709
+ event.source_path, event.line_offset,
3710
+ event.source_root_key, event.conversation_key,
3711
+ event.native_thread_id, event.root_thread_id,
3712
+ event.parent_thread_id, event.timestamp_utc,
3713
+ event.record_type, event.event_type, event.turn_id,
3714
+ event.call_id, event.payload_json,
3715
+ ))
3716
+ for quota in emission.quotas:
3717
+ quota_rows.append((
3718
+ quota.source, quota.source_root_key,
3719
+ quota.source_path, quota.line_offset,
3720
+ quota.captured_at_utc, quota.observed_slot,
3721
+ quota.logical_limit_key, quota.limit_id,
3722
+ quota.limit_name, quota.window_minutes,
3723
+ quota.used_percent, quota.resets_at_utc,
3724
+ quota.plan_type, quota.individual_limit_json,
3725
+ quota.reached_type,
3726
+ ))
3727
+ if (thread := emission.thread) is not None and (
3728
+ thread.conversation_key is not None
3729
+ and thread.native_thread_id is not None
3730
+ and thread.root_thread_id is not None
3731
+ ):
3732
+ thread_rows.append((
3733
+ thread.conversation_key, thread.source_root_key,
3734
+ thread.native_thread_id, thread.root_thread_id,
3735
+ thread.parent_thread_id, thread.source_path,
3736
+ thread.cwd, thread.git_json, thread.source_kind,
3737
+ thread.thread_source_json, thread.model_provider,
3738
+ thread.context_window,
3739
+ ))
3740
+ if (entry := emission.accounting) is None:
3741
+ continue
3742
+ accounting_rows.append((
3149
3743
  path_str,
3150
- offset,
3744
+ emission.line_offset,
3151
3745
  entry.timestamp.astimezone(dt.timezone.utc).isoformat(),
3152
3746
  entry.session_id,
3153
3747
  entry.model,
@@ -3156,6 +3750,8 @@ def sync_codex_cache(
3156
3750
  entry.output_tokens,
3157
3751
  entry.reasoning_output_tokens,
3158
3752
  entry.total_tokens,
3753
+ discovered.source_root_key,
3754
+ event.conversation_key,
3159
3755
  ))
3160
3756
  yielded_count += 1
3161
3757
  final_offset = fh.tell()
@@ -3197,49 +3793,69 @@ def sync_codex_cache(
3197
3793
  new_last_total_tokens: int | None = (
3198
3794
  iter_state.total_tokens if yielded_count > 0 else prev_total_tokens
3199
3795
  )
3796
+ terminal_thread = iter_state.thread
3797
+ new_last_native_thread_id = (
3798
+ terminal_thread.native_thread_id
3799
+ if terminal_thread is not None else prev_native_thread_id
3800
+ )
3801
+ new_last_root_thread_id = (
3802
+ terminal_thread.root_thread_id
3803
+ if terminal_thread is not None else prev_root_thread_id
3804
+ )
3805
+ new_last_parent_thread_id = (
3806
+ terminal_thread.parent_thread_id
3807
+ if terminal_thread is not None else prev_parent_thread_id
3808
+ )
3809
+ new_last_conversation_key = (
3810
+ terminal_thread.conversation_key
3811
+ if terminal_thread is not None else prev_conversation_key
3812
+ )
3200
3813
 
3201
- # Python's sqlite3 module starts an implicit transaction on the
3202
- # first DML statement and commits on conn.commit(). We do NOT
3203
- # call "BEGIN IMMEDIATE" ourselves see sync_cache() for the
3204
- # full rationale. DELETE + INSERTs + UPDATE happen atomically in
3205
- # a single commit.
3206
- try:
3207
- if truncated:
3208
- conn.execute(
3209
- "DELETE FROM codex_session_entries WHERE source_path = ?",
3210
- (path_str,),
3814
+ # Every derived row above was buffered before the first DML. A
3815
+ # late database failure therefore rolls the whole file back and
3816
+ # retries that same in-memory batch exactly once.
3817
+ committed = False
3818
+ for attempt in range(2):
3819
+ try:
3820
+ file_rows_changed = _write_codex_file_batch(
3821
+ conn,
3822
+ discovered=discovered,
3823
+ path_str=path_str,
3824
+ size=size,
3825
+ mtime_ns=mtime_ns,
3826
+ final_offset=final_offset,
3827
+ last_session_id=new_last_session_id,
3828
+ last_model=new_last_model,
3829
+ last_total_tokens=new_last_total_tokens,
3830
+ last_native_thread_id=new_last_native_thread_id,
3831
+ last_root_thread_id=new_last_root_thread_id,
3832
+ last_parent_thread_id=new_last_parent_thread_id,
3833
+ last_conversation_key=new_last_conversation_key,
3834
+ reset_file=truncated or requalified,
3835
+ accounting_rows=accounting_rows,
3836
+ quota_rows=quota_rows,
3837
+ thread_rows=thread_rows,
3838
+ event_rows=event_rows,
3839
+ active_root_keys={item.source_root_key for item in files},
3211
3840
  )
3841
+ except sqlite3.DatabaseError as exc:
3842
+ conn.rollback()
3843
+ if attempt == 0:
3844
+ # Private test seam: the callback runs after the
3845
+ # failed file transaction has rolled back, and before
3846
+ # the sole in-memory-batch retry starts.
3847
+ if _on_first_file_rollback is not None:
3848
+ _on_first_file_rollback()
3849
+ continue
3850
+ eprint(f"[codex-cache] db error on {jp}: {exc}")
3851
+ break
3852
+ stats.rows_changed += file_rows_changed
3853
+ if truncated or requalified:
3212
3854
  stats.files_reset_truncated += 1
3213
- if rows:
3214
- before = conn.total_changes
3215
- conn.executemany(
3216
- """INSERT OR IGNORE INTO codex_session_entries
3217
- (source_path, line_offset, timestamp_utc, session_id,
3218
- model, input_tokens, cached_input_tokens,
3219
- output_tokens, reasoning_output_tokens,
3220
- total_tokens)
3221
- VALUES (?,?,?,?,?,?,?,?,?,?)""",
3222
- rows,
3223
- )
3224
- stats.rows_changed += conn.total_changes - before
3225
- conn.execute(
3226
- """INSERT OR REPLACE INTO codex_session_files
3227
- (path, size_bytes, mtime_ns, last_byte_offset,
3228
- last_ingested_at, last_session_id, last_model,
3229
- last_total_tokens)
3230
- VALUES (?,?,?,?,?,?,?,?)""",
3231
- (
3232
- path_str, size, mtime_ns, final_offset,
3233
- dt.datetime.now(dt.timezone.utc).isoformat(),
3234
- new_last_session_id, new_last_model,
3235
- new_last_total_tokens,
3236
- ),
3237
- )
3238
- conn.commit()
3239
3855
  stats.files_processed += 1
3240
- except sqlite3.DatabaseError as exc:
3241
- eprint(f"[codex-cache] db error on {jp}: {exc}")
3242
- conn.rollback()
3856
+ committed = True
3857
+ break
3858
+ if not committed:
3243
3859
  continue
3244
3860
 
3245
3861
  if progress is not None:
@@ -3262,7 +3878,21 @@ def sync_codex_cache(
3262
3878
  skip_reasons=stats.skip_reasons,
3263
3879
  rebuild=rebuild,
3264
3880
  )
3265
- return stats
3881
+ # Codex creates/extends cache.db sidecars independently of Claude's
3882
+ # sync path. Harden them while the Codex flock is still held and after
3883
+ # all Codex writes, before the optional checkpoint can rotate a WAL.
3884
+ _harden_cache_sidecars()
3885
+ # #297: forced end-of-sync WAL drain (Codex half). Claude and Codex
3886
+ # ingests use SEPARATE flocks but commit into the SAME cache.db WAL, so
3887
+ # the fail-fast short timeout naturally dedupes concurrent attempts —
3888
+ # whoever wins truncates; the other gets `busy` immediately and moves
3889
+ # on. All Codex ingest work is committed here (no active txn).
3890
+ _maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
3891
+ # Projection intentionally runs only after this function releases the
3892
+ # Codex cache flock in ``finally`` below. cache.db and stats.db are not
3893
+ # cross-database atomic: after this committed ingest, a projection
3894
+ # interruption is repaired by the next full reconciliation.
3895
+ project_after_unlock = True
3266
3896
  finally:
3267
3897
  try:
3268
3898
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
@@ -3270,6 +3900,11 @@ def sync_codex_cache(
3270
3900
  pass
3271
3901
  lock_fh.close()
3272
3902
 
3903
+ if project_after_unlock:
3904
+ from _cctally_quota import reconcile_codex_quota_projection
3905
+ reconcile_codex_quota_projection()
3906
+ return stats
3907
+
3273
3908
 
3274
3909
  def iter_codex_entries(
3275
3910
  conn: sqlite3.Connection,
@@ -3344,24 +3979,27 @@ def get_codex_entries(
3344
3979
  except (sqlite3.DatabaseError, OSError) as exc:
3345
3980
  eprint(f"[cache] unavailable ({exc}); falling back to direct JSONL parse")
3346
3981
  return _collect_codex_entries_direct(range_start, range_end)
3347
- if skip_sync:
3982
+ try:
3983
+ if skip_sync:
3984
+ return iter_codex_entries(conn, range_start, range_end)
3985
+ stats = sync_codex_cache(conn)
3986
+ if stats.lock_contended:
3987
+ # Sync commits file-by-file, so contention on the ingest lock
3988
+ # (e.g. a concurrent --rebuild, or a first-run sync still in
3989
+ # flight) can leave the cache PARTIALLY populated — some files
3990
+ # ingested, others pending. An "is the table empty?" guard passes
3991
+ # in that window and we'd silently return results missing the
3992
+ # caller's range. Fall back to a direct JSONL parse unconditionally
3993
+ # on contention; correctness > speed in the rare-but-real window
3994
+ # where cache state does not match disk.
3995
+ eprint(
3996
+ "[cache] concurrent codex ingest in progress; "
3997
+ "falling back to direct JSONL parse for correctness"
3998
+ )
3999
+ return _collect_codex_entries_direct(range_start, range_end)
3348
4000
  return iter_codex_entries(conn, range_start, range_end)
3349
- stats = sync_codex_cache(conn)
3350
- if stats.lock_contended:
3351
- # Sync commits file-by-file, so contention on the ingest lock
3352
- # (e.g. a concurrent --rebuild, or a first-run sync still in
3353
- # flight) can leave the cache PARTIALLY populated — some files
3354
- # ingested, others pending. An "is the table empty?" guard passes
3355
- # in that window and we'd silently return results missing the
3356
- # caller's range. Fall back to a direct JSONL parse unconditionally
3357
- # on contention; correctness > speed in the rare-but-real window
3358
- # where cache state does not match disk.
3359
- eprint(
3360
- "[cache] concurrent codex ingest in progress; "
3361
- "falling back to direct JSONL parse for correctness"
3362
- )
3363
- return _collect_codex_entries_direct(range_start, range_end)
3364
- return iter_codex_entries(conn, range_start, range_end)
4001
+ finally:
4002
+ conn.close()
3365
4003
 
3366
4004
 
3367
4005
  def _sum_codex_cost_for_range(
@@ -3513,7 +4151,14 @@ def open_cache_db() -> sqlite3.Connection:
3513
4151
  eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
3514
4152
 
3515
4153
  conn.execute("PRAGMA journal_mode=WAL")
3516
- conn.execute("PRAGMA busy_timeout=5000")
4154
+ # #297: 15 s (was 5 s) lets a writer wait out a slow-but-normal sync (>5 s)
4155
+ # instead of instantly erroring with "database is locked". Covers plain
4156
+ # writer-vs-writer SQLITE_BUSY, not SQLITE_BUSY_SNAPSHOT (defended by
4157
+ # write-lock-up-front at the write sites, #87).
4158
+ conn.execute("PRAGMA busy_timeout=15000")
4159
+ # #297: bound the persistent WAL file so a resetting checkpoint truncates it
4160
+ # back to this cap instead of leaving it at its multi-GB high-water size.
4161
+ conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
3517
4162
  # Re-derivable DB under WAL: NORMAL risks at most the tail transaction on
3518
4163
  # power loss, and cache.db can always be rebuilt (cache-sync --rebuild).
3519
4164
  # Fewer ingest fsyncs than the default FULL. Matches stats.db