cctally 1.68.0 → 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 +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  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 +168 -5
  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 +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -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 +202 -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 +324 -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
@@ -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
@@ -589,6 +598,361 @@ def _resolve_project_key(
589
598
  # === Region 2: Codex sessions-dir helpers (was bin/cctally:2072-2099) ===
590
599
 
591
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
+
592
956
  def _iter_codex_jsonl_paths(roots: list[pathlib.Path]) -> Iterator[pathlib.Path]:
593
957
  """Yield each existing *.jsonl under the given roots, de-duped by RESOLVED
594
958
  path (first occurrence wins — collapses overlapping/prefix roots and
@@ -1922,6 +2286,12 @@ def sync_cache(
1922
2286
  # rows the rail's old live aggregate read, so the rollup stays
1923
2287
  # byte-identical to that aggregate.
1924
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)
1925
2295
  if _conversation_sessions_backfill_pending(conn):
1926
2296
  _recompute_conversation_sessions(conn)
1927
2297
  conn.execute(
@@ -2215,6 +2585,37 @@ def _conversation_sessions_backfill_pending(conn) -> bool:
2215
2585
  return False
2216
2586
 
2217
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
+
2218
2619
  def _recompute_conversation_sessions(conn, session_ids=None) -> None:
2219
2620
  """Recompute the ``conversation_sessions`` browse-rail rollup from
2220
2621
  ``conversation_messages``. The caller holds the cache.db.lock flock and owns
@@ -2265,26 +2666,34 @@ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
2265
2666
 
2266
2667
  def _fill_conversation_sessions_filter_columns(conn, session_ids):
2267
2668
  """Fill the rollup's browse-FILTER columns (project_label / cost_usd /
2268
- 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
2269
2671
  ``session_ids is None``. The structural COUNT/MIN/MAX columns are filled by
2270
2672
  the INSERT in _recompute_conversation_sessions; this is the second pass that
2271
- materializes the three filter axes so the rail's date/project/cost/rebuild
2272
- filters are pure-SQL predicates.
2273
-
2274
- project_label + cost reuse the query kernel's batch maps (the SAME
2275
- _project_label / _session_cost_map the rail's per-page Python path used), so
2276
- a filtered/displayed value equals what the live rail produced. cost is
2277
- rounded to 6dp to match list_conversations' per-row rounding.
2278
- cache_rebuild_count is a per-session lightweight rebuild-count via the
2279
- query kernel's single-source-of-truth helper (no full assembly U1; the
2280
- rule is shared with the reader path) a whole-session property
2281
- (recompute, never increment).
2282
-
2283
- No-op when the columns are absent (a pre-migration-015 cache.db being
2284
- re-derived before its 015 ALTER lands), so an early/partial sync never
2285
- 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)."""
2286
2695
  cols = {r[1] for r in conn.execute("PRAGMA table_info(conversation_sessions)")}
2287
- if "cache_rebuild_count" not in cols:
2696
+ if not {"cache_rebuild_count", "git_branch", "models_json", "title"} <= cols:
2288
2697
  return
2289
2698
  lq = _load_lib("_lib_conversation_query")
2290
2699
  if session_ids is None:
@@ -2296,13 +2705,21 @@ def _fill_conversation_sessions_filter_columns(conn, session_ids):
2296
2705
  return
2297
2706
  cost = lq._session_cost_map(conn, ids)
2298
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)
2299
2710
  for sid in ids:
2300
2711
  proj = lq._project_label(meta.get(sid, (None, None))[0])
2712
+ branch = meta.get(sid, (None, None))[1]
2301
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)
2302
2717
  conn.execute(
2303
2718
  "UPDATE conversation_sessions SET project_label=?, cost_usd=?, "
2304
- "cache_rebuild_count=? WHERE session_id=?",
2305
- (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),
2306
2723
  )
2307
2724
 
2308
2725
 
@@ -3059,6 +3476,7 @@ def sync_codex_cache(
3059
3476
  progress: Callable[[CodexIngestStats], None] | None = None,
3060
3477
  rebuild: bool = False,
3061
3478
  lock_timeout: "float | None" = None,
3479
+ _on_first_file_rollback: Callable[[], None] | None = None,
3062
3480
  ) -> CodexIngestStats:
