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.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +6035 -722
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +400 -2
- package/bin/_cctally_dashboard.py +98 -20
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +285 -213
- package/bin/_cctally_db.py +1177 -23
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +572 -45
- package/bin/_cctally_five_hour.py +84 -33
- package/bin/_cctally_forecast.py +108 -15
- package/bin/_cctally_journal.py +3950 -116
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +227 -37
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +691 -149
- package/bin/_cctally_rederive.py +320 -41
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +83 -17
- package/bin/_cctally_statusline.py +6154 -517
- package/bin/_cctally_store.py +367 -53
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +61 -56
- package/bin/_cctally_weekrefs.py +128 -21
- 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_report.py +35 -6
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +297 -49
- 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 +106 -59
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +714 -69
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_fmt.py +11 -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 +231 -86
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +199 -9
- package/bin/_lib_pricing_check.py +20 -4
- 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_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +319 -82
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +31 -1
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +19 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -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
|
|
@@ -168,25 +169,100 @@ def open_index(store: str) -> sqlite3.Connection:
|
|
|
168
169
|
return conn
|
|
169
170
|
|
|
170
171
|
|
|
171
|
-
|
|
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
|
-
|
|
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(
|
|
185
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3269
|
+
return "error"
|
|
3064
3270
|
_unlink_stats_epoch_marker()
|
|
3065
3271
|
_log_stats_epoch_rebuild("success")
|
|
3066
|
-
return
|
|
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
|
|
3117
|
-
``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."""
|
|
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.
|
|
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")
|