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.
- package/CHANGELOG.md +20 -0
- package/bin/_cctally_cache.py +501 -49
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_db.py +286 -1
- package/bin/_cctally_doctor.py +59 -0
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +1422 -0
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +809 -0
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +8 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -178,6 +178,11 @@ _should_replace = _lib_jsonl._should_replace
|
|
|
178
178
|
_lib_conversation = _load_lib("_lib_conversation")
|
|
179
179
|
_iter_message_rows = _lib_conversation.iter_message_rows
|
|
180
180
|
|
|
181
|
+
# #294 S6: the pure Codex conversation normalization kernel. Pure stdlib leaf
|
|
182
|
+
# (imports only the _lib_conversation / _lib_conversation_query display helpers),
|
|
183
|
+
# so it loads at module-load time alongside _lib_conversation.
|
|
184
|
+
_lib_codex_conversation = _load_lib("_lib_codex_conversation")
|
|
185
|
+
|
|
181
186
|
# Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
|
|
182
187
|
# stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
|
|
183
188
|
# shared no-op singleton), so the sync_cache seam wraps below cost nothing on
|
|
@@ -320,6 +325,10 @@ _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
|
|
|
320
325
|
# drop/recreate dance so the per-row delete trigger never fires O(rows) under
|
|
321
326
|
# the held lock on a rebuild / truncation escalation.
|
|
322
327
|
clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
|
|
328
|
+
# #294 S6: storm-free FULL clear of the Codex normalized derived tables (drop
|
|
329
|
+
# triggers -> truncate -> 'delete-all' -> recreate triggers). Used only by the
|
|
330
|
+
# cache-rebuild path + migration 025; partial deletes ride the per-row triggers.
|
|
331
|
+
_codex_conversation_fts_full_clear = _cctally_db_sib._codex_conversation_fts_full_clear
|
|
323
332
|
# cache_meta key/value upsert helper — reused by the resumable reingest cursor
|
|
324
333
|
# writes (#179) so the ON CONFLICT idiom lives in one place. Caller commits.
|
|
325
334
|
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
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
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
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
|
-
|
|
3985
|
-
|
|
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
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
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)
|