cctally 1.114.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.
- package/CHANGELOG.md +25 -0
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +4394 -279
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +178 -2
- package/bin/_cctally_dashboard.py +2 -2
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_sources.py +18 -9
- package/bin/_cctally_db.py +316 -5
- package/bin/_cctally_doctor.py +436 -27
- package/bin/_cctally_five_hour.py +82 -31
- package/bin/_cctally_forecast.py +81 -9
- package/bin/_cctally_journal.py +3884 -112
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +26 -4
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +677 -149
- package/bin/_cctally_rederive.py +297 -32
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +23 -2
- package/bin/_cctally_statusline.py +6137 -517
- package/bin/_cctally_store.py +287 -48
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +57 -53
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation_query.py +133 -19
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +11 -3
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +452 -1
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +29 -302
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_pricing.py +7 -2
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_rederive.py +94 -5
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/cctally +23 -1
- package/package.json +14 -1
package/bin/_cctally_store.py
CHANGED
|
@@ -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
|
|
@@ -858,11 +859,12 @@ def stats_open_time_guard(
|
|
|
858
859
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
859
860
|
acquired = True
|
|
860
861
|
break
|
|
861
|
-
except (BlockingIOError, OSError):
|
|
862
|
+
except (BlockingIOError, OSError) as exc:
|
|
862
863
|
if time.monotonic() >= deadline:
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
864
|
+
_GUARD_FAILURE.set(
|
|
865
|
+
"contention" if isinstance(exc, BlockingIOError)
|
|
866
|
+
else "error")
|
|
867
|
+
raise _maintenance_busy_error("exclusive")
|
|
866
868
|
time.sleep(0.02)
|
|
867
869
|
_cctally_core.note_stats_maintenance_acquired()
|
|
868
870
|
try:
|
|
@@ -954,6 +956,27 @@ def _stats_open_maintenance_timeout_message() -> str:
|
|
|
954
956
|
return _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
|
|
955
957
|
|
|
956
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
|
+
|
|
957
980
|
def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
|
|
958
981
|
"""Poll for ``operation`` on ``lock_fh`` until ``timeout_s`` expires.
|
|
959
982
|
|
|
@@ -964,12 +987,15 @@ def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
|
|
|
964
987
|
sequence.
|
|
965
988
|
"""
|
|
966
989
|
deadline = time.monotonic() + timeout_s
|
|
990
|
+
_GUARD_FAILURE.set(None)
|
|
967
991
|
while True:
|
|
968
992
|
try:
|
|
969
993
|
fcntl.flock(lock_fh, operation | fcntl.LOCK_NB)
|
|
970
994
|
return True
|
|
971
|
-
except (BlockingIOError, OSError):
|
|
995
|
+
except (BlockingIOError, OSError) as exc:
|
|
972
996
|
if time.monotonic() >= deadline:
|
|
997
|
+
_GUARD_FAILURE.set(
|
|
998
|
+
"contention" if isinstance(exc, BlockingIOError) else "error")
|
|
973
999
|
return False
|
|
974
1000
|
time.sleep(0.02)
|
|
975
1001
|
|
|
@@ -1470,19 +1496,39 @@ def _recover_or_reclaim_interrupted_stats_rebuild(
|
|
|
1470
1496
|
"stats.db interrupted-rebuild recovery timed out waiting for "
|
|
1471
1497
|
"journal ingest serialization; retry after active cctally commands exit"
|
|
1472
1498
|
)
|
|
1499
|
+
import _cctally_completion
|
|
1500
|
+
|
|
1501
|
+
obligation_failure = None
|
|
1473
1502
|
try:
|
|
1474
1503
|
with stats_write_scope("maintenance-interrupted-rebuild"):
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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
|
|
1481
1518
|
_remove_stale_stats_rebuild_artifacts(artifacts)
|
|
1482
1519
|
_discard_pending_stats_publication_marker(db_path)
|
|
1483
|
-
return True
|
|
1484
1520
|
finally:
|
|
1485
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
|
|
1486
1532
|
|
|
1487
1533
|
|
|
1488
1534
|
#: #778 — every guarded stats connection is built with the statement cache OFF.
|
|
@@ -1547,9 +1593,7 @@ def stats_open_guarded(
|
|
|
1547
1593
|
if not _flock_bounded(
|
|
1548
1594
|
lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
|
|
1549
1595
|
):
|
|
1550
|
-
raise
|
|
1551
|
-
_stats_open_maintenance_timeout_message()
|
|
1552
|
-
)
|
|
1596
|
+
raise _maintenance_busy_error("shared")
|
|
1553
1597
|
if marker.exists():
|
|
1554
1598
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
1555
1599
|
raise _cctally_db.StatsDbMaintenanceError()
|
|
@@ -1561,9 +1605,7 @@ def stats_open_guarded(
|
|
|
1561
1605
|
if not _flock_bounded(
|
|
1562
1606
|
lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
|
|
1563
1607
|
):
|
|
1564
|
-
raise
|
|
1565
|
-
_stats_open_maintenance_timeout_message()
|
|
1566
|
-
)
|
|
1608
|
+
raise _maintenance_busy_error("exclusive")
|
|
1567
1609
|
try:
|
|
1568
1610
|
if marker.exists():
|
|
1569
1611
|
raise _cctally_db.StatsDbMaintenanceError()
|
|
@@ -1586,9 +1628,7 @@ def stats_open_guarded(
|
|
|
1586
1628
|
if not _flock_bounded(
|
|
1587
1629
|
lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
|
|
1588
1630
|
):
|
|
1589
|
-
raise
|
|
1590
|
-
_stats_open_maintenance_timeout_message()
|
|
1591
|
-
)
|
|
1631
|
+
raise _maintenance_busy_error("exclusive")
|
|
1592
1632
|
# Recovery calls `rebuild_stats_index`, whose in-place publisher
|
|
1593
1633
|
# reopens the live destination through `stats_open_guarded`.
|
|
1594
1634
|
# `flock` conflicts are per open-file-DESCRIPTION and apply
|
|
@@ -1628,13 +1668,16 @@ def stats_open_guarded(
|
|
|
1628
1668
|
_cctally_core.note_stats_maintenance_released()
|
|
1629
1669
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
1630
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()
|
|
1631
1676
|
continue
|
|
1632
1677
|
if not _flock_bounded(
|
|
1633
1678
|
lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
|
|
1634
1679
|
):
|
|
1635
|
-
raise
|
|
1636
|
-
_stats_open_maintenance_timeout_message()
|
|
1637
|
-
)
|
|
1680
|
+
raise _maintenance_busy_error("shared")
|
|
1638
1681
|
if marker.exists() or pending.exists():
|
|
1639
1682
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
1640
1683
|
continue
|
|
@@ -1652,9 +1695,7 @@ def stats_open_guarded(
|
|
|
1652
1695
|
if not _flock_bounded(
|
|
1653
1696
|
lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
|
|
1654
1697
|
):
|
|
1655
|
-
raise
|
|
1656
|
-
_stats_open_maintenance_timeout_message()
|
|
1657
|
-
)
|
|
1698
|
+
raise _maintenance_busy_error("exclusive")
|
|
1658
1699
|
try:
|
|
1659
1700
|
_resolve_stats_publication_marker(db_path)
|
|
1660
1701
|
finally:
|
|
@@ -1662,9 +1703,7 @@ def stats_open_guarded(
|
|
|
1662
1703
|
if not _flock_bounded(
|
|
1663
1704
|
lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
|
|
1664
1705
|
):
|
|
1665
|
-
raise
|
|
1666
|
-
_stats_open_maintenance_timeout_message()
|
|
1667
|
-
)
|
|
1706
|
+
raise _maintenance_busy_error("shared")
|
|
1668
1707
|
if marker.exists() or pending.exists():
|
|
1669
1708
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
1670
1709
|
continue
|
|
@@ -2633,27 +2672,49 @@ def _run_stats_corruption_heal(request: dict) -> str:
|
|
|
2633
2672
|
)
|
|
2634
2673
|
if not confirmed:
|
|
2635
2674
|
return "declined-unconfirmed"
|
|
2675
|
+
import _cctally_completion
|
|
2676
|
+
obligation_failure = None
|
|
2636
2677
|
with stats_write_scope("maintenance-heal"):
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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)
|
|
2645
2695
|
finally:
|
|
2646
2696
|
if ingest_fd is not None:
|
|
2647
2697
|
_heal_release_flock(ingest_fd)
|
|
2648
|
-
_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
|
|
2649
2702
|
return "success"
|
|
2650
2703
|
finally:
|
|
2651
2704
|
_heal_release_maintenance_flock(maint_fd)
|
|
2652
2705
|
|
|
2653
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
|
+
|
|
2654
2714
|
def cmd_stats_corruption_heal_internal(args) -> int:
|
|
2655
2715
|
"""Hidden detached worker: heal one corrupt stats index exactly once."""
|
|
2656
2716
|
del args
|
|
2717
|
+
healed = False
|
|
2657
2718
|
try:
|
|
2658
2719
|
pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
|
|
2659
2720
|
worker_fd = os.open(
|
|
@@ -2684,12 +2745,17 @@ def cmd_stats_corruption_heal_internal(args) -> int:
|
|
|
2684
2745
|
if outcome in ("maintenance-busy", "ingest-busy"):
|
|
2685
2746
|
_log_stats_heal(outcome, heal_id=heal_id)
|
|
2686
2747
|
return 0
|
|
2687
|
-
|
|
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:
|
|
2688
2753
|
_record_stats_heal_outcome(heal_id, outcome)
|
|
2689
2754
|
if not _finalize_stats_heal_request(heal_id):
|
|
2690
2755
|
_log_stats_heal("finalization-pending", heal_id=heal_id)
|
|
2691
2756
|
return 0
|
|
2692
2757
|
_log_stats_heal(outcome, heal_id=heal_id)
|
|
2758
|
+
healed = published
|
|
2693
2759
|
return 0
|
|
2694
2760
|
finally:
|
|
2695
2761
|
try:
|
|
@@ -2697,6 +2763,11 @@ def cmd_stats_corruption_heal_internal(args) -> int:
|
|
|
2697
2763
|
except OSError:
|
|
2698
2764
|
pass
|
|
2699
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()
|
|
2700
2771
|
|
|
2701
2772
|
|
|
2702
2773
|
# --------------------------------------------------------------------------
|
|
@@ -3108,8 +3179,62 @@ def defer_stats_epoch_rebuild() -> str:
|
|
|
3108
3179
|
os.close(admission_fd)
|
|
3109
3180
|
|
|
3110
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
|
+
|
|
3111
3217
|
def cmd_stats_epoch_rebuild_internal(args) -> int:
|
|
3112
|
-
"""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."""
|
|
3113
3238
|
del args
|
|
3114
3239
|
try:
|
|
3115
3240
|
pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
|
|
@@ -3120,25 +3245,31 @@ def cmd_stats_epoch_rebuild_internal(args) -> int:
|
|
|
3120
3245
|
)
|
|
3121
3246
|
except OSError as exc:
|
|
3122
3247
|
_log_stats_epoch_rebuild("error", error=exc)
|
|
3123
|
-
return
|
|
3248
|
+
return "error"
|
|
3124
3249
|
try:
|
|
3125
3250
|
try:
|
|
3126
3251
|
fcntl.flock(worker_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
3127
3252
|
except OSError:
|
|
3128
|
-
return
|
|
3253
|
+
return "busy"
|
|
3129
3254
|
if not stats_epoch_rebuild_pending():
|
|
3130
3255
|
_unlink_stats_epoch_marker()
|
|
3131
3256
|
_log_stats_epoch_rebuild("current")
|
|
3132
|
-
return
|
|
3257
|
+
return "current"
|
|
3258
|
+
import _cctally_completion
|
|
3133
3259
|
try:
|
|
3134
3260
|
conn = resolve_stats_epoch_mismatch()
|
|
3135
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
|
|
3136
3267
|
except Exception as exc:
|
|
3137
3268
|
_log_stats_epoch_rebuild("error", error=exc)
|
|
3138
|
-
return
|
|
3269
|
+
return "error"
|
|
3139
3270
|
_unlink_stats_epoch_marker()
|
|
3140
3271
|
_log_stats_epoch_rebuild("success")
|
|
3141
|
-
return
|
|
3272
|
+
return "success"
|
|
3142
3273
|
finally:
|
|
3143
3274
|
try:
|
|
3144
3275
|
fcntl.flock(worker_fd, fcntl.LOCK_UN)
|
|
@@ -3188,8 +3319,10 @@ def stats_epoch_rebuild_pending(path=None) -> bool:
|
|
|
3188
3319
|
|
|
3189
3320
|
def resolve_stats_epoch_mismatch():
|
|
3190
3321
|
"""Resolve a version-mismatched stats.db by journal rebuild (spec §7.1).
|
|
3191
|
-
Called by
|
|
3192
|
-
``open_db
|
|
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."""
|
|
3193
3326
|
global _EPOCH_MISMATCH_ACTIVE
|
|
3194
3327
|
import _cctally_journal
|
|
3195
3328
|
|
|
@@ -3248,6 +3381,15 @@ def resolve_stats_epoch_mismatch():
|
|
|
3248
3381
|
# Append the idempotent account coordinator input, then let
|
|
3249
3382
|
# the common rebuild cutover preserve the version-ahead
|
|
3250
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.
|
|
3251
3393
|
with stats_write_scope("maintenance-epoch"):
|
|
3252
3394
|
_cctally_journal.run_epoch_transition()
|
|
3253
3395
|
finally:
|
|
@@ -3258,3 +3400,100 @@ def resolve_stats_epoch_mismatch():
|
|
|
3258
3400
|
return _cctally_core.open_db()
|
|
3259
3401
|
finally:
|
|
3260
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.
|
|
53
|
-
# this branch
|
|
54
|
-
# rebuild marker armed,
|
|
55
|
-
# and
|
|
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
|
-
#
|
|
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")
|