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.
@@ -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
- return stats
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
- project_after_unlock = True
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