cctally 1.113.0 → 1.115.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -59,6 +59,7 @@ true and must not be restored.
59
59
  from __future__ import annotations
60
60
 
61
61
  import contextlib
62
+ import contextvars
62
63
  import datetime as dt
63
64
  import fcntl
64
65
  import json
@@ -168,25 +169,100 @@ def open_index(store: str) -> sqlite3.Connection:
168
169
  return conn
169
170
 
170
171
 
171
- def apply_policy(conn: sqlite3.Connection, store: str) -> None:
172
+ #: `PRAGMA auto_vacuum`'s numeric report for each policy value.
173
+ _AUTO_VACUUM_MODES = {"NONE": 0, "FULL": 1, "INCREMENTAL": 2}
174
+
175
+
176
+ def apply_policy(
177
+ conn: sqlite3.Connection, store: str, *, memory_temp_store: bool = True,
178
+ ) -> None:
172
179
  """Apply the §6.1 PRAGMA policy for ``store`` to an open connection.
173
180
 
174
181
  ``auto_vacuum`` (when set) is emitted first: it only takes effect before the
175
182
  first page is written, so it must precede ``journal_mode`` / any DDL.
183
+ ``memory_temp_store`` is forwarded to ``apply_connection_policy``.
184
+
185
+ #901 W9 (Q14): the pragma is emitted only when the database does not
186
+ already report the policy's mode. Setting INCREMENTAL or FULL rewrites
187
+ page 1 (a write transaction) even when the value is unchanged, so a
188
+ current-schema conversations.db open wrote a WAL frame on every pass.
189
+ Creation (an empty file reports 0) and a database in another mode still
190
+ receive it, the latter so a later ``VACUUM`` on that connection converts.
176
191
  """
177
192
  policy = STORE_POLICY[store]
178
193
  if policy.auto_vacuum is not None:
179
- conn.execute(f"PRAGMA auto_vacuum={policy.auto_vacuum}")
194
+ wanted = _AUTO_VACUUM_MODES[policy.auto_vacuum.upper()]
195
+ current = conn.execute("PRAGMA auto_vacuum").fetchone()[0]
196
+ if int(current) != wanted:
197
+ conn.execute(f"PRAGMA auto_vacuum={policy.auto_vacuum}")
180
198
  conn.execute(f"PRAGMA journal_mode={policy.journal_mode}")
181
- apply_connection_policy(conn, store)
199
+ apply_connection_policy(conn, store, memory_temp_store=memory_temp_store)
182
200
 
183
201
 
184
- def apply_connection_policy(conn: sqlite3.Connection, store: str) -> None:
185
- """Apply non-schema connection settings without changing journal mode."""
202
+ def apply_connection_policy(
203
+ conn: sqlite3.Connection, store: str, *, memory_temp_store: bool = True,
204
+ ) -> None:
205
+ """Apply non-schema connection settings without changing journal mode.
206
+
207
+ Every ordinary writable opener calls this once at open, before any
208
+ transaction, so it also carries the #901 writer temp-store policy
209
+ (``apply_writer_temp_store``). ``memory_temp_store=False`` is for a
210
+ writer §4.7 does not enumerate, which keeps SQLite's default.
211
+ """
186
212
  policy = STORE_POLICY[store]
187
213
  conn.execute(f"PRAGMA synchronous={policy.synchronous}")
188
214
  conn.execute(f"PRAGMA busy_timeout={policy.busy_timeout}")
189
215
  conn.execute(f"PRAGMA journal_size_limit={policy.journal_size_limit}")