3063
3481
  """Read-through delta ingest of ~/.codex/sessions/**/*.jsonl.
3064
3482
 
@@ -3072,6 +3490,7 @@ def sync_codex_cache(
3072
3490
  untouched and the caller sees `lock_contended=True`.
3073
3491
  """
3074
3492
  stats = CodexIngestStats()
3493
+ project_after_unlock = False
3075
3494
  c = _cctally()
3076
3495
  _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
3077
3496
  _cctally_core.CACHE_LOCK_CODEX_PATH.touch()
@@ -3090,18 +3509,19 @@ def sync_codex_cache(
3090
3509
  # Clear INSIDE the lock — see sync_cache() for the full
3091
3510
  # rationale. Done before the existing SELECT so delta
3092
3511
  # detection sees an empty baseline.
3093
- conn.execute("DELETE FROM codex_session_entries")
3094
- 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)
3095
3516
  conn.commit()
3096
3517
  eprint("[cache-sync] rebuild: cleared Codex cached entries")
3097
3518
 
3098
- roots = _cctally()._codex_session_roots()
3099
3519
  # Pure read (glob + is_file only); safe to run before the SELECT and
3100
3520
  # the per-file loop, where no cache.db write lock may be held.
3101
3521
  with _perf.phase("discover") as _p_disc:
3102
- paths: list[pathlib.Path] = list(_iter_codex_jsonl_paths(roots))
3103
- stats.files_total = len(paths)
3104
- _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))
3105
3525
 
3106
3526
  # Scope the cache to the CURRENT root set: drop rows ingested under a
3107
3527
  # prior $CODEX_HOME (issue #108). iter_codex_entries() has NO root
@@ -3117,7 +3537,9 @@ def sync_codex_cache(
3117
3537
  # processes with different $CODEX_HOME would prune each other; the
3118
3538
  # flock serializes them and that is a pathological configuration.
3119
3539
  if not rebuild: # --rebuild already cleared both tables above
3120
- 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
+ }
3121
3543
  # Only prune ABSOLUTE source_paths. _codex_home_roots() makes
3122
3544
  # every real root absolute (via .absolute()), so a real ingested
3123
3545
  # row always stores an absolute str(jp) — INCLUDING a relative
