cctally 1.112.0 → 1.114.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 +27 -0
- package/README.md +4 -5
- package/bin/_cctally_cache.py +1683 -485
- package/bin/_cctally_core.py +223 -1
- package/bin/_cctally_dashboard.py +96 -18
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +267 -204
- package/bin/_cctally_db.py +861 -18
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +136 -18
- package/bin/_cctally_five_hour.py +2 -2
- package/bin/_cctally_forecast.py +27 -6
- package/bin/_cctally_journal.py +67 -5
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_quota.py +201 -33
- package/bin/_cctally_record.py +14 -0
- package/bin/_cctally_rederive.py +23 -9
- package/bin/_cctally_source_analytics.py +60 -15
- package/bin/_cctally_statusline.py +17 -0
- package/bin/_cctally_store.py +80 -5
- package/bin/_cctally_tui.py +4 -3
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +164 -30
- package/bin/_lib_conversation_query.py +95 -56
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_doctor.py +263 -69
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_merge_gate.py +469 -51
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +209 -8
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +225 -77
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +8 -0
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -99,6 +99,7 @@ from __future__ import annotations
|
|
|
99
99
|
|
|
100
100
|
import argparse
|
|
101
101
|
import bisect
|
|
102
|
+
import collections
|
|
102
103
|
import contextlib
|
|
103
104
|
import datetime as dt
|
|
104
105
|
import fcntl
|
|
@@ -109,6 +110,7 @@ import select
|
|
|
109
110
|
import shutil
|
|
110
111
|
import signal
|
|
111
112
|
import sqlite3
|
|
113
|
+
import stat
|
|
112
114
|
import subprocess
|
|
113
115
|
import sys
|
|
114
116
|
import tempfile
|
|
@@ -136,6 +138,9 @@ import _lib_quota
|
|
|
136
138
|
# #496 S5b §4: the journal-to-cache coverage certificate kernel. Stdlib-only,
|
|
137
139
|
# so it is circular-safe for the same reason.
|
|
138
140
|
import _lib_cache_coverage
|
|
141
|
+
# #901 §5.3c (W9): the writer-owned checkpoint policy. A stdlib-only leaf, so
|
|
142
|
+
# it is circular-safe for the same reason.
|
|
143
|
+
import _lib_wal_checkpoint as _wal_checkpoint
|
|
139
144
|
# #845 §4.1: the decode helper is a stdlib-only leaf that imports nothing from
|
|
140
145
|
# this package, so binding it here is circular-safe for the same reason.
|
|
141
146
|
import _lib_codex_metadata
|
|
@@ -178,6 +183,7 @@ def _load_lib(name: str):
|
|
|
178
183
|
|
|
179
184
|
|
|
180
185
|
_lib_jsonl = _load_lib("_lib_jsonl")
|
|
186
|
+
_lib_sqlite_close = _load_lib("_lib_sqlite_close")
|
|
181
187
|
UsageEntry = _lib_jsonl.UsageEntry
|
|
182
188
|
CodexEntry = _lib_jsonl.CodexEntry
|
|
183
189
|
_CodexIterState = _lib_jsonl._CodexIterState
|
|
@@ -742,6 +748,12 @@ def _maybe_truncate_wal(conn, db_path) -> None:
|
|
|
742
748
|
PRECONDITION: ``conn`` has no active transaction — the caller has committed
|
|
743
749
|
all ingest work by this point.
|
|
744
750
|
"""
|
|
751
|
+
# #901 §5.3c (W9): a failed shrink is not retried for
|
|
752
|
+
# ``_wal_checkpoint.SHRINK_RETRY_SECONDS``, and a successful one completes
|
|
753
|
+
# the writer's pending checkpoint. The test seam turns this off with the
|
|
754
|
+
# rest of W9.
|
|
755
|
+
policy = (None if _wal_checkpoint.disabled()
|
|
756
|
+
else _wal_checkpoint.policy_for_path(db_path))
|
|
745
757
|
try:
|
|
746
758
|
trigger = CACHE_WAL_CHECKPOINT_TRIGGER_BYTES
|
|
747
759
|
test_trigger = os.environ.get("CCTALLY_TEST_CACHE_WAL_TRIGGER_BYTES")
|
|
@@ -752,17 +764,136 @@ def _maybe_truncate_wal(conn, db_path) -> None:
|
|
|
752
764
|
pass
|
|
753
765
|
if _wal_file_size(db_path) <= trigger:
|
|
754
766
|
return
|
|
767
|
+
if policy is not None and not policy.shrink_allowed():
|
|
768
|
+
return
|
|
755
769
|
# After this point, resuming the private stopped process necessarily
|
|
756
770
|
# enters a real TRUNCATE checkpoint rather than a threshold no-op.
|
|
757
771
|
_cache_storm_test_pause("cache_precheckpoint")
|
|
758
772
|
prior = conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
|
759
773
|
try:
|
|
760
774
|
conn.execute(f"PRAGMA busy_timeout={CHECKPOINT_AUTO_BUSY_TIMEOUT_MS}")
|
|
761
|
-
_run_wal_truncate(conn, db_path, db_label="cache.db")
|
|
775
|
+
result = _run_wal_truncate(conn, db_path, db_label="cache.db")
|
|
762
776
|
finally:
|
|
763
777
|
conn.execute(f"PRAGMA busy_timeout={prior}")
|
|
778
|
+
if policy is not None:
|
|
779
|
+
policy.record_shrink(result.truncated)
|
|
780
|
+
except sqlite3.DatabaseError:
|
|
781
|
+
if policy is not None:
|
|
782
|
+
policy.record_shrink(False)
|
|
783
|
+
# best-effort; observability/hygiene must never fail a sync
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _set_cache_meta_if_changed(
|
|
787
|
+
conn: sqlite3.Connection, key: str, value: str,
|
|
788
|
+
) -> None:
|
|
789
|
+
"""Store ``value`` under ``key`` only when it differs (#901 §5.3c W8).
|
|
790
|
+
|
|
791
|
+
The upsert's ``WHERE`` makes an unchanged value a no-op, so a per-pass
|
|
792
|
+
marker that already holds its value writes no page: a write that would
|
|
793
|
+
store an unchanged value is not issued.
|
|
794
|
+
"""
|
|
795
|
+
conn.execute(
|
|
796
|
+
"INSERT INTO cache_meta(key, value) VALUES(?, ?) "
|
|
797
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value "
|
|
798
|
+
"WHERE cache_meta.value IS NOT excluded.value",
|
|
799
|
+
(key, value),
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _withdraw_cache_meta_marker(conn: sqlite3.Connection, key: str) -> None:
|
|
804
|
+
"""Remove a walk-completion marker, writing only when it is present.
|
|
805
|
+
|
|
806
|
+
#901 §5.3c (W8). A completion marker certifies the most recent exhaustive
|
|
807
|
+
walk, so a walk that did not finish cleanly withdraws it. Withdrawing
|
|
808
|
+
replaces the old delete-at-start, which rewrote the marker on every
|
|
809
|
+
complete walk: a complete walk now leaves a present marker untouched and
|
|
810
|
+
writes nothing. Called on every exit of a walk that has not settled its
|
|
811
|
+
marker — an early return, an incomplete end, or an exception, where any
|
|
812
|
+
uncommitted partial work is rolled back first, as a crash would. Best
|
|
813
|
+
effort: a store too damaged to take the delete has larger problems, and
|
|
814
|
+
the original exception must keep propagating.
|
|
815
|
+
"""
|
|
816
|
+
try:
|
|
817
|
+
if conn.in_transaction:
|
|
818
|
+
conn.rollback()
|
|
819
|
+
if conn.execute(
|
|
820
|
+
"SELECT 1 FROM cache_meta WHERE key=?", (key,)
|
|
821
|
+
).fetchone() is not None:
|
|
822
|
+
conn.execute("DELETE FROM cache_meta WHERE key=?", (key,))
|
|
823
|
+
conn.commit()
|
|
824
|
+
except sqlite3.DatabaseError:
|
|
825
|
+
try:
|
|
826
|
+
conn.rollback()
|
|
827
|
+
except sqlite3.DatabaseError:
|
|
828
|
+
pass
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
_CODEX_FINALIZATION_SAVEPOINT = "SAVEPOINT codex_finalization_stage"
|
|
832
|
+
_CODEX_FINALIZATION_RELEASE = "RELEASE SAVEPOINT codex_finalization_stage"
|
|
833
|
+
_CODEX_FINALIZATION_ROLLBACK_TO = (
|
|
834
|
+
"ROLLBACK TO SAVEPOINT codex_finalization_stage")
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _open_codex_finalization_step(conn: sqlite3.Connection) -> None:
|
|
838
|
+
"""Open one step of ``sync_codex_cache``'s finalization transaction (W8).
|
|
839
|
+
|
|
840
|
+
Opens the transaction when it is not open yet (also after SQLite ended it
|
|
841
|
+
on its own), then the step's savepoint. Steps run one after another, each
|
|
842
|
+
released before the next opens, so at most one is ever open.
|
|
843
|
+
"""
|
|
844
|
+
if not conn.in_transaction:
|
|
845
|
+
conn.execute("BEGIN")
|
|
846
|
+
conn.execute(_CODEX_FINALIZATION_SAVEPOINT)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _undo_open_codex_finalization_step(conn: sqlite3.Connection) -> None:
|
|
850
|
+
"""Undo the open step, if any, keeping the steps already released."""
|
|
851
|
+
if not conn.in_transaction:
|
|
852
|
+
return
|
|
853
|
+
try:
|
|
854
|
+
conn.execute(_CODEX_FINALIZATION_ROLLBACK_TO)
|
|
855
|
+
conn.execute(_CODEX_FINALIZATION_RELEASE)
|
|
764
856
|
except sqlite3.DatabaseError:
|
|
765
|
-
|
|
857
|
+
# No step is open ("no such savepoint"): nothing to undo.
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
@contextlib.contextmanager
|
|
862
|
+
def _codex_finalization_stage(conn: sqlite3.Connection):
|
|
863
|
+
"""One step of ``sync_codex_cache``'s finalization transaction (W8).
|
|
864
|
+
|
|
865
|
+
Runs the step inside its own savepoint. Any exception rolls back to the
|
|
866
|
+
savepoint, so the step's writes are undone while the earlier steps' stay,
|
|
867
|
+
and re-raises for the caller's existing failure handling. When SQLite has
|
|
868
|
+
already ended the whole transaction on its own, nothing is left to undo and
|
|
869
|
+
the next step opens a new one.
|
|
870
|
+
"""
|
|
871
|
+
_open_codex_finalization_step(conn)
|
|
872
|
+
try:
|
|
873
|
+
yield
|
|
874
|
+
except BaseException:
|
|
875
|
+
_undo_open_codex_finalization_step(conn)
|
|
876
|
+
raise
|
|
877
|
+
conn.execute(_CODEX_FINALIZATION_RELEASE)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _commit_codex_finalization_so_far(conn: sqlite3.Connection) -> None:
|
|
881
|
+
"""Commit the finalization steps that completed before an exception.
|
|
882
|
+
|
|
883
|
+
Before #901 W8 every step committed on its own, so a step that raised left
|
|
884
|
+
the earlier steps durable; this keeps that. The step that was open when the
|
|
885
|
+
exception struck is undone first, as its own uncommitted transaction was
|
|
886
|
+
before. Best effort: the exception being raised is the one that matters.
|
|
887
|
+
"""
|
|
888
|
+
try:
|
|
889
|
+
_undo_open_codex_finalization_step(conn)
|
|
890
|
+
if conn.in_transaction:
|
|
891
|
+
conn.commit()
|
|
892
|
+
except sqlite3.DatabaseError:
|
|
893
|
+
try:
|
|
894
|
+
conn.rollback()
|
|
895
|
+
except sqlite3.DatabaseError:
|
|
896
|
+
pass
|
|
766
897
|
|
|
767
898
|
|
|
768
899
|
_PARSE_HEALTH_SCHEMA = 1
|
|
@@ -777,6 +908,7 @@ def _update_parse_health_meta(
|
|
|
777
908
|
lines_skipped: int,
|
|
778
909
|
skip_reasons: dict,
|
|
779
910
|
rebuild: bool,
|
|
911
|
+
commit: bool = True,
|
|
780
912
|
) -> None:
|
|
781
913
|
"""Anomaly-delta-gated rolling parse-health record (#279 S2 F1 /
|
|
782
914
|
Codex P1-2). Writes ONLY when (a) this sync's malformed+skipped delta
|
|
@@ -829,6 +961,16 @@ def _update_parse_health_meta(
|
|
|
829
961
|
for r, n in (skip_reasons or {}).items():
|
|
830
962
|
reasons[r] = int(reasons.get(r, 0) or 0) + int(n)
|
|
831
963
|
record["reasons"] = reasons
|
|
964
|
+
if not commit:
|
|
965
|
+
# #901 §5.3c (W8): inside ``sync_codex_cache``'s one finalization
|
|
966
|
+
# transaction the record is one savepoint, so a failed write undoes
|
|
967
|
+
# only itself and the caller's single commit carries it.
|
|
968
|
+
try:
|
|
969
|
+
with _codex_finalization_stage(conn):
|
|
970
|
+
_set_cache_meta(conn, key, json.dumps(record, sort_keys=True))
|
|
971
|
+
except sqlite3.DatabaseError:
|
|
972
|
+
pass # observability must never fail the sync
|
|
973
|
+
return
|
|
832
974
|
try:
|
|
833
975
|
_set_cache_meta(conn, key, json.dumps(record, sort_keys=True))
|
|
834
976
|
conn.commit()
|
|
@@ -1075,12 +1217,16 @@ def set_codex_file_incarnation(
|
|
|
1075
1217
|
the per-file batch transaction that the ingest may roll back and retry —
|
|
1076
1218
|
replaying an increment would double-bump, replaying a MAX-set converges.
|
|
1077
1219
|
"""
|
|
1220
|
+
# #901 §5.3c (W8): the MAX-set writes only when the incarnation advances,
|
|
1221
|
+
# so re-observing an unchanged identity issues no write.
|
|
1222
|
+
# ``updated_at_utc`` is therefore the time the stored incarnation was set.
|
|
1078
1223
|
conn.execute(
|
|
1079
1224
|
"INSERT INTO codex_file_incarnations (file_identity, incarnation, updated_at_utc) "
|
|
1080
1225
|
"VALUES (?,?,?) "
|
|
1081
1226
|
"ON CONFLICT(file_identity) DO UPDATE SET "
|
|
1082
|
-
" incarnation =
|
|
1083
|
-
" updated_at_utc = excluded.updated_at_utc"
|
|
1227
|
+
" incarnation = excluded.incarnation, "
|
|
1228
|
+
" updated_at_utc = excluded.updated_at_utc "
|
|
1229
|
+
"WHERE excluded.incarnation > codex_file_incarnations.incarnation",
|
|
1084
1230
|
(file_identity, incarnation, at_utc),
|
|
1085
1231
|
)
|
|
1086
1232
|
|
|
@@ -1303,13 +1449,29 @@ def load_active_window_attributions(
|
|
|
1303
1449
|
`idx_codex_window_attributions_root(source_root_key, window_minutes)`
|
|
1304
1450
|
already anticipates the first. Task 2's overlay re-checks the model-scoped
|
|
1305
1451
|
axis independently; this is defense in depth, not the only check.
|
|
1452
|
+
|
|
1453
|
+
#901 (I1/G1, Amendment 1 item 1): the read streams in assertion order
|
|
1454
|
+
through `idx_codex_window_attributions_read_order(window_minutes,
|
|
1455
|
+
asserted_at_utc, op_id)`, pinned with `INDEXED BY` when the index exists so
|
|
1456
|
+
no planner statistics can put it back on the pre-#901 plan: a seek of the
|
|
1457
|
+
root index followed by a temp b-tree sort of every matching assertion
|
|
1458
|
+
(measured before the index existed), a population that grows by one
|
|
1459
|
+
assertion per attributed window. The root, retraction and account
|
|
1460
|
+
predicates are evaluated along the ordered scan, so the returned tuple is
|
|
1461
|
+
unchanged; a store without the index keeps the unpinned SQL.
|
|
1306
1462
|
"""
|
|
1463
|
+
read_order_index = conn.execute(
|
|
1464
|
+
"SELECT 1 FROM sqlite_master WHERE type='index' "
|
|
1465
|
+
"AND name='idx_codex_window_attributions_read_order'"
|
|
1466
|
+
).fetchone() is not None
|
|
1307
1467
|
sql = (
|
|
1308
1468
|
"SELECT op_id, account_key, source_root_key, logical_limit_key, "
|
|
1309
1469
|
" observed_slot, window_minutes, raw_resets_at_utc, "
|
|
1310
1470
|
" canonical_resets_at_utc, asserted_at_utc "
|
|
1311
1471
|
"FROM codex_window_attributions "
|
|
1312
|
-
"
|
|
1472
|
+
+ ("INDEXED BY idx_codex_window_attributions_read_order "
|
|
1473
|
+
if read_order_index else "")
|
|
1474
|
+
+ "WHERE retracted_by_op_id IS "
|
|
1313
1475
|
+ ("NOT NULL " if retracted_only else "NULL ")
|
|
1314
1476
|
+ " AND window_minutes = ? "
|
|
1315
1477
|
" AND account_key <> ?"
|
|
@@ -1487,15 +1649,24 @@ class CodexResetAnchorResolver:
|
|
|
1487
1649
|
candidates = _lib_jsonl.codex_snap_equivalent_limit_keys(
|
|
1488
1650
|
str(logical_limit_key))
|
|
1489
1651
|
placeholders = ",".join("?" for _ in candidates)
|
|
1652
|
+
# #901 Q11: the unary `+` keeps the plan this read always had, a
|
|
1653
|
+
# walk of the `(source, source_path, line_offset, ...)` unique
|
|
1654
|
+
# index that sorts only one rollout line's tied rows. Cache
|
|
1655
|
+
# migration 048 made `idx_qws_physical_group` covering for this
|
|
1656
|
+
# SELECT list, and the planner then preferred seeking it and
|
|
1657
|
+
# sorting the root's whole limit history (measured: "USE TEMP
|
|
1658
|
+
# B-TREE FOR ORDER BY"). `+` removes the column's affinity, which
|
|
1659
|
+
# is equivalent here only because the root key is TEXT bound as a
|
|
1660
|
+
# `str`.
|
|
1490
1661
|
try:
|
|
1491
1662
|
rows = self._conn.execute(
|
|
1492
1663
|
"SELECT source_path, line_offset, id, resets_at_utc "
|
|
1493
1664
|
"FROM quota_window_snapshots "
|
|
1494
|
-
"WHERE source = 'codex' AND source_root_key = ? "
|
|
1665
|
+
"WHERE source = 'codex' AND +source_root_key = ? "
|
|
1495
1666
|
" AND observed_slot = ? "
|
|
1496
1667
|
f" AND logical_limit_key IN ({placeholders}) "
|
|
1497
1668
|
"ORDER BY source_path, line_offset, id",
|
|
1498
|
-
(group[0], group[1], *candidates),
|
|
1669
|
+
(str(group[0]), group[1], *candidates),
|
|
1499
1670
|
).fetchall()
|
|
1500
1671
|
except sqlite3.DatabaseError:
|
|
1501
1672
|
# A cache that has not yet gained the column (an old binary
|
|
@@ -1554,6 +1725,20 @@ class CodexResetAnchorResolver:
|
|
|
1554
1725
|
def mark_file_committed(self) -> None:
|
|
1555
1726
|
self._pending_merges.clear()
|
|
1556
1727
|
|
|
1728
|
+
def take_pending_merges(self) -> list:
|
|
1729
|
+
"""This file's queued merges, handed to the caller and cleared (W11).
|
|
1730
|
+
|
|
1731
|
+
An unbudgeted ``sync_codex_cache`` prepares several files before it
|
|
1732
|
+
applies any, so each prepared file carries its own merges and the
|
|
1733
|
+
apply hands them back with ``restore_pending_merges``."""
|
|
1734
|
+
merges = list(self._pending_merges)
|
|
1735
|
+
self._pending_merges.clear()
|
|
1736
|
+
return merges
|
|
1737
|
+
|
|
1738
|
+
def restore_pending_merges(self, merges: list) -> None:
|
|
1739
|
+
"""Make ``merges`` the queue ``apply_pending_merges`` applies next."""
|
|
1740
|
+
self._pending_merges = list(merges)
|
|
1741
|
+
|
|
1557
1742
|
def discard_uncommitted_file(self) -> None:
|
|
1558
1743
|
"""Forget buffered evidence after its file did not commit."""
|
|
1559
1744
|
self._groups.clear()
|
|
@@ -2672,16 +2857,24 @@ def _load_codex_normalized_rows(
|
|
|
2672
2857
|
conn: sqlite3.Connection, conversation_key: str,
|
|
2673
2858
|
) -> list:
|
|
2674
2859
|
"""Load a conversation's normalized rows (all files) in physical order as
|
|
2675
|
-
kernel CodexNormalizedRow objects.
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
(
|
|
2683
|
-
|
|
2684
|
-
|
|
2860
|
+
kernel CodexNormalizedRow objects.
|
|
2861
|
+
|
|
2862
|
+
#901 Amendment 19 T1: read in ``(timestamp_utc, id)`` index order, each
|
|
2863
|
+
timestamp tie run put in physical order by
|
|
2864
|
+
``_lib_codex_conversation.in_physical_order`` (no partial sorter)."""
|
|
2865
|
+
return _lib_codex_conversation.in_physical_order(
|
|
2866
|
+
(
|
|
2867
|
+
_lib_codex_conversation.CodexNormalizedRow(*row)
|
|
2868
|
+
for row in conn.execute(
|
|
2869
|
+
"SELECT " + _CODEX_NORM_COLS + " FROM codex_conversation_messages "
|
|
2870
|
+
"WHERE conversation_key = ? "
|
|
2871
|
+
"ORDER BY timestamp_utc, id",
|
|
2872
|
+
(conversation_key,),
|
|
2873
|
+
)
|
|
2874
|
+
),
|
|
2875
|
+
timestamp=lambda row: row.timestamp_utc,
|
|
2876
|
+
position=lambda row: (row.source_path, row.line_offset),
|
|
2877
|
+
)
|
|
2685
2878
|
|
|
2686
2879
|
|
|
2687
2880
|
def _insert_codex_normalized_rows(
|
|
@@ -2736,6 +2929,7 @@ def _recompute_codex_rollups(
|
|
|
2736
2929
|
conversation_keys,
|
|
2737
2930
|
*,
|
|
2738
2931
|
advance_render_revision: bool = True,
|
|
2932
|
+
inputs_changed: "bool | set[str] | frozenset[str]" = True,
|
|
2739
2933
|
) -> None:
|
|
2740
2934
|
"""Recompute-affected-or-delete the rollup for each conversation (§3.2).
|
|
2741
2935
|
|
|
@@ -2744,15 +2938,30 @@ def _recompute_codex_rollups(
|
|
|
2744
2938
|
rollups, stamp item_count (rendered LOGICAL items), title, project attribution,
|
|
2745
2939
|
times, and models. Called by every write/delete path so no stale rollup
|
|
2746
2940
|
survives.
|
|
2941
|
+
|
|
2942
|
+
#901 §5.3b: ``inputs_changed`` names the conversations whose retained render
|
|
2943
|
+
inputs (normalized message rows or events) this transaction inserted,
|
|
2944
|
+
deleted or updated — ``True`` (the default, for callers that cannot tell)
|
|
2945
|
+
means all of them. Such a conversation's row is always written with a fresh
|
|
2946
|
+
``render_revision``, even when its aggregate values come out equal, so the
|
|
2947
|
+
outline memo keyed on it is never stale. Any other conversation's row is
|
|
2948
|
+
written only when one of its stored values would change, so a true no-op
|
|
2949
|
+
writes nothing (and reserves no revision).
|
|
2747
2950
|
"""
|
|
2748
2951
|
kern = _lib_codex_conversation
|
|
2749
2952
|
keys = {key for key in conversation_keys if key}
|
|
2750
2953
|
if not keys:
|
|
2751
2954
|
return
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2955
|
+
reserved_revision: "int | None" = None
|
|
2956
|
+
|
|
2957
|
+
def render_revision() -> int:
|
|
2958
|
+
nonlocal reserved_revision
|
|
2959
|
+
if not advance_render_revision:
|
|
2960
|
+
return 0
|
|
2961
|
+
if reserved_revision is None:
|
|
2962
|
+
reserved_revision = _next_conversation_render_revision(conn)
|
|
2963
|
+
return reserved_revision
|
|
2964
|
+
|
|
2756
2965
|
for conversation_key in keys:
|
|
2757
2966
|
rows = _load_codex_normalized_rows(conn, conversation_key)
|
|
2758
2967
|
if not rows:
|
|
@@ -2808,6 +3017,19 @@ def _recompute_codex_rollups(
|
|
|
2808
3017
|
else:
|
|
2809
3018
|
project_key, project_label = _codex_conversation_project_attribution(
|
|
2810
3019
|
source_root_key, cwd, git_json)
|
|
3020
|
+
values = (source_root_key, parent_thread_id, item_count, started,
|
|
3021
|
+
last_activity, project_key, project_label, models_json, title)
|
|
3022
|
+
if inputs_changed is not True and conversation_key not in (
|
|
3023
|
+
inputs_changed or ()):
|
|
3024
|
+
stored = conn.execute(
|
|
3025
|
+
"SELECT source_root_key, parent_thread_id, item_count, "
|
|
3026
|
+
"started_utc, last_activity_utc, project_key, project_label, "
|
|
3027
|
+
"models_json, title FROM codex_conversation_rollups "
|
|
3028
|
+
"WHERE conversation_key = ?",
|
|
3029
|
+
(conversation_key,),
|
|
3030
|
+
).fetchone()
|
|
3031
|
+
if stored is not None and tuple(stored) == values:
|
|
3032
|
+
continue
|
|
2811
3033
|
conn.execute(
|
|
2812
3034
|
"INSERT INTO codex_conversation_rollups "
|
|
2813
3035
|
"(conversation_key, source_root_key, parent_thread_id, item_count, "
|
|
@@ -2821,9 +3043,7 @@ def _recompute_codex_rollups(
|
|
|
2821
3043
|
" project_key=excluded.project_key, project_label=excluded.project_label, "
|
|
2822
3044
|
" models_json=excluded.models_json, title=excluded.title, "
|
|
2823
3045
|
" render_revision=excluded.render_revision",
|
|
2824
|
-
(conversation_key,
|
|
2825
|
-
started, last_activity, project_key, project_label, models_json, title,
|
|
2826
|
-
render_revision),
|
|
3046
|
+
(conversation_key, *values, render_revision()),
|
|
2827
3047
|
)
|
|
2828
3048
|
|
|
2829
3049
|
|
|
@@ -2891,7 +3111,10 @@ def _replay_codex_normalization(
|
|
|
2891
3111
|
).fetchone() is not None
|
|
2892
3112
|
if projection_exists:
|
|
2893
3113
|
import _lib_codex_conversation_query as query
|
|
2894
|
-
|
|
3114
|
+
# The caller full-cleared every normalized message in this transaction
|
|
3115
|
+
# (§3.4), so the projection rows went with them (#901 §5.3b): the
|
|
3116
|
+
# generation must advance even where the rebuilt rows come out equal.
|
|
3117
|
+
query.materialize_codex_find_projection(conn, affected, cascaded=True)
|
|
2895
3118
|
|
|
2896
3119
|
|
|
2897
3120
|
def run_codex_find_projection_backfill(
|
|
@@ -2975,6 +3198,13 @@ def _repair_codex_turn_ids_for_source(
|
|
|
2975
3198
|
or abort record exposes its turn id. Run this bounded per-file repair only
|
|
2976
3199
|
when such a native proof arrives; ordinary append ticks remain delta-only.
|
|
2977
3200
|
The physical log stays authoritative.
|
|
3201
|
+
|
|
3202
|
+
#901 Amendment 19 T1 (spec I1): the affected conversations are collected
|
|
3203
|
+
from the one scan of the file's rows the repair already makes. A separate
|
|
3204
|
+
``SELECT DISTINCT conversation_key`` built a temp b-tree over the file's
|
|
3205
|
+
whole message history, and one rollout file is not a bound. Same set:
|
|
3206
|
+
every non-empty key on the path (``conversation_key`` is NOT NULL, and the
|
|
3207
|
+
UPDATE below changes only ``turn_id``).
|
|
2978
3208
|
"""
|
|
2979
3209
|
events = [
|
|
2980
3210
|
_lib_jsonl.CodexPhysicalEvent(*row)
|
|
@@ -2988,20 +3218,14 @@ def _repair_codex_turn_ids_for_source(
|
|
|
2988
3218
|
]
|
|
2989
3219
|
inferred, _terminal = _lib_codex_conversation.infer_codex_event_turns(events)
|
|
2990
3220
|
expected = {event.line_offset: turn for event, turn in zip(events, inferred)}
|
|
2991
|
-
affected =
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
"WHERE source_path=?",
|
|
2996
|
-
(source_path,),
|
|
2997
|
-
)
|
|
2998
|
-
if row[0]
|
|
2999
|
-
}
|
|
3000
|
-
for line_offset, stored_turn in conn.execute(
|
|
3001
|
-
"SELECT line_offset, turn_id FROM codex_conversation_messages "
|
|
3002
|
-
"WHERE source_path=?",
|
|
3221
|
+
affected: set[str] = set()
|
|
3222
|
+
for line_offset, stored_turn, conversation_key in conn.execute(
|
|
3223
|
+
"SELECT line_offset, turn_id, conversation_key "
|
|
3224
|
+
"FROM codex_conversation_messages WHERE source_path=?",
|
|
3003
3225
|
(source_path,),
|
|
3004
3226
|
):
|
|
3227
|
+
if conversation_key:
|
|
3228
|
+
affected.add(conversation_key)
|
|
3005
3229
|
inferred_turn = expected.get(line_offset)
|
|
3006
3230
|
if stored_turn != inferred_turn:
|
|
3007
3231
|
conn.execute(
|
|
@@ -3012,10 +3236,34 @@ def _repair_codex_turn_ids_for_source(
|
|
|
3012
3236
|
return affected
|
|
3013
3237
|
|
|
3014
3238
|
|
|
3015
|
-
|
|
3239
|
+
@dataclass(frozen=True)
|
|
3240
|
+
class _RetainedCodexInventory:
|
|
3241
|
+
"""Every retained Codex source identity, from one scan of each family.
|
|
3242
|
+
|
|
3243
|
+
``identities`` and ``root_keys`` are what
|
|
3244
|
+
`_collect_retained_codex_paths_and_roots` returns. ``terminal_file_identities``
|
|
3245
|
+
is every ``codex_session_files`` ``(path, source_root_key)`` row, relative
|
|
3246
|
+
fixture rows included, which the inactive-path partition reads to send an
|
|
3247
|
+
old terminal file at a currently discovered path to requalification.
|
|
3248
|
+
|
|
3249
|
+
#901 Amendment 20 W1 (dc18 S2): the ordinary prune path used to collect
|
|
3250
|
+
the identities twice per pass and scan the terminal rows a third time,
|
|
3251
|
+
and each collection is four history-sized family scans. It now collects
|
|
3252
|
+
once and hands the result to both decisions. The collections ran back to
|
|
3253
|
+
back under the cache writer and Codex provider flocks, which every writer
|
|
3254
|
+
of these families takes, with only pure partitioning and the filesystem
|
|
3255
|
+
prune-scope probe between them, so one collection sees what two did.
|
|
3256
|
+
"""
|
|
3257
|
+
|
|
3258
|
+
identities: tuple[tuple[str, str | None], ...]
|
|
3259
|
+
root_keys: frozenset[str]
|
|
3260
|
+
terminal_file_identities: frozenset[tuple[str, str | None]]
|
|
3261
|
+
|
|
3262
|
+
|
|
3263
|
+
def _collect_retained_codex_inventory(
|
|
3016
3264
|
conn: sqlite3.Connection,
|
|
3017
|
-
) ->
|
|
3018
|
-
"""
|
|
3265
|
+
) -> _RetainedCodexInventory:
|
|
3266
|
+
"""Collect every retained real source identity and provider root once.
|
|
3019
3267
|
|
|
3020
3268
|
A failed/partial prior write can leave any S1 child family without its
|
|
3021
3269
|
terminal ``codex_session_files`` row. Safety decisions must therefore see
|
|
@@ -3024,14 +3272,21 @@ def _collect_retained_codex_paths_and_roots(
|
|
|
3024
3272
|
on-disk authority and remain outside filesystem pruning.
|
|
3025
3273
|
"""
|
|
3026
3274
|
retained_identities: set[tuple[str, str | None]] = set()
|
|
3027
|
-
|
|
3028
|
-
|
|
3275
|
+
terminal_file_identities: set[tuple[str, str | None]] = set()
|
|
3276
|
+
for source_path, root_key in conn.execute(
|
|
3277
|
+
"SELECT path, source_root_key FROM codex_session_files"
|
|
3278
|
+
):
|
|
3279
|
+
terminal_file_identities.add((source_path, root_key))
|
|
3280
|
+
if not os.path.isabs(source_path):
|
|
3281
|
+
continue
|
|
3282
|
+
retained_identities.add((source_path, root_key))
|
|
3283
|
+
child_family_queries = (
|
|
3029
3284
|
"SELECT source_path, source_root_key FROM codex_session_entries",
|
|
3030
3285
|
"SELECT source_path, source_root_key FROM quota_window_snapshots "
|
|
3031
3286
|
"WHERE source = 'codex'",
|
|
3032
3287
|
"SELECT source_path, source_root_key FROM codex_conversation_threads",
|
|
3033
3288
|
)
|
|
3034
|
-
for query in
|
|
3289
|
+
for query in child_family_queries:
|
|
3035
3290
|
for source_path, root_key in conn.execute(query):
|
|
3036
3291
|
if not os.path.isabs(source_path):
|
|
3037
3292
|
continue
|
|
@@ -3045,28 +3300,41 @@ def _collect_retained_codex_paths_and_roots(
|
|
|
3045
3300
|
"SELECT source_root_key FROM codex_source_roots"
|
|
3046
3301
|
)
|
|
3047
3302
|
)
|
|
3048
|
-
return (
|
|
3049
|
-
sorted(
|
|
3050
|
-
|
|
3303
|
+
return _RetainedCodexInventory(
|
|
3304
|
+
identities=tuple(sorted(
|
|
3305
|
+
retained_identities, key=lambda item: (item[0], item[1] or ""),
|
|
3306
|
+
)),
|
|
3307
|
+
root_keys=frozenset(retained_root_keys),
|
|
3308
|
+
terminal_file_identities=frozenset(terminal_file_identities),
|
|
3051
3309
|
)
|
|
3052
3310
|
|
|
3053
3311
|
|
|
3312
|
+
def _collect_retained_codex_paths_and_roots(
|
|
3313
|
+
conn: sqlite3.Connection,
|
|
3314
|
+
) -> tuple[list[tuple[str, str | None]], set[str]]:
|
|
3315
|
+
"""Return every retained real source identity and provider root."""
|
|
3316
|
+
inventory = _collect_retained_codex_inventory(conn)
|
|
3317
|
+
return list(inventory.identities), set(inventory.root_keys)
|
|
3318
|
+
|
|
3319
|
+
|
|
3054
3320
|
def _collect_inactive_codex_paths_and_roots(
|
|
3055
3321
|
conn: sqlite3.Connection,
|
|
3056
3322
|
current_file_identities: set[tuple[str, str]],
|
|
3057
3323
|
active_root_keys: set[str],
|
|
3324
|
+
*,
|
|
3325
|
+
inventory: "_RetainedCodexInventory | None" = None,
|
|
3058
3326
|
) -> tuple[list[tuple[str, str | None]], set[str]]:
|
|
3059
|
-
"""Return stale retained source identities and candidate root keys.
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3327
|
+
"""Return stale retained source identities and candidate root keys.
|
|
3328
|
+
|
|
3329
|
+
``inventory`` is the pass's one collection (#901 Amendment 20 W1); without
|
|
3330
|
+
it the function collects its own.
|
|
3331
|
+
"""
|
|
3332
|
+
if inventory is None:
|
|
3333
|
+
inventory = _collect_retained_codex_inventory(conn)
|
|
3334
|
+
retained_identities = inventory.identities
|
|
3335
|
+
retained_root_keys = inventory.root_keys
|
|
3063
3336
|
current_paths = {path for path, _root_key in current_file_identities}
|
|
3064
|
-
terminal_file_identities =
|
|
3065
|
-
(path, root_key)
|
|
3066
|
-
for path, root_key in conn.execute(
|
|
3067
|
-
"SELECT path, source_root_key FROM codex_session_files"
|
|
3068
|
-
)
|
|
3069
|
-
}
|
|
3337
|
+
terminal_file_identities = inventory.terminal_file_identities
|
|
3070
3338
|
stale_identities = {
|
|
3071
3339
|
identity
|
|
3072
3340
|
for identity in retained_identities
|
|
@@ -3089,6 +3357,81 @@ def _collect_inactive_codex_paths_and_roots(
|
|
|
3089
3357
|
return sorted(stale_identities, key=lambda item: (item[0], item[1] or "")), stale_root_keys
|
|
3090
3358
|
|
|
3091
3359
|
|
|
3360
|
+
@dataclass(frozen=True)
|
|
3361
|
+
class _OrdinaryCodexPrunePlan:
|
|
3362
|
+
"""The ordinary (non-targeted, non-rebuild) prune path's decisions.
|
|
3363
|
+
|
|
3364
|
+
``safe_*`` / ``refused_orphan_*`` partition the inactive orphans;
|
|
3365
|
+
``refused_sources`` / ``refused_root_keys`` partition every retained
|
|
3366
|
+
identity, so a refused path is protected from the per-file loop's
|
|
3367
|
+
requalification even when it is not an orphan.
|
|
3368
|
+
"""
|
|
3369
|
+
|
|
3370
|
+
orphan_sources: list[tuple[str, str | None]]
|
|
3371
|
+
orphan_root_keys: set[str]
|
|
3372
|
+
safe_sources: list[tuple[str, str | None]]
|
|
3373
|
+
refused_orphan_sources: list[tuple[str, str | None]]
|
|
3374
|
+
safe_root_keys: set[str]
|
|
3375
|
+
refused_orphan_root_keys: set[str]
|
|
3376
|
+
retained_sources: list[tuple[str, str | None]]
|
|
3377
|
+
refused_sources: list[tuple[str, str | None]]
|
|
3378
|
+
refused_root_keys: set[str]
|
|
3379
|
+
|
|
3380
|
+
|
|
3381
|
+
def _plan_ordinary_codex_prune(
|
|
3382
|
+
conn: sqlite3.Connection,
|
|
3383
|
+
current_file_identities: set[tuple[str, str]],
|
|
3384
|
+
active_root_keys: set[str],
|
|
3385
|
+
prune_scope: "_CodexPruneScope",
|
|
3386
|
+
) -> _OrdinaryCodexPrunePlan:
|
|
3387
|
+
"""Decide one ordinary pass's orphan prune from ONE retained collection.
|
|
3388
|
+
|
|
3389
|
+
#901 Amendment 20 W1 (dc18 S2): the orphan partition and the protection
|
|
3390
|
+
partition read the same `_collect_retained_codex_inventory` result instead
|
|
3391
|
+
of collecting twice. Every family, the root registry, the relative-path
|
|
3392
|
+
exclusion and the orphan detection of children without a terminal file
|
|
3393
|
+
row are unchanged; only the duplicate scans are gone. The caller holds the
|
|
3394
|
+
cache writer and Codex provider flocks across this call.
|
|
3395
|
+
"""
|
|
3396
|
+
inventory = _collect_retained_codex_inventory(conn)
|
|
3397
|
+
orphan_sources, orphan_root_keys = _collect_inactive_codex_paths_and_roots(
|
|
3398
|
+
conn, current_file_identities, active_root_keys, inventory=inventory,
|
|
3399
|
+
)
|
|
3400
|
+
(
|
|
3401
|
+
safe_sources,
|
|
3402
|
+
refused_orphan_sources,
|
|
3403
|
+
safe_root_keys,
|
|
3404
|
+
refused_orphan_root_keys,
|
|
3405
|
+
) = _partition_codex_prune_candidates(
|
|
3406
|
+
prune_scope, orphan_sources, orphan_root_keys
|
|
3407
|
+
)
|
|
3408
|
+
retained_sources = list(inventory.identities)
|
|
3409
|
+
retained_source_root_keys = {
|
|
3410
|
+
root_key
|
|
3411
|
+
for _path, root_key in retained_sources
|
|
3412
|
+
if root_key is not None
|
|
3413
|
+
}
|
|
3414
|
+
(
|
|
3415
|
+
_safe_retained_sources,
|
|
3416
|
+
refused_sources,
|
|
3417
|
+
_safe_retained_roots,
|
|
3418
|
+
refused_root_keys,
|
|
3419
|
+
) = _partition_codex_prune_candidates(
|
|
3420
|
+
prune_scope, retained_sources, retained_source_root_keys
|
|
3421
|
+
)
|
|
3422
|
+
return _OrdinaryCodexPrunePlan(
|
|
3423
|
+
orphan_sources=orphan_sources,
|
|
3424
|
+
orphan_root_keys=orphan_root_keys,
|
|
3425
|
+
safe_sources=safe_sources,
|
|
3426
|
+
refused_orphan_sources=refused_orphan_sources,
|
|
3427
|
+
safe_root_keys=safe_root_keys,
|
|
3428
|
+
refused_orphan_root_keys=refused_orphan_root_keys,
|
|
3429
|
+
retained_sources=retained_sources,
|
|
3430
|
+
refused_sources=refused_sources,
|
|
3431
|
+
refused_root_keys=refused_root_keys,
|
|
3432
|
+
)
|
|
3433
|
+
|
|
3434
|
+
|
|
3092
3435
|
def _prune_inactive_codex_source_roots(
|
|
3093
3436
|
conn: sqlite3.Connection,
|
|
3094
3437
|
active_root_keys: set[str],
|
|
@@ -3237,6 +3580,80 @@ def _append_codex_file_account_decision(
|
|
|
3237
3580
|
))
|
|
3238
3581
|
|
|
3239
3582
|
|
|
3583
|
+
#: #901 §5.3c (W11, Q14): an unbudgeted ``sync_codex_cache`` prepares its
|
|
3584
|
+
#: changed files (reads and parses their new records) with no write
|
|
3585
|
+
#: transaction open and applies them in one transaction, each file in its own
|
|
3586
|
+
#: savepoint. One transaction holds at most this many files and this many
|
|
3587
|
+
#: bytes of newly consumed input (a file's size minus its cursor, known before
|
|
3588
|
+
#: it is read); a file whose own new input exceeds the byte bound is applied
|
|
3589
|
+
#: alone. The hook's budgeted path keeps its per-file commits.
|
|
3590
|
+
CODEX_ACCOUNTING_BATCH_FILES = 64
|
|
3591
|
+
CODEX_ACCOUNTING_BATCH_BYTES = 4 * 1024 * 1024
|
|
3592
|
+
#: Test seam: False keeps an unbudgeted call on the pre-W11 per-file commits,
|
|
3593
|
+
#: the per-file control G10 (l) compares against.
|
|
3594
|
+
_CODEX_W11_ENABLED = True
|
|
3595
|
+
#: Test seam: prepare every file first and apply bounded slices afterwards. It
|
|
3596
|
+
#: exists only so G10 (l) can show its preparation-trace oracle fails it.
|
|
3597
|
+
_CODEX_W11_EAGER_PREPARE_FOR_TESTS = False
|
|
3598
|
+
#: Test seam: called with W11's preparation trace events: ``("prepare_start",
|
|
3599
|
+
#: path, new_bytes)``, ``("prepare_drop", path)``, ``("prepare_end", path,
|
|
3600
|
+
#: new_bytes)``, ``("commit", paths)`` and ``("release", paths, bytes)``.
|
|
3601
|
+
_CODEX_W11_TRACE = None
|
|
3602
|
+
#: Test seam: called immediately before each W11 commit (crash injection).
|
|
3603
|
+
_CODEX_BATCH_PRECOMMIT_HOOK = None
|
|
3604
|
+
_CODEX_W11_FILE_SAVEPOINT = "SAVEPOINT codex_w11_file"
|
|
3605
|
+
_CODEX_W11_FILE_RELEASE = "RELEASE SAVEPOINT codex_w11_file"
|
|
3606
|
+
_CODEX_W11_FILE_ROLLBACK_TO = "ROLLBACK TO SAVEPOINT codex_w11_file"
|
|
3607
|
+
#: 901-QI-001: held from the moment the last batch is deferred to W8's
|
|
3608
|
+
#: finalization transaction until just before its completion marker. A
|
|
3609
|
+
#: transaction SQLite ended on its own takes the savepoint with it, and a
|
|
3610
|
+
#: later stage's fresh BEGIN cannot recreate it, so releasing it proves the
|
|
3611
|
+
#: deferred files are still in the transaction that will commit.
|
|
3612
|
+
_CODEX_W11_DEFERRED_SAVEPOINT = "SAVEPOINT codex_w11_deferred"
|
|
3613
|
+
_CODEX_W11_DEFERRED_RELEASE = "RELEASE SAVEPOINT codex_w11_deferred"
|
|
3614
|
+
|
|
3615
|
+
|
|
3616
|
+
@dataclass
|
|
3617
|
+
class _CodexPreparedFile:
|
|
3618
|
+
"""One changed Codex file read and parsed, waiting for its batch (W11)."""
|
|
3619
|
+
|
|
3620
|
+
walk_index: int
|
|
3621
|
+
discovered: Any
|
|
3622
|
+
path_str: str
|
|
3623
|
+
new_input: int
|
|
3624
|
+
write_kwargs: dict
|
|
3625
|
+
merges: list
|
|
3626
|
+
reset_counted: bool
|
|
3627
|
+
scan_target: int
|
|
3628
|
+
final_offset: int
|
|
3629
|
+
stopped_short: bool
|
|
3630
|
+
accounting_rows: list
|
|
3631
|
+
quota_rows: list
|
|
3632
|
+
#: The parse counters this file folded into the sync's statistics.
|
|
3633
|
+
lines_seen: int
|
|
3634
|
+
lines_malformed: int
|
|
3635
|
+
token_events_skipped: int
|
|
3636
|
+
skip_reasons: dict
|
|
3637
|
+
#: Accounting rows its apply inserted, once applied.
|
|
3638
|
+
rows_changed: int = 0
|
|
3639
|
+
|
|
3640
|
+
|
|
3641
|
+
def _codex_w11_trace(*event) -> None:
|
|
3642
|
+
hook = _CODEX_W11_TRACE
|
|
3643
|
+
if hook is not None:
|
|
3644
|
+
hook(*event)
|
|
3645
|
+
|
|
3646
|
+
|
|
3647
|
+
def _codex_batch_precommit() -> None:
|
|
3648
|
+
"""Immediately before a W11 commit that carries applied files: the
|
|
3649
|
+
killed-writer harness's `codex_precommit` point (where the per-file path
|
|
3650
|
+
pauses before its own commit) and the crash-injection seam."""
|
|
3651
|
+
_cache_storm_test_pause("codex_precommit")
|
|
3652
|
+
hook = _CODEX_BATCH_PRECOMMIT_HOOK
|
|
3653
|
+
if hook is not None:
|
|
3654
|
+
hook()
|
|
3655
|
+
|
|
3656
|
+
|
|
3240
3657
|
def _write_codex_file_batch(
|
|
3241
3658
|
conn: sqlite3.Connection,
|
|
3242
3659
|
*,
|
|
@@ -3267,9 +3684,14 @@ def _write_codex_file_batch(
|
|
|
3267
3684
|
ingest_complete: bool = True,
|
|
3268
3685
|
device_id: "int | None" = None,
|
|
3269
3686
|
inode: "int | None" = None,
|
|
3687
|
+
commit: bool = True,
|
|
3270
3688
|
) -> int:
|
|
3271
3689
|
"""Write one fully-buffered Codex file atomically and return entry changes.
|
|
3272
3690
|
|
|
3691
|
+
``commit=False`` (#901 W11) leaves the caller's transaction open: an
|
|
3692
|
+
unbudgeted sync applies each file inside its own savepoint and commits the
|
|
3693
|
+
batch once.
|
|
3694
|
+
|
|
3273
3695
|
``prune_roots`` gates the whole-tree ``_prune_inactive_codex_source_roots``
|
|
3274
3696
|
call: a targeted (only_paths) ingest passes ``False`` so it never deletes a
|
|
3275
3697
|
``codex_source_roots`` row for a root it wasn't asked about (spec §5.1
|
|
@@ -3283,10 +3705,11 @@ def _write_codex_file_batch(
|
|
|
3283
3705
|
|
|
3284
3706
|
``ingest_complete`` (public #5) records whether ingestion actually reached
|
|
3285
3707
|
the ``size`` it is about to persist. It MUST be listed in the cursor upsert
|
|
3286
|
-
below
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3708
|
+
below, in both its value list and its ``DO UPDATE SET``: an omitted column
|
|
3709
|
+
keeps a stale value (it once reverted to its schema DEFAULT of 1 under the
|
|
3710
|
+
former ``INSERT OR REPLACE``) — a budgeted stop would have its own record
|
|
3711
|
+
erased by its own commit, the unread suffix would be skipped as unchanged
|
|
3712
|
+
forever, and nothing would raise."""
|
|
3290
3713
|
now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
3291
3714
|
if reset_file:
|
|
3292
3715
|
_delete_codex_file_derived_rows(conn, path_str)
|
|
@@ -3369,14 +3792,37 @@ def _write_codex_file_batch(
|
|
|
3369
3792
|
# target, the final offset and the completion flag. A replacement can
|
|
3370
3793
|
# therefore never leave a new identity paired with an old offset: either
|
|
3371
3794
|
# the whole file batch commits or none of it does.
|
|
3795
|
+
#
|
|
3796
|
+
# #901 §5.3c (W8): an explicit upsert that sets exactly the columns the
|
|
3797
|
+
# former ``INSERT OR REPLACE`` wrote. An existing file's offset-only advance
|
|
3798
|
+
# is an UPDATE, which ``trg_codex_accounting_file_upd`` logs only when an
|
|
3799
|
+
# identity-relevant field changes; the replace fired the INSERT trigger and
|
|
3800
|
+
# logged an accounting change on every advance. A new file still fires the
|
|
3801
|
+
# INSERT trigger, and an identity change still logs through the UPDATE one.
|
|
3372
3802
|
conn.execute(
|
|
3373
|
-
"""INSERT
|
|
3803
|
+
"""INSERT INTO codex_session_files
|
|
3374
3804
|
(path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
|
|
3375
3805
|
last_session_id, last_model, last_total_tokens, source_root_key,
|
|
3376
3806
|
last_native_thread_id, last_root_thread_id, last_parent_thread_id,
|
|
3377
3807
|
last_conversation_key, last_turn_id, account_key, ingest_complete,
|
|
3378
3808
|
device_id, inode)
|
|
3379
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
3809
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
3810
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
3811
|
+
size_bytes=excluded.size_bytes, mtime_ns=excluded.mtime_ns,
|
|
3812
|
+
last_byte_offset=excluded.last_byte_offset,
|
|
3813
|
+
last_ingested_at=excluded.last_ingested_at,
|
|
3814
|
+
last_session_id=excluded.last_session_id,
|
|
3815
|
+
last_model=excluded.last_model,
|
|
3816
|
+
last_total_tokens=excluded.last_total_tokens,
|
|
3817
|
+
source_root_key=excluded.source_root_key,
|
|
3818
|
+
last_native_thread_id=excluded.last_native_thread_id,
|
|
3819
|
+
last_root_thread_id=excluded.last_root_thread_id,
|
|
3820
|
+
last_parent_thread_id=excluded.last_parent_thread_id,
|
|
3821
|
+
last_conversation_key=excluded.last_conversation_key,
|
|
3822
|
+
last_turn_id=excluded.last_turn_id,
|
|
3823
|
+
account_key=excluded.account_key,
|
|
3824
|
+
ingest_complete=excluded.ingest_complete,
|
|
3825
|
+
device_id=excluded.device_id, inode=excluded.inode""",
|
|
3380
3826
|
(
|
|
3381
3827
|
path_str, size, mtime_ns, final_offset, now_iso, last_session_id,
|
|
3382
3828
|
last_model, last_total_tokens, discovered.source_root_key,
|
|
@@ -3393,8 +3839,9 @@ def _write_codex_file_batch(
|
|
|
3393
3839
|
# facts as one physical unit. Keep the version bump in that same commit so
|
|
3394
3840
|
# a rolled-back batch never appears newer to the dashboard signature.
|
|
3395
3841
|
_bump_codex_physical_mutation_seq(conn)
|
|
3396
|
-
|
|
3397
|
-
|
|
3842
|
+
if commit:
|
|
3843
|
+
_cache_storm_test_pause("codex_precommit")
|
|
3844
|
+
conn.commit()
|
|
3398
3845
|
return rows_changed
|
|
3399
3846
|
|
|
3400
3847
|
|
|
@@ -3982,6 +4429,10 @@ def sync_cache(
|
|
|
3982
4429
|
_cctally_core.CACHE_LOCK_PATH.touch()
|
|
3983
4430
|
|
|
3984
4431
|
lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
|
|
4432
|
+
# #901 §5.3c (W8): True while this call owes the walk-complete marker a
|
|
4433
|
+
# verdict. Settled by the clean end-of-walk write; every other exit
|
|
4434
|
+
# withdraws a present marker in ``finally``.
|
|
4435
|
+
walk_marker_owed = False
|
|
3985
4436
|
try:
|
|
3986
4437
|
with _perf.phase("flock") as _p_flock:
|
|
3987
4438
|
_acquired = _acquire_cache_flock(lock_fh, timeout=lock_timeout)
|
|
@@ -3990,6 +4441,8 @@ def sync_cache(
|
|
|
3990
4441
|
eprint("[cache] sync already in progress; using existing cache")
|
|
3991
4442
|
stats.lock_contended = True
|
|
3992
4443
|
return stats
|
|
4444
|
+
# #901 §5.3c (W9): a sync writer owns its checkpoints.
|
|
4445
|
+
_wal_checkpoint.arm(conn)
|
|
3993
4446
|
|
|
3994
4447
|
targeted = only_paths is not None
|
|
3995
4448
|
if targeted:
|
|
@@ -4047,15 +4500,15 @@ def sync_cache(
|
|
|
4047
4500
|
walk_clean = True
|
|
4048
4501
|
|
|
4049
4502
|
# The marker certifies the MOST RECENT exhaustive walk, not merely some
|
|
4050
|
-
# historical clean walk.
|
|
4051
|
-
#
|
|
4052
|
-
#
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4503
|
+
# historical clean walk. Any early return, per-file failure or
|
|
4504
|
+
# exception must leave the store visibly incomplete, and only this same
|
|
4505
|
+
# call's clean end-of-walk write may keep it. #901 §5.3c (W8): the
|
|
4506
|
+
# verdict is owed from here and settled at the exit, rather than
|
|
4507
|
+
# retiring the marker with a delete-and-commit at the start, which made
|
|
4508
|
+
# every complete walk rewrite it. Destructive paths (rebuild, the
|
|
4509
|
+
# truncation reset, orphaned rows) still delete it atomically with
|
|
4510
|
+
# their own writes.
|
|
4511
|
+
walk_marker_owed = not targeted
|
|
4059
4512
|
|
|
4060
4513
|
if rebuild:
|
|
4061
4514
|
# Clear INSIDE the lock — a concurrent rebuild that lost the
|
|
@@ -4853,6 +5306,7 @@ def sync_cache(
|
|
|
4853
5306
|
)
|
|
4854
5307
|
_cache_storm_test_pause("claude_precommit")
|
|
4855
5308
|
conn.commit()
|
|
5309
|
+
_wal_checkpoint.after_commit(conn)
|
|
4856
5310
|
stats.files_processed += 1
|
|
4857
5311
|
# Browse-rail rollup: record the session_ids this file just
|
|
4858
5312
|
# committed so the post-walk recompute can scope its DELETE+INSERT
|
|
@@ -4958,11 +5412,16 @@ def sync_cache(
|
|
|
4958
5412
|
# completeness. A lock-contended sync returned early above and never
|
|
4959
5413
|
# reaches here. Presence (not the timestamp) is the gate signal; the
|
|
4960
5414
|
# value stores the completion instant for doctor/debugging.
|
|
5415
|
+
#
|
|
5416
|
+
# #901 §5.3c (W8): written only when absent, so a run of complete walks
|
|
5417
|
+
# writes nothing after the first; the value is therefore the instant
|
|
5418
|
+
# the current run of complete walks began. Any exit that does not reach
|
|
5419
|
+
# here withdraws the marker (``walk_marker_owed`` in ``finally``).
|
|
4961
5420
|
if walk_clean and applied_at_start and not targeted:
|
|
4962
5421
|
conn.execute(
|
|
4963
5422
|
"INSERT INTO cache_meta(key, value) "
|
|
4964
5423
|
"VALUES('claude_ingest_walk_complete', ?) "
|
|
4965
|
-
"ON CONFLICT(key) DO
|
|
5424
|
+
"ON CONFLICT(key) DO NOTHING",
|
|
4966
5425
|
(dt.datetime.now(dt.timezone.utc).isoformat(),),
|
|
4967
5426
|
)
|
|
4968
5427
|
# #195: the same clean-full-walk condition retires the split
|
|
@@ -4975,6 +5434,7 @@ def sync_cache(
|
|
|
4975
5434
|
)
|
|
4976
5435
|
conn.commit()
|
|
4977
5436
|
stats.full_walk_complete = True
|
|
5437
|
+
walk_marker_owed = False
|
|
4978
5438
|
# #279 S2 F1: rolling parse-health record. Anomaly-delta-gated so
|
|
4979
5439
|
# steady-state (incl. targeted live-tail) syncs stay zero-write;
|
|
4980
5440
|
# targeted syncs still accumulate — they ingest real new bytes.
|
|
@@ -4998,6 +5458,10 @@ def sync_cache(
|
|
|
4998
5458
|
# stalling the lock under heavy-reader contention.
|
|
4999
5459
|
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
5000
5460
|
finally:
|
|
5461
|
+
if walk_marker_owed:
|
|
5462
|
+
_withdraw_cache_meta_marker(conn, "claude_ingest_walk_complete")
|
|
5463
|
+
# #901 §5.3c (W9): the end of a cache sync is a checkpoint boundary.
|
|
5464
|
+
_wal_checkpoint.at_boundary(conn)
|
|
5001
5465
|
try:
|
|
5002
5466
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5003
5467
|
except OSError:
|
|
@@ -6907,7 +7371,8 @@ def _write_codex_backlog_record(
|
|
|
6907
7371
|
forward rather than re-stamped: re-minting it on every tick would keep the
|
|
6908
7372
|
age below an hour forever and the WARN would never fire.
|
|
6909
7373
|
"""
|
|
6910
|
-
|
|
7374
|
+
# #901 §5.3c (W8): an unchanged backlog is not written again.
|
|
7375
|
+
_set_cache_meta_if_changed(conn, _CODEX_BACKLOG_KEY, json.dumps({
|
|
6911
7376
|
"files": int(files),
|
|
6912
7377
|
"bytes": int(owed_bytes),
|
|
6913
7378
|
"since": since or dt.datetime.now(dt.timezone.utc).isoformat(
|
|
@@ -7145,6 +7610,7 @@ def sync_codex_cache(
|
|
|
7145
7610
|
quota_reconcile: str = "auto",
|
|
7146
7611
|
_on_first_file_rollback: Callable[[], None] | None = None,
|
|
7147
7612
|
_on_file_committed: Callable[[str], None] | None = None,
|
|
7613
|
+
_on_file_prepared: Callable[[str], None] | None = None,
|
|
7148
7614
|
) -> CodexIngestStats:
|
|
7149
7615
|
"""Read-through delta ingest of ~/.codex/sessions/**/*.jsonl.
|
|
7150
7616
|
|
|
@@ -7216,6 +7682,13 @@ def sync_codex_cache(
|
|
|
7216
7682
|
|
|
7217
7683
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
7218
7684
|
held_writer_flocks: list[int] = []
|
|
7685
|
+
# #901 §5.3c (W8): True while this call owes the full-walk marker a
|
|
7686
|
+
# verdict. Settled inside the finalization transaction; every other exit
|
|
7687
|
+
# withdraws a present marker in ``finally``, under the flocks.
|
|
7688
|
+
walk_marker_owed = False
|
|
7689
|
+
# True while the one finalization transaction is open: an exception then
|
|
7690
|
+
# keeps its completed steps, as their per-step commits used to.
|
|
7691
|
+
finalizing = False
|
|
7219
7692
|
try:
|
|
7220
7693
|
with _perf.phase("flock") as _p_flock:
|
|
7221
7694
|
acquired = acquire_cache_writer_flocks(
|
|
@@ -7230,6 +7703,8 @@ def sync_codex_cache(
|
|
|
7230
7703
|
eprint("[codex-cache] sync already in progress; using existing cache")
|
|
7231
7704
|
stats.lock_contended = True
|
|
7232
7705
|
return stats
|
|
7706
|
+
# #901 §5.3c (W9): a sync writer owns its checkpoints.
|
|
7707
|
+
_wal_checkpoint.arm(conn)
|
|
7233
7708
|
|
|
7234
7709
|
# #294 S7 targeted (only_paths) live-tail fast path (§5.1). Mutually
|
|
7235
7710
|
# exclusive with rebuild (matches the Claude rule). Targeted mode
|
|
@@ -7258,14 +7733,12 @@ def sync_codex_cache(
|
|
|
7258
7733
|
"sync_codex_cache: budget_seconds is incompatible with rebuild")
|
|
7259
7734
|
|
|
7260
7735
|
# As on the Claude side, this sentinel describes the latest exhaustive
|
|
7261
|
-
# pass
|
|
7262
|
-
#
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
)
|
|
7268
|
-
conn.commit()
|
|
7736
|
+
# pass: any full-pass decline, failed walk or exception withdraws it,
|
|
7737
|
+
# and only a walk whose every file and maintenance leg certifies clean
|
|
7738
|
+
# keeps it. #901 §5.3c (W8): the verdict is owed from here and settled
|
|
7739
|
+
# at the exit instead of retiring the marker with a delete-and-commit
|
|
7740
|
+
# here, which made every complete walk rewrite it.
|
|
7741
|
+
walk_marker_owed = not targeted
|
|
7269
7742
|
|
|
7270
7743
|
# A pending byte-zero replay is consumed HERE, not by the migration that
|
|
7271
7744
|
# armed it, so the rebuild path below captures `rebuild_known_identities`
|
|
@@ -7559,35 +8032,17 @@ def sync_codex_cache(
|
|
|
7559
8032
|
# with no on-disk JSONL to scope against; pruning it would wipe a
|
|
7560
8033
|
# cache meant to be read as-is (issue #108).
|
|
7561
8034
|
active_root_keys = {item.source_root_key for item in files}
|
|
7562
|
-
orphan_sources, orphan_root_keys = _collect_inactive_codex_paths_and_roots(
|
|
7563
|
-
conn, current_file_identities, active_root_keys,
|
|
7564
|
-
)
|
|
7565
8035
|
prune_scope = _codex_prune_scope(files)
|
|
7566
8036
|
ordinary_prune_scope = prune_scope
|
|
7567
|
-
|
|
7568
|
-
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
_refused_orphan_root_keys,
|
|
7572
|
-
) = _partition_codex_prune_candidates(
|
|
7573
|
-
prune_scope, orphan_sources, orphan_root_keys
|
|
7574
|
-
)
|
|
7575
|
-
retained_sources, _retained_root_keys = (
|
|
7576
|
-
_collect_retained_codex_paths_and_roots(conn)
|
|
7577
|
-
)
|
|
7578
|
-
retained_source_root_keys = {
|
|
7579
|
-
root_key
|
|
7580
|
-
for _path, root_key in retained_sources
|
|
7581
|
-
if root_key is not None
|
|
7582
|
-
}
|
|
7583
|
-
(
|
|
7584
|
-
_safe_retained_sources,
|
|
7585
|
-
refused_sources,
|
|
7586
|
-
_safe_retained_roots,
|
|
7587
|
-
refused_root_keys,
|
|
7588
|
-
) = _partition_codex_prune_candidates(
|
|
7589
|
-
prune_scope, retained_sources, retained_source_root_keys
|
|
8037
|
+
# #901 Amendment 20 W1: one retained collection serves both the
|
|
8038
|
+
# orphan partition and the protection partition.
|
|
8039
|
+
prune_plan = _plan_ordinary_codex_prune(
|
|
8040
|
+
conn, current_file_identities, active_root_keys, prune_scope,
|
|
7590
8041
|
)
|
|
8042
|
+
safe_sources = prune_plan.safe_sources
|
|
8043
|
+
safe_root_keys = prune_plan.safe_root_keys
|
|
8044
|
+
refused_sources = prune_plan.refused_sources
|
|
8045
|
+
refused_root_keys = prune_plan.refused_root_keys
|
|
7591
8046
|
protected_paths = {path for path, _root in refused_sources}
|
|
7592
8047
|
if safe_sources or safe_root_keys:
|
|
7593
8048
|
before_prune = conn.total_changes
|
|
@@ -7779,12 +8234,282 @@ def sync_codex_cache(
|
|
|
7779
8234
|
# "first sight wins and the anchor never moves" across sync boundaries,
|
|
7780
8235
|
# not just within one.
|
|
7781
8236
|
anchor_resolver = CodexResetAnchorResolver(conn)
|
|
8237
|
+
# #901 §5.3c (W11, Q14): every call without the hook's budget prepares
|
|
8238
|
+
# its delta with no write transaction open and applies it in one
|
|
8239
|
+
# transaction, each file in its own savepoint, at most
|
|
8240
|
+
# CODEX_ACCOUNTING_BATCH_FILES files and CODEX_ACCOUNTING_BATCH_BYTES
|
|
8241
|
+
# of new input per transaction; the last batch shares W8's
|
|
8242
|
+
# finalization transaction and its one COMMIT. The budgeted (hook)
|
|
8243
|
+
# path keeps today's per-file commits and continuation exactly.
|
|
8244
|
+
w11 = deadline is None and _CODEX_W11_ENABLED
|
|
8245
|
+
w11_eager = w11 and _CODEX_W11_EAGER_PREPARE_FOR_TESTS
|
|
8246
|
+
w11_batch: "list[_CodexPreparedFile]" = []
|
|
8247
|
+
# The applied files of the batch that rides the finalization
|
|
8248
|
+
# transaction: published only after its COMMIT.
|
|
8249
|
+
w11_deferred: "list[_CodexPreparedFile]" = []
|
|
8250
|
+
# Attribution decisions this call journaled for a file it had to
|
|
8251
|
+
# prepare again: the durable decision is reused, never re-decided.
|
|
8252
|
+
w11_journaled: "dict[tuple[str, int], tuple[int, str | None]]" = {}
|
|
8253
|
+
# Whole-transaction losses per file, so a persistent one ends.
|
|
8254
|
+
w11_losses: "dict[str, int]" = {}
|
|
8255
|
+
walk_queue = collections.deque(enumerate(walk))
|
|
8256
|
+
|
|
8257
|
+
def _w11_batch_bytes() -> int:
|
|
8258
|
+
return sum(item.new_input for item in w11_batch)
|
|
8259
|
+
|
|
8260
|
+
def _w11_unfold(prepared: _CodexPreparedFile) -> None:
|
|
8261
|
+
"""Take a prepared file's parse counters back out of the stats."""
|
|
8262
|
+
stats.lines_seen -= prepared.lines_seen
|
|
8263
|
+
stats.lines_malformed -= prepared.lines_malformed
|
|
8264
|
+
stats.token_events_skipped -= prepared.token_events_skipped
|
|
8265
|
+
for reason, count in prepared.skip_reasons.items():
|
|
8266
|
+
left = stats.skip_reasons.get(reason, 0) - count
|
|
8267
|
+
if left > 0:
|
|
8268
|
+
stats.skip_reasons[reason] = left
|
|
8269
|
+
else:
|
|
8270
|
+
stats.skip_reasons.pop(reason, None)
|
|
8271
|
+
|
|
8272
|
+
def _w11_publish(applied: "list[_CodexPreparedFile]") -> None:
|
|
8273
|
+
"""After a COMMIT: release the batch, then the publication
|
|
8274
|
+
callbacks the per-file path ran after each file's commit."""
|
|
8275
|
+
paths = [item.path_str for item in applied]
|
|
8276
|
+
_codex_w11_trace("commit", paths)
|
|
8277
|
+
_codex_w11_trace(
|
|
8278
|
+
"release", paths, sum(item.new_input for item in applied))
|
|
8279
|
+
_wal_checkpoint.after_commit(conn)
|
|
8280
|
+
for item in applied:
|
|
8281
|
+
if _on_file_committed is not None:
|
|
8282
|
+
_on_file_committed(item.path_str)
|
|
8283
|
+
if applied and progress is not None:
|
|
8284
|
+
progress(stats)
|
|
8285
|
+
|
|
8286
|
+
def _w11_apply(batch: "list[_CodexPreparedFile]", *,
|
|
8287
|
+
final: bool) -> "list[tuple[int, Any]]":
|
|
8288
|
+
"""Apply one prepared batch; return the walk entries to prepare
|
|
8289
|
+
again. ``final`` leaves the transaction open for W8's
|
|
8290
|
+
finalization unless a file failed."""
|
|
8291
|
+
applied: "list[_CodexPreparedFile]" = []
|
|
8292
|
+
requeue: "list[tuple[int, Any]]" = []
|
|
8293
|
+
index = 0
|
|
8294
|
+
first_attempt_failed = False
|
|
8295
|
+
while index < len(batch):
|
|
8296
|
+
prepared = batch[index]
|
|
8297
|
+
if not conn.in_transaction:
|
|
8298
|
+
conn.execute("BEGIN")
|
|
8299
|
+
conn.execute(_CODEX_W11_FILE_SAVEPOINT)
|
|
8300
|
+
anchor_resolver.restore_pending_merges(prepared.merges)
|
|
8301
|
+
try:
|
|
8302
|
+
file_rows_changed = _write_codex_file_batch(
|
|
8303
|
+
conn, anchor_resolver=anchor_resolver, commit=False,
|
|
8304
|
+
**prepared.write_kwargs)
|
|
8305
|
+
except sqlite3.DatabaseError as exc:
|
|
8306
|
+
if _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
8307
|
+
raise
|
|
8308
|
+
if not conn.in_transaction:
|
|
8309
|
+
# SQLite ended the whole transaction on its own: every
|
|
8310
|
+
# file applied in it is undone. Prepare them all again,
|
|
8311
|
+
# as a crash before the commit would leave them; a file
|
|
8312
|
+
# that ends a transaction twice fails alone.
|
|
8313
|
+
losses = w11_losses.get(prepared.path_str, 0) + 1
|
|
8314
|
+
w11_losses[prepared.path_str] = losses
|
|
8315
|
+
# OV-2: files an earlier final apply deferred (the
|
|
8316
|
+
# eager seam only) were in this transaction too.
|
|
8317
|
+
undone = list(w11_deferred) + applied
|
|
8318
|
+
for item in undone:
|
|
8319
|
+
stats.rows_changed -= item.rows_changed
|
|
8320
|
+
stats.files_processed -= 1
|
|
8321
|
+
if item.reset_counted:
|
|
8322
|
+
stats.files_reset_truncated -= 1
|
|
8323
|
+
committed_state.pop(item.path_str, None)
|
|
8324
|
+
for item in w11_deferred:
|
|
8325
|
+
_w11_unfold(item)
|
|
8326
|
+
requeue.append((item.walk_index, item.discovered))
|
|
8327
|
+
_codex_w11_trace("prepare_drop", item.path_str)
|
|
8328
|
+
w11_deferred.clear()
|
|
8329
|
+
for item in batch:
|
|
8330
|
+
if item is prepared and losses >= 2:
|
|
8331
|
+
eprint(f"[codex-cache] db error on "
|
|
8332
|
+
f"{prepared.discovered.source_path}: "
|
|
8333
|
+
f"{exc}")
|
|
8334
|
+
stats.files_failed += 1
|
|
8335
|
+
else:
|
|
8336
|
+
_w11_unfold(item)
|
|
8337
|
+
requeue.append(
|
|
8338
|
+
(item.walk_index, item.discovered))
|
|
8339
|
+
_codex_w11_trace("prepare_drop", item.path_str)
|
|
8340
|
+
anchor_resolver.discard_uncommitted_file()
|
|
8341
|
+
return requeue
|
|
8342
|
+
conn.execute(_CODEX_W11_FILE_ROLLBACK_TO)
|
|
8343
|
+
conn.execute(_CODEX_W11_FILE_RELEASE)
|
|
8344
|
+
if not first_attempt_failed:
|
|
8345
|
+
first_attempt_failed = True
|
|
8346
|
+
# Private test seam, as on the per-file path: after
|
|
8347
|
+
# the failed file rolled back, before its one retry.
|
|
8348
|
+
if _on_first_file_rollback is not None:
|
|
8349
|
+
_on_first_file_rollback()
|
|
8350
|
+
continue
|
|
8351
|
+
eprint(f"[codex-cache] db error on "
|
|
8352
|
+
f"{prepared.discovered.source_path}: {exc}")
|
|
8353
|
+
stats.files_failed += 1
|
|
8354
|
+
anchor_resolver.discard_uncommitted_file()
|
|
8355
|
+
_codex_w11_trace("prepare_drop", prepared.path_str)
|
|
8356
|
+
# The files prepared after it used its anchor evidence;
|
|
8357
|
+
# a per-file walk would have prepared them without it.
|
|
8358
|
+
for item in batch[index + 1:]:
|
|
8359
|
+
_w11_unfold(item)
|
|
8360
|
+
requeue.append((item.walk_index, item.discovered))
|
|
8361
|
+
_codex_w11_trace("prepare_drop", item.path_str)
|
|
8362
|
+
break
|
|
8363
|
+
except BaseException:
|
|
8364
|
+
# Leave only whole files in the transaction.
|
|
8365
|
+
if conn.in_transaction:
|
|
8366
|
+
try:
|
|
8367
|
+
conn.execute(_CODEX_W11_FILE_ROLLBACK_TO)
|
|
8368
|
+
conn.execute(_CODEX_W11_FILE_RELEASE)
|
|
8369
|
+
except sqlite3.DatabaseError:
|
|
8370
|
+
pass
|
|
8371
|
+
raise
|
|
8372
|
+
conn.execute(_CODEX_W11_FILE_RELEASE)
|
|
8373
|
+
anchor_resolver.mark_file_committed()
|
|
8374
|
+
first_attempt_failed = False
|
|
8375
|
+
prepared.rows_changed = file_rows_changed
|
|
8376
|
+
stats.rows_changed += file_rows_changed
|
|
8377
|
+
if prepared.reset_counted:
|
|
8378
|
+
stats.files_reset_truncated += 1
|
|
8379
|
+
stats.files_processed += 1
|
|
8380
|
+
committed_state[prepared.path_str] = (
|
|
8381
|
+
prepared.scan_target, prepared.final_offset,
|
|
8382
|
+
not prepared.stopped_short)
|
|
8383
|
+
if not rebuild:
|
|
8384
|
+
if prepared.accounting_rows:
|
|
8385
|
+
for _extreme in (
|
|
8386
|
+
min(_r[2] for _r in prepared.accounting_rows),
|
|
8387
|
+
max(_r[2] for _r in prepared.accounting_rows),
|
|
8388
|
+
):
|
|
8389
|
+
_extend_codex_touched_span(
|
|
8390
|
+
adoption_spans,
|
|
8391
|
+
prepared.discovered.source_root_key,
|
|
8392
|
+
_parse_anchor_iso(_extreme))
|
|
8393
|
+
for _qrow in prepared.quota_rows:
|
|
8394
|
+
_extend_codex_touched_span(
|
|
8395
|
+
adoption_spans, _qrow[1],
|
|
8396
|
+
_parse_anchor_iso(_qrow[17]))
|
|
8397
|
+
applied.append(prepared)
|
|
8398
|
+
index += 1
|
|
8399
|
+
if final and not requeue:
|
|
8400
|
+
if applied and conn.in_transaction:
|
|
8401
|
+
conn.execute(_CODEX_W11_DEFERRED_SAVEPOINT)
|
|
8402
|
+
w11_deferred.extend(applied)
|
|
8403
|
+
return requeue
|
|
8404
|
+
if conn.in_transaction:
|
|
8405
|
+
_codex_batch_precommit()
|
|
8406
|
+
conn.commit()
|
|
8407
|
+
# OV-2: this commit also carried any files an earlier final apply
|
|
8408
|
+
# deferred (the eager seam only); they are published with it.
|
|
8409
|
+
committed_now = list(w11_deferred) + applied
|
|
8410
|
+
w11_deferred.clear()
|
|
8411
|
+
_w11_publish(committed_now)
|
|
8412
|
+
return requeue
|
|
8413
|
+
|
|
8414
|
+
def _w11_confirm_deferred() -> "list[_CodexPreparedFile]":
|
|
8415
|
+
"""The last batch's files withdrawn because they are no longer in
|
|
8416
|
+
the finalization transaction (901-QI-001); empty when they are.
|
|
8417
|
+
|
|
8418
|
+
A finalization step can lose the whole transaction to SQLite (the
|
|
8419
|
+
parse-health write and the best-effort maintenance stages swallow
|
|
8420
|
+
the error), and the next step then opens a new one. The deferred
|
|
8421
|
+
files' rows and cursors are gone with it, so they are withdrawn
|
|
8422
|
+
as a crash before the commit would leave them, exactly as
|
|
8423
|
+
`_w11_apply` withdraws a batch whose transaction ended: no
|
|
8424
|
+
statistics, no committed state, no publication and no certified
|
|
8425
|
+
walk. Each one counts as a failed file, so no caller reads the
|
|
8426
|
+
sync as clean (`targeted_clean`, the frontier's certificate); the
|
|
8427
|
+
next sync ingests them again. Whatever the later steps wrote in the
|
|
8428
|
+
new transaction is rolled back too: the caller runs the
|
|
8429
|
+
finalization again without the withdrawn files (901-QI-002)."""
|
|
8430
|
+
if not w11_deferred:
|
|
8431
|
+
return []
|
|
8432
|
+
try:
|
|
8433
|
+
conn.execute(_CODEX_W11_DEFERRED_RELEASE)
|
|
8434
|
+
return []
|
|
8435
|
+
except sqlite3.DatabaseError as exc:
|
|
8436
|
+
if _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
8437
|
+
raise
|
|
8438
|
+
if conn.in_transaction:
|
|
8439
|
+
conn.rollback()
|
|
8440
|
+
withdrawn = list(w11_deferred)
|
|
8441
|
+
for item in withdrawn:
|
|
8442
|
+
stats.rows_changed -= item.rows_changed
|
|
8443
|
+
stats.files_processed -= 1
|
|
8444
|
+
if item.reset_counted:
|
|
8445
|
+
stats.files_reset_truncated -= 1
|
|
8446
|
+
committed_state.pop(item.path_str, None)
|
|
8447
|
+
_w11_unfold(item)
|
|
8448
|
+
eprint(f"[codex-cache] db error on "
|
|
8449
|
+
f"{item.discovered.source_path}: the finalization "
|
|
8450
|
+
"transaction was lost")
|
|
8451
|
+
stats.files_failed += 1
|
|
8452
|
+
_codex_w11_trace("prepare_drop", item.path_str)
|
|
8453
|
+
w11_deferred.clear()
|
|
8454
|
+
return withdrawn
|
|
8455
|
+
|
|
8456
|
+
def _w11_flush(*, final: bool) -> "list[tuple[int, Any]]":
|
|
8457
|
+
"""Apply the prepared batch (bounded slices under the eager seam)
|
|
8458
|
+
and forget its prepared records."""
|
|
8459
|
+
batch = list(w11_batch)
|
|
8460
|
+
w11_batch.clear()
|
|
8461
|
+
if not batch:
|
|
8462
|
+
return []
|
|
8463
|
+
if not w11_eager:
|
|
8464
|
+
return _w11_apply(batch, final=final)
|
|
8465
|
+
# The eager seam: every file was prepared before the first commit.
|
|
8466
|
+
# It commits bounded slices but releases nothing until the last.
|
|
8467
|
+
slices: "list[list[_CodexPreparedFile]]" = [[]]
|
|
8468
|
+
for item in batch:
|
|
8469
|
+
current = slices[-1]
|
|
8470
|
+
if current and (
|
|
8471
|
+
len(current) >= CODEX_ACCOUNTING_BATCH_FILES
|
|
8472
|
+
or sum(x.new_input for x in current) + item.new_input
|
|
8473
|
+
> CODEX_ACCOUNTING_BATCH_BYTES
|
|
8474
|
+
):
|
|
8475
|
+
slices.append([])
|
|
8476
|
+
slices[-1].append(item)
|
|
8477
|
+
saved_trace = _CODEX_W11_TRACE
|
|
8478
|
+
requeue: "list[tuple[int, Any]]" = []
|
|
8479
|
+
for number, chunk in enumerate(slices):
|
|
8480
|
+
last = number == len(slices) - 1
|
|
8481
|
+
globals()["_CODEX_W11_TRACE"] = None
|
|
8482
|
+
try:
|
|
8483
|
+
requeue += _w11_apply(chunk, final=final and last)
|
|
8484
|
+
finally:
|
|
8485
|
+
globals()["_CODEX_W11_TRACE"] = saved_trace
|
|
8486
|
+
if not (final and last):
|
|
8487
|
+
_codex_w11_trace("commit", [x.path_str for x in chunk])
|
|
8488
|
+
if not final:
|
|
8489
|
+
_codex_w11_trace(
|
|
8490
|
+
"release", [x.path_str for x in batch],
|
|
8491
|
+
sum(x.new_input for x in batch))
|
|
8492
|
+
return requeue
|
|
8493
|
+
|
|
8494
|
+
def _walk_items():
|
|
8495
|
+
"""The walk, refillable: a W11 batch that has to prepare files
|
|
8496
|
+
again puts them back in walk order."""
|
|
8497
|
+
while True:
|
|
8498
|
+
while walk_queue:
|
|
8499
|
+
yield walk_queue.popleft()
|
|
8500
|
+
if not (w11 and w11_batch):
|
|
8501
|
+
return
|
|
8502
|
+
requeue = _w11_flush(final=True)
|
|
8503
|
+
if not requeue:
|
|
8504
|
+
return
|
|
8505
|
+
walk_queue.extend(requeue)
|
|
8506
|
+
|
|
7782
8507
|
# #279 S2 F4: ONE coarse `walk` phase bracketing the per-file loop
|
|
7783
8508
|
# (count = files_processed, never per-row — §2 rule). Manual CM so
|
|
7784
8509
|
# the loop stays flat, mirroring sync_cache's walk seam.
|
|
7785
8510
|
_p_walk = _perf.phase("walk")
|
|
7786
8511
|
_p_walk.__enter__()
|
|
7787
|
-
for _walk_index, discovered in
|
|
8512
|
+
for _walk_index, discovered in _walk_items():
|
|
7788
8513
|
# The budget is checked BEFORE a file is opened, so a tick either
|
|
7789
8514
|
# commits a file whole (or to a recorded partial offset) or does not
|
|
7790
8515
|
# touch it at all.
|
|
@@ -7935,6 +8660,24 @@ def sync_codex_cache(
|
|
|
7935
8660
|
initial_total_tokens = 0
|
|
7936
8661
|
prev_total_tokens = None
|
|
7937
8662
|
|
|
8663
|
+
# #901 §5.3c (W11): a file's new input is known before it is read.
|
|
8664
|
+
# Preparation stops before the file that would take the batch past
|
|
8665
|
+
# either bound; that batch is applied and committed, and
|
|
8666
|
+
# preparation resumes with this file. A file whose own new input
|
|
8667
|
+
# exceeds the byte bound becomes a batch of its own.
|
|
8668
|
+
new_input = max(0, int(scan_target) - int(start_offset))
|
|
8669
|
+
if w11 and not w11_eager and w11_batch and (
|
|
8670
|
+
len(w11_batch) >= CODEX_ACCOUNTING_BATCH_FILES
|
|
8671
|
+
or _w11_batch_bytes() + new_input
|
|
8672
|
+
> CODEX_ACCOUNTING_BATCH_BYTES
|
|
8673
|
+
):
|
|
8674
|
+
requeue = _w11_flush(final=False)
|
|
8675
|
+
walk_queue.extendleft(
|
|
8676
|
+
reversed(requeue + [(_walk_index, discovered)]))
|
|
8677
|
+
continue
|
|
8678
|
+
if w11:
|
|
8679
|
+
_codex_w11_trace("prepare_start", path_str, new_input)
|
|
8680
|
+
|
|
7938
8681
|
# #416 spec §3: attribution is DECIDED ONCE at first ingest of a byte
|
|
7939
8682
|
# range, journaled durably, and thereafter only REPLAYED. The live
|
|
7940
8683
|
# auth.json is an input to that decision, never a source consulted
|
|
@@ -7954,6 +8697,17 @@ def sync_codex_cache(
|
|
|
7954
8697
|
)
|
|
7955
8698
|
account_ranges = load_codex_file_account_ranges(
|
|
7956
8699
|
conn, file_identity, incarnation)
|
|
8700
|
+
# W11: a decision this call already journaled for a file it is
|
|
8701
|
+
# preparing again (its batch had to be redone) is durable truth
|
|
8702
|
+
# that cache.db does not hold yet: replay it, never re-decide.
|
|
8703
|
+
memo_decision = (
|
|
8704
|
+
w11_journaled.get((file_identity, incarnation)) if w11
|
|
8705
|
+
else None)
|
|
8706
|
+
if memo_decision is not None and memo_decision in account_ranges:
|
|
8707
|
+
memo_decision = None
|
|
8708
|
+
if memo_decision is not None:
|
|
8709
|
+
account_ranges = sorted(
|
|
8710
|
+
account_ranges + [memo_decision], key=lambda r: r[0])
|
|
7957
8711
|
covered, decided_key = codex_account_for_offset(
|
|
7958
8712
|
account_ranges, start_offset)
|
|
7959
8713
|
pending_decision: "tuple[int, str | None] | None" = None
|
|
@@ -8006,6 +8760,8 @@ def sync_codex_cache(
|
|
|
8006
8760
|
stats.files_deferred_torn += 1
|
|
8007
8761
|
if targeted:
|
|
8008
8762
|
stats.files_failed += 1
|
|
8763
|
+
if w11:
|
|
8764
|
+
_codex_w11_trace("prepare_drop", path_str)
|
|
8009
8765
|
continue
|
|
8010
8766
|
if root_account.account_key != decided_key:
|
|
8011
8767
|
file_account_key = root_account.account_key
|
|
@@ -8053,6 +8809,8 @@ def sync_codex_cache(
|
|
|
8053
8809
|
stats.files_deferred_torn += 1
|
|
8054
8810
|
if targeted:
|
|
8055
8811
|
stats.files_failed += 1 # §5.1 deferred → call dirty
|
|
8812
|
+
if w11:
|
|
8813
|
+
_codex_w11_trace("prepare_drop", path_str)
|
|
8056
8814
|
continue
|
|
8057
8815
|
file_account_key = root_account.account_key
|
|
8058
8816
|
pending_decision = (start_offset, file_account_key)
|
|
@@ -8248,6 +9006,13 @@ def sync_codex_cache(
|
|
|
8248
9006
|
stats.skip_reasons[_r] = stats.skip_reasons.get(_r, 0) + _n
|
|
8249
9007
|
except OSError as exc:
|
|
8250
9008
|
eprint(f"[codex-cache] could not read {jp}: {exc}")
|
|
9009
|
+
if w11:
|
|
9010
|
+
_codex_w11_trace("prepare_drop", path_str)
|
|
9011
|
+
if w11_batch:
|
|
9012
|
+
# A per-file walk re-seeds the resolver from rows that
|
|
9013
|
+
# include every earlier file, so apply them first.
|
|
9014
|
+
walk_queue.extendleft(
|
|
9015
|
+
reversed(_w11_flush(final=False)))
|
|
8251
9016
|
anchor_resolver.discard_uncommitted_file()
|
|
8252
9017
|
stats.files_failed += 1
|
|
8253
9018
|
continue
|
|
@@ -8305,7 +9070,11 @@ def sync_codex_cache(
|
|
|
8305
9070
|
# CLOSED. If the append cannot be made durable, the file is deferred
|
|
8306
9071
|
# with zero mutations — a committed batch behind a lost decision
|
|
8307
9072
|
# would be permanently un-replayable.
|
|
8308
|
-
|
|
9073
|
+
decision_journaled = False
|
|
9074
|
+
if pending_decision is None and memo_decision is not None:
|
|
9075
|
+
pending_decision = memo_decision
|
|
9076
|
+
decision_journaled = True
|
|
9077
|
+
if pending_decision is not None and not decision_journaled:
|
|
8309
9078
|
decision_offset, decision_key = pending_decision
|
|
8310
9079
|
try:
|
|
8311
9080
|
_append_codex_file_account_decision(
|
|
@@ -8321,9 +9090,17 @@ def sync_codex_cache(
|
|
|
8321
9090
|
eprint(
|
|
8322
9091
|
f"[codex-cache] attribution decision journal append "
|
|
8323
9092
|
f"failed for {jp}: {exc}; deferring the file")
|
|
9093
|
+
if w11:
|
|
9094
|
+
_codex_w11_trace("prepare_drop", path_str)
|
|
9095
|
+
if w11_batch:
|
|
9096
|
+
walk_queue.extendleft(
|
|
9097
|
+
reversed(_w11_flush(final=False)))
|
|
8324
9098
|
stats.files_failed += 1
|
|
8325
9099
|
anchor_resolver.discard_uncommitted_file()
|
|
8326
9100
|
continue
|
|
9101
|
+
if w11:
|
|
9102
|
+
w11_journaled[(file_identity, incarnation)] = (
|
|
9103
|
+
pending_decision)
|
|
8327
9104
|
|
|
8328
9105
|
# Task 7 Item 1: journal the Codex quota observations BEFORE the cache
|
|
8329
9106
|
# write (and before the offset advances), under the codex flock this
|
|
@@ -8335,6 +9112,65 @@ def sync_codex_cache(
|
|
|
8335
9112
|
anchor_resolver.normalize_quota_rows(quota_rows)
|
|
8336
9113
|
_append_codex_quota_obs(quota_rows)
|
|
8337
9114
|
|
|
9115
|
+
if w11:
|
|
9116
|
+
# W11: the file is prepared. Its rows wait for its batch;
|
|
9117
|
+
# nothing is written while later files are read.
|
|
9118
|
+
w11_batch.append(_CodexPreparedFile(
|
|
9119
|
+
walk_index=_walk_index,
|
|
9120
|
+
discovered=discovered,
|
|
9121
|
+
path_str=path_str,
|
|
9122
|
+
new_input=new_input,
|
|
9123
|
+
write_kwargs=dict(
|
|
9124
|
+
discovered=discovered,
|
|
9125
|
+
path_str=path_str,
|
|
9126
|
+
size=scan_target,
|
|
9127
|
+
mtime_ns=mtime_ns,
|
|
9128
|
+
final_offset=final_offset,
|
|
9129
|
+
last_session_id=new_last_session_id,
|
|
9130
|
+
last_model=new_last_model,
|
|
9131
|
+
last_total_tokens=new_last_total_tokens,
|
|
9132
|
+
last_native_thread_id=new_last_native_thread_id,
|
|
9133
|
+
last_root_thread_id=new_last_root_thread_id,
|
|
9134
|
+
last_parent_thread_id=new_last_parent_thread_id,
|
|
9135
|
+
last_conversation_key=new_last_conversation_key,
|
|
9136
|
+
last_turn_id=new_last_turn_id,
|
|
9137
|
+
reset_file=truncated or requalified,
|
|
9138
|
+
accounting_rows=accounting_rows,
|
|
9139
|
+
quota_rows=quota_rows,
|
|
9140
|
+
thread_rows=thread_rows,
|
|
9141
|
+
active_root_keys={
|
|
9142
|
+
item.source_root_key for item in files},
|
|
9143
|
+
prune_roots=not targeted,
|
|
9144
|
+
account_key=file_account_key,
|
|
9145
|
+
file_identity=file_identity,
|
|
9146
|
+
incarnation=incarnation,
|
|
9147
|
+
file_account_decision=pending_decision,
|
|
9148
|
+
ingest_complete=not stopped_short["value"],
|
|
9149
|
+
device_id=st.st_dev,
|
|
9150
|
+
inode=st.st_ino,
|
|
9151
|
+
),
|
|
9152
|
+
merges=anchor_resolver.take_pending_merges(),
|
|
9153
|
+
reset_counted=bool(truncated or requalified),
|
|
9154
|
+
scan_target=scan_target,
|
|
9155
|
+
final_offset=final_offset,
|
|
9156
|
+
stopped_short=bool(stopped_short["value"]),
|
|
9157
|
+
accounting_rows=accounting_rows,
|
|
9158
|
+
quota_rows=quota_rows,
|
|
9159
|
+
lines_seen=iter_state.lines_seen,
|
|
9160
|
+
lines_malformed=iter_state.lines_malformed,
|
|
9161
|
+
token_events_skipped=iter_state.token_events_skipped,
|
|
9162
|
+
skip_reasons=dict(iter_state.skip_reasons),
|
|
9163
|
+
))
|
|
9164
|
+
_codex_w11_trace("prepare_end", path_str, new_input)
|
|
9165
|
+
# Private test seam: after a file is prepared and before the
|
|
9166
|
+
# next file's preparation, which under W11 is where a later
|
|
9167
|
+
# target's turn begins (its commit comes with its batch's).
|
|
9168
|
+
if _on_file_prepared is not None:
|
|
9169
|
+
_on_file_prepared(path_str)
|
|
9170
|
+
continue
|
|
9171
|
+
|
|
9172
|
+
if _on_file_prepared is not None:
|
|
9173
|
+
_on_file_prepared(path_str)
|
|
8338
9174
|
# Every derived row above was buffered before the first DML. A
|
|
8339
9175
|
# late database failure therefore rolls the whole file back and
|
|
8340
9176
|
# retries that same in-memory batch exactly once.
|
|
@@ -8413,6 +9249,8 @@ def sync_codex_cache(
|
|
|
8413
9249
|
anchor_resolver.discard_uncommitted_file()
|
|
8414
9250
|
continue
|
|
8415
9251
|
anchor_resolver.mark_file_committed()
|
|
9252
|
+
# #901 §5.3c (W9): the size trigger is evaluated after each commit.
|
|
9253
|
+
_wal_checkpoint.after_commit(conn)
|
|
8416
9254
|
committed_state[path_str] = (
|
|
8417
9255
|
scan_target, final_offset, not stopped_short["value"])
|
|
8418
9256
|
if stopped_short["value"]:
|
|
@@ -8446,7 +9284,7 @@ def sync_codex_cache(
|
|
|
8446
9284
|
if progress is not None:
|
|
8447
9285
|
progress(stats)
|
|
8448
9286
|
|
|
8449
|
-
if progress is not None:
|
|
9287
|
+
if progress is not None and not w11:
|
|
8450
9288
|
progress(stats)
|
|
8451
9289
|
_p_walk.__exit__(None, None, None)
|
|
8452
9290
|
# public #5: did this walk actually reach every discovered file? The
|
|
@@ -8473,242 +9311,275 @@ def sync_codex_cache(
|
|
|
8473
9311
|
# Runs for every whole-tree sync, budgeted or not: an explicit
|
|
8474
9312
|
# `cache-sync` completes the walk and must therefore CLEAR the backlog
|
|
8475
9313
|
# it just drained, or doctor and the dashboard would keep reporting it.
|
|
8476
|
-
if not targeted:
|
|
8477
|
-
# Two different questions. The CURSOR asks "where does the next
|
|
8478
|
-
# tick resume", which is where the overall deadline landed:
|
|
8479
|
-
# `partial_file` (the last stop-short) if there is one, else the
|
|
8480
|
-
# first file the walk never opened. The BACKLOG asks "what is still
|
|
8481
|
-
# owed", which is every stop-short plus everything unwalked.
|
|
8482
|
-
cursor_head = (
|
|
8483
|
-
partial_file if partial_file is not None
|
|
8484
|
-
else (unwalked[0] if unwalked else None))
|
|
8485
|
-
backlog_files, backlog_bytes = _codex_backlog_after(
|
|
8486
|
-
partial_files + unwalked, existing, committed_state)
|
|
8487
|
-
stats.backlog_files = backlog_files
|
|
8488
|
-
stats.backlog_bytes = backlog_bytes
|
|
8489
|
-
if cursor_head is not None:
|
|
8490
|
-
head = cursor_head
|
|
8491
|
-
ordinal = 0
|
|
8492
|
-
head_key = _codex_walk_key(head)
|
|
8493
|
-
for index, item in enumerate(files):
|
|
8494
|
-
# Qualified by root, exactly like the cursor it records: two
|
|
8495
|
-
# configured roots can resolve to the same canonical path,
|
|
8496
|
-
# and matching on the path alone would then store the wrong
|
|
8497
|
-
# ordinal for the fallback.
|
|
8498
|
-
if _codex_walk_key(item) == head_key:
|
|
8499
|
-
ordinal = index
|
|
8500
|
-
break
|
|
8501
|
-
_set_cache_meta(conn, _CODEX_RESUME_CURSOR_KEY, json.dumps({
|
|
8502
|
-
"root_key": head.source_root_key,
|
|
8503
|
-
"path": str(head.physical_path),
|
|
8504
|
-
"ordinal": ordinal,
|
|
8505
|
-
}, sort_keys=True))
|
|
8506
|
-
else:
|
|
8507
|
-
# A complete cycle: the next tick starts at the top again.
|
|
8508
|
-
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
8509
|
-
(_CODEX_RESUME_CURSOR_KEY,))
|
|
8510
|
-
if backlog_files:
|
|
8511
|
-
prior = _load_codex_backlog_record(conn)
|
|
8512
|
-
_write_codex_backlog_record(
|
|
8513
|
-
conn, files=backlog_files, owed_bytes=backlog_bytes,
|
|
8514
|
-
since=None if prior is None else prior.get("since"))
|
|
8515
|
-
else:
|
|
8516
|
-
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
8517
|
-
(_CODEX_BACKLOG_KEY,))
|
|
8518
|
-
conn.commit()
|
|
8519
|
-
# #279 S2 F1: rolling parse-health record (codex half). Same
|
|
8520
|
-
# anomaly-delta gate as the Claude tail; the global writer flock
|
|
8521
|
-
# excludes a concurrent Claude sync.
|
|
8522
|
-
_update_parse_health_meta(
|
|
8523
|
-
conn, "parse_health_codex",
|
|
8524
|
-
lines_seen=stats.lines_seen,
|
|
8525
|
-
lines_malformed=stats.lines_malformed,
|
|
8526
|
-
lines_skipped=stats.token_events_skipped,
|
|
8527
|
-
skip_reasons=stats.skip_reasons,
|
|
8528
|
-
rebuild=rebuild,
|
|
8529
|
-
)
|
|
8530
|
-
# #416 fix-round review B4: make a persistently torn `auth.json`
|
|
8531
|
-
# VISIBLE. The defer itself is correct (spec §3.6 stable-read protocol
|
|
8532
|
-
# — never guess an account), but since a growing DECIDED file also
|
|
8533
|
-
# consults auth.json, a truncated/half-written auth.json now halts every
|
|
8534
|
-
# rollout under that root, not just the never-decided ones. `cache-sync`
|
|
8535
|
-
# still exits 0, so without a durable record the operator sees Codex
|
|
8536
|
-
# spend and quota silently freeze. This marker is what `doctor` reads.
|
|
8537
|
-
#
|
|
8538
|
-
# Whole-tree syncs only: a targeted (`only_paths`) call looks at a
|
|
8539
|
-
# handful of files, so its zero deferral count says nothing about the
|
|
8540
|
-
# rest of the tree and must never clear the marker.
|
|
8541
|
-
#
|
|
8542
|
-
# NOT R8-gated. This is a health signal, not account decoration — it
|
|
8543
|
-
# names no account and adds no per-account column, the same carve-out
|
|
8544
|
-
# `alerts.log`'s runtime state has (docs/accounts-gotchas.md).
|
|
8545
9314
|
#
|
|
8546
|
-
#
|
|
8547
|
-
#
|
|
8548
|
-
#
|
|
8549
|
-
#
|
|
8550
|
-
#
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
|
|
8554
|
-
|
|
8555
|
-
|
|
8556
|
-
|
|
8557
|
-
|
|
8558
|
-
|
|
8559
|
-
|
|
8560
|
-
|
|
8561
|
-
|
|
8562
|
-
|
|
8563
|
-
|
|
8564
|
-
|
|
8565
|
-
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
|
|
9315
|
+
# #901 §5.3c (W8): from here to the completion marker is ONE
|
|
9316
|
+
# finalization transaction, committed once at the end: the resume and
|
|
9317
|
+
# backlog state, parse health, the torn/replay/prune-refusal markers,
|
|
9318
|
+
# the ordered maintenance stages (each in its own savepoint, keeping its
|
|
9319
|
+
# recoverable-failure behaviour) and the completion marker. A value that
|
|
9320
|
+
# is already stored is not written again. Success notes are printed
|
|
9321
|
+
# only after that commit.
|
|
9322
|
+
finalization_notes: list[str] = []
|
|
9323
|
+
finalizing = True
|
|
9324
|
+
|
|
9325
|
+
def _finalization_steps(
|
|
9326
|
+
walk_complete: bool,
|
|
9327
|
+
withdrawn: "list[_CodexPreparedFile]",
|
|
9328
|
+
) -> None:
|
|
9329
|
+
"""The finalization steps up to the completion marker. Run
|
|
9330
|
+
once, and again without the last batch when it was withdrawn
|
|
9331
|
+
(901-QI-002): ``withdrawn`` files are owed, and a walk that
|
|
9332
|
+
withdrew files is not complete."""
|
|
9333
|
+
if not targeted:
|
|
9334
|
+
_open_codex_finalization_step(conn)
|
|
9335
|
+
# Two different questions. The CURSOR asks "where does the next
|
|
9336
|
+
# tick resume", which is where the overall deadline landed:
|
|
9337
|
+
# `partial_file` (the last stop-short) if there is one, else the
|
|
9338
|
+
# first file the walk never opened. The BACKLOG asks "what is still
|
|
9339
|
+
# owed", which is every stop-short plus everything unwalked.
|
|
9340
|
+
cursor_head = (
|
|
9341
|
+
partial_file if partial_file is not None
|
|
9342
|
+
else (unwalked[0] if unwalked else None))
|
|
9343
|
+
backlog_files, backlog_bytes = _codex_backlog_after(
|
|
9344
|
+
partial_files + unwalked
|
|
9345
|
+
+ [item.discovered for item in withdrawn],
|
|
9346
|
+
existing, committed_state)
|
|
9347
|
+
stats.backlog_files = backlog_files
|
|
9348
|
+
stats.backlog_bytes = backlog_bytes
|
|
9349
|
+
if cursor_head is not None:
|
|
9350
|
+
head = cursor_head
|
|
9351
|
+
ordinal = 0
|
|
9352
|
+
head_key = _codex_walk_key(head)
|
|
9353
|
+
for index, item in enumerate(files):
|
|
9354
|
+
# Qualified by root, exactly like the cursor it records: two
|
|
9355
|
+
# configured roots can resolve to the same canonical path,
|
|
9356
|
+
# and matching on the path alone would then store the wrong
|
|
9357
|
+
# ordinal for the fallback.
|
|
9358
|
+
if _codex_walk_key(item) == head_key:
|
|
9359
|
+
ordinal = index
|
|
9360
|
+
break
|
|
9361
|
+
_set_cache_meta_if_changed(
|
|
9362
|
+
conn, _CODEX_RESUME_CURSOR_KEY, json.dumps({
|
|
9363
|
+
"root_key": head.source_root_key,
|
|
9364
|
+
"path": str(head.physical_path),
|
|
9365
|
+
"ordinal": ordinal,
|
|
9366
|
+
}, sort_keys=True))
|
|
9367
|
+
else:
|
|
9368
|
+
# A complete cycle: the next tick starts at the top again.
|
|
9369
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
9370
|
+
(_CODEX_RESUME_CURSOR_KEY,))
|
|
9371
|
+
if backlog_files:
|
|
9372
|
+
prior = _load_codex_backlog_record(conn)
|
|
9373
|
+
_write_codex_backlog_record(
|
|
9374
|
+
conn, files=backlog_files, owed_bytes=backlog_bytes,
|
|
9375
|
+
since=None if prior is None else prior.get("since"))
|
|
9376
|
+
else:
|
|
9377
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
9378
|
+
(_CODEX_BACKLOG_KEY,))
|
|
9379
|
+
conn.execute(_CODEX_FINALIZATION_RELEASE)
|
|
9380
|
+
# #279 S2 F1: rolling parse-health record (codex half). Same
|
|
9381
|
+
# anomaly-delta gate as the Claude tail; the global writer flock
|
|
9382
|
+
# excludes a concurrent Claude sync.
|
|
9383
|
+
_update_parse_health_meta(
|
|
9384
|
+
conn, "parse_health_codex",
|
|
9385
|
+
lines_seen=stats.lines_seen,
|
|
9386
|
+
lines_malformed=stats.lines_malformed,
|
|
9387
|
+
lines_skipped=stats.token_events_skipped,
|
|
9388
|
+
skip_reasons=stats.skip_reasons,
|
|
9389
|
+
rebuild=rebuild,
|
|
9390
|
+
commit=False,
|
|
9391
|
+
)
|
|
9392
|
+
# #416 fix-round review B4: make a persistently torn `auth.json`
|
|
9393
|
+
# VISIBLE. The defer itself is correct (spec §3.6 stable-read protocol
|
|
9394
|
+
# — never guess an account), but since a growing DECIDED file also
|
|
9395
|
+
# consults auth.json, a truncated/half-written auth.json now halts every
|
|
9396
|
+
# rollout under that root, not just the never-decided ones. `cache-sync`
|
|
9397
|
+
# still exits 0, so without a durable record the operator sees Codex
|
|
9398
|
+
# spend and quota silently freeze. This marker is what `doctor` reads.
|
|
8572
9399
|
#
|
|
8573
|
-
#
|
|
8574
|
-
#
|
|
8575
|
-
#
|
|
8576
|
-
#
|
|
8577
|
-
#
|
|
8578
|
-
#
|
|
8579
|
-
#
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
9400
|
+
# Whole-tree syncs only: a targeted (`only_paths`) call looks at a
|
|
9401
|
+
# handful of files, so its zero deferral count says nothing about the
|
|
9402
|
+
# rest of the tree and must never clear the marker.
|
|
9403
|
+
#
|
|
9404
|
+
# NOT R8-gated. This is a health signal, not account decoration — it
|
|
9405
|
+
# names no account and adds no per-account column, the same carve-out
|
|
9406
|
+
# `alerts.log`'s runtime state has (docs/accounts-gotchas.md).
|
|
9407
|
+
#
|
|
9408
|
+
# Clearing it requires a walk that actually reached every file
|
|
9409
|
+
# (`walk_complete`). A budgeted walk that stopped before the torn file
|
|
9410
|
+
# defers nothing and would otherwise clear a marker describing a real,
|
|
9411
|
+
# ongoing condition — `doctor` would stop reporting the frozen login
|
|
9412
|
+
# while every rollout under that root stayed stalled.
|
|
9413
|
+
if not targeted:
|
|
9414
|
+
_open_codex_finalization_step(conn)
|
|
9415
|
+
if stats.files_deferred_torn:
|
|
9416
|
+
_set_cache_meta(conn, "codex_torn_auth_deferred", json.dumps({
|
|
9417
|
+
"files": stats.files_deferred_torn,
|
|
9418
|
+
"at": dt.datetime.now(dt.timezone.utc).isoformat(
|
|
9419
|
+
timespec="seconds").replace("+00:00", "Z"),
|
|
9420
|
+
}, sort_keys=True))
|
|
9421
|
+
elif walk_complete:
|
|
9422
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
9423
|
+
("codex_torn_auth_deferred",))
|
|
9424
|
+
# Consume the byte-zero replay marker only after a clean full walk,
|
|
9425
|
+
# and only when THIS call observed it. A contended call returned
|
|
9426
|
+
# long before here, and a walk that failed or deferred a file leaves
|
|
9427
|
+
# the marker standing — a surviving marker is what makes the repair
|
|
9428
|
+
# retry on the next sync, and it is also what keeps
|
|
9429
|
+
# `sync_codex_conversations` deferred until the cache side genuinely
|
|
9430
|
+
# holds the replayed thread rows (§4.2). The `replay_pending` guard
|
|
9431
|
+
# is defense in depth: `open_cache_db` and this walk share an
|
|
9432
|
+
# exclusive lock today, so nothing can arm the marker in between —
|
|
9433
|
+
# but the conversations side has no such exclusion, and the two
|
|
9434
|
+
# clears must keep the same shape.
|
|
9435
|
+
#
|
|
9436
|
+
# `walk_complete` is the third term for the same reason the torn
|
|
9437
|
+
# marker needs it: a budget-truncated walk fails nothing and defers
|
|
9438
|
+
# nothing, so the zero-count condition alone would consume a marker
|
|
9439
|
+
# (and erase a blocked record) on a walk that reached almost no
|
|
9440
|
+
# file. It is defense in depth today — a budgeted tick declines a
|
|
9441
|
+
# pending replay outright above — but the two clears must keep the
|
|
9442
|
+
# same shape.
|
|
8586
9443
|
if (
|
|
8587
|
-
|
|
8588
|
-
and
|
|
8589
|
-
and
|
|
8590
|
-
and (
|
|
8591
|
-
stats.files_processed + stats.files_skipped_unchanged
|
|
8592
|
-
== stats.files_total
|
|
8593
|
-
)
|
|
9444
|
+
walk_complete
|
|
9445
|
+
and stats.files_failed == 0
|
|
9446
|
+
and stats.files_deferred_torn == 0
|
|
8594
9447
|
):
|
|
8595
|
-
|
|
8596
|
-
|
|
9448
|
+
recovery_scope = rebuild_prune_scope or ordinary_prune_scope
|
|
9449
|
+
if (
|
|
9450
|
+
recovery_scope is not None
|
|
9451
|
+
and recovery_scope.recognized_root_keys
|
|
9452
|
+
and not stats.prune_refused
|
|
9453
|
+
and (
|
|
9454
|
+
stats.files_processed + stats.files_skipped_unchanged
|
|
9455
|
+
== stats.files_total
|
|
9456
|
+
)
|
|
9457
|
+
):
|
|
9458
|
+
_clear_codex_prune_refusal(conn)
|
|
9459
|
+
if replay_pending:
|
|
9460
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
9461
|
+
(CODEX_REPLAY_FROM_ZERO_KEY,))
|
|
8597
9462
|
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
8598
|
-
(
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
8646
|
-
|
|
8647
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
restored
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
conn
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
#
|
|
8705
|
-
#
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
9463
|
+
(CODEX_REPLAY_BLOCKED_KEY,))
|
|
9464
|
+
# The budgeted-decline record is cleared by the same walk, for
|
|
9465
|
+
# the same reason: an unbudgeted walk reaching here is exactly
|
|
9466
|
+
# the caller whose absence the record was reporting.
|
|
9467
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
9468
|
+
(CODEX_REPLAY_DEFERRED_KEY,))
|
|
9469
|
+
elif replay_pending and walk_complete:
|
|
9470
|
+
# A full walk ran and could NOT consume the marker, so the
|
|
9471
|
+
# replay — and with it every Codex transcript ingest, which
|
|
9472
|
+
# defers behind this marker — is stalled rather than merely
|
|
9473
|
+
# not-yet-run. `doctor` reads this; the deferral itself stays,
|
|
9474
|
+
# because running ahead is what stamps "(unassigned)" (§4.2).
|
|
9475
|
+
_set_cache_meta(conn, CODEX_REPLAY_BLOCKED_KEY, json.dumps({
|
|
9476
|
+
"at": dt.datetime.now(dt.timezone.utc).isoformat(
|
|
9477
|
+
timespec="seconds").replace("+00:00", "Z"),
|
|
9478
|
+
"files_failed": stats.files_failed,
|
|
9479
|
+
"files_deferred_torn": stats.files_deferred_torn,
|
|
9480
|
+
}, sort_keys=True))
|
|
9481
|
+
conn.execute(_CODEX_FINALIZATION_RELEASE)
|
|
9482
|
+
# Public #5: resolve any still-unstamped `quota_window_snapshots.
|
|
9483
|
+
# observed_model` from the accounting corpus, using the exact expression
|
|
9484
|
+
# the read path used before the fallback was removed. Cache migration
|
|
9485
|
+
# 039 is the one-time leg; this is the standing one, because `db skip
|
|
9486
|
+
# 039_…` and a fresh journal-repopulated cache both bypass the migration
|
|
9487
|
+
# and would then classify a Spark window as account weekly quota (#373).
|
|
9488
|
+
# Ordered BEFORE the spend adoption below, which reads the model to
|
|
9489
|
+
# decide `model_scoped` and must not see a stale NULL. A row it changes
|
|
9490
|
+
# is real interpretation drift, so the physical mutation sequence
|
|
9491
|
+
# advances with it — otherwise the projection certificate would still
|
|
9492
|
+
# read as current and the reconcile would short-circuit past the ledger
|
|
9493
|
+
# entries the triggers just wrote. Best-effort, like the adoption below:
|
|
9494
|
+
# the resolution is fully re-derivable on the next sync.
|
|
9495
|
+
try:
|
|
9496
|
+
with _codex_finalization_stage(conn):
|
|
9497
|
+
with _perf.phase("accounting"):
|
|
9498
|
+
resolved_models = (
|
|
9499
|
+
_cctally_db_sib.backfill_codex_quota_observed_model(conn))
|
|
9500
|
+
if resolved_models:
|
|
9501
|
+
_bump_codex_physical_mutation_seq(conn)
|
|
9502
|
+
except sqlite3.DatabaseError as exc:
|
|
9503
|
+
if _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
9504
|
+
raise
|
|
9505
|
+
stats.maintenance_failed = True
|
|
9506
|
+
eprint("[cache-sync] could not resolve Codex quota model "
|
|
9507
|
+
f"attribution: {exc}")
|
|
9508
|
+
# Window-scoped spend adoption (spec
|
|
9509
|
+
# docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md).
|
|
9510
|
+
# Runs AFTER the walk committed and while both cache writer flocks are
|
|
9511
|
+
# still held, so the observation evidence and the accounting rows it
|
|
9512
|
+
# stamps are the same committed generation. Cache-only — no stats.db read
|
|
9513
|
+
# — so the lock-order law is untouched. A failure here is never fatal:
|
|
9514
|
+
# the stamp is fully re-derivable, so the next sync (or the migration)
|
|
9515
|
+
# repeats it.
|
|
9516
|
+
try:
|
|
9517
|
+
with _codex_finalization_stage(conn):
|
|
9518
|
+
with _perf.phase("accounting"):
|
|
9519
|
+
adopted = apply_codex_window_spend_adoption(
|
|
9520
|
+
conn, touched=None if rebuild else adoption_spans)
|
|
9521
|
+
# Terse, and silent on zero: a rebuild re-derives every row and so
|
|
9522
|
+
# legitimately re-stamps the same population each time, which would
|
|
9523
|
+
# otherwise read as a recurring anomaly rather than convergence.
|
|
9524
|
+
if adopted:
|
|
9525
|
+
finalization_notes.append(
|
|
9526
|
+
f"[cache-sync] attributed {adopted} Codex row(s) "
|
|
9527
|
+
"from quota windows")
|
|
9528
|
+
except sqlite3.DatabaseError as exc:
|
|
9529
|
+
if _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
9530
|
+
# Classified family corruption belongs to the shared recovery
|
|
9531
|
+
# boundary, never to a best-effort local except.
|
|
9532
|
+
raise
|
|
9533
|
+
stats.maintenance_failed = True
|
|
9534
|
+
eprint(f"[cache-sync] could not adopt Codex window spend: {exc}")
|
|
9535
|
+
# #500 §7.1: the STANDING half of operator attribution. The condition it
|
|
9536
|
+
# repairs is created by ordinary ingest, not only by an operator command
|
|
9537
|
+
# — native evidence arriving for an attributed group, a second assertion,
|
|
9538
|
+
# a component split, a window re-materializing as model-scoped — so it
|
|
9539
|
+
# has to run wherever ingest runs, not only where `account attribute`
|
|
9540
|
+
# does. Ordered AFTER the pass above so the reconciliation has the last
|
|
9541
|
+
# word on any row that pass could also have touched, and inside the same
|
|
9542
|
+
# flocks for the same reason: the observation evidence and the accounting
|
|
9543
|
+
# rows it restores are one committed generation. Costs a store with no
|
|
9544
|
+
# attribution records one indexed read; best-effort, like the pass above.
|
|
9545
|
+
try:
|
|
9546
|
+
with _codex_finalization_stage(conn):
|
|
9547
|
+
with _perf.phase("accounting"):
|
|
9548
|
+
restored, readopted = (
|
|
9549
|
+
reconcile_codex_window_attribution_spend(conn))
|
|
9550
|
+
if restored:
|
|
9551
|
+
finalization_notes.append(
|
|
9552
|
+
f"[cache-sync] restored {restored} Codex row(s) whose "
|
|
9553
|
+
"operator attribution no longer resolves; "
|
|
9554
|
+
f"re-attributed {readopted}")
|
|
9555
|
+
except sqlite3.DatabaseError as exc:
|
|
9556
|
+
if _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
9557
|
+
raise
|
|
9558
|
+
stats.maintenance_failed = True
|
|
9559
|
+
eprint("[cache-sync] could not reconcile Codex window "
|
|
9560
|
+
f"attribution spend: {exc}")
|
|
9561
|
+
# #582: keep only a generous mutation-sequence tail. A dashboard that
|
|
9562
|
+
# falls behind it detects the gap and cold-loads, preserving truth.
|
|
9563
|
+
# A failure here propagates exactly as before, with the finalization
|
|
9564
|
+
# so far committed (``finalizing`` in ``finally``), as it was when
|
|
9565
|
+
# every earlier step committed alone.
|
|
9566
|
+
with _codex_finalization_stage(conn):
|
|
9567
|
+
_prune_codex_accounting_change_log(conn)
|
|
9568
|
+
_finalization_steps(walk_complete, [])
|
|
9569
|
+
# 901-QI-001: before anything is decided or certified from them, the
|
|
9570
|
+
# last batch's files must still be in this transaction.
|
|
9571
|
+
w11_withdrawn = _w11_confirm_deferred() if w11 else []
|
|
9572
|
+
w11_lost = bool(w11_withdrawn)
|
|
9573
|
+
if w11_lost:
|
|
9574
|
+
# 901-QI-002: the withdrawal rolled back the finalization owed by
|
|
9575
|
+
# the batches that did commit, among it the window spend adoption
|
|
9576
|
+
# over their spans, which the next sync would never revisit. Run
|
|
9577
|
+
# it again without the withdrawn files: they count as backlog,
|
|
9578
|
+
# and an incomplete walk clears and consumes no marker. Notes and
|
|
9579
|
+
# failures from the undone pass go with it (OV-3).
|
|
9580
|
+
stats.maintenance_failed = False
|
|
9581
|
+
finalization_notes.clear()
|
|
9582
|
+
_finalization_steps(False, w11_withdrawn)
|
|
8712
9583
|
# Projection intentionally runs only after this function releases the
|
|
8713
9584
|
# Codex cache flock in ``finally`` below. cache.db and stats.db are not
|
|
8714
9585
|
# cross-database atomic: after this committed ingest, a projection
|
|
@@ -8780,18 +9651,75 @@ def sync_codex_cache(
|
|
|
8780
9651
|
and stats.files_deferred_torn == 0
|
|
8781
9652
|
and not stats.prune_refused
|
|
8782
9653
|
and not stats.maintenance_failed
|
|
9654
|
+
and not w11_lost
|
|
8783
9655
|
and (
|
|
8784
9656
|
stats.files_processed + stats.files_skipped_unchanged
|
|
8785
9657
|
== stats.files_total
|
|
8786
9658
|
)
|
|
8787
9659
|
)
|
|
8788
|
-
if
|
|
8789
|
-
conn
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
|
|
9660
|
+
if not targeted:
|
|
9661
|
+
with _codex_finalization_stage(conn):
|
|
9662
|
+
if stats.full_walk_complete:
|
|
9663
|
+
conn.execute(
|
|
9664
|
+
"INSERT OR IGNORE INTO cache_meta(key,value) "
|
|
9665
|
+
"VALUES(?, '1')",
|
|
9666
|
+
(_ingest_frontier.CODEX_FULL_WALK_COMPLETE_KEY,),
|
|
9667
|
+
)
|
|
9668
|
+
else:
|
|
9669
|
+
conn.execute(
|
|
9670
|
+
"DELETE FROM cache_meta WHERE key=?",
|
|
9671
|
+
(_ingest_frontier.CODEX_FULL_WALK_COMPLETE_KEY,),
|
|
9672
|
+
)
|
|
9673
|
+
# #901 §5.3c (W8): the one commit of the finalization transaction:
|
|
9674
|
+
# resume and backlog state, parse health, the walk's markers, the
|
|
9675
|
+
# ordered maintenance stages and the completion marker — and, under
|
|
9676
|
+
# W11, the last batch's files.
|
|
9677
|
+
if conn.in_transaction:
|
|
9678
|
+
if w11 and w11_deferred:
|
|
9679
|
+
_codex_batch_precommit()
|
|
8793
9680
|
conn.commit()
|
|
9681
|
+
finalizing = False
|
|
9682
|
+
if w11:
|
|
9683
|
+
# Publication follows the commit it describes.
|
|
9684
|
+
_w11_publish(w11_deferred)
|
|
9685
|
+
w11_deferred.clear()
|
|
9686
|
+
if progress is not None:
|
|
9687
|
+
progress(stats)
|
|
9688
|
+
walk_marker_owed = False
|
|
9689
|
+
for note in finalization_notes:
|
|
9690
|
+
eprint(note)
|
|
9691
|
+
# Codex creates/extends cache.db sidecars independently of Claude's
|
|
9692
|
+
# sync path. Harden them while both cache flocks are still held and
|
|
9693
|
+
# after all Codex writes, before the optional checkpoint can rotate a
|
|
9694
|
+
# WAL.
|
|
9695
|
+
_harden_cache_sidecars()
|
|
9696
|
+
# #297/#344: forced end-of-sync WAL drain (Codex half). The global
|
|
9697
|
+
# writer flock excludes every Claude write/checkpoint until this
|
|
9698
|
+
# checkpoint finishes. All Codex ingest work is committed here (no
|
|
9699
|
+
# active transaction).
|
|
9700
|
+
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
8794
9701
|
finally:
|
|
9702
|
+
if finalizing:
|
|
9703
|
+
_commit_codex_finalization_so_far(conn)
|
|
9704
|
+
elif conn.in_transaction:
|
|
9705
|
+
# #901 Amendment 19 PR-5: an exception outside the finalization —
|
|
9706
|
+
# a W11 batch that raised, SQLite corruption included — leaves
|
|
9707
|
+
# only uncommitted partial work, which is undone as a crash would
|
|
9708
|
+
# undo it, before the writer flocks are released. A whole-tree walk
|
|
9709
|
+
# did this in `_withdraw_cache_meta_marker`; a targeted
|
|
9710
|
+
# (`only_paths`) call owes no marker and left the batch open on the
|
|
9711
|
+
# caller's connection. Best effort: the exception propagating is
|
|
9712
|
+
# the one that matters.
|
|
9713
|
+
try:
|
|
9714
|
+
conn.rollback()
|
|
9715
|
+
except sqlite3.DatabaseError:
|
|
9716
|
+
pass
|
|
9717
|
+
if walk_marker_owed:
|
|
9718
|
+
_withdraw_cache_meta_marker(
|
|
9719
|
+
conn, _ingest_frontier.CODEX_FULL_WALK_COMPLETE_KEY)
|
|
9720
|
+
if held_writer_flocks:
|
|
9721
|
+
# #901 §5.3c (W9): the end of a cache sync is a boundary.
|
|
9722
|
+
_wal_checkpoint.at_boundary(conn)
|
|
8795
9723
|
release_cache_writer_flocks(held_writer_flocks)
|
|
8796
9724
|
|
|
8797
9725
|
if deferred_cert_roots is not None:
|
|
@@ -9620,6 +10548,44 @@ def _harden_cache_sidecars() -> None:
|
|
|
9620
10548
|
# === Region 6: open_cache_db (was bin/cctally:9040-9155) ===
|
|
9621
10549
|
|
|
9622
10550
|
|
|
10551
|
+
def _path_mode_state(
|
|
10552
|
+
path, mode: int, *, directory: bool = False,
|
|
10553
|
+
) -> "tuple[bool, bool]":
|
|
10554
|
+
"""``(present, already exactly mode)`` for ``path`` from one ``os.stat``.
|
|
10555
|
+
|
|
10556
|
+
#901 Amendment 20 W2 (a): the cache opener asks this before re-creating
|
|
10557
|
+
the data directory or re-applying a permission, so a steady open does
|
|
10558
|
+
neither. A path that cannot be stat'ed reports ``(False, False)`` and the
|
|
10559
|
+
caller falls back to creating and hardening it as before. ``directory``
|
|
10560
|
+
requires a directory for "present"; ``os.stat`` follows symlinks, as
|
|
10561
|
+
``Path.mkdir``'s existence check and ``os.chmod`` do.
|
|
10562
|
+
"""
|
|
10563
|
+
try:
|
|
10564
|
+
st = os.stat(path)
|
|
10565
|
+
except OSError:
|
|
10566
|
+
return False, False
|
|
10567
|
+
if directory and not stat.S_ISDIR(st.st_mode):
|
|
10568
|
+
return False, False
|
|
10569
|
+
return True, stat.S_IMODE(st.st_mode) == mode
|
|
10570
|
+
|
|
10571
|
+
|
|
10572
|
+
def _open_maintenance_lock_fd(lock_path: pathlib.Path) -> int:
|
|
10573
|
+
"""Open (creating) a maintenance flock file as a raw descriptor.
|
|
10574
|
+
|
|
10575
|
+
#901 Amendment 20 W2 (a): the flags and mode are exactly what
|
|
10576
|
+
``open(path, "a+")`` passes (non-inheritable, as Python makes every
|
|
10577
|
+
descriptor it opens), without the buffered text wrapper the flock never
|
|
10578
|
+
used; the parent directory is created only when the open reports it
|
|
10579
|
+
missing, instead of on every open.
|
|
10580
|
+
"""
|
|
10581
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_APPEND | getattr(os, "O_CLOEXEC", 0)
|
|
10582
|
+
try:
|
|
10583
|
+
return os.open(lock_path, flags, 0o666)
|
|
10584
|
+
except FileNotFoundError:
|
|
10585
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
10586
|
+
return os.open(lock_path, flags, 0o666)
|
|
10587
|
+
|
|
10588
|
+
|
|
9623
10589
|
def _cache_open_guarded() -> sqlite3.Connection:
|
|
9624
10590
|
"""Open cache.db while excluding a destructive recovery handshake.
|
|
9625
10591
|
|
|
@@ -9631,9 +10597,12 @@ def _cache_open_guarded() -> sqlite3.Connection:
|
|
|
9631
10597
|
path = pathlib.Path(_cctally_core.CACHE_DB_PATH)
|
|
9632
10598
|
marker = _cctally_db_sib._repair_marker_path(path)
|
|
9633
10599
|
pending = _cctally_db_sib._quarantine_pending_path(path)
|
|
10600
|
+
# The marker and pending-quarantine lookups stay ``Path.exists`` (#901
|
|
10601
|
+
# Amendment 20 review W-004): on Python 3.11 and 3.12 it raises an error
|
|
10602
|
+
# other than "absent" (EIO, EACCES, ...), where ``os.path.exists`` would
|
|
10603
|
+
# return False and admit a connection while a repair may be running.
|
|
9634
10604
|
lock_path = pathlib.Path(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH)
|
|
9635
|
-
|
|
9636
|
-
lock_fh = open(lock_path, "a+")
|
|
10605
|
+
lock_fh = _open_maintenance_lock_fd(lock_path)
|
|
9637
10606
|
try:
|
|
9638
10607
|
for _attempt in range(2):
|
|
9639
10608
|
conn = None
|
|
@@ -9662,30 +10631,37 @@ def _cache_open_guarded() -> sqlite3.Connection:
|
|
|
9662
10631
|
)
|
|
9663
10632
|
if pending.exists():
|
|
9664
10633
|
try:
|
|
9665
|
-
|
|
10634
|
+
# #901 W9 (Q15): other processes' idle checkpoint
|
|
10635
|
+
# keepers close on request; wait for them within the
|
|
10636
|
+
# drain grace, then resume under the same request.
|
|
10637
|
+
with _cctally_db_sib._keeper_yield_request(
|
|
9666
10638
|
path
|
|
9667
|
-
)
|
|
9668
|
-
|
|
9669
|
-
|
|
9670
|
-
"could not verify that the database family "
|
|
9671
|
-
"has no open handles"
|
|
10639
|
+
) as deadline:
|
|
10640
|
+
open_pids = _cctally_db_sib._await_family_drained(
|
|
10641
|
+
path, deadline
|
|
9672
10642
|
)
|
|
9673
|
-
|
|
9674
|
-
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
10643
|
+
if open_pids is None:
|
|
10644
|
+
raise OSError(
|
|
10645
|
+
"could not verify that the database "
|
|
10646
|
+
"family has no open handles"
|
|
10647
|
+
)
|
|
10648
|
+
if open_pids:
|
|
10649
|
+
raise OSError(
|
|
10650
|
+
"database family is still open in "
|
|
10651
|
+
"process(es) "
|
|
10652
|
+
+ ", ".join(
|
|
10653
|
+
str(pid) for pid in sorted(open_pids)
|
|
10654
|
+
)
|
|
10655
|
+
)
|
|
10656
|
+
# #496 S6 §5.3: a resume publishes the incident's
|
|
10657
|
+
# final manifest, so it is a producer too.
|
|
10658
|
+
import _cctally_retention as _retention_sib
|
|
10659
|
+
with _retention_sib.retention_shared(
|
|
10660
|
+
label="cache quarantine resume"
|
|
10661
|
+
):
|
|
10662
|
+
_cctally_db_sib.quarantine_db_family(
|
|
10663
|
+
path, strict=True,
|
|
9678
10664
|
)
|
|
9679
|
-
)
|
|
9680
|
-
# #496 S6 §5.3: a resume publishes the incident's final
|
|
9681
|
-
# manifest, so it is a producer too.
|
|
9682
|
-
import _cctally_retention as _retention_sib
|
|
9683
|
-
with _retention_sib.retention_shared(
|
|
9684
|
-
label="cache quarantine resume"
|
|
9685
|
-
):
|
|
9686
|
-
_cctally_db_sib.quarantine_db_family(
|
|
9687
|
-
path, strict=True,
|
|
9688
|
-
)
|
|
9689
10665
|
except OSError as exc:
|
|
9690
10666
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
9691
10667
|
raise sqlite3.DatabaseError(
|
|
@@ -9745,7 +10721,7 @@ def _cache_open_guarded() -> sqlite3.Connection:
|
|
|
9745
10721
|
"cache.db stale maintenance marker could not be reclaimed"
|
|
9746
10722
|
)
|
|
9747
10723
|
finally:
|
|
9748
|
-
|
|
10724
|
+
os.close(lock_fh)
|
|
9749
10725
|
|
|
9750
10726
|
|
|
9751
10727
|
def _set_cache_no_checkpoint_on_close(
|
|
@@ -9753,61 +10729,19 @@ def _set_cache_no_checkpoint_on_close(
|
|
|
9753
10729
|
) -> None:
|
|
9754
10730
|
"""Set SQLite's per-connection checkpoint-on-close policy.
|
|
9755
10731
|
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
|
|
9760
|
-
|
|
9761
|
-
"""
|
|
9762
|
-
option = getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None)
|
|
9763
|
-
setconfig = getattr(conn, "setconfig", None)
|
|
9764
|
-
if option is not None and setconfig is not None:
|
|
9765
|
-
setconfig(option, bool(disabled))
|
|
9766
|
-
return
|
|
9767
|
-
|
|
9768
|
-
_set_cache_no_checkpoint_on_close_cpython(conn, disabled)
|
|
10732
|
+
The implementation is the connection-generic helper in
|
|
10733
|
+
`_lib_sqlite_close` (#901 paced reclaim needs it for any connection);
|
|
10734
|
+
this name keeps cache recovery's call sites, messages and test seam."""
|
|
10735
|
+
_lib_sqlite_close.set_no_checkpoint_on_close(
|
|
10736
|
+
conn, disabled, purpose="cache recovery")
|
|
9769
10737
|
|
|
9770
10738
|
|
|
9771
10739
|
def _set_cache_no_checkpoint_on_close_cpython(
|
|
9772
10740
|
conn: sqlite3.Connection, disabled: bool,
|
|
9773
10741
|
) -> None:
|
|
9774
10742
|
"""Python 3.11 compatibility adapter for sqlite3_db_config()."""
|
|
9775
|
-
|
|
9776
|
-
|
|
9777
|
-
"cache recovery requires SQLite no-checkpoint-on-close support"
|
|
9778
|
-
)
|
|
9779
|
-
|
|
9780
|
-
# CPython 3.11's public sqlite3 module does not expose db_config(), but its
|
|
9781
|
-
# connection layout begins with PyObject_HEAD followed by ``sqlite3 *db``.
|
|
9782
|
-
# The layout and audit-visible handle are defined by Modules/_sqlite in
|
|
9783
|
-
# every supported CPython release. Load sqlite3_db_config from the same
|
|
9784
|
-
# extension dependency so we never bind a different SQLite instance.
|
|
9785
|
-
import _sqlite3
|
|
9786
|
-
import ctypes
|
|
9787
|
-
|
|
9788
|
-
sqlite_lib = ctypes.CDLL(_sqlite3.__file__)
|
|
9789
|
-
db_config = sqlite_lib.sqlite3_db_config
|
|
9790
|
-
db_config.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
|
9791
|
-
db_config.restype = ctypes.c_int
|
|
9792
|
-
pointer_size = ctypes.sizeof(ctypes.c_void_p)
|
|
9793
|
-
db_pointer = ctypes.c_void_p.from_address(
|
|
9794
|
-
id(conn) + (2 * pointer_size)
|
|
9795
|
-
).value
|
|
9796
|
-
if not db_pointer:
|
|
9797
|
-
raise sqlite3.NotSupportedError(
|
|
9798
|
-
"cache recovery could not resolve the SQLite connection handle"
|
|
9799
|
-
)
|
|
9800
|
-
current = ctypes.c_int()
|
|
9801
|
-
rc = db_config(
|
|
9802
|
-
ctypes.c_void_p(db_pointer),
|
|
9803
|
-
1006, # SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE
|
|
9804
|
-
ctypes.c_int(1 if disabled else 0),
|
|
9805
|
-
ctypes.byref(current),
|
|
9806
|
-
)
|
|
9807
|
-
if rc != sqlite3.SQLITE_OK or current.value != int(bool(disabled)):
|
|
9808
|
-
raise sqlite3.NotSupportedError(
|
|
9809
|
-
"cache recovery could not configure SQLite close checkpointing"
|
|
9810
|
-
)
|
|
10743
|
+
_lib_sqlite_close.set_no_checkpoint_on_close_cpython(
|
|
10744
|
+
conn, disabled, purpose="cache recovery")
|
|
9811
10745
|
|
|
9812
10746
|
|
|
9813
10747
|
@dataclass(frozen=True)
|
|
@@ -9946,12 +10880,18 @@ def _recover_corrupt_cache(
|
|
|
9946
10880
|
# finally, below the maintenance flock it sits under in the lock order.
|
|
9947
10881
|
import _cctally_retention as _retention_sib
|
|
9948
10882
|
retention = contextlib.ExitStack()
|
|
10883
|
+
# #901 W9 (Q15): the keeper-yield request, held from the drain check to
|
|
10884
|
+
# the end of the recovery and removed before the maintenance flock.
|
|
10885
|
+
keeper_yield = contextlib.ExitStack()
|
|
9949
10886
|
try:
|
|
9950
10887
|
fcntl.flock(lock_fh, fcntl.LOCK_EX)
|
|
9951
10888
|
if active_conn is not None:
|
|
9952
10889
|
_close_cache_trigger_connection(active_conn, path)
|
|
9953
10890
|
active_conn = None
|
|
9954
|
-
|
|
10891
|
+
deadline = keeper_yield.enter_context(
|
|
10892
|
+
_cctally_db_sib._keeper_yield_request(path)
|
|
10893
|
+
)
|
|
10894
|
+
open_pids = _cctally_db_sib._await_family_drained(path, deadline)
|
|
9955
10895
|
if open_pids is None:
|
|
9956
10896
|
raise sqlite3.DatabaseError(
|
|
9957
10897
|
"cache.db recovery cannot verify that the database family has "
|
|
@@ -10040,6 +10980,7 @@ def _recover_corrupt_cache(
|
|
|
10040
10980
|
return True
|
|
10041
10981
|
finally:
|
|
10042
10982
|
retention.close()
|
|
10983
|
+
keeper_yield.close()
|
|
10043
10984
|
if active_conn is not None:
|
|
10044
10985
|
_close_cache_trigger_connection_best_effort(active_conn)
|
|
10045
10986
|
try:
|
|
@@ -10120,15 +11061,23 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
10120
11061
|
handle check prove replacement cannot invalidate a live WAL reader.
|
|
10121
11062
|
"""
|
|
10122
11063
|
c = _cctally()
|
|
10123
|
-
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
10124
11064
|
# cache.db holds plaintext conversation prose at rest (Plan 2, spec §5).
|
|
10125
11065
|
# Harden the data dir to 0700 so the WAL window between connect and the
|
|
10126
11066
|
# first write (which materializes the -wal/-shm sidecars, hardened in
|
|
10127
11067
|
# sync_cache) is not world-readable. Best-effort: swallow OSError + continue.
|
|
10128
|
-
|
|
10129
|
-
|
|
10130
|
-
|
|
10131
|
-
|
|
11068
|
+
# #901 Amendment 20 W2 (a): one stat decides, so a steady open neither
|
|
11069
|
+
# re-creates the directory nor re-applies an unchanged mode (a chmod still
|
|
11070
|
+
# rewrites the inode's change time).
|
|
11071
|
+
app_dir_present, app_dir_private = _path_mode_state(
|
|
11072
|
+
_cctally_core.APP_DIR, 0o700, directory=True,
|
|
11073
|
+
)
|
|
11074
|
+
if not app_dir_present:
|
|
11075
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
11076
|
+
if not app_dir_private:
|
|
11077
|
+
try:
|
|
11078
|
+
os.chmod(_cctally_core.APP_DIR, 0o700)
|
|
11079
|
+
except OSError as exc:
|
|
11080
|
+
eprint(f"[cache] could not chmod data dir 0700 ({exc}); continuing")
|
|
10132
11081
|
recovered = False
|
|
10133
11082
|
try:
|
|
10134
11083
|
conn = _cache_open_guarded()
|
|
@@ -10149,11 +11098,13 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
10149
11098
|
_cache_storm_test_pause("cache_repair_recreated")
|
|
10150
11099
|
|
|
10151
11100
|
# Best-effort 0600 on cache.db itself (the 0700 dir above backstops the
|
|
10152
|
-
# sidecars until the first write hardens them in sync_cache).
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
11101
|
+
# sidecars until the first write hardens them in sync_cache). Applied only
|
|
11102
|
+
# when the mode differs (#901 Amendment 20 W2 (a)).
|
|
11103
|
+
if not _path_mode_state(_cctally_core.CACHE_DB_PATH, 0o600)[1]:
|
|
11104
|
+
try:
|
|
11105
|
+
os.chmod(_cctally_core.CACHE_DB_PATH, 0o600)
|
|
11106
|
+
except OSError as exc:
|
|
11107
|
+
eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
|
|
10157
11108
|
|
|
10158
11109
|
schema_current = _cctally_store.schema_current(conn, "cache")
|
|
10159
11110
|
compatibility_current = conn.execute(
|
|
@@ -10218,6 +11169,11 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
10218
11169
|
_cctally_store.apply_policy(conn, "cache")
|
|
10219
11170
|
else:
|
|
10220
11171
|
_cctally_store.apply_connection_policy(conn, "cache")
|
|
11172
|
+
# PR-3: the schema and migration work below sorts under FILE, never
|
|
11173
|
+
# in memory; the writer temp store is selected again after it.
|
|
11174
|
+
schema_work = not schema_current or not compatibility_current
|
|
11175
|
+
if schema_work:
|
|
11176
|
+
_cctally_store.apply_schema_work_temp_store(conn)
|
|
10221
11177
|
|
|
10222
11178
|
# §6.2 version gate: schema apply, ALTER/purge, and the complete cache
|
|
10223
11179
|
# migration dispatcher all run under maintenance-exclusive → global writer
|
|
@@ -10243,6 +11199,8 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
10243
11199
|
"SELECT 1 FROM sqlite_master WHERE name='conversation_messages'"
|
|
10244
11200
|
).fetchone() is None:
|
|
10245
11201
|
_cctally_db_sib._apply_cache_schema(conn)
|
|
11202
|
+
if schema_work:
|
|
11203
|
+
_cctally_store.apply_writer_temp_store(conn)
|
|
10246
11204
|
finally:
|
|
10247
11205
|
release_cache_writer_flocks(held)
|
|
10248
11206
|
return conn
|
|
@@ -10472,29 +11430,38 @@ def _conversations_open_guarded(
|
|
|
10472
11430
|
"both provider locks; retry shortly"
|
|
10473
11431
|
)
|
|
10474
11432
|
if pending.exists():
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
|
|
11433
|
+
# #901 W9 (Q15): other processes' idle checkpoint
|
|
11434
|
+
# keepers close on request; wait for them within the
|
|
11435
|
+
# drain grace, then resume under the same request.
|
|
11436
|
+
with _cctally_db_sib._keeper_yield_request(
|
|
11437
|
+
path
|
|
11438
|
+
) as deadline:
|
|
11439
|
+
open_pids = _cctally_db_sib._await_family_drained(
|
|
11440
|
+
path, deadline
|
|
10480
11441
|
)
|
|
10481
|
-
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
11442
|
+
if open_pids is None:
|
|
11443
|
+
raise sqlite3.DatabaseError(
|
|
11444
|
+
"conversations.db pending recovery could "
|
|
11445
|
+
"not verify that the family has no open "
|
|
11446
|
+
"handles"
|
|
11447
|
+
)
|
|
11448
|
+
if open_pids:
|
|
11449
|
+
raise sqlite3.DatabaseError(
|
|
11450
|
+
"conversations.db pending recovery found "
|
|
11451
|
+
"open handles in process(es) "
|
|
11452
|
+
+ ", ".join(
|
|
11453
|
+
str(pid) for pid in sorted(open_pids)
|
|
11454
|
+
)
|
|
11455
|
+
)
|
|
11456
|
+
# #496 S6 §5.3: a resume publishes the incident's
|
|
11457
|
+
# final manifest, so it is a producer too.
|
|
11458
|
+
import _cctally_retention as _retention_sib
|
|
11459
|
+
with _retention_sib.retention_shared(
|
|
11460
|
+
label="conversations quarantine resume"
|
|
11461
|
+
):
|
|
11462
|
+
_cctally_db_sib.quarantine_db_family(
|
|
11463
|
+
path, strict=True,
|
|
10487
11464
|
)
|
|
10488
|
-
)
|
|
10489
|
-
# #496 S6 §5.3: a resume publishes the incident's final
|
|
10490
|
-
# manifest, so it is a producer too.
|
|
10491
|
-
import _cctally_retention as _retention_sib
|
|
10492
|
-
with _retention_sib.retention_shared(
|
|
10493
|
-
label="conversations quarantine resume"
|
|
10494
|
-
):
|
|
10495
|
-
_cctally_db_sib.quarantine_db_family(
|
|
10496
|
-
path, strict=True,
|
|
10497
|
-
)
|
|
10498
11465
|
removed, reclaim_reason = (
|
|
10499
11466
|
_cctally_db_sib._remove_stale_repair_marker(path)
|
|
10500
11467
|
)
|
|
@@ -10621,7 +11588,10 @@ def _open_conversations_db_unlocked(
|
|
|
10621
11588
|
# the head so the next open gates the schema apply out. On an existing
|
|
10622
11589
|
# populated DB _apply_conversations_schema short-circuits on its own marker,
|
|
10623
11590
|
# so no transcript row is touched even when the gate re-runs it.
|
|
10624
|
-
|
|
11591
|
+
schema_work = not _cctally_store.schema_current(conn, "conversations")
|
|
11592
|
+
if schema_work:
|
|
11593
|
+
# PR-3: pending schema and migration work sorts under FILE.
|
|
11594
|
+
_cctally_store.apply_schema_work_temp_store(conn)
|
|
10625
11595
|
_cctally_db_sib._apply_conversations_schema(conn)
|
|
10626
11596
|
# Commit the schema apply BEFORE the dispatcher: _apply_conversations_schema
|
|
10627
11597
|
# can leave an open implicit transaction (its trailing marker INSERT), and
|
|
@@ -10637,6 +11607,8 @@ def _open_conversations_db_unlocked(
|
|
|
10637
11607
|
conn, registry=_CONVERSATIONS_MIGRATIONS, db_label="conversations.db",
|
|
10638
11608
|
recover_version_ahead=True,
|
|
10639
11609
|
)
|
|
11610
|
+
if schema_work:
|
|
11611
|
+
_cctally_store.apply_writer_temp_store(conn)
|
|
10640
11612
|
|
|
10641
11613
|
if attach_cache:
|
|
10642
11614
|
# Ensure the compact schema exists before opening it read-only. This
|
|
@@ -11217,6 +12189,10 @@ def _probe_conversation_rebuild(
|
|
|
11217
12189
|
maintenance_fh = open(maintenance_path, "a+")
|
|
11218
12190
|
provider_locks: list[Any] | None = None
|
|
11219
12191
|
probe: sqlite3.Connection | None = None
|
|
12192
|
+
# #901 W9 (Q15): the keeper-yield request, held from the drain check to
|
|
12193
|
+
# the end of the probe and removed before the provider and maintenance
|
|
12194
|
+
# flocks.
|
|
12195
|
+
keeper_yield = contextlib.ExitStack()
|
|
11220
12196
|
try:
|
|
11221
12197
|
if not _acquire_cache_flock(
|
|
11222
12198
|
maintenance_fh, timeout=lock_timeout,
|
|
@@ -11233,7 +12209,10 @@ def _probe_conversation_rebuild(
|
|
|
11233
12209
|
"conversations.db recovery could not claim both provider "
|
|
11234
12210
|
"locks; leaving the live family untouched"
|
|
11235
12211
|
)
|
|
11236
|
-
|
|
12212
|
+
deadline = keeper_yield.enter_context(
|
|
12213
|
+
_cctally_db_sib._keeper_yield_request(path)
|
|
12214
|
+
)
|
|
12215
|
+
open_pids = _cctally_db_sib._await_family_drained(path, deadline)
|
|
11237
12216
|
if open_pids is None:
|
|
11238
12217
|
raise sqlite3.DatabaseError(
|
|
11239
12218
|
"conversations.db recovery cannot verify that the database "
|
|
@@ -11269,6 +12248,7 @@ def _probe_conversation_rebuild(
|
|
|
11269
12248
|
except sqlite3.DatabaseError as exc:
|
|
11270
12249
|
return exc
|
|
11271
12250
|
finally:
|
|
12251
|
+
keeper_yield.close()
|
|
11272
12252
|
if provider_locks is not None:
|
|
11273
12253
|
_release_conversation_provider_locks(provider_locks)
|
|
11274
12254
|
try:
|
|
@@ -11309,6 +12289,10 @@ def _recover_corrupt_conversations(
|
|
|
11309
12289
|
# #496 S6 §5.3, same span as the cache producer above.
|
|
11310
12290
|
import _cctally_retention as _retention_sib
|
|
11311
12291
|
retention = contextlib.ExitStack()
|
|
12292
|
+
# #901 W9 (Q15): the keeper-yield request, held from the drain check to
|
|
12293
|
+
# the end of the recovery and removed before the provider and maintenance
|
|
12294
|
+
# flocks.
|
|
12295
|
+
keeper_yield = contextlib.ExitStack()
|
|
11312
12296
|
try:
|
|
11313
12297
|
lock_path = pathlib.Path(
|
|
11314
12298
|
_cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
@@ -11328,7 +12312,10 @@ def _recover_corrupt_conversations(
|
|
|
11328
12312
|
"conversations.db recovery could not claim both provider "
|
|
11329
12313
|
"locks; leaving the live family untouched"
|
|
11330
12314
|
) from exc
|
|
11331
|
-
|
|
12315
|
+
deadline = keeper_yield.enter_context(
|
|
12316
|
+
_cctally_db_sib._keeper_yield_request(path)
|
|
12317
|
+
)
|
|
12318
|
+
open_pids = _cctally_db_sib._await_family_drained(path, deadline)
|
|
11332
12319
|
if open_pids is None:
|
|
11333
12320
|
raise sqlite3.DatabaseError(
|
|
11334
12321
|
"conversations.db recovery cannot verify that the database "
|
|
@@ -11421,6 +12408,7 @@ def _recover_corrupt_conversations(
|
|
|
11421
12408
|
return True
|
|
11422
12409
|
finally:
|
|
11423
12410
|
retention.close()
|
|
12411
|
+
keeper_yield.close()
|
|
11424
12412
|
if provider_locks is not None:
|
|
11425
12413
|
_release_conversation_provider_locks(provider_locks)
|
|
11426
12414
|
if lock_fh is not None:
|
|
@@ -12321,6 +13309,8 @@ def sync_claude_conversations(
|
|
|
12321
13309
|
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
12322
13310
|
stats.lock_contended = True
|
|
12323
13311
|
return stats
|
|
13312
|
+
# #901 §5.3c (W9): a sync writer owns its checkpoints.
|
|
13313
|
+
_wal_checkpoint.arm(conn)
|
|
12324
13314
|
|
|
12325
13315
|
# #347 observe-and-stamp mirrors the accounting ingest boundary. A
|
|
12326
13316
|
# torn credential read is undecided, not unattributed: defer before any
|
|
@@ -12426,13 +13416,12 @@ def sync_claude_conversations(
|
|
|
12426
13416
|
< _conversation_rebuild_free_bytes_required(conn)):
|
|
12427
13417
|
stats.deferred_reason = "insufficient_free_space"
|
|
12428
13418
|
return stats
|
|
12429
|
-
# #780: refuse a new rebuild while the reclaim
|
|
12430
|
-
# hard ceiling. A rebuild adds a whole
|
|
12431
|
-
# to a store
|
|
12432
|
-
#
|
|
13419
|
+
# #780 / #901: refuse a new rebuild while the OBSERVED reclaim
|
|
13420
|
+
# backlog is at or over its hard ceiling. A rebuild adds a whole
|
|
13421
|
+
# staged generation of churn to a store already holding that much
|
|
13422
|
+
# free space. Reaching the ceiling never accelerates reclaim.
|
|
12433
13423
|
_retention_sib = _load_lib("_lib_conversation_retention")
|
|
12434
|
-
if _retention_sib.
|
|
12435
|
-
_retention_sib.read_reclaim_pending(conn)):
|
|
13424
|
+
if _retention_sib.observed_backlog_over_ceiling(conn):
|
|
12436
13425
|
stats.deferred_reason = "reclaim_backlog_over_ceiling"
|
|
12437
13426
|
return stats
|
|
12438
13427
|
# Commit the retry marker before the destructive clear. A killed
|
|
@@ -12747,6 +13736,7 @@ def sync_claude_conversations(
|
|
|
12747
13736
|
),
|
|
12748
13737
|
)
|
|
12749
13738
|
conn.commit()
|
|
13739
|
+
_wal_checkpoint.after_commit(conn)
|
|
12750
13740
|
stats.files_processed += 1
|
|
12751
13741
|
touched_sessions.update(
|
|
12752
13742
|
row[0] for row in conv_rows if row[0] is not None
|
|
@@ -12929,6 +13919,8 @@ def sync_claude_conversations(
|
|
|
12929
13919
|
did_from_zero_replay = (
|
|
12930
13920
|
rebuild or stats.files_reset_truncated > 0 or maintenance_replayed)
|
|
12931
13921
|
finally:
|
|
13922
|
+
# #901 §5.3c (W9): the end of a conversation-sync pass is a boundary.
|
|
13923
|
+
_wal_checkpoint.at_boundary(conn)
|
|
12932
13924
|
try:
|
|
12933
13925
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
12934
13926
|
except OSError:
|
|
@@ -12966,6 +13958,126 @@ def _clear_codex_conversation_store(conn: sqlite3.Connection) -> None:
|
|
|
12966
13958
|
conn.execute("DELETE FROM codex_conversation_source_files")
|
|
12967
13959
|
|
|
12968
13960
|
|
|
13961
|
+
# #901 §5.3c (W7, Q13): the bounds of one batch inside a Codex
|
|
13962
|
+
# conversation-sync pass. Consecutive changed files share one transaction, up to
|
|
13963
|
+
# this many files and this much newly consumed input in total; a file whose own
|
|
13964
|
+
# new input exceeds the byte bound runs alone. The bound limits the work in one
|
|
13965
|
+
# transaction, not its elapsed time. Module constants so a test can inject a
|
|
13966
|
+
# one-file control (spec G10 (a)).
|
|
13967
|
+
CODEX_CONVERSATION_BATCH_FILES = 4
|
|
13968
|
+
CODEX_CONVERSATION_BATCH_BYTES = 256 * 1024
|
|
13969
|
+
|
|
13970
|
+
|
|
13971
|
+
@dataclass
|
|
13972
|
+
class _CodexConversationBatch:
|
|
13973
|
+
"""The open W7 batch of one Codex conversation-sync pass (#901 §5.3c).
|
|
13974
|
+
|
|
13975
|
+
Holds exactly the bookkeeping of the files whose writes have been released
|
|
13976
|
+
into the batch's open transaction and not yet committed. A file joins only
|
|
13977
|
+
after its savepoint is released, so a file that rolled back to its savepoint
|
|
13978
|
+
contributes nothing here, and the pass statistics only learn about the
|
|
13979
|
+
batch's files once ``_commit_codex_conversation_batch`` has committed them.
|
|
13980
|
+
"""
|
|
13981
|
+
|
|
13982
|
+
files: int = 0
|
|
13983
|
+
consumed_bytes: int = 0
|
|
13984
|
+
reset_truncated: int = 0
|
|
13985
|
+
cascaded: bool = False
|
|
13986
|
+
affected_keys: set = field(default_factory=set)
|
|
13987
|
+
inputs_changed: set = field(default_factory=set)
|
|
13988
|
+
paths: list = field(default_factory=list)
|
|
13989
|
+
|
|
13990
|
+
|
|
13991
|
+
def _codex_conversation_batch_admits(
|
|
13992
|
+
batch: _CodexConversationBatch, consumed: int,
|
|
13993
|
+
) -> bool:
|
|
13994
|
+
"""Whether a changed file with ``consumed`` new bytes may join ``batch``."""
|
|
13995
|
+
if batch.files == 0:
|
|
13996
|
+
return True
|
|
13997
|
+
return (
|
|
13998
|
+
batch.files < CODEX_CONVERSATION_BATCH_FILES
|
|
13999
|
+
and consumed <= CODEX_CONVERSATION_BATCH_BYTES
|
|
14000
|
+
and batch.consumed_bytes + consumed <= CODEX_CONVERSATION_BATCH_BYTES
|
|
14001
|
+
)
|
|
14002
|
+
|
|
14003
|
+
|
|
14004
|
+
def _codex_conversation_batch_full(batch: _CodexConversationBatch) -> bool:
|
|
14005
|
+
return (
|
|
14006
|
+
batch.files >= CODEX_CONVERSATION_BATCH_FILES
|
|
14007
|
+
or batch.consumed_bytes >= CODEX_CONVERSATION_BATCH_BYTES
|
|
14008
|
+
)
|
|
14009
|
+
|
|
14010
|
+
|
|
14011
|
+
def _commit_codex_conversation_batch(
|
|
14012
|
+
conn: sqlite3.Connection,
|
|
14013
|
+
batch: _CodexConversationBatch,
|
|
14014
|
+
stats: "CodexIngestStats",
|
|
14015
|
+
progress: "Callable[[str, CodexIngestStats], None] | None",
|
|
14016
|
+
) -> None:
|
|
14017
|
+
"""Finish one W7 batch: derived state once, then commit (#901 §5.3c).
|
|
14018
|
+
|
|
14019
|
+
The rollups and the search projection are recomputed once, over the union
|
|
14020
|
+
of the batch's affected conversations, after its last file, so the
|
|
14021
|
+
projection generation advances at most once for the transaction (§5.3b).
|
|
14022
|
+
Events, normalized rows, repairs, projection, rollups, render revision and
|
|
14023
|
+
every file's cursor then commit together or not at all: a database error
|
|
14024
|
+
here rolls the whole batch back and counts each of its files as failed
|
|
14025
|
+
once, and their unchanged cursors make the next pass retry them. Progress
|
|
14026
|
+
and statistics are reported only after the commit.
|
|
14027
|
+
"""
|
|
14028
|
+
if batch.files == 0:
|
|
14029
|
+
# Only rolled-back savepoints can be open here; end that empty
|
|
14030
|
+
# transaction rather than leave it on the caller's connection.
|
|
14031
|
+
if conn.in_transaction:
|
|
14032
|
+
conn.rollback()
|
|
14033
|
+
return
|
|
14034
|
+
try:
|
|
14035
|
+
_recompute_codex_rollups(
|
|
14036
|
+
conn, batch.affected_keys, inputs_changed=batch.inputs_changed)
|
|
14037
|
+
import _lib_codex_conversation_query as query
|
|
14038
|
+
query.materialize_codex_find_projection(
|
|
14039
|
+
conn, batch.affected_keys, cascaded=batch.cascaded)
|
|
14040
|
+
conn.commit()
|
|
14041
|
+
_wal_checkpoint.after_commit(conn)
|
|
14042
|
+
except sqlite3.DatabaseError as exc:
|
|
14043
|
+
conn.rollback()
|
|
14044
|
+
for path in batch.paths:
|
|
14045
|
+
eprint(f"[codex-conversations] db error on {path}: {exc}")
|
|
14046
|
+
stats.files_failed += batch.files
|
|
14047
|
+
else:
|
|
14048
|
+
stats.files_processed += batch.files
|
|
14049
|
+
stats.files_reset_truncated += batch.reset_truncated
|
|
14050
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
14051
|
+
|
|
14052
|
+
|
|
14053
|
+
_CODEX_FILE_SAVEPOINT = "SAVEPOINT codex_conversation_file"
|
|
14054
|
+
_CODEX_FILE_RELEASE = "RELEASE SAVEPOINT codex_conversation_file"
|
|
14055
|
+
_CODEX_FILE_ROLLBACK_TO = "ROLLBACK TO SAVEPOINT codex_conversation_file"
|
|
14056
|
+
|
|
14057
|
+
|
|
14058
|
+
def _rollback_codex_file_savepoint(conn: sqlite3.Connection) -> bool:
|
|
14059
|
+
"""Undo one file's writes inside its batch (#901 §5.3c W7).
|
|
14060
|
+
|
|
14061
|
+
True when the file's savepoint was rolled back and released, leaving the
|
|
14062
|
+
batch's earlier files in place. False when SQLite had already ended the
|
|
14063
|
+
whole transaction (some errors roll it back on their own), or the savepoint
|
|
14064
|
+
cannot be undone: the batch's earlier files are then gone too, and the
|
|
14065
|
+
connection is left with no open transaction.
|
|
14066
|
+
"""
|
|
14067
|
+
if conn.in_transaction:
|
|
14068
|
+
try:
|
|
14069
|
+
conn.execute(_CODEX_FILE_ROLLBACK_TO)
|
|
14070
|
+
conn.execute(_CODEX_FILE_RELEASE)
|
|
14071
|
+
return True
|
|
14072
|
+
except sqlite3.DatabaseError:
|
|
14073
|
+
pass
|
|
14074
|
+
try:
|
|
14075
|
+
conn.rollback()
|
|
14076
|
+
except sqlite3.DatabaseError:
|
|
14077
|
+
pass
|
|
14078
|
+
return False
|
|
14079
|
+
|
|
14080
|
+
|
|
12969
14081
|
def sync_codex_conversations(
|
|
12970
14082
|
conn: sqlite3.Connection,
|
|
12971
14083
|
*,
|
|
@@ -13007,6 +14119,8 @@ def sync_codex_conversations(
|
|
|
13007
14119
|
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
13008
14120
|
stats.lock_contended = True
|
|
13009
14121
|
return stats
|
|
14122
|
+
# #901 §5.3c (W9): a sync writer owns its checkpoints.
|
|
14123
|
+
_wal_checkpoint.arm(conn)
|
|
13010
14124
|
targeted = only_paths is not None
|
|
13011
14125
|
pending_rebuild = conn.execute(
|
|
13012
14126
|
"SELECT 1 FROM cache_meta "
|
|
@@ -13166,22 +14280,31 @@ def sync_codex_conversations(
|
|
|
13166
14280
|
prune_scope, retained_sources, set()
|
|
13167
14281
|
)
|
|
13168
14282
|
protected_paths = {path for path, _root in refused_sources}
|
|
14283
|
+
# #901 §5.3b: the whole prune is one transaction, so its rollups
|
|
14284
|
+
# and projection are repaired once, over every conversation it
|
|
14285
|
+
# touched, after all of its deletes. Each is a pure function of the
|
|
14286
|
+
# surviving rows, so the result equals the old per-path repair; the
|
|
14287
|
+
# projection generation advances once for the transaction, and the
|
|
14288
|
+
# cascade signal covers a surviving conversation whose rebuilt rows
|
|
14289
|
+
# come out equal (the deleted file held only its final messages).
|
|
14290
|
+
prune_affected: set[str] = set()
|
|
14291
|
+
prune_deleted_messages = 0
|
|
13169
14292
|
for stale_path, _stale_root_key in safe_sources:
|
|
13170
|
-
|
|
14293
|
+
prune_affected.update(
|
|
13171
14294
|
row[0] for row in conn.execute(
|
|
13172
14295
|
"SELECT DISTINCT conversation_key "
|
|
13173
14296
|
"FROM codex_conversation_messages WHERE source_path=?",
|
|
13174
14297
|
(stale_path,),
|
|
13175
14298
|
) if row[0]
|
|
13176
|
-
|
|
14299
|
+
)
|
|
13177
14300
|
conn.execute(
|
|
13178
14301
|
"DELETE FROM codex_conversation_file_touches WHERE source_path=?",
|
|
13179
14302
|
(stale_path,),
|
|
13180
14303
|
)
|
|
13181
|
-
conn.execute(
|
|
14304
|
+
prune_deleted_messages += max(0, conn.execute(
|
|
13182
14305
|
"DELETE FROM codex_conversation_messages WHERE source_path=?",
|
|
13183
14306
|
(stale_path,),
|
|
13184
|
-
)
|
|
14307
|
+
).rowcount)
|
|
13185
14308
|
conn.execute(
|
|
13186
14309
|
"DELETE FROM codex_conversation_events WHERE source_path=?",
|
|
13187
14310
|
(stale_path,),
|
|
@@ -13190,9 +14313,11 @@ def sync_codex_conversations(
|
|
|
13190
14313
|
"DELETE FROM codex_conversation_source_files WHERE path=?",
|
|
13191
14314
|
(stale_path,),
|
|
13192
14315
|
)
|
|
13193
|
-
|
|
14316
|
+
if prune_affected:
|
|
14317
|
+
_recompute_codex_rollups(conn, prune_affected)
|
|
13194
14318
|
import _lib_codex_conversation_query as query
|
|
13195
|
-
query.materialize_codex_find_projection(
|
|
14319
|
+
query.materialize_codex_find_projection(
|
|
14320
|
+
conn, prune_affected, cascaded=prune_deleted_messages > 0)
|
|
13196
14321
|
stats.files_pruned = len({path for path, _root in safe_sources})
|
|
13197
14322
|
if refused_sources:
|
|
13198
14323
|
_codex_prune_refusal_record(
|
|
@@ -13207,7 +14332,14 @@ def sync_codex_conversations(
|
|
|
13207
14332
|
path for path, _root in refused_sources
|
|
13208
14333
|
})
|
|
13209
14334
|
conn.commit()
|
|
13210
|
-
|
|
14335
|
+
# #901 §5.3c (W9): the end of the stale-source prune is a boundary.
|
|
14336
|
+
_wal_checkpoint.at_boundary(conn)
|
|
14337
|
+
|
|
14338
|
+
# #901 §5.3c (W7): consecutive changed files share one transaction,
|
|
14339
|
+
# bounded by CODEX_CONVERSATION_BATCH_FILES and _BYTES. Unchanged,
|
|
14340
|
+
# unreadable and declined files write nothing, so they neither join nor
|
|
14341
|
+
# break a batch.
|
|
14342
|
+
batch = _CodexConversationBatch()
|
|
13211
14343
|
for discovered in files:
|
|
13212
14344
|
jp = discovered.source_path
|
|
13213
14345
|
path_str = str(jp)
|
|
@@ -13256,6 +14388,8 @@ def sync_codex_conversations(
|
|
|
13256
14388
|
else "source_replaced" if replaced
|
|
13257
14389
|
else "truncation"
|
|
13258
14390
|
)
|
|
14391
|
+
# The files already ingested keep today's committed result.
|
|
14392
|
+
_commit_codex_conversation_batch(conn, batch, stats, progress)
|
|
13259
14393
|
return stats
|
|
13260
14394
|
start_offset = 0 if prev is None or reset_file else int(prev[2])
|
|
13261
14395
|
initial_session_id = prev[4] if prev else None
|
|
@@ -13394,6 +14528,9 @@ def sync_codex_conversations(
|
|
|
13394
14528
|
)
|
|
13395
14529
|
except Exception as exc: # noqa: BLE001
|
|
13396
14530
|
if not targeted:
|
|
14531
|
+
# The batch's earlier files keep today's committed result.
|
|
14532
|
+
_commit_codex_conversation_batch(
|
|
14533
|
+
conn, batch, stats, progress)
|
|
13397
14534
|
raise
|
|
13398
14535
|
eprint(
|
|
13399
14536
|
f"[codex-conversations] normalization failed for {jp}: {exc}"
|
|
@@ -13401,6 +14538,10 @@ def sync_codex_conversations(
|
|
|
13401
14538
|
stats.files_failed += 1
|
|
13402
14539
|
_report_conversation_progress(progress, "ingest", stats)
|
|
13403
14540
|
continue
|
|
14541
|
+
consumed = max(0, int(final_offset) - int(start_offset))
|
|
14542
|
+
if not _codex_conversation_batch_admits(batch, consumed):
|
|
14543
|
+
_commit_codex_conversation_batch(conn, batch, stats, progress)
|
|
14544
|
+
batch = _CodexConversationBatch()
|
|
13404
14545
|
affected_keys = {
|
|
13405
14546
|
row[0]
|
|
13406
14547
|
for row in conn.execute(
|
|
@@ -13410,22 +14551,36 @@ def sync_codex_conversations(
|
|
|
13410
14551
|
)
|
|
13411
14552
|
if row[0]
|
|
13412
14553
|
} if reset_file else set()
|
|
14554
|
+
# #901 §5.3c (W7): the batch's transaction is opened explicitly,
|
|
14555
|
+
# so releasing a file's savepoint never commits it on its own.
|
|
14556
|
+
if not conn.in_transaction:
|
|
14557
|
+
conn.execute("BEGIN")
|
|
14558
|
+
conn.execute(_CODEX_FILE_SAVEPOINT)
|
|
13413
14559
|
try:
|
|
14560
|
+
# #901 §5.3b: what this file's writes changed decides what its
|
|
14561
|
+
# batch's derived state must write. Any inserted, deleted or
|
|
14562
|
+
# updated event or message row (counted before the rollup and
|
|
14563
|
+
# the projection run) is a change to the affected
|
|
14564
|
+
# conversations' retained render inputs; deleted messages also
|
|
14565
|
+
# cascaded their projection rows away, which the projector must
|
|
14566
|
+
# count as a find-visible change even when it finds no
|
|
14567
|
+
# difference.
|
|
14568
|
+
changes_before_inputs = conn.total_changes
|
|
14569
|
+
deleted_messages = 0
|
|
13414
14570
|
if reset_file:
|
|
13415
14571
|
conn.execute(
|
|
13416
14572
|
"DELETE FROM codex_conversation_file_touches "
|
|
13417
14573
|
"WHERE source_path=?",
|
|
13418
14574
|
(path_str,),
|
|
13419
14575
|
)
|
|
13420
|
-
conn.execute(
|
|
14576
|
+
deleted_messages = max(0, conn.execute(
|
|
13421
14577
|
"DELETE FROM codex_conversation_messages WHERE source_path=?",
|
|
13422
14578
|
(path_str,),
|
|
13423
|
-
)
|
|
14579
|
+
).rowcount)
|
|
13424
14580
|
conn.execute(
|
|
13425
14581
|
"DELETE FROM codex_conversation_events WHERE source_path=?",
|
|
13426
14582
|
(path_str,),
|
|
13427
14583
|
)
|
|
13428
|
-
stats.files_reset_truncated += 1
|
|
13429
14584
|
if event_rows:
|
|
13430
14585
|
conn.executemany(
|
|
13431
14586
|
"INSERT OR IGNORE INTO codex_conversation_events "
|
|
@@ -13449,9 +14604,10 @@ def sync_codex_conversations(
|
|
|
13449
14604
|
affected_keys.update(
|
|
13450
14605
|
_repair_codex_turn_ids_for_source(conn, path_str)
|
|
13451
14606
|
)
|
|
13452
|
-
|
|
13453
|
-
|
|
13454
|
-
|
|
14607
|
+
inputs_changed = (
|
|
14608
|
+
set(affected_keys)
|
|
14609
|
+
if conn.total_changes != changes_before_inputs else set()
|
|
14610
|
+
)
|
|
13455
14611
|
terminal = state.thread
|
|
13456
14612
|
conn.execute(
|
|
13457
14613
|
"INSERT INTO codex_conversation_source_files "
|
|
@@ -13496,14 +14652,30 @@ def sync_codex_conversations(
|
|
|
13496
14652
|
int(st.st_ino),
|
|
13497
14653
|
),
|
|
13498
14654
|
)
|
|
13499
|
-
conn.
|
|
13500
|
-
stats.files_processed += 1
|
|
13501
|
-
_report_conversation_progress(progress, "ingest", stats)
|
|
14655
|
+
conn.execute(_CODEX_FILE_RELEASE)
|
|
13502
14656
|
except sqlite3.DatabaseError as exc:
|
|
13503
|
-
|
|
14657
|
+
# This file alone rolls back: its rows, cursor and bookkeeping
|
|
14658
|
+
# never reached the batch, whose other files still commit.
|
|
13504
14659
|
eprint(f"[codex-conversations] db error on {jp}: {exc}")
|
|
14660
|
+
if not _rollback_codex_file_savepoint(conn):
|
|
14661
|
+
# SQLite ended the whole transaction, so the batch's
|
|
14662
|
+
# earlier files rolled back with it.
|
|
14663
|
+
stats.files_failed += batch.files
|
|
14664
|
+
batch = _CodexConversationBatch()
|
|
13505
14665
|
stats.files_failed += 1
|
|
13506
14666
|
_report_conversation_progress(progress, "ingest", stats)
|
|
14667
|
+
continue
|
|
14668
|
+
batch.files += 1
|
|
14669
|
+
batch.consumed_bytes += consumed
|
|
14670
|
+
batch.reset_truncated += 1 if reset_file else 0
|
|
14671
|
+
batch.cascaded = batch.cascaded or deleted_messages > 0
|
|
14672
|
+
batch.affected_keys.update(affected_keys)
|
|
14673
|
+
batch.inputs_changed.update(inputs_changed)
|
|
14674
|
+
batch.paths.append(jp)
|
|
14675
|
+
if _codex_conversation_batch_full(batch):
|
|
14676
|
+
_commit_codex_conversation_batch(conn, batch, stats, progress)
|
|
14677
|
+
batch = _CodexConversationBatch()
|
|
14678
|
+
_commit_codex_conversation_batch(conn, batch, stats, progress)
|
|
13507
14679
|
|
|
13508
14680
|
run_codex_find_projection_backfill(conn)
|
|
13509
14681
|
_report_conversation_progress(progress, "finalize", stats)
|
|
@@ -13519,12 +14691,12 @@ def sync_codex_conversations(
|
|
|
13519
14691
|
)
|
|
13520
14692
|
):
|
|
13521
14693
|
_clear_codex_prune_refusal(conn)
|
|
13522
|
-
|
|
13523
|
-
|
|
13524
|
-
|
|
13525
|
-
|
|
13526
|
-
|
|
13527
|
-
|
|
14694
|
+
# #901 §5.3c (W8): written only when it changes; deleting an
|
|
14695
|
+
# absent row below already writes nothing.
|
|
14696
|
+
_set_cache_meta_if_changed(
|
|
14697
|
+
conn,
|
|
14698
|
+
"codex_conversation_contract_version",
|
|
14699
|
+
_lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION,
|
|
13528
14700
|
)
|
|
13529
14701
|
conn.execute(
|
|
13530
14702
|
"DELETE FROM cache_meta "
|
|
@@ -13546,7 +14718,19 @@ def sync_codex_conversations(
|
|
|
13546
14718
|
_harden_conversation_sidecars()
|
|
13547
14719
|
_maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
|
|
13548
14720
|
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
14721
|
+
except BaseException:
|
|
14722
|
+
# #901 §5.3c (W7): an exception that escapes the pass must not leave an
|
|
14723
|
+
# open batch on the caller's connection. Its files' cursors roll back
|
|
14724
|
+
# with their rows, so the next pass retries them, as after a crash.
|
|
14725
|
+
if conn.in_transaction:
|
|
14726
|
+
try:
|
|
14727
|
+
conn.rollback()
|
|
14728
|
+
except sqlite3.DatabaseError:
|
|
14729
|
+
pass
|
|
14730
|
+
raise
|
|
13549
14731
|
finally:
|
|
14732
|
+
# #901 §5.3c (W9): the end of a conversation-sync pass is a boundary.
|
|
14733
|
+
_wal_checkpoint.at_boundary(conn)
|
|
13550
14734
|
try:
|
|
13551
14735
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
13552
14736
|
except OSError:
|
|
@@ -13694,6 +14878,13 @@ def _run_transcript_rebuild_worker(
|
|
|
13694
14878
|
conn.close()
|
|
13695
14879
|
except Exception:
|
|
13696
14880
|
pass
|
|
14881
|
+
# #901 W9 (Q14): `os._exit` skips `atexit`; the checkpoint
|
|
14882
|
+
# policy's finalizer attempts one PASSIVE per armed store and
|
|
14883
|
+
# closes the keepers before the child ends.
|
|
14884
|
+
try:
|
|
14885
|
+
_wal_checkpoint.finalize()
|
|
14886
|
+
except Exception: # noqa: BLE001
|
|
14887
|
+
pass
|
|
13697
14888
|
os.close(write_fd)
|
|
13698
14889
|
os._exit(0)
|
|
13699
14890
|
|
|
@@ -13943,6 +15134,13 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
13943
15134
|
f"{result.codex_events} event(s). "
|
|
13944
15135
|
f"Run `cctally db vacuum --db conversations` to reclaim the freed space."
|
|
13945
15136
|
)
|
|
15137
|
+
if not result.complete:
|
|
15138
|
+
eprint(
|
|
15139
|
+
"[cache-sync] stopped early: the transcript maintenance "
|
|
15140
|
+
"allowance (4 MiB/min) is spent; the remaining expired "
|
|
15141
|
+
"transcripts are pruned on later runs or by a running "
|
|
15142
|
+
"dashboard."
|
|
15143
|
+
)
|
|
13946
15144
|
conn.close()
|
|
13947
15145
|
return 0
|
|
13948
15146
|
|