cctally 1.71.0 → 1.73.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
@@ -683,6 +692,78 @@ def _discover_codex_files_with_roots() -> list[CodexDiscoveredFile]:
683
692
  return discovered
684
693
 
685
694
 
695
+ def _qualify_codex_targets(only_paths: "set[str]") -> list[CodexDiscoveredFile]:
696
+ """Resolve each requested path through the ordered configured roots exactly
697
+ as full discovery would (spec §5.1) — producing the same per-file facts a
698
+ ``CodexDiscoveredFile`` carries (configured spelling, physical path, provider
699
+ root, walk root, source-root key) with first-match containment + physical
700
+ dedup. A path resolving under no configured root, not a ``*.jsonl`` file, or
701
+ an alias of an already-resolved physical file is DROPPED (clean, not
702
+ ingested). Targeted mode's analogue of ``_discover_codex_files_with_roots``:
703
+ it does NOT walk any tree — it qualifies only the caller's exact paths."""
704
+ roots = _codex_provider_roots()
705
+ resolved: list[CodexDiscoveredFile] = []
706
+ seen_physical: set[pathlib.Path] = set()
707
+ # Deterministic order so first-match physical dedup is stable across a set.
708
+ for p in sorted(only_paths):
709
+ candidate = pathlib.Path(p)
710
+ for root in roots:
711
+ try:
712
+ inside = candidate.is_relative_to(root.walk_root)
713
+ except (ValueError, TypeError):
714
+ inside = False
715
+ if not inside:
716
+ continue
717
+ # Under this root's walk boundary: it is qualified here (first match)
718
+ # or dropped — a different-spelled alias never re-qualifies under a
719
+ # later root, matching full discovery's yielded-source-path identity.
720
+ if candidate.suffix != ".jsonl" or not candidate.is_file():
721
+ break # vanished / non-rollout under this root → drop (clean)
722
+ physical = _canonical_codex_path(candidate)
723
+ if physical in seen_physical:
724
+ break # first-match physical dedup → drop the alias
725
+ seen_physical.add(physical)
726
+ resolved.append(CodexDiscoveredFile(
727
+ source_path=candidate,
728
+ physical_path=physical,
729
+ provider_root=root.provider_root,
730
+ walk_root=root.walk_root,
731
+ source_root_key=root.source_root_key,
732
+ ))
733
+ break
734
+ return resolved
735
+
736
+
737
+ def _load_codex_session_files_rows(
738
+ conn: sqlite3.Connection, paths: "list[str]"
739
+ ) -> dict:
740
+ """Cursor rows from ``codex_session_files`` for ONLY the given paths (spec
741
+ §5.1 — the targeted preload must never load every row like the full-sync
742
+ path). Same 12-tuple value shape as ``sync_codex_cache``'s full ``existing``
743
+ map, so the per-file delta logic is byte-identical between the two modes."""
744
+ out: dict = {}
745
+ if not paths:
746
+ return out
747
+ cols = (
748
+ "path, size_bytes, mtime_ns, last_byte_offset, "
749
+ "last_session_id, last_model, last_total_tokens, source_root_key, "
750
+ "last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
751
+ "last_conversation_key, last_turn_id"
752
+ )
753
+ for i in range(0, len(paths), 400):
754
+ chunk = paths[i:i + 400]
755
+ placeholders = ",".join("?" for _ in chunk)
756
+ for row in conn.execute(
757
+ f"SELECT {cols} FROM codex_session_files WHERE path IN ({placeholders})",
758
+ chunk,
759
+ ):
760
+ out[row[0]] = (
761
+ row[1], row[2], row[3], row[4], row[5], row[6], row[7],
762
+ row[8], row[9], row[10], row[11], row[12],
763
+ )
764
+ return out
765
+
766
+
686
767
  def _delete_codex_file_derived_rows(
687
768
  conn: sqlite3.Connection,
688
769
  path_str: str,
@@ -711,6 +792,21 @@ def _delete_codex_file_derived_rows(
711
792
  "DELETE FROM codex_conversation_threads WHERE source_path = ?" + root_clause,
712
793
  params,
713
794
  )
795
+ # #294 S6: normalized rows + file touches for this file. This is a PARTIAL
796
+ # delete (one file), so it rides the per-row FTS delete trigger — surviving
797
+ # conversations keep their postings (§3.4 two-path policy). File touches have
798
+ # no source_root_key column; a source_path maps to exactly one file/root, so
799
+ # scoping them by source_path alone is exact. Rollups are repaired by the
800
+ # caller's _recompute_codex_rollups (§3.2), which needs the affected keys
801
+ # captured BEFORE this delete.
802
+ conn.execute(
803
+ "DELETE FROM codex_conversation_file_touches WHERE source_path = ?",
804
+ (path_str,),
805
+ )
806
+ conn.execute(
807
+ "DELETE FROM codex_conversation_messages WHERE source_path = ?" + root_clause,
808
+ params,
809
+ )
714
810
  conn.execute(
715
811
  "DELETE FROM codex_session_files WHERE path = ?" + root_clause,
716
812
  params,
@@ -725,6 +821,10 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
725
821
  conn.execute("DELETE FROM codex_conversation_events")
726
822
  conn.execute("DELETE FROM codex_session_files")
727
823
  conn.execute("DELETE FROM codex_source_roots")
824
+ # #294 S6: FULL clear of the normalized derived tables (messages + touches +
825
+ # rollups) via the storm-free helper — this is a whole-corpus rebuild, so
826
+ # 'delete-all' resets the FTS shadow tables cleanly (§3.4 full-clear path).
827
+ _codex_conversation_fts_full_clear(conn)
728
828
  # F3: this clears the Codex physical quota state, so any stored
729
829
  # quota-projection certificate would become stale-valid (its cache
730
830
  # sequence unchanged) and let the reconcile short-circuit skip over
@@ -743,6 +843,181 @@ def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
743
843
  )
744
844
 
745
845
 
846
+ # ── #294 S6: normalized-conversation write helpers (kernel-backed) ────────────
847
+
848
+ _CODEX_NORM_COLS = (
849
+ "conversation_key, source_root_key, source_path, line_offset, timestamp_utc, "
850
+ "turn_id, call_id, kind, event_type, record_family, model, text, "
851
+ "content_digest, content_len, detail_json, search_tool, search_thinking"
852
+ )
853
+ _CODEX_MSG_INSERT_SQL = (
854
+ "INSERT OR IGNORE INTO codex_conversation_messages (" + _CODEX_NORM_COLS + ") "
855
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
856
+ )
857
+
858
+
859
+ def _codex_conversation_project_attribution(
860
+ source_root_key: str | None, cwd: object, git_json: object,
861
+ ) -> tuple[str | None, str | None]:
862
+ """(project_key, project_label) for one conversation's thread facts (§3.2).
863
+
864
+ Mirrors the S3 read-time attribution (cwd git-root resolution → basename
865
+ label, else git identity, else unassigned) so the browse live-recompute
866
+ fallback matches the stored rollup. Degrades to (None, None) when the S3
867
+ kernel or a valid source root is unavailable — never guesses.
868
+ """
869
+ if not source_root_key:
870
+ return None, None
871
+ try:
872
+ from _cctally_source_analytics import _project_label, _git_resolved_key
873
+ from _lib_source_analytics import opaque_project_key
874
+ except Exception:
875
+ return None, None
876
+ if isinstance(cwd, str) and cwd:
877
+ project = _resolve_project_key(cwd, "git-root", {})
878
+ resolved_key = project.bucket_path
879
+ cwd_label = _project_label(cwd)
880
+ project_label = (
881
+ cwd_label if cwd_label in {"(home)", "(root)"}
882
+ else _project_label(project.display_key)
883
+ )
884
+ elif isinstance(git_json, str) and git_json:
885
+ git_key = _git_resolved_key(git_json)
886
+ if git_key is None:
887
+ resolved_key, project_label = "(unassigned)", "(unassigned)"
888
+ else:
889
+ resolved_key, project_label = git_key, "Git project"
890
+ else:
891
+ resolved_key, project_label = "(unassigned)", "(unassigned)"
892
+ try:
893
+ return opaque_project_key("codex", source_root_key, resolved_key), project_label
894
+ except ValueError:
895
+ return None, None
896
+
897
+
898
+ def _load_codex_normalized_rows(
899
+ conn: sqlite3.Connection, conversation_key: str,
900
+ ) -> list:
901
+ """Load a conversation's normalized rows (all files) in physical order as
902
+ kernel CodexNormalizedRow objects."""
903
+ return [
904
+ _lib_codex_conversation.CodexNormalizedRow(*row)
905
+ for row in conn.execute(
906
+ "SELECT " + _CODEX_NORM_COLS + " FROM codex_conversation_messages "
907
+ "WHERE conversation_key = ? "
908
+ "ORDER BY timestamp_utc, source_path, line_offset",
909
+ (conversation_key,),
910
+ )
911
+ ]
912
+
913
+
914
+ def _insert_codex_normalized_rows(conn: sqlite3.Connection, rows: list, touches: list) -> None:
915
+ """Insert normalized rows (INSERT OR IGNORE on the physical key) + their file
916
+ touches (message linkage resolved via (source_path, line_offset))."""
917
+ if rows:
918
+ conn.executemany(_CODEX_MSG_INSERT_SQL, [
919
+ (r.conversation_key, r.source_root_key, r.source_path, r.line_offset,
920
+ r.timestamp_utc, r.turn_id, r.call_id, r.kind, r.event_type,
921
+ r.record_family, r.model, r.text, r.content_digest, r.content_len,
922
+ r.detail_json, r.search_tool, r.search_thinking)
923
+ for r in rows
924
+ ])
925
+ for touch in touches:
926
+ conn.execute(
927
+ "INSERT OR IGNORE INTO codex_conversation_file_touches "
928
+ "(message_id, conversation_key, source_path, file_path, tool) "
929
+ "SELECT m.id, ?, ?, ?, ? FROM codex_conversation_messages m "
930
+ "WHERE m.source_path = ? AND m.line_offset = ?",
931
+ (touch.conversation_key, touch.source_path, touch.file_path, touch.tool,
932
+ touch.source_path, touch.line_offset),
933
+ )
934
+
935
+
936
+ def _recompute_codex_rollups(conn: sqlite3.Connection, conversation_keys) -> None:
937
+ """Recompute-affected-or-delete the rollup for each conversation (§3.2).
938
+
939
+ A rollup is a pure function of surviving codex_conversation_messages (+ thread
940
+ metadata): aggregate across ALL files of the conversation, delete emptied
941
+ rollups, stamp item_count (rendered LOGICAL items), title, project attribution,
942
+ times, and models. Called by every write/delete path so no stale rollup
943
+ survives.
944
+ """
945
+ kern = _lib_codex_conversation
946
+ for conversation_key in {key for key in conversation_keys if key}:
947
+ rows = _load_codex_normalized_rows(conn, conversation_key)
948
+ if not rows:
949
+ conn.execute(
950
+ "DELETE FROM codex_conversation_rollups WHERE conversation_key = ?",
951
+ (conversation_key,),
952
+ )
953
+ continue
954
+ item_count = kern.rollup_item_count(rows)
955
+ title = kern.derive_title(rows)
956
+ timestamps = [r.timestamp_utc for r in rows if r.timestamp_utc]
957
+ started = min(timestamps) if timestamps else None
958
+ last_activity = max(timestamps) if timestamps else None
959
+ models = sorted({r.model for r in rows if r.model})
960
+ models_json = json.dumps(models) if models else None
961
+ source_root_key = rows[0].source_root_key
962
+ thread = conn.execute(
963
+ "SELECT cwd, git_json, parent_thread_id, source_root_key "
964
+ "FROM codex_conversation_threads WHERE conversation_key = ?",
965
+ (conversation_key,),
966
+ ).fetchone()
967
+ cwd = git_json = parent_thread_id = None
968
+ if thread is not None:
969
+ cwd, git_json, parent_thread_id, thread_root = thread
970
+ if thread_root:
971
+ source_root_key = thread_root
972
+ project_key, project_label = _codex_conversation_project_attribution(
973
+ source_root_key, cwd, git_json)
974
+ conn.execute(
975
+ "INSERT INTO codex_conversation_rollups "
976
+ "(conversation_key, source_root_key, parent_thread_id, item_count, "
977
+ " started_utc, last_activity_utc, project_key, project_label, "
978
+ " models_json, title) VALUES (?,?,?,?,?,?,?,?,?,?) "
979
+ "ON CONFLICT(conversation_key) DO UPDATE SET "
980
+ " source_root_key=excluded.source_root_key, "
981
+ " parent_thread_id=excluded.parent_thread_id, "
982
+ " item_count=excluded.item_count, started_utc=excluded.started_utc, "
983
+ " last_activity_utc=excluded.last_activity_utc, "
984
+ " project_key=excluded.project_key, project_label=excluded.project_label, "
985
+ " models_json=excluded.models_json, title=excluded.title",
986
+ (conversation_key, source_root_key, parent_thread_id, item_count,
987
+ started, last_activity, project_key, project_label, models_json, title),
988
+ )
989
+
990
+
991
+ def _replay_codex_normalization(conn: sqlite3.Connection) -> None:
992
+ """Re-derive ALL normalized rows/touches/rollups from stored
993
+ codex_conversation_events, per file in (source_path ASC, line_offset ASC)
994
+ order (migration 025). Runs inside the caller's transaction — the caller
995
+ full-clears first (§3.4 helper) and owns the commit. Deterministic order +
996
+ the plain rowid alias make a re-run byte-idempotent."""
997
+ kern = _lib_codex_conversation
998
+ events_by_file: dict[str, list] = {}
999
+ order: list[str] = []
1000
+ for row in conn.execute(
1001
+ "SELECT source_path, line_offset, source_root_key, conversation_key, "
1002
+ "native_thread_id, root_thread_id, parent_thread_id, timestamp_utc, "
1003
+ "record_type, event_type, turn_id, call_id, payload_json "
1004
+ "FROM codex_conversation_events "
1005
+ "ORDER BY source_path ASC, line_offset ASC"
1006
+ ):
1007
+ event = _lib_jsonl.CodexPhysicalEvent(*row)
1008
+ if event.source_path not in events_by_file:
1009
+ events_by_file[event.source_path] = []
1010
+ order.append(event.source_path)
1011
+ events_by_file[event.source_path].append(event)
1012
+ affected: set = set()
1013
+ for source_path in order:
1014
+ result = kern.normalize_codex_events(
1015
+ events_by_file[source_path], initial=kern.CodexStickyState())
1016
+ _insert_codex_normalized_rows(conn, result.rows, result.touches)
1017
+ affected.update(r.conversation_key for r in result.rows)
1018
+ _recompute_codex_rollups(conn, affected)
1019
+
1020
+
746
1021
  def _collect_inactive_codex_paths_and_roots(
747
1022
  conn: sqlite3.Connection,
748
1023
  current_file_identities: set[tuple[str, str]],
@@ -863,16 +1138,33 @@ def _write_codex_file_batch(
863
1138
  last_root_thread_id: str | None,
864
1139
  last_parent_thread_id: str | None,
865
1140
  last_conversation_key: str | None,
1141
+ last_turn_id: str | None,
866
1142
  reset_file: bool,
867
1143
  accounting_rows: list[tuple[Any, ...]],
868
1144
  quota_rows: list[tuple[Any, ...]],
869
1145
  thread_rows: list[tuple[Any, ...]],
870
1146
  event_rows: list[tuple[Any, ...]],
1147
+ normalized_rows: list,
1148
+ normalized_touches: list,
871
1149
  active_root_keys: set[str],
1150
+ prune_roots: bool = True,
872
1151
  ) -> int:
873
- """Write one fully-buffered Codex file atomically and return entry changes."""
1152
+ """Write one fully-buffered Codex file atomically and return entry changes.
1153
+
1154
+ ``prune_roots`` gates the whole-tree ``_prune_inactive_codex_source_roots``
1155
+ call: a targeted (only_paths) ingest passes ``False`` so it never deletes a
1156
+ ``codex_source_roots`` row for a root it wasn't asked about (spec §5.1
1157
+ whole-tree bypass — ``active_root_keys`` then covers only the targets)."""
874
1158
  now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
1159
+ # #294 S6: rollups are recomputed-affected-or-deleted (§3.2), so capture the
1160
+ # conversation keys this file's normalized rows touch BEFORE a reset delete
1161
+ # removes them.
1162
+ affected_keys: set = set()
875
1163
  if reset_file:
1164
+ affected_keys.update(
1165
+ row[0] for row in conn.execute(
1166
+ "SELECT DISTINCT conversation_key FROM codex_conversation_messages "
1167
+ "WHERE source_path = ?", (path_str,)) if row[0])
876
1168
  _delete_codex_file_derived_rows(conn, path_str)
877
1169
  conn.execute(
878
1170
  """INSERT INTO codex_source_roots
@@ -937,21 +1229,31 @@ def _write_codex_file_batch(
937
1229
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
938
1230
  event_rows,
939
1231
  )
1232
+ # #294 S6: normalized rows + file touches ride the same per-file transaction
1233
+ # as the events themselves. INSERT OR IGNORE on the physical key keeps a delta
1234
+ # re-read idempotent; the per-row FTS triggers index them. Then recompute the
1235
+ # rollup for every affected conversation (aggregating across all its files),
1236
+ # deleting emptied rollups. Threads were inserted above, so project
1237
+ # attribution can read them.
1238
+ _insert_codex_normalized_rows(conn, normalized_rows, normalized_touches)
1239
+ affected_keys.update(r.conversation_key for r in normalized_rows)
1240
+ _recompute_codex_rollups(conn, affected_keys)
940
1241
  conn.execute(
941
1242
  """INSERT OR REPLACE INTO codex_session_files
942
1243
  (path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
943
1244
  last_session_id, last_model, last_total_tokens, source_root_key,
944
1245
  last_native_thread_id, last_root_thread_id, last_parent_thread_id,
945
- last_conversation_key)
946
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1246
+ last_conversation_key, last_turn_id)
1247
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
947
1248
  (
948
1249
  path_str, size, mtime_ns, final_offset, now_iso, last_session_id,
949
1250
  last_model, last_total_tokens, discovered.source_root_key,
950
1251
  last_native_thread_id, last_root_thread_id, last_parent_thread_id,
951
- last_conversation_key,
1252
+ last_conversation_key, last_turn_id,
952
1253
  ),
953
1254
  )
954
- _prune_inactive_codex_source_roots(conn, active_root_keys)
1255
+ if prune_roots:
1256
+ _prune_inactive_codex_source_roots(conn, active_root_keys)
955
1257
  # A file batch owns accounting, quota, thread, event, root, and cursor
956
1258
  # facts as one physical unit. Keep the version bump in that same commit so
957
1259
  # a rolled-back batch never appears newer to the dashboard signature.
@@ -3496,6 +3798,25 @@ class CodexIngestStats:
3496
3798
  lines_malformed: int = 0
3497
3799
  token_events_skipped: int = 0
3498
3800
  skip_reasons: dict = field(default_factory=dict)
3801
+ # #294 S7 targeted (only_paths) live-tail fast-path fields. Default-clean so
3802
+ # every existing only_paths=None caller reads targeted_clean=True and is
3803
+ # otherwise unaffected — the exact Claude ``IngestStats`` semantics (§5.1).
3804
+ # ``deferred_reason`` carries the whole-call preflight decline
3805
+ # (shrink/requalification — Codex's ENTIRE pending-global condition; there is
3806
+ # NO ``cache_meta`` decline-marker tuple, pinned §5.1). ``files_failed``
3807
+ # counts per-file declines (post-preflight late shrink/requalification, I/O,
3808
+ # normalization, DB exception).
3809
+ files_failed: int = 0
3810
+ deferred_reason: "str | None" = None
3811
+
3812
+ @property
3813
+ def targeted_clean(self) -> bool:
3814
+ """True ⇔ a targeted ingest fully applied: not contended, not deferred,
3815
+ and no per-file failure (§5.1). The watch loop emits + advances ``seen``
3816
+ only when this is True — byte-for-byte the Claude ``IngestStats`` rule."""
3817
+ return (not self.lock_contended
3818
+ and self.deferred_reason is None
3819
+ and self.files_failed == 0)
3499
3820
 
3500
3821
 
3501
3822
  def _progress_codex_stderr(stats: CodexIngestStats, *, force: bool = False) -> None:
@@ -3513,8 +3834,10 @@ def sync_codex_cache(
3513
3834
  *,
3514
3835
  progress: Callable[[CodexIngestStats], None] | None = None,
3515
3836
  rebuild: bool = False,
3837
+ only_paths: "set[str] | None" = None,
3516
3838
  lock_timeout: "float | None" = None,
3517
3839
  _on_first_file_rollback: Callable[[], None] | None = None,
3840
+ _on_file_committed: Callable[[str], None] | None = None,
3518
3841
  ) -> CodexIngestStats:
3519
3842
  """Read-through delta ingest of ~/.codex/sessions/**/*.jsonl.
3520
3843
 
@@ -3554,6 +3877,16 @@ def sync_codex_cache(
3554
3877
  stats.lock_contended = True
3555
3878
  return stats
3556
3879
 
3880
+ # #294 S7 targeted (only_paths) live-tail fast path (§5.1). Mutually
3881
+ # exclusive with rebuild (matches the Claude rule). Targeted mode
3882
+ # qualifies ONLY the caller's paths, scopes the cursor preload to them,
3883
+ # and bypasses every whole-tree operation (orphan prune, root prune,
3884
+ # global quota reconcile) — see the guards threaded through below.
3885
+ targeted = only_paths is not None
3886
+ if targeted and rebuild:
3887
+ raise ValueError(
3888
+ "sync_codex_cache: only_paths is incompatible with rebuild")
3889
+
3557
3890
  # F4 (#313): the reconcile trigger gate is "did the Codex physical
3558
3891
  # mutation sequence advance during this sync", NOT rows_changed —
3559
3892
  # rows_changed counts only inserted accounting rows and misses
@@ -3578,9 +3911,13 @@ def sync_codex_cache(
3578
3911
  eprint("[cache-sync] rebuild: cleared Codex cached entries")
3579
3912
 
3580
3913
  # Pure read (glob + is_file only); safe to run before the SELECT and
3581
- # the per-file loop, where no cache.db write lock may be held.
3914
+ # the per-file loop, where no cache.db write lock may be held. Targeted
3915
+ # mode qualifies ONLY the requested paths — never a tree walk (§5.1).
3582
3916
  with _perf.phase("discover") as _p_disc:
3583
- files = _discover_codex_files_with_roots()
3917
+ if targeted:
3918
+ files = _qualify_codex_targets(only_paths)
3919
+ else:
3920
+ files = _discover_codex_files_with_roots()
3584
3921
  stats.files_total = len(files)
3585
3922
  _p_disc.set_count(len(files))
3586
3923
 
@@ -3597,7 +3934,7 @@ def sync_codex_cache(
3597
3934
  # ingest (same invariant as the --rebuild clear above). Concurrent
3598
3935
  # processes with different $CODEX_HOME would prune each other; the
3599
3936
  # flock serializes them and that is a pathological configuration.
3600
- if not rebuild: # --rebuild already cleared both tables above
3937
+ if not rebuild and not targeted: # --rebuild already cleared; targeted bypasses
3601
3938
  current_file_identities = {
3602
3939
  (str(item.source_path), item.source_root_key) for item in files
3603
3940
  }
@@ -3615,7 +3952,14 @@ def sync_codex_cache(
3615
3952
  )
3616
3953
  if orphan_sources or orphan_root_keys:
3617
3954
  before_prune = conn.total_changes
3955
+ # #294 S6: capture the conversation keys the orphan rows belong to
3956
+ # BEFORE deleting, so the rollups can be repaired/deleted after.
3957
+ orphan_keys: set = set()
3618
3958
  for orphan_path, orphan_root_key in orphan_sources:
3959
+ orphan_keys.update(
3960
+ row[0] for row in conn.execute(
3961
+ "SELECT DISTINCT conversation_key FROM codex_conversation_messages "
3962
+ "WHERE source_path = ?", (orphan_path,)) if row[0])
3619
3963
  _delete_codex_file_derived_rows(
3620
3964
  conn,
3621
3965
  orphan_path,
@@ -3626,6 +3970,10 @@ def sync_codex_cache(
3626
3970
  conn, active_root_keys,
3627
3971
  candidate_root_keys=orphan_root_keys,
3628
3972
  )
3973
+ # Recompute-affected-or-delete the rollups the prune touched (§3.2):
3974
+ # a conversation with no surviving rows loses its rollup, one that
3975
+ # survives in another file is recomputed from the survivors.
3976
+ _recompute_codex_rollups(conn, orphan_keys)
3629
3977
  if conn.total_changes != before_prune:
3630
3978
  _bump_codex_physical_mutation_seq(conn)
3631
3979
  conn.commit()
@@ -3641,19 +3989,53 @@ def sync_codex_cache(
3641
3989
  # delta detection consults size alone (Codex rollout JSONLs are
3642
3990
  # append-only, so a size change is a sufficient signal and mtime
3643
3991
  # is prone to clock-skew false-positives).
3644
- existing = {
3645
- row[0]: (
3646
- row[1], row[2], row[3], row[4], row[5], row[6], row[7],
3647
- row[8], row[9], row[10], row[11],
3648
- )
3649
- for row in conn.execute(
3650
- "SELECT path, size_bytes, mtime_ns, last_byte_offset, "
3651
- "last_session_id, last_model, last_total_tokens, source_root_key, "
3652
- "last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
3653
- "last_conversation_key "
3654
- "FROM codex_session_files"
3655
- )
3656
- }
3992
+ if targeted:
3993
+ # §5.1: the cursor preload queries codex_session_files for the
3994
+ # REQUESTED paths only (the full-sync path loads every row; targeted
3995
+ # must not, so its cost stays proportional to its targets).
3996
+ existing = _load_codex_session_files_rows(
3997
+ conn, [str(item.source_path) for item in files])
3998
+ else:
3999
+ existing = {
4000
+ row[0]: (
4001
+ row[1], row[2], row[3], row[4], row[5], row[6], row[7],
4002
+ row[8], row[9], row[10], row[11], row[12],
4003
+ )
4004
+ for row in conn.execute(
4005
+ "SELECT path, size_bytes, mtime_ns, last_byte_offset, "
4006
+ "last_session_id, last_model, last_total_tokens, source_root_key, "
4007
+ "last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
4008
+ "last_conversation_key, last_turn_id "
4009
+ "FROM codex_session_files"
4010
+ )
4011
+ }
4012
+
4013
+ # §5.1 whole-call read-only shrink/requalification preflight. Because
4014
+ # ``targeted_clean`` is CALL-wide while the Codex path commits per file,
4015
+ # a shrink/requalification on ANY resolved target declines the whole call
4016
+ # with ZERO mutations (no partial commit where a healthy file's cursor
4017
+ # advances but no event can be emitted). A shrink landing AFTER this
4018
+ # snapshot is caught per-file at its write turn (below). This is Codex's
4019
+ # ENTIRE pending-global condition — there is NO cache_meta decline-marker
4020
+ # tuple (pinned §5.1). A target that vanished between qualification and
4021
+ # here is skipped, not declined — the per-file loop clean-drops it.
4022
+ if targeted:
4023
+ for discovered in files:
4024
+ prev = existing.get(str(discovered.source_path))
4025
+ if prev is None:
4026
+ continue # brand-new file: nothing to shrink/requalify
4027
+ prev_size = prev[0]
4028
+ prev_root_key = prev[6]
4029
+ if prev_root_key != discovered.source_root_key:
4030
+ stats.deferred_reason = "requalification"
4031
+ return stats
4032
+ try:
4033
+ cur_size = discovered.source_path.stat().st_size
4034
+ except OSError:
4035
+ continue # vanished post-qualify → clean-drop in the loop
4036
+ if cur_size < prev_size:
4037
+ stats.deferred_reason = "truncation"
4038
+ return stats
3657
4039
 
3658
4040
  # #279 S2 F4: ONE coarse `walk` phase bracketing the per-file loop
3659
4041
  # (count = files_processed, never per-row — §2 rule). Manual CM so
@@ -3667,6 +4049,8 @@ def sync_codex_cache(
3667
4049
  st = jp.stat()
3668
4050
  except OSError as exc:
3669
4051
  eprint(f"[codex-cache] stat failed for {jp}: {exc}")
4052
+ if targeted:
4053
+ stats.files_failed += 1 # §5.1 I/O decline → call dirty
3670
4054
  continue
3671
4055
 
3672
4056
  size = st.st_size
@@ -3677,6 +4061,8 @@ def sync_codex_cache(
3677
4061
  initial_session_id: str | None = None
3678
4062
  initial_model: str | None = None
3679
4063
  initial_total_tokens = 0
4064
+ # #294 S6: the sticky-turn resume seed (parallel to initial_model).
4065
+ initial_turn_id: str | None = None
3680
4066
  prev_total_tokens: int | None = None
3681
4067
  prev_native_thread_id: str | None = None
3682
4068
  prev_root_thread_id: str | None = None
@@ -3687,12 +4073,23 @@ def sync_codex_cache(
3687
4073
  (
3688
4074
  prev_size, _, prev_offset, prev_sid, prev_model, prev_ttot,
3689
4075
  prev_root_key, prev_native_thread_id, prev_root_thread_id,
3690
- prev_parent_thread_id, prev_conversation_key,
4076
+ prev_parent_thread_id, prev_conversation_key, prev_turn_id,
3691
4077
  ) = prev
3692
4078
  prev_total_tokens = (
3693
4079
  int(prev_ttot) if prev_ttot is not None else None
3694
4080
  )
3695
4081
  requalified = prev_root_key != discovered.source_root_key
4082
+ if targeted and (requalified or size < prev_size):
4083
+ # §5.1 preflight-snapshot scoped: a shrink or requalification
4084
+ # landing AFTER the preflight is declined HERE, per file —
4085
+ # earlier per-file commits in this call stand, the call still
4086
+ # reports dirty (files_failed → not targeted_clean), so the
4087
+ # watch advances no cursor and emits nothing, and recovery
4088
+ # rides the next full sync's per-file reset. Targeted mode
4089
+ # NEVER runs the full-file offset-0 reset re-ingest (below)
4090
+ # — that whole-cache-affecting escalation is the full sync's.
4091
+ stats.files_failed += 1
4092
+ continue
3696
4093
  if not requalified and size == prev_size:
3697
4094
  stats.files_skipped_unchanged += 1
3698
4095
  continue
@@ -3701,18 +4098,23 @@ def sync_codex_cache(
3701
4098
  initial_session_id = prev_sid
3702
4099
  initial_model = prev_model
3703
4100
  initial_total_tokens = prev_total_tokens or 0
4101
+ initial_turn_id = prev_turn_id
3704
4102
  else:
3705
4103
  truncated = True
3706
4104
  start_offset = 0
3707
4105
  initial_session_id = None
3708
4106
  initial_model = None
3709
4107
  initial_total_tokens = 0
4108
+ initial_turn_id = None
3710
4109
  prev_total_tokens = None
3711
4110
 
3712
4111
  accounting_rows: list[tuple[Any, ...]] = []
3713
4112
  quota_rows: list[tuple[Any, ...]] = []
3714
4113
  thread_rows: list[tuple[Any, ...]] = []
3715
4114
  event_rows: list[tuple[Any, ...]] = []
4115
+ # #294 S6: the physical event objects for this file window, fed to the
4116
+ # normalization kernel after the drain (in offset order).
4117
+ events_list: list = []
3716
4118
  final_offset = start_offset
3717
4119
  # Mutable tracker that the iterator updates on every
3718
4120
  # session_meta / turn_context record, regardless of whether a
@@ -3766,6 +4168,7 @@ def sync_codex_cache(
3766
4168
  state=iter_state,
3767
4169
  ):
3768
4170
  event = emission.event
4171
+ events_list.append(event)
3769
4172
  event_rows.append((
3770
4173
  event.source_path, event.line_offset,
3771
4174
  event.source_root_key, event.conversation_key,
@@ -3828,6 +4231,8 @@ def sync_codex_cache(
3828
4231
  stats.skip_reasons[_r] = stats.skip_reasons.get(_r, 0) + _n
3829
4232
  except OSError as exc:
3830
4233
  eprint(f"[codex-cache] could not read {jp}: {exc}")
4234
+ if targeted:
4235
+ stats.files_failed += 1 # §5.1 I/O decline → call dirty
3831
4236
  continue
3832
4237
 
3833
4238
  # Pull terminal session_id/model from the iterator's tracker.
@@ -3872,6 +4277,29 @@ def sync_codex_cache(
3872
4277
  if terminal_thread is not None else prev_conversation_key
3873
4278
  )
3874
4279
 
4280
+ # #294 S6: normalize this file window's physical events into
4281
+ # normalized rows + file touches, replaying sticky turn/model state
4282
+ # seeded from the persisted resume seed. Buffered before the first DML
4283
+ # (like every other family), so the single retry re-runs the same
4284
+ # in-memory batch. The terminal sticky turn persists as last_turn_id.
4285
+ try:
4286
+ norm_result = _lib_codex_conversation.normalize_codex_events(
4287
+ events_list,
4288
+ initial=_lib_codex_conversation.CodexStickyState(
4289
+ turn_id=initial_turn_id, model=initial_model),
4290
+ )
4291
+ except Exception as exc: # noqa: BLE001
4292
+ # §5.1: a normalization exception during a targeted ingest is a
4293
+ # per-file decline → the call is dirty (files_failed) but earlier
4294
+ # commits stand. A full sync keeps its historical crash-loud
4295
+ # behavior (there the whole walk is authoritative).
4296
+ if not targeted:
4297
+ raise
4298
+ eprint(f"[codex-cache] normalization failed for {jp}: {exc}")
4299
+ stats.files_failed += 1
4300
+ continue
4301
+ new_last_turn_id = norm_result.terminal.turn_id
4302
+
3875
4303
  # Every derived row above was buffered before the first DML. A
3876
4304
  # late database failure therefore rolls the whole file back and
3877
4305
  # retries that same in-memory batch exactly once.
@@ -3892,12 +4320,18 @@ def sync_codex_cache(
3892
4320
  last_root_thread_id=new_last_root_thread_id,
3893
4321
  last_parent_thread_id=new_last_parent_thread_id,
3894
4322
  last_conversation_key=new_last_conversation_key,
4323
+ last_turn_id=new_last_turn_id,
3895
4324
  reset_file=truncated or requalified,
3896
4325
  accounting_rows=accounting_rows,
3897
4326
  quota_rows=quota_rows,
3898
4327
  thread_rows=thread_rows,
3899
4328
  event_rows=event_rows,
4329
+ normalized_rows=norm_result.rows,
4330
+ normalized_touches=norm_result.touches,
3900
4331
  active_root_keys={item.source_root_key for item in files},
4332
+ # §5.1 whole-tree bypass: targeted mode never prunes
4333
+ # codex_source_roots for roots outside its target set.
4334
+ prune_roots=not targeted,
3901
4335
  )
3902
4336
  except sqlite3.DatabaseError as exc:
3903
4337
  conn.rollback()
@@ -3917,8 +4351,16 @@ def sync_codex_cache(
3917
4351
  committed = True
3918
4352
  break
3919
4353
  if not committed:
4354
+ if targeted:
4355
+ stats.files_failed += 1 # §5.1 DB-exception decline → dirty
3920
4356
  continue
3921
4357
 
4358
+ # Private test seam (§5.1 post-preflight late-shrink race): fires
4359
+ # after each file's successful commit, so a race test can shrink a
4360
+ # not-yet-written target and assert the earlier commit stands.
4361
+ if _on_file_committed is not None:
4362
+ _on_file_committed(path_str)
4363
+
3922
4364
  if progress is not None:
3923
4365
  progress(stats)
3924
4366
 
@@ -3973,36 +4415,46 @@ def sync_codex_cache(
3973
4415
  # projection, so the skip decision on that branch is DEFERRED to the
3974
4416
  # post-flock stats-side signature check below — making the gate's
3975
4417
  # skip-condition identical to reconcile's own short-circuit-condition.
3976
- cur_seq = codex_physical_mutation_seq(conn)
3977
- if cur_seq != seq_before:
3978
- project_after_unlock = True
3979
- else:
3980
- active_roots = _cache_root_keys(conn)
3981
- if not active_roots:
3982
- project_after_unlock = False
4418
+ #
4419
+ # §5.1: a targeted (only_paths) ingest NEVER invokes the global quota
4420
+ # reconciler (its observation load reconciles all roots at seconds-scale
4421
+ # cost). Quota projection is deferred to the next full sync, which the
4422
+ # ordinary hook cadence supplies. Skip the whole decision block so
4423
+ # project_after_unlock / deferred_cert_* / did_from_zero_replay keep
4424
+ # their no-op defaults — the post-flock reconcile paths below then all
4425
+ # short-circuit for a targeted call.
4426
+ if not targeted:
4427
+ cur_seq = codex_physical_mutation_seq(conn)
4428
+ if cur_seq != seq_before:
4429
+ project_after_unlock = True
3983
4430
  else:
3984
- certificate = load_codex_quota_projection_certificate(conn)
3985
- certificate_current = (
3986
- certificate is not None
3987
- and certificate[0] == cur_seq
3988
- and active_roots <= set(certificate[1])
3989
- )
3990
- if certificate_current:
3991
- # The CACHE certificate is current, but that alone does NOT
3992
- # prove stats.db still holds the projection (F1). Defer the
3993
- # stats-side signature match until AFTER the Codex flock
3994
- # releases below — only then may the reconcile be skipped.
4431
+ active_roots = _cache_root_keys(conn)
4432
+ if not active_roots:
3995
4433
  project_after_unlock = False
3996
- deferred_cert_roots = set(active_roots)
3997
- assert certificate is not None
3998
- deferred_cert_sigs = dict(certificate[1])
3999
4434
  else:
4000
- project_after_unlock = True
4001
- # #313 P3 (F9): a Codex rebuild or a truncation/requalification
4002
- # re-ingest replays from offset 0 and restores >retention-day
4003
- # codex_conversation_events. Force an unthrottled prune after the flock
4004
- # releases (below), so restored old rows don't persist for up to 24h.
4005
- did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
4435
+ certificate = load_codex_quota_projection_certificate(conn)
4436
+ certificate_current = (
4437
+ certificate is not None
4438
+ and certificate[0] == cur_seq
4439
+ and active_roots <= set(certificate[1])
4440
+ )
4441
+ if certificate_current:
4442
+ # The CACHE certificate is current, but that alone does
4443
+ # NOT prove stats.db still holds the projection (F1).
4444
+ # Defer the stats-side signature match until AFTER the
4445
+ # Codex flock releases below — only then may the
4446
+ # reconcile be skipped.
4447
+ project_after_unlock = False
4448
+ deferred_cert_roots = set(active_roots)
4449
+ assert certificate is not None
4450
+ deferred_cert_sigs = dict(certificate[1])
4451
+ else:
4452
+ project_after_unlock = True
4453
+ # #313 P3 (F9): a Codex rebuild or a truncation/requalification
4454
+ # re-ingest replays from offset 0 and restores >retention-day
4455
+ # codex_conversation_events. Force an unthrottled prune after the
4456
+ # flock releases (below), so restored old rows don't persist for 24h.
4457
+ did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
4006
4458
  finally:
4007
4459
  try:
4008
4460
  fcntl.flock(lock_fh, fcntl.LOCK_UN)