@@ -3126,22 +3548,27 @@ def sync_codex_cache(
3126
3548
  # synthetic baked-cache fixture row (e.g. build-speed-fixtures.py)
3127
3549
  # with no on-disk JSONL to scope against; pruning it would wipe a
3128
3550
  # cache meant to be read as-is (issue #108).
3129
- orphan_paths = [
3130
- row[0]
3131
- for row in conn.execute("SELECT path FROM codex_session_files")
3132
- if row[0] not in current_paths and os.path.isabs(row[0])
3133
- ]
3134
- if orphan_paths:
3135
- conn.executemany(
3136
- "DELETE FROM codex_session_entries WHERE source_path = ?",
3137
- [(p,) for p in orphan_paths],
3138
- )
3139
- conn.executemany(
3140
- "DELETE FROM codex_session_files WHERE path = ?",
3141
- [(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,
3142
3567
  )
3568
+ if conn.total_changes != before_prune:
3569
+ _bump_codex_physical_mutation_seq(conn)
3143
3570
  conn.commit()
3144
- stats.files_pruned = len(orphan_paths)
3571
+ stats.files_pruned = len({path for path, _root in orphan_sources})
3145
3572
 
3146
3573
  # This SELECT does NOT open an implicit transaction (Python's
3147
3574
  # sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
@@ -3154,10 +3581,15 @@ def sync_codex_cache(
3154
3581
  # append-only, so a size change is a sufficient signal and mtime
3155
3582
  # is prone to clock-skew false-positives).
3156
3583
  existing = {
3157
- 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
+ )
3158
3588
  for row in conn.execute(
3159
3589
  "SELECT path, size_bytes, mtime_ns, last_byte_offset, "
3160
- "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 "
3161
3593
  "FROM codex_session_files"
3162
3594
  )
3163
3595
  }
@@ -3167,7 +3599,8 @@ def sync_codex_cache(
3167
3599
  # the loop stays flat, mirroring sync_cache's walk seam.
3168
3600
  _p_walk = _perf.phase("walk")
3169
3601
  _p_walk.__enter__()
3170
- for jp in paths:
3602
+ for discovered in files:
3603
+ jp = discovered.source_path
3171
3604
  path_str = str(jp)
3172
3605
  try:
3173
3606
  st = jp.stat()
@@ -3184,17 +3617,25 @@ def sync_codex_cache(
3184
3617
  initial_model: str | None = None
3185
3618
  initial_total_tokens = 0
3186
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
3187
3625
  if prev is not None:
3188
3626
  (
3189
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,
3190
3630
  ) = prev
3191
3631
  prev_total_tokens = (
3192
3632
  int(prev_ttot) if prev_ttot is not None else None
3193
3633
  )
3194
- if size == prev_size:
3634
+ requalified = prev_root_key != discovered.source_root_key
3635
+ if not requalified and size == prev_size:
3195
3636
  stats.files_skipped_unchanged += 1
3196
3637
  continue
3197
- if size > prev_size:
3638
+ if not requalified and size > prev_size:
3198
3639
  start_offset = prev_offset
3199
3640
  initial_session_id = prev_sid
3200
3641
  initial_model = prev_model
@@ -3207,7 +3648,10 @@ def sync_codex_cache(
3207
3648
  initial_total_tokens = 0
3208
3649
  prev_total_tokens = None
3209
3650
 
3210
- 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, ...]] = []
3211
3655
  final_offset = start_offset
3212
3656
  # Mutable tracker that the iterator updates on every
3213
3657
  # session_meta / turn_context record, regardless of whether a
@@ -3228,21 +3672,76 @@ def sync_codex_cache(
3228
3672
  model=initial_model,
3229
3673
  total_tokens=initial_total_tokens,
3230
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
+ )
3231
3694
  yielded_count = 0
3232
3695
  try:
3233
- with open(jp, "r", encoding="utf-8", errors="replace") as fh:
3696
+ with open(jp, "rb") as fh:
3234
3697
  fh.seek(start_offset)
3235
- for offset, entry in _iter_codex_jsonl_entries_with_offsets(
3698
+ for emission in _iter_codex_fused_records_with_offsets(
3236
3699
  fh,
3237
3700
  path_str,
3238
3701
  initial_session_id=initial_session_id,
3239
3702
  initial_model=initial_model,
3240
3703
  initial_total_tokens=initial_total_tokens,
3704
+ source_root_key=discovered.source_root_key,
3241
3705
  state=iter_state,
3242
3706
  ):
3243
- 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((
3244
3743
  path_str,
3245
- offset,
3744
+ emission.line_offset,
3246
3745
  entry.timestamp.astimezone(dt.timezone.utc).isoformat(),
3247
3746
  entry.session_id,
3248
3747
  entry.model,
@@ -3251,6 +3750,8 @@ def sync_codex_cache(
3251
3750
  entry.output_tokens,
3252
3751
  entry.reasoning_output_tokens,
3253
3752
  entry.total_tokens,
3753
+ discovered.source_root_key,
3754
+ event.conversation_key,
3254
3755
  ))
3255
3756
  yielded_count += 1
3256
3757
  final_offset = fh.tell()
@@ -3292,49 +3793,69 @@ def sync_codex_cache(
3292
3793
  new_last_total_tokens: int | None = (
3293
3794
  iter_state.total_tokens if yielded_count > 0 else prev_total_tokens
3294
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
+ )
3295
3813
 
3296
- # Python's sqlite3 module starts an implicit transaction on the
3297
- # first DML statement and commits on conn.commit(). We do NOT
3298
- # call "BEGIN IMMEDIATE" ourselves see sync_cache() for the
3299
- # full rationale. DELETE + INSERTs + UPDATE happen atomically in
3300
- # a single commit.
3301
- try:
3302
- if truncated:
3303
- conn.execute(
3304
- "DELETE FROM codex_session_entries WHERE source_path = ?",
3305
- (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},
3306
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:
3307
3854
  stats.files_reset_truncated += 1
3308
- if rows:
3309
- before = conn.total_changes
3310
- conn.executemany(
3311
- """INSERT OR IGNORE INTO codex_session_entries
3312
- (source_path, line_offset, timestamp_utc, session_id,
3313
- model, input_tokens, cached_input_tokens,
3314
- output_tokens, reasoning_output_tokens,
3315
- total_tokens)
3316
- VALUES (?,?,?,?,?,?,?,?,?,?)""",
3317
- rows,
3318
- )
3319
- stats.rows_changed += conn.total_changes - before
3320
- conn.execute(
3321
- """INSERT OR REPLACE INTO codex_session_files
3322
- (path, size_bytes, mtime_ns, last_byte_offset,
3323
- last_ingested_at, last_session_id, last_model,
3324
- last_total_tokens)
3325
- VALUES (?,?,?,?,?,?,?,?)""",
3326
- (
3327
- path_str, size, mtime_ns, final_offset,
3328
- dt.datetime.now(dt.timezone.utc).isoformat(),
3329
- new_last_session_id, new_last_model,
3330
- new_last_total_tokens,
3331
- ),
3332
- )
3333
- conn.commit()
3334
3855
  stats.files_processed += 1
3335
- except sqlite3.DatabaseError as exc:
3336
- eprint(f"[codex-cache] db error on {jp}: {exc}")
3337
- conn.rollback()
3856
+ committed = True
3857
+ break
3858
+ if not committed:
3338
3859
  continue
3339
3860
 
3340
3861
  if progress is not None:
@@ -3357,13 +3878,21 @@ def sync_codex_cache(
3357
3878
  skip_reasons=stats.skip_reasons,
3358
3879
  rebuild=rebuild,
3359
3880
  )
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()
3360
3885
  # #297: forced end-of-sync WAL drain (Codex half). Claude and Codex
3361
3886
  # ingests use SEPARATE flocks but commit into the SAME cache.db WAL, so
3362
3887
  # the fail-fast short timeout naturally dedupes concurrent attempts —
3363
3888
  # whoever wins truncates; the other gets `busy` immediately and moves
3364
3889
  # on. All Codex ingest work is committed here (no active txn).
3365
3890
  _maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
3366
- return stats
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
3367
3896
  finally:
3368
3897
  try:
3369
3898
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
@@ -3371,6 +3900,11 @@ def sync_codex_cache(
3371
3900
  pass
3372
3901
  lock_fh.close()
3373
3902
 
3903
+ if project_after_unlock:
3904
+ from _cctally_quota import reconcile_codex_quota_projection
3905
+ reconcile_codex_quota_projection()
3906
+ return stats
3907
+
3374
3908
 
3375
3909
  def iter_codex_entries(
3376
3910
  conn: sqlite3.Connection,
@@ -3445,24 +3979,27 @@ def get_codex_entries(
3445
3979
  except (sqlite3.DatabaseError, OSError) as exc:
3446
3980
  eprint(f"[cache] unavailable ({exc}); falling back to direct JSONL parse")
3447
3981
  return _collect_codex_entries_direct(range_start, range_end)
3448
- 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)
3449
4000
  return iter_codex_entries(conn, range_start, range_end)
3450
- stats = sync_codex_cache(conn)
3451
- if stats.lock_contended:
3452
- # Sync commits file-by-file, so contention on the ingest lock
3453
- # (e.g. a concurrent --rebuild, or a first-run sync still in
3454
- # flight) can leave the cache PARTIALLY populated — some files
3455
- # ingested, others pending. An "is the table empty?" guard passes
3456
- # in that window and we'd silently return results missing the
3457
- # caller's range. Fall back to a direct JSONL parse unconditionally
3458
- # on contention; correctness > speed in the rare-but-real window
3459
- # where cache state does not match disk.
3460
- eprint(
3461
- "[cache] concurrent codex ingest in progress; "
3462
- "falling back to direct JSONL parse for correctness"
3463
- )
3464
- return _collect_codex_entries_direct(range_start, range_end)
3465
- return iter_codex_entries(conn, range_start, range_end)
4001
+ finally:
4002
+ conn.close()
3466
4003
 
3467
4004
 
3468
4005
  def _sum_codex_cost_for_range(