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 +9 -0
- package/bin/_cctally_cache.py +263 -6
- package/bin/_cctally_db.py +286 -1
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_query.py +950 -0
- package/bin/_lib_conversation_dispatch.py +547 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/package.json +5 -1
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -178,6 +178,11 @@ _should_replace = _lib_jsonl._should_replace
|
|
|
178
178
|
_lib_conversation = _load_lib("_lib_conversation")
|
|
179
179
|
_iter_message_rows = _lib_conversation.iter_message_rows
|
|
180
180
|
|
|
181
|
+
# #294 S6: the pure Codex conversation normalization kernel. Pure stdlib leaf
|
|
182
|
+
# (imports only the _lib_conversation / _lib_conversation_query display helpers),
|
|
183
|
+
# so it loads at module-load time alongside _lib_conversation.
|
|
184
|
+
_lib_codex_conversation = _load_lib("_lib_codex_conversation")
|
|
185
|
+
|
|
181
186
|
# Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
|
|
182
187
|
# stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
|
|
183
188
|
# shared no-op singleton), so the sync_cache seam wraps below cost nothing on
|
|
@@ -320,6 +325,10 @@ _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
|
|
|
320
325
|
# drop/recreate dance so the per-row delete trigger never fires O(rows) under
|
|
321
326
|
# the held lock on a rebuild / truncation escalation.
|
|
322
327
|
clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
|
|
328
|
+
# #294 S6: storm-free FULL clear of the Codex normalized derived tables (drop
|
|
329
|
+
# triggers -> truncate -> 'delete-all' -> recreate triggers). Used only by the
|
|
330
|
+
# cache-rebuild path + migration 025; partial deletes ride the per-row triggers.
|
|
331
|
+
_codex_conversation_fts_full_clear = _cctally_db_sib._codex_conversation_fts_full_clear
|
|
323
332
|
# cache_meta key/value upsert helper — reused by the resumable reingest cursor
|
|
324
333
|
# writes (#179) so the ON CONFLICT idiom lives in one place. Caller commits.
|
|
325
334
|
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
@@ -711,6 +720,21 @@ def _delete_codex_file_derived_rows(
|
|
|
711
720
|
"DELETE FROM codex_conversation_threads WHERE source_path = ?" + root_clause,
|
|
712
721
|
params,
|
|
713
722
|
)
|
|
723
|
+
# #294 S6: normalized rows + file touches for this file. This is a PARTIAL
|
|
724
|
+
# delete (one file), so it rides the per-row FTS delete trigger — surviving
|
|
725
|
+
# conversations keep their postings (§3.4 two-path policy). File touches have
|
|
726
|
+
# no source_root_key column; a source_path maps to exactly one file/root, so
|
|
727
|
+
# scoping them by source_path alone is exact. Rollups are repaired by the
|
|
728
|
+
# caller's _recompute_codex_rollups (§3.2), which needs the affected keys
|
|
729
|
+
# captured BEFORE this delete.
|
|
730
|
+
conn.execute(
|
|
731
|
+
"DELETE FROM codex_conversation_file_touches WHERE source_path = ?",
|
|
732
|
+
(path_str,),
|
|
733
|
+
)
|
|
734
|
+
conn.execute(
|
|
735
|
+
"DELETE FROM codex_conversation_messages WHERE source_path = ?" + root_clause,
|
|
736
|
+
params,
|
|
737
|
+
)
|
|
714
738
|
conn.execute(
|
|
715
739
|
"DELETE FROM codex_session_files WHERE path = ?" + root_clause,
|
|
716
740
|
params,
|
|
@@ -725,6 +749,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:
|