cctally 1.113.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_cache.py +1683 -485
  4. package/bin/_cctally_core.py +223 -1
  5. package/bin/_cctally_dashboard.py +96 -18
  6. package/bin/_cctally_dashboard_perf.py +128 -9
  7. package/bin/_cctally_dashboard_sources.py +267 -204
  8. package/bin/_cctally_db.py +861 -18
  9. package/bin/_cctally_diagnosis_sources.py +22 -4
  10. package/bin/_cctally_doctor.py +136 -18
  11. package/bin/_cctally_five_hour.py +2 -2
  12. package/bin/_cctally_forecast.py +27 -6
  13. package/bin/_cctally_journal.py +67 -5
  14. package/bin/_cctally_milestone_history.py +82 -9
  15. package/bin/_cctally_percent_breakdown.py +3 -2
  16. package/bin/_cctally_quota.py +201 -33
  17. package/bin/_cctally_record.py +14 -0
  18. package/bin/_cctally_rederive.py +23 -9
  19. package/bin/_cctally_source_analytics.py +60 -15
  20. package/bin/_cctally_statusline.py +17 -0
  21. package/bin/_cctally_store.py +80 -5
  22. package/bin/_cctally_tui.py +4 -3
  23. package/bin/_cctally_weekrefs.py +128 -21
  24. package/bin/_lib_cache_report.py +35 -6
  25. package/bin/_lib_codex_conversation.py +32 -0
  26. package/bin/_lib_codex_conversation_query.py +164 -30
  27. package/bin/_lib_conversation_query.py +95 -56
  28. package/bin/_lib_conversation_retention.py +1605 -640
  29. package/bin/_lib_cost_provenance.py +53 -2
  30. package/bin/_lib_doctor.py +263 -69
  31. package/bin/_lib_fmt.py +11 -0
  32. package/bin/_lib_merge_gate.py +469 -51
  33. package/bin/_lib_perf.py +27 -1
  34. package/bin/_lib_pricing.py +193 -8
  35. package/bin/_lib_pricing_check.py +20 -4
  36. package/bin/_lib_reclaim_planner.py +792 -0
  37. package/bin/_lib_rederive.py +225 -77
  38. package/bin/_lib_snapshot_cache.py +21 -0
  39. package/bin/_lib_sqlite_close.py +81 -0
  40. package/bin/_lib_subscription_weeks.py +100 -17
  41. package/bin/_lib_tick_stats.py +217 -17
  42. package/bin/_lib_wal_checkpoint.py +1077 -0
  43. package/bin/_lib_write_budget.py +483 -0
  44. package/bin/_lib_write_io.py +703 -0
  45. package/bin/cctally +8 -0
  46. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  47. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  48. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  49. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  50. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  51. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  52. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  53. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  54. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  55. package/dashboard/static/dashboard.html +2 -2
  56. package/package.json +6 -1
  57. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  58. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -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
