cctally 1.70.0 → 1.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/bin/_cctally_cache.py +430 -8
- package/bin/_cctally_config.py +108 -0
- package/bin/_cctally_core.py +6 -1
- package/bin/_cctally_dashboard.py +106 -11
- package/bin/_cctally_db.py +445 -1
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +58 -6
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_query.py +950 -0
- package/bin/_lib_conversation_dispatch.py +547 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/cctally +1 -0
- package/dashboard/static/assets/index-BWadAHxC.js +80 -0
- package/dashboard/static/assets/index-D2nwo_ln.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-CBbErI-P.js +0 -80
- package/dashboard/static/assets/index-kDDVOLa_.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -178,6 +178,11 @@ _should_replace = _lib_jsonl._should_replace
|
|
|
178
178
|
_lib_conversation = _load_lib("_lib_conversation")
|
|
179
179
|
_iter_message_rows = _lib_conversation.iter_message_rows
|
|
180
180
|
|
|
181
|
+
# #294 S6: the pure Codex conversation normalization kernel. Pure stdlib leaf
|
|
182
|
+
# (imports only the _lib_conversation / _lib_conversation_query display helpers),
|
|
183
|
+
# so it loads at module-load time alongside _lib_conversation.
|
|
184
|
+
_lib_codex_conversation = _load_lib("_lib_codex_conversation")
|
|
185
|
+
|
|
181
186
|
# Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
|
|
182
187
|
# stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
|
|
183
188
|
# shared no-op singleton), so the sync_cache seam wraps below cost nothing on
|
|
@@ -320,6 +325,10 @@ _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
|
|
|
320
325
|
# drop/recreate dance so the per-row delete trigger never fires O(rows) under
|
|
321
326
|
# the held lock on a rebuild / truncation escalation.
|
|
322
327
|
clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
|
|
328
|
+
# #294 S6: storm-free FULL clear of the Codex normalized derived tables (drop
|
|
329
|
+
# triggers -> truncate -> 'delete-all' -> recreate triggers). Used only by the
|
|
330
|
+
# cache-rebuild path + migration 025; partial deletes ride the per-row triggers.
|
|
331
|
+
_codex_conversation_fts_full_clear = _cctally_db_sib._codex_conversation_fts_full_clear
|
|
323
332
|
# cache_meta key/value upsert helper — reused by the resumable reingest cursor
|
|
324
333
|
# writes (#179) so the ON CONFLICT idiom lives in one place. Caller commits.
|
|
325
334
|
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
@@ -711,6 +720,21 @@ def _delete_codex_file_derived_rows(
|
|
|
711
720
|
"DELETE FROM codex_conversation_threads WHERE source_path = ?" + root_clause,
|
|
712
721
|
params,
|
|
713
722
|
)
|
|
723
|
+
# #294 S6: normalized rows + file touches for this file. This is a PARTIAL
|
|
724
|
+
# delete (one file), so it rides the per-row FTS delete trigger — surviving
|
|
725
|
+
# conversations keep their postings (§3.4 two-path policy). File touches have
|
|
726
|
+
# no source_root_key column; a source_path maps to exactly one file/root, so
|
|
727
|
+
# scoping them by source_path alone is exact. Rollups are repaired by the
|
|
728
|
+
# caller's _recompute_codex_rollups (§3.2), which needs the affected keys
|
|
729
|
+
# captured BEFORE this delete.
|
|
730
|
+
conn.execute(
|
|
731
|
+
"DELETE FROM codex_conversation_file_touches WHERE source_path = ?",
|
|
732
|
+
(path_str,),
|
|
733
|
+
)
|
|
734
|
+
conn.execute(
|
|
735
|
+
"DELETE FROM codex_conversation_messages WHERE source_path = ?" + root_clause,
|
|
736
|
+
params,
|
|
737
|
+
)
|
|
714
738
|
conn.execute(
|
|
715
739
|
"DELETE FROM codex_session_files WHERE path = ?" + root_clause,
|
|
716
740
|
params,
|
|
@@ -725,6 +749,17 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
|
|
|
725
749
|
conn.execute("DELETE FROM codex_conversation_events")
|
|
726
750
|
conn.execute("DELETE FROM codex_session_files")
|
|
727
751
|
conn.execute("DELETE FROM codex_source_roots")
|
|
752
|
+
# #294 S6: FULL clear of the normalized derived tables (messages + touches +
|
|
753
|
+
# rollups) via the storm-free helper — this is a whole-corpus rebuild, so
|
|
754
|
+
# 'delete-all' resets the FTS shadow tables cleanly (§3.4 full-clear path).
|
|
755
|
+
_codex_conversation_fts_full_clear(conn)
|
|
756
|
+
# F3: this clears the Codex physical quota state, so any stored
|
|
757
|
+
# quota-projection certificate would become stale-valid (its cache
|
|
758
|
+
# sequence unchanged) and let the reconcile short-circuit skip over
|
|
759
|
+
# now-deleted data. Invalidate it in the same transaction.
|
|
760
|
+
conn.execute(
|
|
761
|
+
"DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
|
|
762
|
+
)
|
|
728
763
|
|
|
729
764
|
|
|
730
765
|
def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
|
|
@@ -736,6 +771,181 @@ def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
|
|
|
736
771
|
)
|
|
737
772
|
|
|
738
773
|
|
|
774
|
+
# ── #294 S6: normalized-conversation write helpers (kernel-backed) ────────────
|
|
775
|
+
|
|
776
|
+
_CODEX_NORM_COLS = (
|
|
777
|
+
"conversation_key, source_root_key, source_path, line_offset, timestamp_utc, "
|
|
778
|
+
"turn_id, call_id, kind, event_type, record_family, model, text, "
|
|
779
|
+
"content_digest, content_len, detail_json, search_tool, search_thinking"
|
|
780
|
+
)
|
|
781
|
+
_CODEX_MSG_INSERT_SQL = (
|
|
782
|
+
"INSERT OR IGNORE INTO codex_conversation_messages (" + _CODEX_NORM_COLS + ") "
|
|
783
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _codex_conversation_project_attribution(
|
|
788
|
+
source_root_key: str | None, cwd: object, git_json: object,
|
|
789
|
+
) -> tuple[str | None, str | None]:
|
|
790
|
+
"""(project_key, project_label) for one conversation's thread facts (§3.2).
|
|
791
|
+
|
|
792
|
+
Mirrors the S3 read-time attribution (cwd git-root resolution → basename
|
|
793
|
+
label, else git identity, else unassigned) so the browse live-recompute
|
|
794
|
+
fallback matches the stored rollup. Degrades to (None, None) when the S3
|
|
795
|
+
kernel or a valid source root is unavailable — never guesses.
|
|
796
|
+
"""
|
|
797
|
+
if not source_root_key:
|
|
798
|
+
return None, None
|
|
799
|
+
try:
|
|
800
|
+
from _cctally_source_analytics import _project_label, _git_resolved_key
|
|
801
|
+
from _lib_source_analytics import opaque_project_key
|
|
802
|
+
except Exception:
|
|
803
|
+
return None, None
|
|
804
|
+
if isinstance(cwd, str) and cwd:
|
|
805
|
+
project = _resolve_project_key(cwd, "git-root", {})
|
|
806
|
+
resolved_key = project.bucket_path
|
|
807
|
+
cwd_label = _project_label(cwd)
|
|
808
|
+
project_label = (
|
|
809
|
+
cwd_label if cwd_label in {"(home)", "(root)"}
|
|
810
|
+
else _project_label(project.display_key)
|
|
811
|
+
)
|
|
812
|
+
elif isinstance(git_json, str) and git_json:
|
|
813
|
+
git_key = _git_resolved_key(git_json)
|
|
814
|
+
if git_key is None:
|
|
815
|
+
resolved_key, project_label = "(unassigned)", "(unassigned)"
|
|
816
|
+
else:
|
|
817
|
+
resolved_key, project_label = git_key, "Git project"
|
|
818
|
+
else:
|
|
819
|
+
resolved_key, project_label = "(unassigned)", "(unassigned)"
|
|
820
|
+
try:
|
|
821
|
+
return opaque_project_key("codex", source_root_key, resolved_key), project_label
|
|
822
|
+
except ValueError:
|
|
823
|
+
return None, None
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _load_codex_normalized_rows(
|
|
827
|
+
conn: sqlite3.Connection, conversation_key: str,
|
|
828
|
+
) -> list:
|
|
829
|
+
"""Load a conversation's normalized rows (all files) in physical order as
|
|
830
|
+
kernel CodexNormalizedRow objects."""
|
|
831
|
+
return [
|
|
832
|
+
_lib_codex_conversation.CodexNormalizedRow(*row)
|
|
833
|
+
for row in conn.execute(
|
|
834
|
+
"SELECT " + _CODEX_NORM_COLS + " FROM codex_conversation_messages "
|
|
835
|
+
"WHERE conversation_key = ? "
|
|
836
|
+
"ORDER BY timestamp_utc, source_path, line_offset",
|
|
837
|
+
(conversation_key,),
|
|
838
|
+
)
|
|
839
|
+
]
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _insert_codex_normalized_rows(conn: sqlite3.Connection, rows: list, touches: list) -> None:
|
|
843
|
+
"""Insert normalized rows (INSERT OR IGNORE on the physical key) + their file
|
|
844
|
+
touches (message linkage resolved via (source_path, line_offset))."""
|
|
845
|
+
if rows:
|
|
846
|
+
conn.executemany(_CODEX_MSG_INSERT_SQL, [
|
|
847
|
+
(r.conversation_key, r.source_root_key, r.source_path, r.line_offset,
|
|
848
|
+
r.timestamp_utc, r.turn_id, r.call_id, r.kind, r.event_type,
|
|
849
|
+
r.record_family, r.model, r.text, r.content_digest, r.content_len,
|
|
850
|
+
r.detail_json, r.search_tool, r.search_thinking)
|
|
851
|
+
for r in rows
|
|
852
|
+
])
|
|
853
|
+
for touch in touches:
|
|
854
|
+
conn.execute(
|
|
855
|
+
"INSERT OR IGNORE INTO codex_conversation_file_touches "
|
|
856
|
+
"(message_id, conversation_key, source_path, file_path, tool) "
|
|
857
|
+
"SELECT m.id, ?, ?, ?, ? FROM codex_conversation_messages m "
|
|
858
|
+
"WHERE m.source_path = ? AND m.line_offset = ?",
|
|
859
|
+
(touch.conversation_key, touch.source_path, touch.file_path, touch.tool,
|
|
860
|
+
touch.source_path, touch.line_offset),
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _recompute_codex_rollups(conn: sqlite3.Connection, conversation_keys) -> None:
|
|
865
|
+
"""Recompute-affected-or-delete the rollup for each conversation (§3.2).
|
|
866
|
+
|
|
867
|
+
A rollup is a pure function of surviving codex_conversation_messages (+ thread
|
|
868
|
+
metadata): aggregate across ALL files of the conversation, delete emptied
|
|
869
|
+
rollups, stamp item_count (rendered LOGICAL items), title, project attribution,
|
|
870
|
+
times, and models. Called by every write/delete path so no stale rollup
|
|
871
|
+
survives.
|
|
872
|
+
"""
|
|
873
|
+
kern = _lib_codex_conversation
|
|
874
|
+
for conversation_key in {key for key in conversation_keys if key}:
|
|
875
|
+
rows = _load_codex_normalized_rows(conn, conversation_key)
|
|
876
|
+
if not rows:
|
|
877
|
+
conn.execute(
|
|
878
|
+
"DELETE FROM codex_conversation_rollups WHERE conversation_key = ?",
|
|
879
|
+
(conversation_key,),
|
|
880
|
+
)
|
|
881
|
+
continue
|
|
882
|
+
item_count = kern.rollup_item_count(rows)
|
|
883
|
+
title = kern.derive_title(rows)
|
|
884
|
+
timestamps = [r.timestamp_utc for r in rows if r.timestamp_utc]
|
|
885
|
+
started = min(timestamps) if timestamps else None
|
|
886
|
+
last_activity = max(timestamps) if timestamps else None
|
|
887
|
+
models = sorted({r.model for r in rows if r.model})
|
|
888
|
+
models_json = json.dumps(models) if models else None
|
|
889
|
+
source_root_key = rows[0].source_root_key
|
|
890
|
+
thread = conn.execute(
|
|
891
|
+
"SELECT cwd, git_json, parent_thread_id, source_root_key "
|
|
892
|
+
"FROM codex_conversation_threads WHERE conversation_key = ?",
|
|
893
|
+
(conversation_key,),
|
|
894
|
+
).fetchone()
|
|
895
|
+
cwd = git_json = parent_thread_id = None
|
|
896
|
+
if thread is not None:
|
|
897
|
+
cwd, git_json, parent_thread_id, thread_root = thread
|
|
898
|
+
if thread_root:
|
|
899
|
+
source_root_key = thread_root
|
|
900
|
+
project_key, project_label = _codex_conversation_project_attribution(
|
|
901
|
+
source_root_key, cwd, git_json)
|
|
902
|
+
conn.execute(
|
|
903
|
+
"INSERT INTO codex_conversation_rollups "
|
|
904
|
+
"(conversation_key, source_root_key, parent_thread_id, item_count, "
|
|
905
|
+
" started_utc, last_activity_utc, project_key, project_label, "
|
|
906
|
+
" models_json, title) VALUES (?,?,?,?,?,?,?,?,?,?) "
|
|
907
|
+
"ON CONFLICT(conversation_key) DO UPDATE SET "
|
|
908
|
+
" source_root_key=excluded.source_root_key, "
|
|
909
|
+
" parent_thread_id=excluded.parent_thread_id, "
|
|
910
|
+
" item_count=excluded.item_count, started_utc=excluded.started_utc, "
|
|
911
|
+
" last_activity_utc=excluded.last_activity_utc, "
|
|
912
|
+
" project_key=excluded.project_key, project_label=excluded.project_label, "
|
|
913
|
+
" models_json=excluded.models_json, title=excluded.title",
|
|
914
|
+
(conversation_key, source_root_key, parent_thread_id, item_count,
|
|
915
|
+
started, last_activity, project_key, project_label, models_json, title),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _replay_codex_normalization(conn: sqlite3.Connection) -> None:
|
|
920
|
+
"""Re-derive ALL normalized rows/touches/rollups from stored
|
|
921
|
+
codex_conversation_events, per file in (source_path ASC, line_offset ASC)
|
|
922
|
+
order (migration 025). Runs inside the caller's transaction — the caller
|
|
923
|
+
full-clears first (§3.4 helper) and owns the commit. Deterministic order +
|
|
924
|
+
the plain rowid alias make a re-run byte-idempotent."""
|
|
925
|
+
kern = _lib_codex_conversation
|
|
926
|
+
events_by_file: dict[str, list] = {}
|
|
927
|
+
order: list[str] = []
|
|
928
|
+
for row in conn.execute(
|
|
929
|
+
"SELECT source_path, line_offset, source_root_key, conversation_key, "
|
|
930
|
+
"native_thread_id, root_thread_id, parent_thread_id, timestamp_utc, "
|
|
931
|
+
"record_type, event_type, turn_id, call_id, payload_json "
|
|
932
|
+
"FROM codex_conversation_events "
|
|
933
|
+
"ORDER BY source_path ASC, line_offset ASC"
|
|
934
|
+
):
|
|
935
|
+
event = _lib_jsonl.CodexPhysicalEvent(*row)
|
|
936
|
+
if event.source_path not in events_by_file:
|
|
937
|
+
events_by_file[event.source_path] = []
|
|
938
|
+
order.append(event.source_path)
|
|
939
|
+
events_by_file[event.source_path].append(event)
|
|
940
|
+
affected: set = set()
|
|
941
|
+
for source_path in order:
|
|
942
|
+
result = kern.normalize_codex_events(
|
|
943
|
+
events_by_file[source_path], initial=kern.CodexStickyState())
|
|
944
|
+
_insert_codex_normalized_rows(conn, result.rows, result.touches)
|
|
945
|
+
affected.update(r.conversation_key for r in result.rows)
|
|
946
|
+
_recompute_codex_rollups(conn, affected)
|
|
947
|
+
|
|
948
|
+
|
|
739
949
|
def _collect_inactive_codex_paths_and_roots(
|
|
740
950
|
conn: sqlite3.Connection,
|
|
741
951
|
current_file_identities: set[tuple[str, str]],
|
|
@@ -856,16 +1066,27 @@ def _write_codex_file_batch(
|
|
|
856
1066
|
last_root_thread_id: str | None,
|
|
857
1067
|
last_parent_thread_id: str | None,
|
|
858
1068
|
last_conversation_key: str | None,
|
|
1069
|
+
last_turn_id: str | None,
|
|
859
1070
|
reset_file: bool,
|
|
860
1071
|
accounting_rows: list[tuple[Any, ...]],
|
|
861
1072
|
quota_rows: list[tuple[Any, ...]],
|
|
862
1073
|
thread_rows: list[tuple[Any, ...]],
|
|
863
1074
|
event_rows: list[tuple[Any, ...]],
|
|
1075
|
+
normalized_rows: list,
|
|
1076
|
+
normalized_touches: list,
|
|
864
1077
|
active_root_keys: set[str],
|
|
865
1078
|
) -> int:
|
|
866
1079
|
"""Write one fully-buffered Codex file atomically and return entry changes."""
|
|
867
1080
|
now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
1081
|
+
# #294 S6: rollups are recomputed-affected-or-deleted (§3.2), so capture the
|
|
1082
|
+
# conversation keys this file's normalized rows touch BEFORE a reset delete
|
|
1083
|
+
# removes them.
|
|
1084
|
+
affected_keys: set = set()
|
|
868
1085
|
if reset_file:
|
|
1086
|
+
affected_keys.update(
|
|
1087
|
+
row[0] for row in conn.execute(
|
|
1088
|
+
"SELECT DISTINCT conversation_key FROM codex_conversation_messages "
|
|
1089
|
+
"WHERE source_path = ?", (path_str,)) if row[0])
|
|
869
1090
|
_delete_codex_file_derived_rows(conn, path_str)
|
|
870
1091
|
conn.execute(
|
|
871
1092
|
"""INSERT INTO codex_source_roots
|
|
@@ -930,18 +1151,27 @@ def _write_codex_file_batch(
|
|
|
930
1151
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
931
1152
|
event_rows,
|
|
932
1153
|
)
|
|
1154
|
+
# #294 S6: normalized rows + file touches ride the same per-file transaction
|
|
1155
|
+
# as the events themselves. INSERT OR IGNORE on the physical key keeps a delta
|
|
1156
|
+
# re-read idempotent; the per-row FTS triggers index them. Then recompute the
|
|
1157
|
+
# rollup for every affected conversation (aggregating across all its files),
|
|
1158
|
+
# deleting emptied rollups. Threads were inserted above, so project
|
|
1159
|
+
# attribution can read them.
|
|
1160
|
+
_insert_codex_normalized_rows(conn, normalized_rows, normalized_touches)
|
|
1161
|
+
affected_keys.update(r.conversation_key for r in normalized_rows)
|
|
1162
|
+
_recompute_codex_rollups(conn, affected_keys)
|
|
933
1163
|
conn.execute(
|
|
934
1164
|
"""INSERT OR REPLACE INTO codex_session_files
|
|
935
1165
|
(path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
|
|
936
1166
|
last_session_id, last_model, last_total_tokens, source_root_key,
|
|
937
1167
|
last_native_thread_id, last_root_thread_id, last_parent_thread_id,
|
|
938
|
-
last_conversation_key)
|
|
939
|
-
VALUES (
|
|
1168
|
+
last_conversation_key, last_turn_id)
|
|
1169
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
940
1170
|
(
|
|
941
1171
|
path_str, size, mtime_ns, final_offset, now_iso, last_session_id,
|
|
942
1172
|
last_model, last_total_tokens, discovered.source_root_key,
|
|
943
1173
|
last_native_thread_id, last_root_thread_id, last_parent_thread_id,
|
|
944
|
-
last_conversation_key,
|
|
1174
|
+
last_conversation_key, last_turn_id,
|
|
945
1175
|
),
|
|
946
1176
|
)
|
|
947
1177
|
_prune_inactive_codex_source_roots(conn, active_root_keys)
|
|
@@ -1388,6 +1618,30 @@ def _bump_mutation_seq(conn: sqlite3.Connection) -> int:
|
|
|
1388
1618
|
return int(row[0])
|
|
1389
1619
|
|
|
1390
1620
|
|
|
1621
|
+
def _force_retention_prune_after_replay(conn: sqlite3.Connection) -> None:
|
|
1622
|
+
"""#313 P3 (F9): run an UNTHROTTLED transcript retention prune after a
|
|
1623
|
+
from-zero replay (a ``--rebuild`` or a truncation/requalification re-ingest,
|
|
1624
|
+
both of which replay from offset 0 and restore >retention-day rows the
|
|
1625
|
+
throttled prune already trimmed). Best-effort — a prune failure must never
|
|
1626
|
+
break a sync. The caller invokes this only after the sync released its
|
|
1627
|
+
provider flock, so the orchestrator can re-acquire it. No-op when retention
|
|
1628
|
+
is disabled (``conversation.retention_days`` 0)."""
|
|
1629
|
+
try:
|
|
1630
|
+
import _lib_conversation_retention as retention
|
|
1631
|
+
from _cctally_config import resolve_retention_days
|
|
1632
|
+
retention_days = resolve_retention_days(_cctally().load_config())
|
|
1633
|
+
if retention_days <= 0:
|
|
1634
|
+
return
|
|
1635
|
+
retention._maybe_prune_conversation_retention(
|
|
1636
|
+
conn,
|
|
1637
|
+
now_utc=dt.datetime.now(dt.timezone.utc),
|
|
1638
|
+
retention_days=retention_days,
|
|
1639
|
+
force=True,
|
|
1640
|
+
)
|
|
1641
|
+
except Exception:
|
|
1642
|
+
pass
|
|
1643
|
+
|
|
1644
|
+
|
|
1391
1645
|
def sync_cache(
|
|
1392
1646
|
conn: sqlite3.Connection,
|
|
1393
1647
|
*,
|
|
@@ -2340,13 +2594,20 @@ def sync_cache(
|
|
|
2340
2594
|
# and the flock is still held, so the short busy_timeout keeps it from
|
|
2341
2595
|
# stalling the lock under heavy-reader contention.
|
|
2342
2596
|
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
2343
|
-
|
|
2597
|
+
# #313 P3 (F9): a rebuild or a truncation escalation replays from offset
|
|
2598
|
+
# 0 and restores >retention-day transcript rows. Force an unthrottled
|
|
2599
|
+
# prune AFTER the flock releases below (early lock-contended / deferred
|
|
2600
|
+
# returns above never reach here, so a no-op sync does not prune).
|
|
2601
|
+
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
2344
2602
|
finally:
|
|
2345
2603
|
try:
|
|
2346
2604
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
2347
2605
|
except OSError:
|
|
2348
2606
|
pass
|
|
2349
2607
|
lock_fh.close()
|
|
2608
|
+
if did_from_zero_replay:
|
|
2609
|
+
_force_retention_prune_after_replay(conn)
|
|
2610
|
+
return stats
|
|
2350
2611
|
|
|
2351
2612
|
|
|
2352
2613
|
def backfill_conversation_messages(conn: sqlite3.Connection) -> int:
|
|
@@ -3491,6 +3752,17 @@ def sync_codex_cache(
|
|
|
3491
3752
|
"""
|
|
3492
3753
|
stats = CodexIngestStats()
|
|
3493
3754
|
project_after_unlock = False
|
|
3755
|
+
did_from_zero_replay = False
|
|
3756
|
+
# #313 P1 review (F4/F1): when the CACHE certificate is current we cannot
|
|
3757
|
+
# yet decide whether to skip the reconcile — reconcile's own short-circuit
|
|
3758
|
+
# ALSO requires the stats-side quota_projection_state signatures to match
|
|
3759
|
+
# (F1: stats.db can be wiped/recovered while cache.db persists). That
|
|
3760
|
+
# cross-DB read must happen AFTER the Codex flock releases (see the design
|
|
3761
|
+
# comment near the reconcile trigger below), so capture the material for the
|
|
3762
|
+
# deferred stats-side check here. ``None`` means "no deferred check pending"
|
|
3763
|
+
# (the seq-advanced / no-roots / stale-cert branches decide immediately).
|
|
3764
|
+
deferred_cert_roots: "set[str] | None" = None
|
|
3765
|
+
deferred_cert_sigs: "dict[str, str] | None" = None
|
|
3494
3766
|
c = _cctally()
|
|
3495
3767
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
3496
3768
|
_cctally_core.CACHE_LOCK_CODEX_PATH.touch()
|
|
@@ -3505,6 +3777,18 @@ def sync_codex_cache(
|
|
|
3505
3777
|
stats.lock_contended = True
|
|
3506
3778
|
return stats
|
|
3507
3779
|
|
|
3780
|
+
# F4 (#313): the reconcile trigger gate is "did the Codex physical
|
|
3781
|
+
# mutation sequence advance during this sync", NOT rows_changed —
|
|
3782
|
+
# rows_changed counts only inserted accounting rows and misses
|
|
3783
|
+
# quota-only / metadata-only / prune-only batches that bump the
|
|
3784
|
+
# sequence. Capture the baseline before any clear/prune/ingest bump.
|
|
3785
|
+
from _cctally_quota import (
|
|
3786
|
+
codex_physical_mutation_seq,
|
|
3787
|
+
load_codex_quota_projection_certificate,
|
|
3788
|
+
_cache_root_keys,
|
|
3789
|
+
)
|
|
3790
|
+
seq_before = codex_physical_mutation_seq(conn)
|
|
3791
|
+
|
|
3508
3792
|
if rebuild:
|
|
3509
3793
|
# Clear INSIDE the lock — see sync_cache() for the full
|
|
3510
3794
|
# rationale. Done before the existing SELECT so delta
|
|
@@ -3554,7 +3838,14 @@ def sync_codex_cache(
|
|
|
3554
3838
|
)
|
|
3555
3839
|
if orphan_sources or orphan_root_keys:
|
|
3556
3840
|
before_prune = conn.total_changes
|
|
3841
|
+
# #294 S6: capture the conversation keys the orphan rows belong to
|
|
3842
|
+
# BEFORE deleting, so the rollups can be repaired/deleted after.
|
|
3843
|
+
orphan_keys: set = set()
|
|
3557
3844
|
for orphan_path, orphan_root_key in orphan_sources:
|
|
3845
|
+
orphan_keys.update(
|
|
3846
|
+
row[0] for row in conn.execute(
|
|
3847
|
+
"SELECT DISTINCT conversation_key FROM codex_conversation_messages "
|
|
3848
|
+
"WHERE source_path = ?", (orphan_path,)) if row[0])
|
|
3558
3849
|
_delete_codex_file_derived_rows(
|
|
3559
3850
|
conn,
|
|
3560
3851
|
orphan_path,
|
|
@@ -3565,6 +3856,10 @@ def sync_codex_cache(
|
|
|
3565
3856
|
conn, active_root_keys,
|
|
3566
3857
|
candidate_root_keys=orphan_root_keys,
|
|
3567
3858
|
)
|
|
3859
|
+
# Recompute-affected-or-delete the rollups the prune touched (§3.2):
|
|
3860
|
+
# a conversation with no surviving rows loses its rollup, one that
|
|
3861
|
+
# survives in another file is recomputed from the survivors.
|
|
3862
|
+
_recompute_codex_rollups(conn, orphan_keys)
|
|
3568
3863
|
if conn.total_changes != before_prune:
|
|
3569
3864
|
_bump_codex_physical_mutation_seq(conn)
|
|
3570
3865
|
conn.commit()
|
|
@@ -3583,13 +3878,13 @@ def sync_codex_cache(
|
|
|
3583
3878
|
existing = {
|
|
3584
3879
|
row[0]: (
|
|
3585
3880
|
row[1], row[2], row[3], row[4], row[5], row[6], row[7],
|
|
3586
|
-
row[8], row[9], row[10], row[11],
|
|
3881
|
+
row[8], row[9], row[10], row[11], row[12],
|
|
3587
3882
|
)
|
|
3588
3883
|
for row in conn.execute(
|
|
3589
3884
|
"SELECT path, size_bytes, mtime_ns, last_byte_offset, "
|
|
3590
3885
|
"last_session_id, last_model, last_total_tokens, source_root_key, "
|
|
3591
3886
|
"last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
|
|
3592
|
-
"last_conversation_key "
|
|
3887
|
+
"last_conversation_key, last_turn_id "
|
|
3593
3888
|
"FROM codex_session_files"
|
|
3594
3889
|
)
|
|
3595
3890
|
}
|
|
@@ -3616,6 +3911,8 @@ def sync_codex_cache(
|
|
|
3616
3911
|
initial_session_id: str | None = None
|
|
3617
3912
|
initial_model: str | None = None
|
|
3618
3913
|
initial_total_tokens = 0
|
|
3914
|
+
# #294 S6: the sticky-turn resume seed (parallel to initial_model).
|
|
3915
|
+
initial_turn_id: str | None = None
|
|
3619
3916
|
prev_total_tokens: int | None = None
|
|
3620
3917
|
prev_native_thread_id: str | None = None
|
|
3621
3918
|
prev_root_thread_id: str | None = None
|
|
@@ -3626,7 +3923,7 @@ def sync_codex_cache(
|
|
|
3626
3923
|
(
|
|
3627
3924
|
prev_size, _, prev_offset, prev_sid, prev_model, prev_ttot,
|
|
3628
3925
|
prev_root_key, prev_native_thread_id, prev_root_thread_id,
|
|
3629
|
-
prev_parent_thread_id, prev_conversation_key,
|
|
3926
|
+
prev_parent_thread_id, prev_conversation_key, prev_turn_id,
|
|
3630
3927
|
) = prev
|
|
3631
3928
|
prev_total_tokens = (
|
|
3632
3929
|
int(prev_ttot) if prev_ttot is not None else None
|
|
@@ -3640,18 +3937,23 @@ def sync_codex_cache(
|
|
|
3640
3937
|
initial_session_id = prev_sid
|
|
3641
3938
|
initial_model = prev_model
|
|
3642
3939
|
initial_total_tokens = prev_total_tokens or 0
|
|
3940
|
+
initial_turn_id = prev_turn_id
|
|
3643
3941
|
else:
|
|
3644
3942
|
truncated = True
|
|
3645
3943
|
start_offset = 0
|
|
3646
3944
|
initial_session_id = None
|
|
3647
3945
|
initial_model = None
|
|
3648
3946
|
initial_total_tokens = 0
|
|
3947
|
+
initial_turn_id = None
|
|
3649
3948
|
prev_total_tokens = None
|
|
3650
3949
|
|
|
3651
3950
|
accounting_rows: list[tuple[Any, ...]] = []
|
|
3652
3951
|
quota_rows: list[tuple[Any, ...]] = []
|
|
3653
3952
|
thread_rows: list[tuple[Any, ...]] = []
|
|
3654
3953
|
event_rows: list[tuple[Any, ...]] = []
|
|
3954
|
+
# #294 S6: the physical event objects for this file window, fed to the
|
|
3955
|
+
# normalization kernel after the drain (in offset order).
|
|
3956
|
+
events_list: list = []
|
|
3655
3957
|
final_offset = start_offset
|
|
3656
3958
|
# Mutable tracker that the iterator updates on every
|
|
3657
3959
|
# session_meta / turn_context record, regardless of whether a
|
|
@@ -3705,6 +4007,7 @@ def sync_codex_cache(
|
|
|
3705
4007
|
state=iter_state,
|
|
3706
4008
|
):
|
|
3707
4009
|
event = emission.event
|
|
4010
|
+
events_list.append(event)
|
|
3708
4011
|
event_rows.append((
|
|
3709
4012
|
event.source_path, event.line_offset,
|
|
3710
4013
|
event.source_root_key, event.conversation_key,
|
|
@@ -3811,6 +4114,18 @@ def sync_codex_cache(
|
|
|
3811
4114
|
if terminal_thread is not None else prev_conversation_key
|
|
3812
4115
|
)
|
|
3813
4116
|
|
|
4117
|
+
# #294 S6: normalize this file window's physical events into
|
|
4118
|
+
# normalized rows + file touches, replaying sticky turn/model state
|
|
4119
|
+
# seeded from the persisted resume seed. Buffered before the first DML
|
|
4120
|
+
# (like every other family), so the single retry re-runs the same
|
|
4121
|
+
# in-memory batch. The terminal sticky turn persists as last_turn_id.
|
|
4122
|
+
norm_result = _lib_codex_conversation.normalize_codex_events(
|
|
4123
|
+
events_list,
|
|
4124
|
+
initial=_lib_codex_conversation.CodexStickyState(
|
|
4125
|
+
turn_id=initial_turn_id, model=initial_model),
|
|
4126
|
+
)
|
|
4127
|
+
new_last_turn_id = norm_result.terminal.turn_id
|
|
4128
|
+
|
|
3814
4129
|
# Every derived row above was buffered before the first DML. A
|
|
3815
4130
|
# late database failure therefore rolls the whole file back and
|
|
3816
4131
|
# retries that same in-memory batch exactly once.
|
|
@@ -3831,11 +4146,14 @@ def sync_codex_cache(
|
|
|
3831
4146
|
last_root_thread_id=new_last_root_thread_id,
|
|
3832
4147
|
last_parent_thread_id=new_last_parent_thread_id,
|
|
3833
4148
|
last_conversation_key=new_last_conversation_key,
|
|
4149
|
+
last_turn_id=new_last_turn_id,
|
|
3834
4150
|
reset_file=truncated or requalified,
|
|
3835
4151
|
accounting_rows=accounting_rows,
|
|
3836
4152
|
quota_rows=quota_rows,
|
|
3837
4153
|
thread_rows=thread_rows,
|
|
3838
4154
|
event_rows=event_rows,
|
|
4155
|
+
normalized_rows=norm_result.rows,
|
|
4156
|
+
normalized_touches=norm_result.touches,
|
|
3839
4157
|
active_root_keys={item.source_root_key for item in files},
|
|
3840
4158
|
)
|
|
3841
4159
|
except sqlite3.DatabaseError as exc:
|
|
@@ -3892,7 +4210,56 @@ def sync_codex_cache(
|
|
|
3892
4210
|
# Codex cache flock in ``finally`` below. cache.db and stats.db are not
|
|
3893
4211
|
# cross-database atomic: after this committed ingest, a projection
|
|
3894
4212
|
# interruption is repaired by the next full reconciliation.
|
|
3895
|
-
|
|
4213
|
+
#
|
|
4214
|
+
# F4 (#313): reconcile when the physical mutation sequence advanced this
|
|
4215
|
+
# sync (a genuine quota/metadata/prune change — NOT rows_changed, which
|
|
4216
|
+
# misses quota-only batches). A pure no-op with an already-coherent
|
|
4217
|
+
# certificate skips even the O(1) reconcile call.
|
|
4218
|
+
#
|
|
4219
|
+
# A no-op sync must STILL reconcile when there are Codex roots but the
|
|
4220
|
+
# projection certificate is missing/stale at the current sequence — a
|
|
4221
|
+
# lost/failed certificate write (best-effort I/O; can fail under a
|
|
4222
|
+
# cache.db lock storm) leaves the dashboard's Codex source "unavailable"
|
|
4223
|
+
# and is recovered by the next unchanged-file sync re-stamping the
|
|
4224
|
+
# certificate. Claude-only users (no Codex roots) always skip.
|
|
4225
|
+
#
|
|
4226
|
+
# F1 (#313 P1 review): even when the CACHE certificate looks current, the
|
|
4227
|
+
# STATS-side quota_projection_state may have been independently
|
|
4228
|
+
# wiped/recovered (this user has a documented stats.db corruption
|
|
4229
|
+
# history). The cache cert alone does NOT prove stats.db still holds the
|
|
4230
|
+
# projection, so the skip decision on that branch is DEFERRED to the
|
|
4231
|
+
# post-flock stats-side signature check below — making the gate's
|
|
4232
|
+
# skip-condition identical to reconcile's own short-circuit-condition.
|
|
4233
|
+
cur_seq = codex_physical_mutation_seq(conn)
|
|
4234
|
+
if cur_seq != seq_before:
|
|
4235
|
+
project_after_unlock = True
|
|
4236
|
+
else:
|
|
4237
|
+
active_roots = _cache_root_keys(conn)
|
|
4238
|
+
if not active_roots:
|
|
4239
|
+
project_after_unlock = False
|
|
4240
|
+
else:
|
|
4241
|
+
certificate = load_codex_quota_projection_certificate(conn)
|
|
4242
|
+
certificate_current = (
|
|
4243
|
+
certificate is not None
|
|
4244
|
+
and certificate[0] == cur_seq
|
|
4245
|
+
and active_roots <= set(certificate[1])
|
|
4246
|
+
)
|
|
4247
|
+
if certificate_current:
|
|
4248
|
+
# The CACHE certificate is current, but that alone does NOT
|
|
4249
|
+
# prove stats.db still holds the projection (F1). Defer the
|
|
4250
|
+
# stats-side signature match until AFTER the Codex flock
|
|
4251
|
+
# releases below — only then may the reconcile be skipped.
|
|
4252
|
+
project_after_unlock = False
|
|
4253
|
+
deferred_cert_roots = set(active_roots)
|
|
4254
|
+
assert certificate is not None
|
|
4255
|
+
deferred_cert_sigs = dict(certificate[1])
|
|
4256
|
+
else:
|
|
4257
|
+
project_after_unlock = True
|
|
4258
|
+
# #313 P3 (F9): a Codex rebuild or a truncation/requalification
|
|
4259
|
+
# re-ingest replays from offset 0 and restores >retention-day
|
|
4260
|
+
# codex_conversation_events. Force an unthrottled prune after the flock
|
|
4261
|
+
# releases (below), so restored old rows don't persist for up to 24h.
|
|
4262
|
+
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
3896
4263
|
finally:
|
|
3897
4264
|
try:
|
|
3898
4265
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
@@ -3900,9 +4267,29 @@ def sync_codex_cache(
|
|
|
3900
4267
|
pass
|
|
3901
4268
|
lock_fh.close()
|
|
3902
4269
|
|
|
4270
|
+
if deferred_cert_roots is not None:
|
|
4271
|
+
# F1: the gate's skip-condition must be IDENTICAL to
|
|
4272
|
+
# reconcile_codex_quota_projection's own short-circuit-condition, which
|
|
4273
|
+
# additionally requires the stats-side quota_projection_state signatures
|
|
4274
|
+
# to match the cache certificate for every active root. Read stats.db
|
|
4275
|
+
# HERE — after the Codex flock released in the ``finally`` above — so the
|
|
4276
|
+
# cross-DB read never widens the Codex flock's critical section (the
|
|
4277
|
+
# design invariant documented near the reconcile trigger). A wiped/stale
|
|
4278
|
+
# stats projection (mismatch) forces the reconcile so it self-heals.
|
|
4279
|
+
from _cctally_quota import _stats_projection_signatures_match
|
|
4280
|
+
stats_conn = _cctally_core.open_db()
|
|
4281
|
+
try:
|
|
4282
|
+
if not _stats_projection_signatures_match(
|
|
4283
|
+
stats_conn, deferred_cert_roots, deferred_cert_sigs or {}
|
|
4284
|
+
):
|
|
4285
|
+
project_after_unlock = True
|
|
4286
|
+
finally:
|
|
4287
|
+
stats_conn.close()
|
|
3903
4288
|
if project_after_unlock:
|
|
3904
4289
|
from _cctally_quota import reconcile_codex_quota_projection
|
|
3905
4290
|
reconcile_codex_quota_projection()
|
|
4291
|
+
if did_from_zero_replay:
|
|
4292
|
+
_force_retention_prune_after_replay(conn)
|
|
3906
4293
|
return stats
|
|
3907
4294
|
|
|
3908
4295
|
|
|
@@ -4253,6 +4640,41 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
4253
4640
|
)
|
|
4254
4641
|
return 0
|
|
4255
4642
|
|
|
4643
|
+
# --prune-conversations: on-demand, UNTHROTTLED transcript retention prune
|
|
4644
|
+
# (#313 P3). Removes >retention-day conversation transcripts (re-derivable
|
|
4645
|
+
# from JSONL). Reclaim the freed disk space with `cctally db vacuum`.
|
|
4646
|
+
if getattr(args, "prune_conversations", False):
|
|
4647
|
+
from _cctally_config import resolve_retention_days
|
|
4648
|
+
import _lib_conversation_retention as retention
|
|
4649
|
+
retention_days = resolve_retention_days(_cctally().load_config())
|
|
4650
|
+
if retention_days <= 0:
|
|
4651
|
+
eprint(
|
|
4652
|
+
"[cache-sync] transcript retention is disabled "
|
|
4653
|
+
"(conversation.retention_days=0); nothing pruned."
|
|
4654
|
+
)
|
|
4655
|
+
return 0
|
|
4656
|
+
result = retention._maybe_prune_conversation_retention(
|
|
4657
|
+
conn,
|
|
4658
|
+
now_utc=dt.datetime.now(dt.timezone.utc),
|
|
4659
|
+
retention_days=retention_days,
|
|
4660
|
+
force=True,
|
|
4661
|
+
)
|
|
4662
|
+
if result is None:
|
|
4663
|
+
eprint(
|
|
4664
|
+
"[cache-sync] prune-conversations skipped: another process "
|
|
4665
|
+
"holds the maintenance or a provider lock; retry shortly."
|
|
4666
|
+
)
|
|
4667
|
+
return 1
|
|
4668
|
+
eprint(
|
|
4669
|
+
f"[cache-sync] pruned transcripts older than {retention_days}d: "
|
|
4670
|
+
f"claude {result.claude_sessions} session(s) / "
|
|
4671
|
+
f"{result.claude_messages} message(s), "
|
|
4672
|
+
f"codex {result.codex_conversations} conversation(s) / "
|
|
4673
|
+
f"{result.codex_events} event(s). "
|
|
4674
|
+
f"Run `cctally db vacuum` to reclaim the freed space."
|
|
4675
|
+
)
|
|
4676
|
+
return 0
|
|
4677
|
+
|
|
4256
4678
|
# Note: when --rebuild is set we delegate the DELETE to sync_cache /
|
|
4257
4679
|
# sync_codex_cache, which execute it AFTER acquiring the flock. A
|
|
4258
4680
|
# pre-sync DELETE here would wipe the cache even when the subsequent
|