216
+ if memory_temp_store:
217
+ apply_writer_temp_store(conn)
218
+
219
+
220
+ # #901 §5.3a (Q11, design answer dc7 D1): the sync and ingest writers keep
221
+ # their statement journals in memory. A statement that dirties more than 64 KiB
222
+ # of pages that existed when it started (an FTS5 segment write behind the
223
+ # conversation-message trigger, a quota insert maintaining eight indexes and
224
+ # two trigger tables) otherwise spills its journal to an `etilqs_*` file, which
225
+ # SQLite unlinks at creation, so nothing in the data directory shows it.
226
+ # Measured on the revision-5 candidate: 3.7-10 MB of such files per dashboard
227
+ # window and per Codex hook.
228
+ #
229
+ # Set once per connection at open, never toggled per pass. The bound is per
230
+ # statement (the original images of the pages that one statement dirties), not
231
+ # a fixed cap; the cache size is unchanged. It is NOT a licence for sorts:
232
+ # G1 (tests/test_901_sql_plan_guard.py) judges every recurring plan regardless
233
+ # of this setting, so moving a history-sized sort into memory never satisfies
234
+ # I1. Openers outside spec §4.7 keep SQLite's default: read-only, state,
235
+ # backup, checkpoint and vacuum connections (VACUUM builds its copy in the temp
236
+ # store, so MEMORY there would hold the whole database in memory), and the
237
+ # stats publication connection. The deletion connection keeps its own Q5
238
+ # MEMORY/FILE selection and reclaim its own settings
239
+ # (`_lib_conversation_retention`).
240
+ #
241
+ #: The temp store every ordinary writable opener selects. A test sets "FILE"
242
+ #: or "DEFAULT" for the file-temp control (tests/test_901_connection_policy.py).
243
+ WRITER_TEMP_STORE = "MEMORY"
244
+
245
+
246
+ def apply_writer_temp_store(conn: sqlite3.Connection) -> None:
247
+ """Select ``WRITER_TEMP_STORE`` on a writable connection, outside any
248
+ transaction (SQLite ignores the change inside one)."""
249
+ conn.execute(f"PRAGMA temp_store = {WRITER_TEMP_STORE}")
250
+
251
+
252
+ #: PR-3: the temp store an opener selects for the duration of PENDING schema
253
+ #: and migration work (a first open, an upgrade, a cutover), then restores
254
+ #: ``WRITER_TEMP_STORE``. A one-time index build or a migration over a
255
+ #: history-sized table sorts and journals in the temp store, which under MEMORY
256
+ #: is a RAM spike as large as that table; before #901 no temp store was
257
+ #: selected, so this is the base behaviour for that work. A current-schema open
258
+ #: never selects it, so the steady state is unchanged.
259
+ SCHEMA_WORK_TEMP_STORE = "FILE"
260
+
261
+
262
+ def apply_schema_work_temp_store(conn: sqlite3.Connection) -> None:
263
+ """Select ``SCHEMA_WORK_TEMP_STORE`` before pending schema work, outside
264
+ any transaction; the opener selects the writer temp store again after."""
265
+ conn.execute(f"PRAGMA temp_store = {SCHEMA_WORK_TEMP_STORE}")
190
266
 
191
267
 
192
268
  # --------------------------------------------------------------------------
@@ -783,11 +859,12 @@ def stats_open_time_guard(
783
859
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
784
860
  acquired = True
785
861
  break
786
- except (BlockingIOError, OSError):
862
+ except (BlockingIOError, OSError) as exc:
787
863
  if time.monotonic() >= deadline:
788
- raise _cctally_db.StatsDbMaintenanceError(
789
- _stats_open_maintenance_timeout_message()
790
- )
864
+ _GUARD_FAILURE.set(
865
+ "contention" if isinstance(exc, BlockingIOError)
866
+ else "error")
867
+ raise _maintenance_busy_error("exclusive")
791
868
  time.sleep(0.02)
792
869
  _cctally_core.note_stats_maintenance_acquired()
793
870
  try:
@@ -879,6 +956,27 @@ def _stats_open_maintenance_timeout_message() -> str:
879
956
  return _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
880
957
 
881
958
 
959
+ #: Why this context's most recent bounded maintenance wait expired
960
+ #: (#825/#832 Codex fix-round-4 Q3): ``contention`` when the lock stayed
961
+ #: held, ``error`` for an OS error no holder caused.
962
+ _GUARD_FAILURE = contextvars.ContextVar("stats_guard_failure", default=None)
963
+
964
+
965
+ def _maintenance_busy_error(mode: str) -> "_cctally_db.StatsDbMaintenanceError":
966
+ """The store guard's bounded-wait expiry, carrying the lock identity the
967
+ failed acquisition lost to (``blocked_by``: a `_cctally_journal.LockBlock`
968
+ for ``maintenance`` in the requested ``mode``) so a deferred statusline
969
+ reduction can leave an obligation for exactly that release (Q3). An
970
+ expiry an I/O error caused is not contention and wakes nobody."""
971
+ import _cctally_journal
972
+
973
+ exc = _cctally_db.StatsDbMaintenanceError(
974
+ _stats_open_maintenance_timeout_message())
975
+ exc.blocked_by = _cctally_journal.LockBlock(
976
+ "maintenance", mode, _GUARD_FAILURE.get() != "error")
977
+ return exc
978
+
979
+
882
980
  def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
883
981
  """Poll for ``operation`` on ``lock_fh`` until ``timeout_s`` expires.
884
982
 
@@ -889,12 +987,15 @@ def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
889
987
  sequence.
890
988
  """
891
989
  deadline = time.monotonic() + timeout_s
990
+ _GUARD_FAILURE.set(None)
892
991
  while True:
893
992
  try:
894
993
  fcntl.flock(lock_fh, operation | fcntl.LOCK_NB)
895
994
  return True
896
- except (BlockingIOError, OSError):
995
+ except (BlockingIOError, OSError) as exc:
897
996
  if time.monotonic() >= deadline:
997
+ _GUARD_FAILURE.set(
998
+ "contention" if isinstance(exc, BlockingIOError) else "error")
898
999
  return False
899
1000
  time.sleep(0.02)
900
1001
 
@@ -1395,19 +1496,39 @@ def _recover_or_reclaim_interrupted_stats_rebuild(
1395
1496
  "stats.db interrupted-rebuild recovery timed out waiting for "
1396
1497
  "journal ingest serialization; retry after active cctally commands exit"
1397
1498
  )
1499
+ import _cctally_completion
1500
+
1501
+ obligation_failure = None
1398
1502
  try:
1399
1503
  with stats_write_scope("maintenance-interrupted-rebuild"):
1400
- _cctally_journal.rebuild_stats_index(
1401
- context=_cctally_journal.RebuildContext(
1402
- trigger="interrupted-rebuild-recovery"
1403
- ),
1404
- high_water=high_water,
1405
- )
1504
+ try:
1505
+ _cctally_journal.rebuild_stats_index(
1506
+ context=_cctally_journal.RebuildContext(
1507
+ trigger="interrupted-rebuild-recovery"
1508
+ ),
1509
+ high_water=high_water,
1510
+ complete_derivation=True,
1511
+ )
1512
+ except _cctally_completion.ResidualObligationNotDurable as exc:
1513
+ # Fix round K12 (K11R-1): the recovered index IS published;
1514
+ # only its residual obligation was lost. The recovery is
1515
+ # complete, so its cleanup runs and the open continues; the
1516
+ # caller posts the publication wake once its locks are gone.
1517
+ obligation_failure = exc
1406
1518
  _remove_stale_stats_rebuild_artifacts(artifacts)
1407
1519
  _discard_pending_stats_publication_marker(db_path)
1408
- return True
1409
1520
  finally:
1410
1521
  _cctally_journal._release_ingest_lock(ingest_fd)
1522
+ if obligation_failure is not None:
1523
+ print(
1524
+ "cctally: stats.db was recovered from an interrupted rebuild, but "
1525
+ "the record that the inputs written during it still need deriving "
1526
+ f"could not be saved ({obligation_failure}). Those inputs stay in "
1527
+ "the journal and the next ingest derives them; check that the "
1528
+ "data directory is writable.",
1529
+ file=sys.stderr,
1530
+ )
1531
+ return True
1411
1532
 
1412
1533
 
1413
1534
  #: #778 — every guarded stats connection is built with the statement cache OFF.
@@ -1472,9 +1593,7 @@ def stats_open_guarded(
1472
1593
  if not _flock_bounded(
1473
1594
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1474
1595
  ):
1475
- raise _cctally_db.StatsDbMaintenanceError(
1476
- _stats_open_maintenance_timeout_message()
1477
- )
1596
+ raise _maintenance_busy_error("shared")
1478
1597
  if marker.exists():
1479
1598
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1480
1599
  raise _cctally_db.StatsDbMaintenanceError()
@@ -1486,9 +1605,7 @@ def stats_open_guarded(
1486
1605
  if not _flock_bounded(
1487
1606
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1488
1607
  ):
1489
- raise _cctally_db.StatsDbMaintenanceError(
1490
- _stats_open_maintenance_timeout_message()
1491
- )
1608
+ raise _maintenance_busy_error("exclusive")
1492
1609
  try:
1493
1610
  if marker.exists():
1494
1611
  raise _cctally_db.StatsDbMaintenanceError()
@@ -1511,9 +1628,7 @@ def stats_open_guarded(
1511
1628
  if not _flock_bounded(
1512
1629
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1513
1630
  ):
1514
- raise _cctally_db.StatsDbMaintenanceError(
1515
- _stats_open_maintenance_timeout_message()
1516
- )
1631
+ raise _maintenance_busy_error("exclusive")
1517
1632
  # Recovery calls `rebuild_stats_index`, whose in-place publisher
1518
1633
  # reopens the live destination through `stats_open_guarded`.
1519
1634
  # `flock` conflicts are per open-file-DESCRIPTION and apply
@@ -1553,13 +1668,16 @@ def stats_open_guarded(
1553
1668
  _cctally_core.note_stats_maintenance_released()
1554
1669
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1555
1670
  if recovered:
1671
+ # #825/#832 spec 4.3 revision 2: interrupted-rebuild
1672
+ # recovery is a live index publication; its wake is
1673
+ # deferred past any lock the caller of this open holds.
1674
+ import _cctally_completion
1675
+ _cctally_completion.post_publication_wake()
1556
1676
  continue
1557
1677
  if not _flock_bounded(
1558
1678
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1559
1679
  ):
1560
- raise _cctally_db.StatsDbMaintenanceError(
1561
- _stats_open_maintenance_timeout_message()
1562
- )
1680
+ raise _maintenance_busy_error("shared")
1563
1681
  if marker.exists() or pending.exists():
1564
1682
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1565
1683
  continue
@@ -1577,9 +1695,7 @@ def stats_open_guarded(
1577
1695
  if not _flock_bounded(
1578
1696
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1579
1697
  ):
1580
- raise _cctally_db.StatsDbMaintenanceError(
1581
- _stats_open_maintenance_timeout_message()
1582
- )
1698
+ raise _maintenance_busy_error("exclusive")
1583
1699
  try:
1584
1700
  _resolve_stats_publication_marker(db_path)
1585
1701
  finally:
@@ -1587,9 +1703,7 @@ def stats_open_guarded(
1587
1703
  if not _flock_bounded(
1588
1704
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1589
1705
  ):
1590
- raise _cctally_db.StatsDbMaintenanceError(
1591
- _stats_open_maintenance_timeout_message()
1592
- )
1706
+ raise _maintenance_busy_error("shared")
1593
1707
  if marker.exists() or pending.exists():
1594
1708
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1595
1709
  continue
@@ -2558,27 +2672,49 @@ def _run_stats_corruption_heal(request: dict) -> str:
2558
2672
  )
2559
2673
  if not confirmed:
2560
2674
  return "declined-unconfirmed"
2675
+ import _cctally_completion
2676
+ obligation_failure = None
2561
2677
  with stats_write_scope("maintenance-heal"):
2562
- result = _cctally_journal.rebuild_stats_index(
2563
- context=_cctally_journal.RebuildContext(
2564
- trigger="corruption-heal",
2565
- trigger_error=str(request.get("triggerError") or ""),
2566
- forensics_path=request.get("forensicsPath"),
2567
- ),
2568
- high_water=high_water,
2569
- )
2678
+ try:
2679
+ result = _cctally_journal.rebuild_stats_index(
2680
+ context=_cctally_journal.RebuildContext(
2681
+ trigger="corruption-heal",
2682
+ trigger_error=str(
2683
+ request.get("triggerError") or ""),
2684
+ forensics_path=request.get("forensicsPath"),
2685
+ ),
2686
+ high_water=high_water,
2687
+ complete_derivation=True,
2688
+ )
2689
+ except _cctally_completion.ResidualObligationNotDurable \
2690
+ as exc:
2691
+ # Fix round K11 (K9K10-R7): the healed index IS
2692
+ # published; only its residual obligation is lost.
2693
+ obligation_failure = exc
2694
+ result = getattr(exc, "result", None)
2570
2695
  finally:
2571
2696
  if ingest_fd is not None:
2572
2697
  _heal_release_flock(ingest_fd)
2573
- _record_stats_heal_outcome(heal_id, "rebuilt", result=result)
2698
+ _record_stats_heal_outcome(heal_id, "rebuilt", result=result,
2699
+ error=obligation_failure)
2700
+ if obligation_failure is not None:
2701
+ return STATS_HEAL_RESIDUAL_NOT_DURABLE
2574
2702
  return "success"
2575
2703
  finally:
2576
2704
  _heal_release_maintenance_flock(maint_fd)
2577
2705
 
2578
2706
 
2707
+ #: Fix round K11 (K9K10-R7): the heal published the rebuilt index, but its
2708
+ #: residual-derivation obligation could not be made durable. The heal is
2709
+ #: done (the request is finalized and the publication wake posted); the ring
2710
+ #: records the rebuild with the obligation error, and the worker log says so.
2711
+ STATS_HEAL_RESIDUAL_NOT_DURABLE = "rebuilt-residual-not-durable"
2712
+
2713
+
2579
2714
  def cmd_stats_corruption_heal_internal(args) -> int:
2580
2715
  """Hidden detached worker: heal one corrupt stats index exactly once."""
2581
2716
  del args
2717
+ healed = False
2582
2718
  try:
2583
2719
  pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
2584
2720
  worker_fd = os.open(
@@ -2609,12 +2745,17 @@ def cmd_stats_corruption_heal_internal(args) -> int:
2609
2745
  if outcome in ("maintenance-busy", "ingest-busy"):
2610
2746
  _log_stats_heal(outcome, heal_id=heal_id)
2611
2747
  return 0
2612
- if outcome != "success":
2748
+ # Fix round K11 (K9K10-R7): a heal that published but lost its
2749
+ # residual obligation is a heal; its ring outcome is already
2750
+ # recorded (with the obligation error) and it wakes like a success.
2751
+ published = outcome in ("success", STATS_HEAL_RESIDUAL_NOT_DURABLE)
2752
+ if not published:
2613
2753
  _record_stats_heal_outcome(heal_id, outcome)
2614
2754
  if not _finalize_stats_heal_request(heal_id):
2615
2755
  _log_stats_heal("finalization-pending", heal_id=heal_id)
2616
2756
  return 0
2617
2757
  _log_stats_heal(outcome, heal_id=heal_id)
2758
+ healed = published
2618
2759
  return 0
2619
2760
  finally:
2620
2761
  try:
@@ -2622,6 +2763,11 @@ def cmd_stats_corruption_heal_internal(args) -> int:
2622
2763
  except OSError:
2623
2764
  pass
2624
2765
  os.close(worker_fd)
2766
+ if healed:
2767
+ # #825/#832 spec 4.3 revision 2: the healed index posts its
2768
+ # completion wake, with the worker flock released.
2769
+ import _cctally_completion
2770
+ _cctally_completion.post_publication_wake()
2625
2771
 
2626
2772
 
2627
2773
  # --------------------------------------------------------------------------
@@ -3033,8 +3179,62 @@ def defer_stats_epoch_rebuild() -> str:
3033
3179
  os.close(admission_fd)
3034
3180
 
3035
3181
 
3182
+ def stats_rebuild_worker_blocks() -> tuple:
3183
+ """#825/#832 Codex fix-round-4 Q3: one failed try-lock per detached
3184
+ rebuild worker lock (the epoch worker's and the heal worker's), each a
3185
+ `_cctally_journal.LockBlock` naming the worker lock that refused it,
3186
+ whether it was held (contention) or the probe hit an I/O error. A worker
3187
+ lock that is free, or whose file does not exist, yields nothing: an
3188
+ unlocked probe proves no release."""
3189
+ import _cctally_journal
3190
+
3191
+ blocks = []
3192
+ for name, path in (("epoch-worker", _stats_epoch_rebuild_worker_path()),
3193
+ ("heal-worker", _stats_heal_worker_path())):
3194
+ try:
3195
+ fd = os.open(path, os.O_RDONLY)
3196
+ except FileNotFoundError:
3197
+ continue
3198
+ except OSError:
3199
+ blocks.append(_cctally_journal.LockBlock(name, "exclusive", False))
3200
+ continue
3201
+ try:
3202
+ try:
3203
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
3204
+ except OSError as exc:
3205
+ blocks.append(_cctally_journal.LockBlock(
3206
+ name, "exclusive", isinstance(exc, BlockingIOError)))
3207
+ continue
3208
+ try:
3209
+ fcntl.flock(fd, fcntl.LOCK_UN)
3210
+ except OSError:
3211
+ pass
3212
+ finally:
3213
+ os.close(fd)
3214
+ return tuple(blocks)
3215
+
3216
+
3036
3217
  def cmd_stats_epoch_rebuild_internal(args) -> int:
3037
- """Hidden detached worker: converge a pending stats epoch exactly once."""
3218
+ """Hidden detached worker: converge a pending stats epoch exactly once,
3219
+ then — with the worker flock released — post the completion wake that
3220
+ resumes deferred statusline work (#825/#832 spec 4.3 revision 2). Only a
3221
+ publication wakes: a failed rebuild manufactures no wake."""
3222
+ if _stats_epoch_rebuild_worker(args) in (
3223
+ "success", "current", STATS_EPOCH_RESIDUAL_NOT_DURABLE):
3224
+ import _cctally_completion
3225
+ _cctally_completion.post_publication_wake()
3226
+ return 0
3227
+
3228
+
3229
+ #: Fix round K11 (K9K10-R7): the epoch rebuild published a current index, but
3230
+ #: its residual-derivation obligation could not be made durable. The epoch
3231
+ #: converged (the marker is removed and the publication wake posted); the
3232
+ #: worker log records the obligation failure.
3233
+ STATS_EPOCH_RESIDUAL_NOT_DURABLE = "success-residual-not-durable"
3234
+
3235
+
3236
+ def _stats_epoch_rebuild_worker(args) -> str:
3237
+ """The worker body; returns its outcome."""
3038
3238
  del args
3039
3239
  try:
3040
3240
  pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
@@ -3045,25 +3245,31 @@ def cmd_stats_epoch_rebuild_internal(args) -> int:
3045
3245
  )
3046
3246
  except OSError as exc:
3047
3247
  _log_stats_epoch_rebuild("error", error=exc)
3048
- return 0
3248
+ return "error"
3049
3249
  try:
3050
3250
  try:
3051
3251
  fcntl.flock(worker_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
3052
3252
  except OSError:
3053
- return 0
3253
+ return "busy"
3054
3254
  if not stats_epoch_rebuild_pending():
3055
3255
  _unlink_stats_epoch_marker()
3056
3256
  _log_stats_epoch_rebuild("current")
3057
- return 0
3257
+ return "current"
3258
+ import _cctally_completion
3058
3259
  try:
3059
3260
  conn = resolve_stats_epoch_mismatch()
3060
3261
  conn.close()
3262
+ except _cctally_completion.ResidualObligationNotDurable as exc:
3263
+ _unlink_stats_epoch_marker()
3264
+ _log_stats_epoch_rebuild(STATS_EPOCH_RESIDUAL_NOT_DURABLE,
3265
+ error=exc)
3266
+ return STATS_EPOCH_RESIDUAL_NOT_DURABLE
3061
3267
  except Exception as exc:
3062
3268
  _log_stats_epoch_rebuild("error", error=exc)
3063
- return 0
3269
+ return "error"
3064
3270
  _unlink_stats_epoch_marker()
3065
3271
  _log_stats_epoch_rebuild("success")
3066
- return 0
3272
+ return "success"
3067
3273
  finally:
3068
3274
  try:
3069
3275
  fcntl.flock(worker_fd, fcntl.LOCK_UN)
@@ -3113,8 +3319,10 @@ def stats_epoch_rebuild_pending(path=None) -> bool:
3113
3319
 
3114
3320
  def resolve_stats_epoch_mismatch():
3115
3321
  """Resolve a version-mismatched stats.db by journal rebuild (spec §7.1).
3116
- Called by ``open_db`` after ``conn.close()`` — returns a fresh steady-state
3117
- ``open_db()`` connection over the rebuilt index."""
3322
+ Called by the detached epoch worker (``_stats_epoch_rebuild_worker``);
3323
+ an ordinary ``open_db`` defers the epoch to that worker instead of
3324
+ rebuilding inline. Returns a fresh steady-state ``open_db()`` connection
3325
+ over the rebuilt index."""
3118
3326
  global _EPOCH_MISMATCH_ACTIVE
3119
3327
  import _cctally_journal
3120
3328
 
@@ -3173,6 +3381,15 @@ def resolve_stats_epoch_mismatch():
3173
3381
  # Append the idempotent account coordinator input, then let
3174
3382
  # the common rebuild cutover preserve the version-ahead
3175
3383
  # family and atomically publish the current epoch.
3384
+ #
3385
+ # #825/#832 (spec C3/F1; Codex fix-round-1 Q1): the
3386
+ # rebuild runs the shared derivation-completion step, which
3387
+ # journals the decisions of every admitted reading no
3388
+ # current binary derived — including the observation of
3389
+ # the very tick whose open deferred here, and a reading a
3390
+ # previous-release ingest derived unstamped. The replaced
3391
+ # index's applied cursor is never trusted as proof that
3392
+ # derivation completed.
3176
3393
  with stats_write_scope("maintenance-epoch"):
3177
3394
  _cctally_journal.run_epoch_transition()
3178
3395
  finally:
@@ -3183,3 +3400,100 @@ def resolve_stats_epoch_mismatch():
3183
3400
  return _cctally_core.open_db()
3184
3401
  finally:
3185
3402
  _EPOCH_MISMATCH_ACTIVE = False
3403
+
3404
+
3405
+ # --------------------------------------------------------------------------
3406
+ # #825/#832 validated read snapshots (spec section 6)
3407
+ # --------------------------------------------------------------------------
3408
+
3409
+ #: The index object whose presence identifies the account-bound protocol's
3410
+ #: interpretation of the journal. A previous-release generation (epoch 1017)
3411
+ #: publishes no such table, so an index an old binary republished fails this
3412
+ #: check even if its `user_version` were somehow current.
3413
+ PROTOCOL_MARKER_TABLE = "journal_admission_summary"
3414
+
3415
+
3416
+ class StatsIndexNotCurrent(RuntimeError):
3417
+ """The stats index this snapshot reads is not the current interpretation
3418
+ of the journal (another epoch, or no protocol marker): typically a
3419
+ previous-release process republished it. Nothing read from it may be
3420
+ persisted or decided on.
3421
+
3422
+ Deliberately NOT an `sqlite3.DatabaseError`: a sound index at another epoch
3423
+ is not corruption, and the corruption classifier must never see it so."""
3424
+
3425
+
3426
+ def validate_stats_identity(conn: sqlite3.Connection, *, expected_epoch: int,
3427
+ expected_protocol: "str | None") -> None:
3428
+ """Check epoch and protocol identity on the caller's CURRENT snapshot.
3429
+
3430
+ Inside a transaction both reads come from that transaction's snapshot, so
3431
+ the check and the caller's own queries describe one database state."""
3432
+ epoch = int(conn.execute("PRAGMA user_version").fetchone()[0])
3433
+ if (epoch <= _cctally_core.LEGACY_STATS_HEAD
3434
+ and int(expected_epoch) == _cctally_core.STATS_INDEX_EPOCH):
3435
+ # A legacy PRE-JOURNAL index (#825/#832 Task 4 regression fix): the
3436
+ # one-time journal cutover has not converted it yet — typically a
3437
+ # gated recompute migration is still deferred — so it was never
3438
+ # materialized from the journal and is not "another interpretation"
3439
+ # of it. No account-bound boundary exists before that cutover
3440
+ # (`AccountBoundCutoverDeferred`), so admission excludes nothing. The
3441
+ # ingest's own open admits exactly this range (`epoch_ok` in
3442
+ # `_run_stats_ingest_once`); rejecting it here made every recording
3443
+ # on such a store exit 1. A previous release's index is at 1017, far
3444
+ # above the legacy head, and stays rejected.
3445
+ return
3446
+ if epoch != int(expected_epoch):
3447
+ raise StatsIndexNotCurrent(
3448
+ f"stats index epoch {epoch} is not the current {expected_epoch}")
3449
+ if expected_protocol is not None:
3450
+ row = conn.execute(
3451
+ "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?",
3452
+ (PROTOCOL_MARKER_TABLE,),
3453
+ ).fetchone()
3454
+ if row is None:
3455
+ raise StatsIndexNotCurrent(
3456
+ f"stats index lacks the {expected_protocol} protocol marker")
3457
+
3458
+
3459
+ @contextlib.contextmanager
3460
+ def validated_read_snapshot(conn: sqlite3.Connection, *, expected_epoch: int,
3461
+ expected_protocol: "str | None"):
3462
+ """ONE read snapshot whose identity is proven first.
3463
+
3464
+ Joins a transaction the caller already holds; otherwise opens a deferred
3465
+ read transaction and rolls it back afterwards (the `one_read_snapshot`
3466
+ discipline). The epoch and protocol identity are read INSIDE the snapshot
3467
+ the enclosed queries use, so a reader that validated its connection at open
3468
+ cannot read an index a previous-release process republished afterwards.
3469
+ Raises `StatsIndexNotCurrent` before the block runs."""
3470
+ own = not conn.in_transaction
3471
+ if own:
3472
+ conn.execute("BEGIN")
3473
+ try:
3474
+ validate_stats_identity(conn, expected_epoch=expected_epoch,
3475
+ expected_protocol=expected_protocol)
3476
+ yield
3477
+ finally:
3478
+ if own:
3479
+ try:
3480
+ conn.rollback()
3481
+ except sqlite3.Error:
3482
+ pass
3483
+
3484
+
3485
+ def current_stats_snapshot(conn: sqlite3.Connection):
3486
+ """`validated_read_snapshot` at `STATS_INDEX_EPOCH` and the account-bound
3487
+ protocol: the read scope every persisted-decision owner uses."""
3488
+ import _lib_account_protocol
3489
+ return validated_read_snapshot(
3490
+ conn, expected_epoch=_cctally_core.STATS_INDEX_EPOCH,
3491
+ expected_protocol=_lib_account_protocol.PROTOCOL_GENERATION)
3492
+
3493
+
3494
+ def require_current_stats_identity(conn: sqlite3.Connection) -> None:
3495
+ """The same check on the caller's own (write) transaction."""
3496
+ import _lib_account_protocol
3497
+ validate_stats_identity(
3498
+ conn, expected_epoch=_cctally_core.STATS_INDEX_EPOCH,
3499
+ expected_protocol=_lib_account_protocol.PROTOCOL_GENERATION)
@@ -49,13 +49,14 @@ def cmd_transcript(args) -> int:
49
49
  # ---- export ----------------------------------------------------------------
50
50
 
51
51
  _SPEED_ONLY_CODEX_MSG = "transcript: --speed applies only to Codex conversations"
52
- # Deliberately generic about WHAT is pending. Three distinct conditions reach
53
- # this branch — a cache predating migration 025, a store with the contract
54
- # rebuild marker armed, and a store with a byte-zero Codex replay marker armed —
55
- # and naming the migration told users to wait for work applied long ago.
52
+ # Deliberately generic about WHAT is pending. Four distinct conditions reach
53
+ # this branch: a cache predating migration 025, a store with the contract
54
+ # rebuild marker armed, a store with a byte-zero Codex replay marker armed,
55
+ # and (#968) a population conversation the publication barrier has not yet
56
+ # published. The last is finished by a background worker, not by the next
57
+ # cache open, so the note names no mechanism (operator decision D5).
56
58
  _PENDING_EXPORT_MSG = (
57
- "transcript: Codex conversation is not yet normalized "
58
- "(the pending work runs on the next cache open) — retry shortly")
59
+ "transcript: Codex conversation is not yet normalized — retry shortly")
59
60
 
60
61
 
61
62
  def _emit_export(md: str, output) -> None:
@@ -265,7 +266,7 @@ def _cmd_transcript_search_claude(args) -> int:
265
266
 
266
267
  # ---- search (Codex) --------------------------------------------------------
267
268
 
268
- # Same generic wording as `_PENDING_EXPORT_MSG`, for the same reason.
269
+ # The store-wide conditions run on the next cache open; D5 changes only export.
269
270
  _CODEX_SEARCH_PENDING_MSG = (
270
271
  "transcript: Codex conversations are not yet normalized "
271
272
  "(the pending work runs on the next cache open); no results yet")