- pass # best-effort; observability/hygiene must never fail a sync
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 = MAX(codex_file_incarnations.incarnation, excluded.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
- "WHERE retracted_by_op_id IS "
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
- return [
2677
- _lib_codex_conversation.CodexNormalizedRow(*row)
2678
- for row in conn.execute(
2679
- "SELECT " + _CODEX_NORM_COLS + " FROM codex_conversation_messages "
2680
- "WHERE conversation_key = ? "
2681
- "ORDER BY timestamp_utc, source_path, line_offset",
2682
- (conversation_key,),
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
- render_revision = (
2753
- _next_conversation_render_revision(conn)
2754
- if advance_render_revision else 0
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, source_root_key, parent_thread_id, item_count,
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
- query.materialize_codex_find_projection(conn, affected)
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
- row[0]
2993
- for row in conn.execute(
2994
- "SELECT DISTINCT conversation_key FROM codex_conversation_messages "
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
- def _collect_retained_codex_paths_and_roots(
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
- ) -> tuple[list[tuple[str, str | None]], set[str]]:
3018
- """Return every retained real source identity and provider root.
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
- family_queries = (
3028
- "SELECT path, source_root_key FROM codex_session_files",
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 family_queries:
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(retained_identities, key=lambda item: (item[0], item[1] or "")),
3050
- retained_root_keys,
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
- retained_identities, retained_root_keys = (
3061
- _collect_retained_codex_paths_and_roots(conn)
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: ``INSERT OR REPLACE`` deletes and reinserts the row, so an omitted
3287
- column silently reverts to its schema DEFAULT of 1 — a budgeted stop would
3288
- have its own record erased by its own commit, the unread suffix would be
3289
- skipped as unchanged forever, and nothing would raise."""
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 OR REPLACE INTO codex_session_files
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
- _cache_storm_test_pause("codex_precommit")
3397
- conn.commit()
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. Retire it before an ordinary full pass too;
4051
- # any early return or per-file failure must leave the store visibly
4052
- # incomplete until this same call reaches the clean end-of-walk write.
4053
- if not targeted and not rebuild:
4054
- conn.execute(
4055
- "DELETE FROM cache_meta "
4056
- "WHERE key='claude_ingest_walk_complete'"
4057
- )
4058
- conn.commit()
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 UPDATE SET value = excluded.value",
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
- _set_cache_meta(conn, _CODEX_BACKLOG_KEY, json.dumps({
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. Clear it before any full-pass decline or walk can occur, then
7262
- # restore it only after every file and maintenance leg certifies clean.
7263
- if not targeted:
7264
- conn.execute(
7265
- "DELETE FROM cache_meta WHERE key=?",
7266
- (_ingest_frontier.CODEX_FULL_WALK_COMPLETE_KEY,),
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
- safe_sources,
7569
- _refused_orphan_sources,
7570
- safe_root_keys,
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 enumerate(walk):
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
- if pending_decision is not None:
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
- # Clearing it requires a walk that actually reached every file
8547
- # (`walk_complete`). A budgeted walk that stopped before the torn file
8548
- # defers nothing and would otherwise clear a marker describing a real,
8549
- # ongoing condition — `doctor` would stop reporting the frozen login
8550
- # while every rollout under that root stayed stalled.
8551
- if not targeted:
8552
- if stats.files_deferred_torn:
8553
- _set_cache_meta(conn, "codex_torn_auth_deferred", json.dumps({
8554
- "files": stats.files_deferred_torn,
8555
- "at": dt.datetime.now(dt.timezone.utc).isoformat(
8556
- timespec="seconds").replace("+00:00", "Z"),
8557
- }, sort_keys=True))
8558
- elif walk_complete:
8559
- conn.execute("DELETE FROM cache_meta WHERE key = ?",
8560
- ("codex_torn_auth_deferred",))
8561
- # Consume the byte-zero replay marker only after a clean full walk,
8562
- # and only when THIS call observed it. A contended call returned
8563
- # long before here, and a walk that failed or deferred a file leaves
8564
- # the marker standing — a surviving marker is what makes the repair
8565
- # retry on the next sync, and it is also what keeps
8566
- # `sync_codex_conversations` deferred until the cache side genuinely
8567
- # holds the replayed thread rows (§4.2). The `replay_pending` guard
8568
- # is defense in depth: `open_cache_db` and this walk share an
8569
- # exclusive lock today, so nothing can arm the marker in between —
8570
- # but the conversations side has no such exclusion, and the two
8571
- # clears must keep the same shape.
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
- # `walk_complete` is the third term for the same reason the torn
8574
- # marker needs it: a budget-truncated walk fails nothing and defers
8575
- # nothing, so the zero-count condition alone would consume a marker
8576
- # (and erase a blocked record) on a walk that reached almost no
8577
- # file. It is defense in depth today — a budgeted tick declines a
8578
- # pending replay outright above — but the two clears must keep the
8579
- # same shape.
8580
- if (
8581
- walk_complete
8582
- and stats.files_failed == 0
8583
- and stats.files_deferred_torn == 0
8584
- ):
8585
- recovery_scope = rebuild_prune_scope or ordinary_prune_scope
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
- recovery_scope is not None
8588
- and recovery_scope.recognized_root_keys
8589
- and not stats.prune_refused
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
- _clear_codex_prune_refusal(conn)
8596
- if replay_pending:
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
- (CODEX_REPLAY_FROM_ZERO_KEY,))
8599
- conn.execute("DELETE FROM cache_meta WHERE key = ?",
8600
- (CODEX_REPLAY_BLOCKED_KEY,))
8601
- # The budgeted-decline record is cleared by the same walk, for
8602
- # the same reason: an unbudgeted walk reaching here is exactly
8603
- # the caller whose absence the record was reporting.
8604
- conn.execute("DELETE FROM cache_meta WHERE key = ?",
8605
- (CODEX_REPLAY_DEFERRED_KEY,))
8606
- elif replay_pending and walk_complete:
8607
- # A full walk ran and could NOT consume the marker, so the
8608
- # replay — and with it every Codex transcript ingest, which
8609
- # defers behind this marker — is stalled rather than merely
8610
- # not-yet-run. `doctor` reads this; the deferral itself stays,
8611
- # because running ahead is what stamps "(unassigned)" (§4.2).
8612
- _set_cache_meta(conn, CODEX_REPLAY_BLOCKED_KEY, json.dumps({
8613
- "at": dt.datetime.now(dt.timezone.utc).isoformat(
8614
- timespec="seconds").replace("+00:00", "Z"),
8615
- "files_failed": stats.files_failed,
8616
- "files_deferred_torn": stats.files_deferred_torn,
8617
- }, sort_keys=True))
8618
- conn.commit()
8619
- # Public #5: resolve any still-unstamped `quota_window_snapshots.
8620
- # observed_model` from the accounting corpus, using the exact expression
8621
- # the read path used before the fallback was removed. Cache migration
8622
- # 039 is the one-time leg; this is the standing one, because `db skip
8623
- # 039_…` and a fresh journal-repopulated cache both bypass the migration
8624
- # and would then classify a Spark window as account weekly quota (#373).
8625
- # Ordered BEFORE the spend adoption below, which reads the model to
8626
- # decide `model_scoped` and must not see a stale NULL. A row it changes
8627
- # is real interpretation drift, so the physical mutation sequence
8628
- # advances with it — otherwise the projection certificate would still
8629
- # read as current and the reconcile would short-circuit past the ledger
8630
- # entries the triggers just wrote. Best-effort, like the adoption below:
8631
- # the resolution is fully re-derivable on the next sync.
8632
- try:
8633
- with _perf.phase("accounting"):
8634
- resolved_models = _cctally_db_sib.backfill_codex_quota_observed_model(
8635
- conn)
8636
- if resolved_models:
8637
- _bump_codex_physical_mutation_seq(conn)
8638
- conn.commit()
8639
- except sqlite3.DatabaseError as exc:
8640
- conn.rollback()
8641
- if _cctally_db_sib._is_sqlite_corruption_error(exc):
8642
- raise
8643
- stats.maintenance_failed = True
8644
- eprint("[cache-sync] could not resolve Codex quota model "
8645
- f"attribution: {exc}")
8646
- # Window-scoped spend adoption (spec
8647
- # docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md).
8648
- # Runs AFTER the walk committed and while both cache writer flocks are
8649
- # still held, so the observation evidence and the accounting rows it
8650
- # stamps are the same committed generation. Cache-only — no stats.db read
8651
- # — so the lock-order law is untouched. A failure here is never fatal:
8652
- # the stamp is fully re-derivable, so the next sync (or the migration)
8653
- # repeats it.
8654
- try:
8655
- with _perf.phase("accounting"):
8656
- adopted = apply_codex_window_spend_adoption(
8657
- conn, touched=None if rebuild else adoption_spans)
8658
- conn.commit()
8659
- # Terse, and silent on zero: a rebuild re-derives every row and so
8660
- # legitimately re-stamps the same population each time, which would
8661
- # otherwise read as a recurring anomaly rather than convergence.
8662
- if adopted:
8663
- eprint(f"[cache-sync] attributed {adopted} Codex row(s) "
8664
- "from quota windows")
8665
- except sqlite3.DatabaseError as exc:
8666
- conn.rollback()
8667
- if _cctally_db_sib._is_sqlite_corruption_error(exc):
8668
- # Classified family corruption belongs to the shared recovery
8669
- # boundary, never to a best-effort local except.
8670
- raise
8671
- stats.maintenance_failed = True
8672
- eprint(f"[cache-sync] could not adopt Codex window spend: {exc}")
8673
- # #500 §7.1: the STANDING half of operator attribution. The condition it
8674
- # repairs is created by ordinary ingest, not only by an operator command
8675
- # — native evidence arriving for an attributed group, a second assertion,
8676
- # a component split, a window re-materializing as model-scoped — so it
8677
- # has to run wherever ingest runs, not only where `account attribute`
8678
- # does. Ordered AFTER the pass above so the reconciliation has the last
8679
- # word on any row that pass could also have touched, and inside the same
8680
- # flocks for the same reason: the observation evidence and the accounting
8681
- # rows it restores are one committed generation. Costs a store with no
8682
- # attribution records one indexed read; best-effort, like the pass above.
8683
- try:
8684
- with _perf.phase("accounting"):
8685
- restored, readopted = reconcile_codex_window_attribution_spend(conn)
8686
- conn.commit()
8687
- if restored:
8688
- eprint(f"[cache-sync] restored {restored} Codex row(s) whose "
8689
- "operator attribution no longer resolves; "
8690
- f"re-attributed {readopted}")
8691
- except sqlite3.DatabaseError as exc:
8692
- conn.rollback()
8693
- if _cctally_db_sib._is_sqlite_corruption_error(exc):
8694
- raise
8695
- stats.maintenance_failed = True
8696
- eprint("[cache-sync] could not reconcile Codex window "
8697
- f"attribution spend: {exc}")
8698
- # #582: keep only a generous mutation-sequence tail. A dashboard that
8699
- # falls behind it detects the gap and cold-loads, preserving truth.
8700
- if _prune_codex_accounting_change_log(conn):
8701
- conn.commit()
8702
- # Codex creates/extends cache.db sidecars independently of Claude's
8703
- # sync path. Harden them while both cache flocks are still held and
8704
- # after all Codex writes, before the optional checkpoint can rotate a
8705
- # WAL.
8706
- _harden_cache_sidecars()
8707
- # #297/#344: forced end-of-sync WAL drain (Codex half). The global
8708
- # writer flock excludes every Claude write/checkpoint until this
8709
- # checkpoint finishes. All Codex ingest work is committed here (no
8710
- # active transaction).
8711
- _maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
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 stats.full_walk_complete:
8789
- conn.execute(
8790
- "INSERT OR IGNORE INTO cache_meta(key,value) VALUES(?, '1')",
8791
- (_ingest_frontier.CODEX_FULL_WALK_COMPLETE_KEY,),
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
- lock_path.parent.mkdir(parents=True, exist_ok=True)
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
- open_pids = _cctally_db_sib._db_family_open_pids(
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
- if open_pids is None:
9669
- raise OSError(
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
- if open_pids:
9674
- raise OSError(
9675
- "database family is still open in process(es) "
9676
- + ", ".join(
9677
- str(pid) for pid in sorted(open_pids)
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
- lock_fh.close()
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
- Python 3.12 added ``Connection.setconfig``. cctally still supports 3.11,
9757
- so CPython 3.11 reaches the same SQLite API through its supported-version
9758
- ``pysqlite_Connection`` layout and the stdlib extension's linked SQLite
9759
- symbol. This helper is reached only after a classified cache failure;
9760
- ordinary opens never depend on the implementation-specific adapter.
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
- if sys.implementation.name != "cpython":
9776
- raise sqlite3.NotSupportedError(
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
- open_pids = _cctally_db_sib._db_family_open_pids(path)
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
- try:
10129
- os.chmod(_cctally_core.APP_DIR, 0o700)
10130
- except OSError as exc:
10131
- eprint(f"[cache] could not chmod data dir 0700 ({exc}); continuing")
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
- try:
10154
- os.chmod(_cctally_core.CACHE_DB_PATH, 0o600)
10155
- except OSError as exc:
10156
- eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
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
- open_pids = _cctally_db_sib._db_family_open_pids(path)
10476
- if open_pids is None:
10477
- raise sqlite3.DatabaseError(
10478
- "conversations.db pending recovery could not "
10479
- "verify that the family has no open handles"
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
- if open_pids:
10482
- raise sqlite3.DatabaseError(
10483
- "conversations.db pending recovery found open "
10484
- "handles in process(es) "
10485
- + ", ".join(
10486
- str(pid) for pid in sorted(open_pids)
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
- if not _cctally_store.schema_current(conn, "conversations"):
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
- open_pids = _cctally_db_sib._db_family_open_pids(path)
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
- open_pids = _cctally_db_sib._db_family_open_pids(path)
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 backlog is over its
12430
- # hard ceiling. A rebuild adds a whole staged generation of churn
12431
- # to a store that is already failing to return the space it freed,
12432
- # so starting one makes the condition worse rather than better.
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.reclaim_backlog_over_ceiling(
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
- affected = {
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
- _recompute_codex_rollups(conn, affected)
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(conn, affected)
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
- _recompute_codex_rollups(conn, affected_keys)
13453
- import _lib_codex_conversation_query as query
13454
- query.materialize_codex_find_projection(conn, affected_keys)
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.commit()
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
- conn.rollback()
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
- conn.execute(
13523
- "INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
13524
- (
13525
- "codex_conversation_contract_version",
13526
- _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION,
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