cctally 1.71.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 CHANGED
@@ -5,6 +5,15 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.72.0] - 2026-07-17
9
+
10
+ ### Added
11
+ - Codex conversations (#294 S6): the write-only Codex physical corpus is now readable through a new normalization and assembly kernel layer. Ingest derives normalized message rows, browse rollups, and an independent FTS index inside the existing per-file sync transaction (replayable by cache migration `025_codex_conversation_normalization`, with an explicit `normalization_pending` state for caches whose events predate it), and provider-neutral kernels assemble first-meaningful-prompt titles, digest-exact mirror pairing, canonical items with stable keys, per-turn cost attribution, thread nesting from thread metadata, outline, browse/facets, and FTS5/LIKE search. Transcript retention (#313) now prunes these derived rows — normalized messages, rollups, file touches, and their FTS postings — in the same transaction as the Codex physical events it removes, so a prune leaves no orphaned browse or search state. A thin provider-neutral dispatch maps both Claude and Codex conversations into one envelope family with a source-tagged token union. This is an internal kernel layer with no user-visible surface yet — S7 wires the routes, transcript CLI, export, and live-tail; the Claude conversation kernels are unchanged. (#294)
12
+
13
+ ### Fixed
14
+ - Packaging: `bin/_lib_conversation_retention.py` (the transcript-retention prune engine shipped in 1.71.0) was missing from the npm `files` list and the public mirror allowlist, so an installed `cctally cache-sync --prune-conversations` and the dashboard's background prune would crash at import (and the public test suite could not import it); it now ships in both. (#313)
15
+ - Internal (maintainer-only): the self-hosted reader e2e (`e2e-reader`) CI lane no longer red-builds when a prior hard-failed run leaks its `cctally dashboard` server on the dedicated Playwright port 8797. A pre-flight `dashboard/web/e2e/free-port.sh` now reaps any stale listener before `npx playwright test` — Playwright's `reuseExistingServer: false` probe aborts on an occupied port *before* it launches `e2e/serve.sh`, so the port has to be cleared ahead of the run rather than inside the launcher. No user-facing behavior change.
16
+
8
17
  ## [1.71.0] - 2026-07-17
9
18
 
10
19
  ### Added
@@ -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,10 @@ 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)
728
756
  # F3: this clears the Codex physical quota state, so any stored
729
757
  # quota-projection certificate would become stale-valid (its cache
730
758
  # sequence unchanged) and let the reconcile short-circuit skip over
@@ -743,6 +771,181 @@ def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
743
771
  )
744
772
 
745
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
+
746
949
  def _collect_inactive_codex_paths_and_roots(
747
950
  conn: sqlite3.Connection,
748
951
  current_file_identities: set[tuple[str, str]],
@@ -863,16 +1066,27 @@ def _write_codex_file_batch(
863
1066
  last_root_thread_id: str | None,
864
1067
  last_parent_thread_id: str | None,
865
1068
  last_conversation_key: str | None,
1069
+ last_turn_id: str | None,
866
1070
  reset_file: bool,
867
1071
  accounting_rows: list[tuple[Any, ...]],
868
1072
  quota_rows: list[tuple[Any, ...]],
869
1073
  thread_rows: list[tuple[Any, ...]],
870
1074
  event_rows: list[tuple[Any, ...]],
1075
+ normalized_rows: list,
1076
+ normalized_touches: list,
871
1077
  active_root_keys: set[str],
872
1078
  ) -> int:
873
1079
  """Write one fully-buffered Codex file atomically and return entry changes."""
874
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()
875
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])
876
1090
  _delete_codex_file_derived_rows(conn, path_str)
877
1091
  conn.execute(
878
1092
  """INSERT INTO codex_source_roots
@@ -937,18 +1151,27 @@ def _write_codex_file_batch(
937
1151
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
938
1152
  event_rows,
939
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)
940
1163
  conn.execute(
941
1164
  """INSERT OR REPLACE INTO codex_session_files
942
1165
  (path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
943
1166
  last_session_id, last_model, last_total_tokens, source_root_key,
944
1167
  last_native_thread_id, last_root_thread_id, last_parent_thread_id,
945
- last_conversation_key)
946
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1168
+ last_conversation_key, last_turn_id)
1169
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
947
1170
  (
948
1171
  path_str, size, mtime_ns, final_offset, now_iso, last_session_id,
949
1172
  last_model, last_total_tokens, discovered.source_root_key,
950
1173
  last_native_thread_id, last_root_thread_id, last_parent_thread_id,
951
- last_conversation_key,
1174
+ last_conversation_key, last_turn_id,
952
1175
  ),
953
1176
  )
954
1177
  _prune_inactive_codex_source_roots(conn, active_root_keys)
@@ -3615,7 +3838,14 @@ def sync_codex_cache(
3615
3838
  )
3616
3839
  if orphan_sources or orphan_root_keys:
3617
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()
3618
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])
3619
3849
  _delete_codex_file_derived_rows(
3620
3850
  conn,
3621
3851
  orphan_path,
@@ -3626,6 +3856,10 @@ def sync_codex_cache(
3626
3856
  conn, active_root_keys,
3627
3857
  candidate_root_keys=orphan_root_keys,
3628
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)
3629
3863
  if conn.total_changes != before_prune:
3630
3864
  _bump_codex_physical_mutation_seq(conn)
3631
3865
  conn.commit()
@@ -3644,13 +3878,13 @@ def sync_codex_cache(
3644
3878
  existing = {
3645
3879
  row[0]: (
3646
3880
  row[1], row[2], row[3], row[4], row[5], row[6], row[7],
3647
- row[8], row[9], row[10], row[11],
3881
+ row[8], row[9], row[10], row[11], row[12],
3648
3882
  )
3649
3883
  for row in conn.execute(
3650
3884
  "SELECT path, size_bytes, mtime_ns, last_byte_offset, "
3651
3885
  "last_session_id, last_model, last_total_tokens, source_root_key, "
3652
3886
  "last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
3653
- "last_conversation_key "
3887
+ "last_conversation_key, last_turn_id "
3654
3888
  "FROM codex_session_files"
3655
3889
  )
3656
3890
  }
@@ -3677,6 +3911,8 @@ def sync_codex_cache(
3677
3911
  initial_session_id: str | None = None
3678
3912
  initial_model: str | None = None
3679
3913
  initial_total_tokens = 0
3914
+ # #294 S6: the sticky-turn resume seed (parallel to initial_model).
3915
+ initial_turn_id: str | None = None
3680
3916
  prev_total_tokens: int | None = None
3681
3917
  prev_native_thread_id: str | None = None
3682
3918
  prev_root_thread_id: str | None = None
@@ -3687,7 +3923,7 @@ def sync_codex_cache(
3687
3923
  (
3688
3924
  prev_size, _, prev_offset, prev_sid, prev_model, prev_ttot,
3689
3925
  prev_root_key, prev_native_thread_id, prev_root_thread_id,
3690
- prev_parent_thread_id, prev_conversation_key,
3926
+ prev_parent_thread_id, prev_conversation_key, prev_turn_id,
3691
3927
  ) = prev
3692
3928
  prev_total_tokens = (
3693
3929
  int(prev_ttot) if prev_ttot is not None else None
@@ -3701,18 +3937,23 @@ def sync_codex_cache(
3701
3937
  initial_session_id = prev_sid
3702
3938
  initial_model = prev_model
3703
3939
  initial_total_tokens = prev_total_tokens or 0
3940
+ initial_turn_id = prev_turn_id
3704
3941
  else:
3705
3942
  truncated = True
3706
3943
  start_offset = 0
3707
3944
  initial_session_id = None
3708
3945
  initial_model = None
3709
3946
  initial_total_tokens = 0
3947
+ initial_turn_id = None
3710
3948
  prev_total_tokens = None
3711
3949
 
3712
3950
  accounting_rows: list[tuple[Any, ...]] = []
3713
3951
  quota_rows: list[tuple[Any, ...]] = []
3714
3952
  thread_rows: list[tuple[Any, ...]] = []
3715
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 = []
3716
3957
  final_offset = start_offset
3717
3958
  # Mutable tracker that the iterator updates on every
3718
3959
  # session_meta / turn_context record, regardless of whether a
@@ -3766,6 +4007,7 @@ def sync_codex_cache(
3766
4007
  state=iter_state,
3767
4008
  ):
3768
4009
  event = emission.event
4010
+ events_list.append(event)
3769
4011
  event_rows.append((
3770
4012
  event.source_path, event.line_offset,
3771
4013
  event.source_root_key, event.conversation_key,
@@ -3872,6 +4114,18 @@ def sync_codex_cache(
3872
4114
  if terminal_thread is not None else prev_conversation_key
3873
4115
  )
3874
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
+
3875
4129
  # Every derived row above was buffered before the first DML. A
3876
4130
  # late database failure therefore rolls the whole file back and
3877
4131
  # retries that same in-memory batch exactly once.
@@ -3892,11 +4146,14 @@ def sync_codex_cache(
3892
4146
  last_root_thread_id=new_last_root_thread_id,
3893
4147
  last_parent_thread_id=new_last_parent_thread_id,
3894
4148
  last_conversation_key=new_last_conversation_key,
4149
+ last_turn_id=new_last_turn_id,
3895
4150
  reset_file=truncated or requalified,
3896
4151
  accounting_rows=accounting_rows,
3897
4152
  quota_rows=quota_rows,
3898
4153
  thread_rows=thread_rows,
3899
4154
  event_rows=event_rows,
4155
+ normalized_rows=norm_result.rows,
4156
+ normalized_touches=norm_result.touches,
3900
4157
  active_root_keys={item.source_root_key for item in files},
3901
4158
  )
3902
4159
  except sqlite3.DatabaseError as exc: