cctally 1.68.0 → 1.69.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -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)
|
|
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
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
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"
|
|
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
|
|
2305
|
-
|
|
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.
|
|
3094
|
-
conn
|
|
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
|
-
|
|
3103
|
-
stats.files_total = len(
|
|
3104
|
-
_p_disc.set_count(len(
|
|
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
|
-
|
|
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
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
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(
|
|
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]: (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
3696
|
+
with open(jp, "rb") as fh:
|
|
3234
3697
|
fh.seek(start_offset)
|
|
3235
|
-
for
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3297
|
-
#
|
|
3298
|
-
#
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
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
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3451
|
-
|
|
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(
|