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_rederive.py
CHANGED
|
@@ -27,10 +27,13 @@ from pathlib import Path
|
|
|
27
27
|
import _cctally_core
|
|
28
28
|
import _cctally_journal as _journal
|
|
29
29
|
import _cctally_record as _record
|
|
30
|
+
import _lib_account_protocol
|
|
30
31
|
import _lib_journal
|
|
31
32
|
import _lib_journal_router
|
|
32
33
|
import _lib_json_envelope
|
|
33
34
|
import _lib_rederive
|
|
35
|
+
import _cctally_store
|
|
36
|
+
from _cctally_store import StatsIndexNotCurrent
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
class RederiveBusy(RuntimeError):
|
|
@@ -48,6 +51,14 @@ class RederiveApplyError(RuntimeError):
|
|
|
48
51
|
self.cause = cause
|
|
49
52
|
|
|
50
53
|
|
|
54
|
+
class RederiveObligationNotDurable(RederiveApplyError):
|
|
55
|
+
"""Fix round K12 (K11R-2): the corrected index IS published, but its
|
|
56
|
+
residual-derivation obligation could not be made durable. Raised only
|
|
57
|
+
after the apply's lock context has exited normally, so the locks are
|
|
58
|
+
released and the holder wake is posted; the command reports it with
|
|
59
|
+
message N2 and exit 3, never as a failed apply."""
|
|
60
|
+
|
|
61
|
+
|
|
51
62
|
@dataclass(frozen=True)
|
|
52
63
|
class RederivePreview:
|
|
53
64
|
plan: _lib_rederive.RederivePlan
|
|
@@ -90,6 +101,13 @@ def _fingerprint(value) -> str:
|
|
|
90
101
|
return "sha256:" + hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
|
91
102
|
|
|
92
103
|
|
|
104
|
+
#: #874: the only tables the cache contract names. Inspecting any other table
|
|
105
|
+
#: is not the planner's business, and on a virtual table whose module this
|
|
106
|
+
#: SQLite build lacks (a legacy FTS5 index on a build without FTS5) even
|
|
107
|
+
#: `PRAGMA table_info` raises before the contract could type the gap.
|
|
108
|
+
_CONTRACT_TABLES = ("session_entries", "session_files", "codex_session_entries")
|
|
109
|
+
|
|
110
|
+
|
|
93
111
|
def _cache_contract(cache_conn: sqlite3.Connection) -> dict[str, set[str]]:
|
|
94
112
|
names = {
|
|
95
113
|
row[0] for row in cache_conn.execute(
|
|
@@ -99,12 +117,18 @@ def _cache_contract(cache_conn: sqlite3.Connection) -> dict[str, set[str]]:
|
|
|
99
117
|
name: {
|
|
100
118
|
row[1] for row in cache_conn.execute(f"PRAGMA table_info({name})")
|
|
101
119
|
}
|
|
102
|
-
for name in
|
|
120
|
+
for name in _CONTRACT_TABLES
|
|
121
|
+
if name in names
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
|
|
106
125
|
def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -> str:
|
|
107
|
-
"""Hash every cost-bearing Claude cache row and its project metadata.
|
|
126
|
+
"""Hash every cost-bearing Claude cache row and its project metadata.
|
|
127
|
+
|
|
128
|
+
The caller must have validated the cache contract first, including
|
|
129
|
+
``validate_codex_cache_contract`` when ``include_codex`` is set (#874):
|
|
130
|
+
a missing column here is a raw sqlite error, not a typed data gap.
|
|
131
|
+
"""
|
|
108
132
|
entry_columns = (
|
|
109
133
|
"source_path", "line_offset", "timestamp_utc", "model", "input_tokens",
|
|
110
134
|
"output_tokens", "cache_create_tokens", "cache_read_tokens",
|
|
@@ -123,10 +147,6 @@ def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -
|
|
|
123
147
|
]
|
|
124
148
|
basis = {"sessionEntries": entries, "sessionFiles": files}
|
|
125
149
|
if include_codex:
|
|
126
|
-
if "codex_session_entries" not in _cache_contract(cache_conn):
|
|
127
|
-
raise _lib_rederive.RederiveDataGap(
|
|
128
|
-
"missing cache.db table codex_session_entries"
|
|
129
|
-
)
|
|
130
150
|
basis["codexEntries"] = [
|
|
131
151
|
list(row) for row in cache_conn.execute(
|
|
132
152
|
"SELECT source_path,line_offset,timestamp_utc,session_id,model,"
|
|
@@ -539,12 +559,19 @@ def _rederivable_raw_records(records: list[dict]) -> list[dict]:
|
|
|
539
559
|
kind = (record.get("payload") or {}).get("kind")
|
|
540
560
|
if kind in {"weekly_credit_floor", "account_observe",
|
|
541
561
|
"account_label", "accounts_cutover", "sync_week",
|
|
542
|
-
|
|
562
|
+
*_REVIEWED_WEEKLY_KINDS}:
|
|
543
563
|
out.append(record)
|
|
544
564
|
return out
|
|
545
565
|
|
|
546
566
|
|
|
547
|
-
|
|
567
|
+
#: #825/#832 spec 5.1: the previous release validates a reviewed weekly
|
|
568
|
+
#: decision by exact equality with its own `make_op`, which a stamped record can
|
|
569
|
+
#: never satisfy, so the current binary emits the versioned kind (an unknown op
|
|
570
|
+
#: to the previous release) and reads both.
|
|
571
|
+
_REVIEWED_WEEKLY_KIND = _lib_account_protocol.WEEKLY_OBSERVATION_DECISION_V2_KIND
|
|
572
|
+
_REVIEWED_WEEKLY_LEGACY_KIND = "claude_weekly_observation_decision"
|
|
573
|
+
_REVIEWED_WEEKLY_KINDS = frozenset((
|
|
574
|
+
_REVIEWED_WEEKLY_LEGACY_KIND, _REVIEWED_WEEKLY_KIND))
|
|
548
575
|
_REVIEWED_MANIFEST_KEYS_V1 = frozenset({
|
|
549
576
|
"schemaVersion", "journalHighWater", "journalPrefixHash",
|
|
550
577
|
"reviewedAt", "reason", "decisions",
|
|
@@ -821,7 +848,8 @@ def _reviewed_weekly_state(records):
|
|
|
821
848
|
ops = []
|
|
822
849
|
for record in records:
|
|
823
850
|
if (record.get("t") != "op" or
|
|
824
|
-
(record.get("payload") or {}).get("kind")
|
|
851
|
+
(record.get("payload") or {}).get("kind")
|
|
852
|
+
not in _REVIEWED_WEEKLY_KINDS):
|
|
825
853
|
continue
|
|
826
854
|
payload = record["payload"]
|
|
827
855
|
version = payload.get("schema_version")
|
|
@@ -901,11 +929,23 @@ def _reviewed_weekly_state(records):
|
|
|
901
929
|
), ops)
|
|
902
930
|
|
|
903
931
|
|
|
932
|
+
class _PinnedPrefixDigest:
|
|
933
|
+
"""The one prefix digest a deferred reviewed-decision validation needs,
|
|
934
|
+
captured inline by the reading pass."""
|
|
935
|
+
|
|
936
|
+
def __init__(self, high_water, digest):
|
|
937
|
+
self._high_water = high_water
|
|
938
|
+
self._digest = digest
|
|
939
|
+
|
|
940
|
+
def digest_at(self, high_water):
|
|
941
|
+
return self._digest if high_water == self._high_water else None
|
|
942
|
+
|
|
943
|
+
|
|
904
944
|
def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
|
|
905
945
|
"""Verify an operator decision against the bytes before its journal line."""
|
|
906
946
|
payload = record.get("payload")
|
|
907
947
|
if (not isinstance(payload, dict)
|
|
908
|
-
or payload.get("kind")
|
|
948
|
+
or payload.get("kind") not in _REVIEWED_WEEKLY_KINDS):
|
|
909
949
|
return
|
|
910
950
|
bound = payload.get("journal_high_water")
|
|
911
951
|
version = payload.get("schema_version")
|
|
@@ -924,6 +964,7 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
|
|
|
924
964
|
or not payload["reason"].strip()
|
|
925
965
|
or record != _lib_journal.make_op(
|
|
926
966
|
at=record.get("at"), src=record.get("src"), payload=payload,
|
|
967
|
+
writer_generation=record.get("wpg"),
|
|
927
968
|
)):
|
|
928
969
|
raise _lib_rederive.RederiveConflict(
|
|
929
970
|
"malformed retained reviewed weekly decision op"
|
|
@@ -1006,6 +1047,47 @@ def _derive_desired_events_into(
|
|
|
1006
1047
|
+ ", ".join(sorted(unknown_ids))
|
|
1007
1048
|
)
|
|
1008
1049
|
conn = _cctally_core.open_db(_target_path=str(scratch_path))
|
|
1050
|
+
# #825/#832 spec 5.6 item 8: the scratch replay re-evaluates the same
|
|
1051
|
+
# triggers and certificates over the corrected history in journal order,
|
|
1052
|
+
# so it can never plan an outcome ordinary processing withholds.
|
|
1053
|
+
import _cctally_quarantine
|
|
1054
|
+
fold = _cctally_quarantine.QuarantineFold.empty(held_ids=held_ids)
|
|
1055
|
+
try:
|
|
1056
|
+
return replay_scratch_records(
|
|
1057
|
+
conn, records, cache_conn, fold=fold, held_ids=held_ids,
|
|
1058
|
+
block_sources=block_sources)
|
|
1059
|
+
finally:
|
|
1060
|
+
conn.close()
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def replay_scratch_records(
|
|
1064
|
+
conn, records, cache_conn, *, fold, held_ids=frozenset(),
|
|
1065
|
+
block_sources: "dict | None" = None, coordinates=None,
|
|
1066
|
+
skip_indexes=frozenset(), on_input=None, read_adapters=None,
|
|
1067
|
+
retention=None,
|
|
1068
|
+
) -> list[dict]:
|
|
1069
|
+
"""THE scratch derivation: run every rederivable raw record of
|
|
1070
|
+
``records`` through the live pipeline hooks and harvest, in journal
|
|
1071
|
+
order, on the private scratch connection ``conn``, capturing every derived
|
|
1072
|
+
candidate instead of journaling it (`db rederive` and the #825/#832
|
|
1073
|
+
derivation-completion step share it, so there is one detector
|
|
1074
|
+
implementation).
|
|
1075
|
+
|
|
1076
|
+
``fold`` is the outcome-quarantine fold, advanced over every slot —
|
|
1077
|
+
excluded placeholders included — so each input's gate is evaluated at its
|
|
1078
|
+
original position. ``coordinates`` (parallel to ``records``, each
|
|
1079
|
+
`(segment, offset)` or None) feeds the fold the physical positions a live
|
|
1080
|
+
ingest gives it. An index in ``skip_indexes`` still advances the fold but
|
|
1081
|
+
its hooks do not run. ``on_input(index, events)`` receives each raw input's
|
|
1082
|
+
own derived candidates. ``read_adapters`` replaces the stable cost/config
|
|
1083
|
+
read adapters over ``cache_conn`` (a context manager), for a caller with no
|
|
1084
|
+
cache to read. ``retention`` (derivation completion only, a
|
|
1085
|
+
`_cctally_journal.ScratchRetention`) keeps the scratch projection on the
|
|
1086
|
+
retained effective decisions: the emitters consult it, and every
|
|
1087
|
+
``evt``/``correction`` line of ``records`` is offered to it at its own
|
|
1088
|
+
position. Returns every candidate in derivation order."""
|
|
1089
|
+
raw_ids = {id(record) for record in _rederivable_raw_records(
|
|
1090
|
+
[record for record in records if isinstance(record, dict)])}
|
|
1009
1091
|
events: list[dict] = []
|
|
1010
1092
|
reviewed_weekly_basis_by_account = {}
|
|
1011
1093
|
hooks = (
|
|
@@ -1014,32 +1096,70 @@ def _derive_desired_events_into(
|
|
|
1014
1096
|
_record._pipeline_record_credit,
|
|
1015
1097
|
_record._pipeline_sync_week,
|
|
1016
1098
|
)
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1099
|
+
import _lib_account_protocol as _protocol
|
|
1100
|
+
adapters = (_scratch_read_adapters(cache_conn) if read_adapters is None
|
|
1101
|
+
else read_adapters)
|
|
1102
|
+
with adapters:
|
|
1103
|
+
for index, record in enumerate(records):
|
|
1104
|
+
if not isinstance(record, dict):
|
|
1105
|
+
continue
|
|
1106
|
+
if record.get("t") == _protocol.EXCLUDED_PLACEHOLDER_TYPE:
|
|
1107
|
+
carried = record.get("quarantine") or {}
|
|
1108
|
+
fold.on_excluded(
|
|
1109
|
+
carried.get("record") or {},
|
|
1110
|
+
segment=carried.get("segment"),
|
|
1111
|
+
offset=carried.get("offset"))
|
|
1112
|
+
continue
|
|
1113
|
+
if (retention is not None
|
|
1114
|
+
and record.get("t") in ("evt", "correction")):
|
|
1031
1115
|
conn.execute("BEGIN IMMEDIATE")
|
|
1032
1116
|
try:
|
|
1033
|
-
|
|
1034
|
-
hook(ctx, record)
|
|
1035
|
-
_journal._harvest(ctx)
|
|
1117
|
+
retention.on_journaled(conn, record)
|
|
1036
1118
|
conn.commit()
|
|
1037
1119
|
except BaseException:
|
|
1038
1120
|
conn.rollback()
|
|
1039
1121
|
raise
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1122
|
+
coordinate = (None if coordinates is None
|
|
1123
|
+
else coordinates[index])
|
|
1124
|
+
if coordinate is None:
|
|
1125
|
+
gate = fold.on_record(record)
|
|
1126
|
+
else:
|
|
1127
|
+
gate = fold.on_record(
|
|
1128
|
+
record, segment=coordinate[0], offset=coordinate[1])
|
|
1129
|
+
if id(record) not in raw_ids or index in skip_indexes:
|
|
1130
|
+
continue
|
|
1131
|
+
before = len(events)
|
|
1132
|
+
ctx = _journal.IngestContext(
|
|
1133
|
+
conn=conn,
|
|
1134
|
+
batch=[record],
|
|
1135
|
+
config={},
|
|
1136
|
+
event_sink=events,
|
|
1137
|
+
projection_writes=False,
|
|
1138
|
+
held_weekly_observation_ids=held_ids,
|
|
1139
|
+
reviewed_weekly_basis_by_account=(
|
|
1140
|
+
reviewed_weekly_basis_by_account),
|
|
1141
|
+
block_source_evidence=block_sources,
|
|
1142
|
+
quarantine=fold,
|
|
1143
|
+
outcome_gate=gate,
|
|
1144
|
+
scratch_retention=retention,
|
|
1145
|
+
)
|
|
1146
|
+
if retention is not None:
|
|
1147
|
+
retention.current_input = index
|
|
1148
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1149
|
+
try:
|
|
1150
|
+
for hook in hooks:
|
|
1151
|
+
hook(ctx, record)
|
|
1152
|
+
_journal._harvest(ctx)
|
|
1153
|
+
conn.commit()
|
|
1154
|
+
except BaseException:
|
|
1155
|
+
conn.rollback()
|
|
1156
|
+
raise
|
|
1157
|
+
finally:
|
|
1158
|
+
if retention is not None:
|
|
1159
|
+
retention.current_input = None
|
|
1160
|
+
if on_input is not None:
|
|
1161
|
+
on_input(index, events[before:])
|
|
1162
|
+
return events
|
|
1043
1163
|
|
|
1044
1164
|
|
|
1045
1165
|
def _planner_op_kinds() -> "set[str]":
|
|
@@ -1088,15 +1208,20 @@ def plan_claude_usage(
|
|
|
1088
1208
|
+ ",".join(report.unclassified_evt_kinds)
|
|
1089
1209
|
+ " op=" + ",".join(report.unclassified_op_kinds)
|
|
1090
1210
|
)
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
_validate_cache_rows(cache_conn, raw_records)
|
|
1211
|
+
tables = _cache_contract(cache_conn)
|
|
1212
|
+
_lib_rederive.validate_claude_cache_contract(tables)
|
|
1094
1213
|
codex_pricing_records = any(
|
|
1095
1214
|
(record.get("payload") or {}).get("vendor") == "codex"
|
|
1096
1215
|
and (record.get("payload") or {}).get("kind") == "budget"
|
|
1097
1216
|
and (record.get("payload") or {}).get("_pricing")
|
|
1098
1217
|
for record in records
|
|
1099
1218
|
)
|
|
1219
|
+
if codex_pricing_records:
|
|
1220
|
+
# #874: the Codex fingerprint and re-pricing read the Codex cache only
|
|
1221
|
+
# here, so its contract is required only here, and before any reader.
|
|
1222
|
+
_lib_rederive.validate_codex_cache_contract(tables)
|
|
1223
|
+
raw_records = _rederivable_raw_records(records)
|
|
1224
|
+
_validate_cache_rows(cache_conn, raw_records)
|
|
1100
1225
|
cache_fingerprint = _cache_fingerprint(
|
|
1101
1226
|
cache_conn, include_codex=codex_pricing_records,
|
|
1102
1227
|
)
|
|
@@ -1172,6 +1297,7 @@ def plan_claude_usage(
|
|
|
1172
1297
|
)
|
|
1173
1298
|
return _lib_rederive.build_claude_usage_plan(
|
|
1174
1299
|
selection=selection,
|
|
1300
|
+
physical_revisions=_excluded_physical_revisions(records),
|
|
1175
1301
|
desired_events=desired,
|
|
1176
1302
|
journal_high_water=journal_high_water,
|
|
1177
1303
|
cache_fingerprint=cache_fingerprint,
|
|
@@ -1188,6 +1314,26 @@ def plan_claude_usage(
|
|
|
1188
1314
|
)
|
|
1189
1315
|
|
|
1190
1316
|
|
|
1317
|
+
def _excluded_physical_revisions(records) -> dict:
|
|
1318
|
+
"""The highest revision per id among the excluded events and correction
|
|
1319
|
+
actions the placeholders of ``records`` carry (#825/#832 fix round A,
|
|
1320
|
+
Codex fix-round-1 Q2)."""
|
|
1321
|
+
import _lib_account_protocol as _protocol
|
|
1322
|
+
out: dict = {}
|
|
1323
|
+
for record in records:
|
|
1324
|
+
if (not isinstance(record, dict)
|
|
1325
|
+
or record.get("t") != _protocol.EXCLUDED_PLACEHOLDER_TYPE):
|
|
1326
|
+
continue
|
|
1327
|
+
carried = (record.get("quarantine") or {}).get("record") or {}
|
|
1328
|
+
event_id = carried.get("id")
|
|
1329
|
+
rev = carried.get("rev", 0)
|
|
1330
|
+
if (carried.get("t") in ("evt", "correction")
|
|
1331
|
+
and isinstance(event_id, str) and type(rev) is int
|
|
1332
|
+
and rev >= 0):
|
|
1333
|
+
out[event_id] = max(rev, out.get(event_id, rev))
|
|
1334
|
+
return out
|
|
1335
|
+
|
|
1336
|
+
|
|
1191
1337
|
def owned_conflicted_event_ids(selection) -> frozenset:
|
|
1192
1338
|
"""The quarantined same-revision groups (#374) this family owns.
|
|
1193
1339
|
|
|
@@ -1240,9 +1386,17 @@ def read_rederive_journal_prefix(
|
|
|
1240
1386
|
records: list[dict] = []
|
|
1241
1387
|
record_ends: list[tuple[str, int]] = []
|
|
1242
1388
|
evidence: list = []
|
|
1389
|
+
evidence_owners: list = []
|
|
1243
1390
|
malformed = 0
|
|
1244
1391
|
prior_high_water = None
|
|
1245
1392
|
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
1393
|
+
# #825/#832 (spec 5.3): admission before interpretation, resolved within
|
|
1394
|
+
# this one pass. A reviewed decision's validation needs the prefix digest
|
|
1395
|
+
# at its own position, so the digest is taken inline and the validation
|
|
1396
|
+
# runs after the pass, for admitted decisions only.
|
|
1397
|
+
pass_boundary = _journal._PassBoundary()
|
|
1398
|
+
coordinates: list = []
|
|
1399
|
+
pending_reviewed: list = []
|
|
1246
1400
|
for segment, offset, raw in _journal._iter_range_with_segments(
|
|
1247
1401
|
None,
|
|
1248
1402
|
high_water,
|
|
@@ -1256,19 +1410,57 @@ def read_rederive_journal_prefix(
|
|
|
1256
1410
|
malformed += 1
|
|
1257
1411
|
prior_high_water = record_end
|
|
1258
1412
|
continue
|
|
1413
|
+
pass_boundary.observe(record)
|
|
1259
1414
|
if record.get("t") == "op":
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1415
|
+
if (record.get("payload") or {}).get("kind") in _REVIEWED_WEEKLY_KINDS:
|
|
1416
|
+
pending_reviewed.append((
|
|
1417
|
+
len(records), record, prior_high_water,
|
|
1418
|
+
None if prior_high_water is None
|
|
1419
|
+
else hasher.digest_at(prior_high_water),
|
|
1420
|
+
))
|
|
1263
1421
|
_journal._capture_protocol_prefix_evidence(
|
|
1264
1422
|
record,
|
|
1265
1423
|
prior_high_water,
|
|
1266
1424
|
evidence,
|
|
1267
1425
|
hasher=hasher,
|
|
1268
1426
|
)
|
|
1427
|
+
if len(evidence) > len(evidence_owners):
|
|
1428
|
+
evidence_owners.append(len(records))
|
|
1269
1429
|
records.append(record)
|
|
1270
1430
|
record_ends.append(record_end)
|
|
1431
|
+
coordinates.append((segment, offset))
|
|
1271
1432
|
prior_high_water = record_end
|
|
1433
|
+
# An inert placeholder keeps every position and sequence number; an
|
|
1434
|
+
# excluded correction, reviewed decision or Claude record is never planning
|
|
1435
|
+
# input (case F7).
|
|
1436
|
+
originals = list(records)
|
|
1437
|
+
placeholder = _lib_account_protocol.excluded_placeholder()
|
|
1438
|
+
_journal._apply_pass_admission(
|
|
1439
|
+
records, coordinates, pass_boundary.resolve(high_water),
|
|
1440
|
+
placeholder=placeholder)
|
|
1441
|
+
# #825/#832 fix round C (CD2-005): an excluded acknowledgement takes its
|
|
1442
|
+
# raw-prefix evidence with it, so selection reads it as absent.
|
|
1443
|
+
evidence = _journal._admitted_prefix_evidence(
|
|
1444
|
+
evidence, evidence_owners, records)
|
|
1445
|
+
# #825/#832 spec 5.6: each excluded slot keeps its original record and
|
|
1446
|
+
# coordinate for the outcome-quarantine fold of the scratch replay (a
|
|
1447
|
+
# trigger whose readings widen the every-account envelope). Nothing else
|
|
1448
|
+
# reads a placeholder's fields.
|
|
1449
|
+
for index, slot in enumerate(records):
|
|
1450
|
+
if slot is placeholder:
|
|
1451
|
+
records[index] = {
|
|
1452
|
+
**placeholder,
|
|
1453
|
+
"quarantine": {
|
|
1454
|
+
"record": originals[index],
|
|
1455
|
+
"segment": coordinates[index][0],
|
|
1456
|
+
"offset": coordinates[index][1],
|
|
1457
|
+
},
|
|
1458
|
+
}
|
|
1459
|
+
for index, record, prior, digest in pending_reviewed:
|
|
1460
|
+
if records[index] is record:
|
|
1461
|
+
_validate_retained_reviewed_op(
|
|
1462
|
+
record, prior, _PinnedPrefixDigest(prior, digest),
|
|
1463
|
+
)
|
|
1272
1464
|
# Released before the raise below, so a malformed prefix does not pin the
|
|
1273
1465
|
# accumulator's buffered segment — 410 MB on the maintainer's journal — on
|
|
1274
1466
|
# the traceback while the exception unwinds. On the success path the frame
|
|
@@ -1441,7 +1633,7 @@ def _preview_from_snapshot(
|
|
|
1441
1633
|
index for index, record in enumerate(records)
|
|
1442
1634
|
if record.get("t") == "op"
|
|
1443
1635
|
and (record.get("payload") or {}).get("kind")
|
|
1444
|
-
|
|
1636
|
+
in _REVIEWED_WEEKLY_KINDS
|
|
1445
1637
|
]
|
|
1446
1638
|
if decision_indexes:
|
|
1447
1639
|
index = decision_indexes[-1]
|
|
@@ -1615,6 +1807,18 @@ def _rederive_locks(*, apply: bool, timeout: float):
|
|
|
1615
1807
|
finally:
|
|
1616
1808
|
_cctally_core.note_stats_maintenance_released()
|
|
1617
1809
|
release_cache_writer_flocks(held)
|
|
1810
|
+
# #825/#832 spec 4.3 revision 2: a current holder posts a wake once its
|
|
1811
|
+
# locks are released (the fast path; the dependency waiter covers every
|
|
1812
|
+
# other exit) — after a rederive's live index publication or none.
|
|
1813
|
+
_post_statusline_wake("holder:rederive")
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def _post_statusline_wake(reason: str) -> None:
|
|
1817
|
+
try:
|
|
1818
|
+
import _cctally_statusline
|
|
1819
|
+
_cctally_statusline.post_wake(reason)
|
|
1820
|
+
except Exception:
|
|
1821
|
+
pass
|
|
1618
1822
|
|
|
1619
1823
|
|
|
1620
1824
|
# --------------------------------------------------------------------------
|
|
@@ -1752,6 +1956,10 @@ def codex_attribution_apply_locks(*, timeout: float = _REDERIVE_LOCK_TIMEOUT_SEC
|
|
|
1752
1956
|
finally:
|
|
1753
1957
|
_cctally_core.note_stats_maintenance_released()
|
|
1754
1958
|
owner.release_all()
|
|
1959
|
+
# #825/#832 spec 4.3 revision 2: the outer lock owner of a
|
|
1960
|
+
# `run_stats_ingest(locks_held=True)` cycle posts its wake once the whole
|
|
1961
|
+
# set is released.
|
|
1962
|
+
_post_statusline_wake("holder:attribution")
|
|
1755
1963
|
|
|
1756
1964
|
|
|
1757
1965
|
def preview_db_rederive(
|
|
@@ -1775,14 +1983,16 @@ def _stats_has_batch(batch_id: str) -> bool:
|
|
|
1775
1983
|
with _open_sqlite_snapshot(
|
|
1776
1984
|
path,
|
|
1777
1985
|
prefix="cctally-rederive-stats-",
|
|
1778
|
-
) as conn:
|
|
1986
|
+
) as conn, _cctally_store.current_stats_snapshot(conn):
|
|
1987
|
+
# #825/#832 spec 6: convergence evidence only from a validated
|
|
1988
|
+
# current index; anything else fails closed (rebuild).
|
|
1779
1989
|
row = conn.execute(
|
|
1780
1990
|
"SELECT 1 FROM journal_effective_events "
|
|
1781
1991
|
"WHERE batch_id = ? LIMIT 1",
|
|
1782
1992
|
(batch_id,),
|
|
1783
1993
|
).fetchone()
|
|
1784
1994
|
return row is not None
|
|
1785
|
-
except (OSError, sqlite3.Error):
|
|
1995
|
+
except (OSError, sqlite3.Error, StatsIndexNotCurrent):
|
|
1786
1996
|
return False
|
|
1787
1997
|
|
|
1788
1998
|
|
|
@@ -1819,7 +2029,8 @@ def _reviewed_op_needs_materialization(preview: RederivePreview) -> bool:
|
|
|
1819
2029
|
last_index = None
|
|
1820
2030
|
for index, record in enumerate(preview.records):
|
|
1821
2031
|
if (record.get("t") == "op" and
|
|
1822
|
-
(record.get("payload") or {}).get("kind")
|
|
2032
|
+
(record.get("payload") or {}).get("kind")
|
|
2033
|
+
in _REVIEWED_WEEKLY_KINDS):
|
|
1823
2034
|
last_index = index
|
|
1824
2035
|
if last_index is None:
|
|
1825
2036
|
return False
|
|
@@ -1919,7 +2130,11 @@ def apply_db_rederive(
|
|
|
1919
2130
|
reviewed_op: "dict | None" = None,
|
|
1920
2131
|
expected_plan_hashes: "tuple[str, str] | None" = None,
|
|
1921
2132
|
) -> RederiveCommandResult:
|
|
1922
|
-
|
|
2133
|
+
import _cctally_completion
|
|
2134
|
+
|
|
2135
|
+
lost: list = []
|
|
2136
|
+
with _rederive_locks(apply=True, timeout=lock_timeout), \
|
|
2137
|
+
_held_past_unlock(lost):
|
|
1923
2138
|
appended_reviewed_op = False
|
|
1924
2139
|
if reviewed_op is not None:
|
|
1925
2140
|
durable_hashes = _reviewed_op_expected_hashes(
|
|
@@ -2035,11 +2250,16 @@ def apply_db_rederive(
|
|
|
2035
2250
|
context=_journal.RebuildContext(trigger="rederive-apply"),
|
|
2036
2251
|
high_water=batch_high_water,
|
|
2037
2252
|
update_quota_cache=False,
|
|
2253
|
+
complete_derivation=True,
|
|
2038
2254
|
before_swap=lambda: _call_crash_hook(
|
|
2039
2255
|
"before-rebuild-swap"
|
|
2040
2256
|
),
|
|
2041
2257
|
)
|
|
2042
2258
|
_call_crash_hook("after-rebuild")
|
|
2259
|
+
except _cctally_completion.ResidualObligationNotDurable as exc:
|
|
2260
|
+
raise _obligation_lost(
|
|
2261
|
+
"stats rebuild", preview, preview.batch_id, exc
|
|
2262
|
+
) from exc
|
|
2043
2263
|
except Exception as exc:
|
|
2044
2264
|
raise RederiveApplyError(
|
|
2045
2265
|
"stats rebuild", preview, preview.batch_id, exc
|
|
@@ -2069,11 +2289,16 @@ def apply_db_rederive(
|
|
|
2069
2289
|
),
|
|
2070
2290
|
high_water=preview.latest_completed_high_water,
|
|
2071
2291
|
update_quota_cache=False,
|
|
2292
|
+
complete_derivation=True,
|
|
2072
2293
|
before_swap=lambda: _call_crash_hook(
|
|
2073
2294
|
"before-rebuild-swap"
|
|
2074
2295
|
),
|
|
2075
2296
|
)
|
|
2076
2297
|
_call_crash_hook("after-rebuild")
|
|
2298
|
+
except _cctally_completion.ResidualObligationNotDurable as exc:
|
|
2299
|
+
raise _obligation_lost(
|
|
2300
|
+
"stats recovery", preview, latest, exc
|
|
2301
|
+
) from exc
|
|
2077
2302
|
except Exception as exc:
|
|
2078
2303
|
raise RederiveApplyError(
|
|
2079
2304
|
"stats recovery", preview, latest, exc
|
|
@@ -2090,7 +2315,13 @@ def apply_db_rederive(
|
|
|
2090
2315
|
context=_journal.RebuildContext(trigger="rederive-recovery"),
|
|
2091
2316
|
high_water=preview.journal_high_water,
|
|
2092
2317
|
update_quota_cache=False,
|
|
2318
|
+
complete_derivation=True,
|
|
2093
2319
|
)
|
|
2320
|
+
except _cctally_completion.ResidualObligationNotDurable as exc:
|
|
2321
|
+
raise _obligation_lost(
|
|
2322
|
+
"reviewed decision materialization", report_preview,
|
|
2323
|
+
latest, exc,
|
|
2324
|
+
) from exc
|
|
2094
2325
|
except Exception as exc:
|
|
2095
2326
|
raise RederiveApplyError(
|
|
2096
2327
|
"reviewed decision materialization", report_preview,
|
|
@@ -2108,6 +2339,23 @@ def apply_db_rederive(
|
|
|
2108
2339
|
batch_id=latest,
|
|
2109
2340
|
rebuild=None,
|
|
2110
2341
|
)
|
|
2342
|
+
raise lost[0]
|
|
2343
|
+
|
|
2344
|
+
|
|
2345
|
+
@contextlib.contextmanager
|
|
2346
|
+
def _held_past_unlock(lost: list):
|
|
2347
|
+
"""Fix round K12 (K11R-2): hold a lost-obligation outcome until the
|
|
2348
|
+
enclosing lock context has exited normally (locks released, holder wake
|
|
2349
|
+
posted); the caller re-raises it from ``lost`` afterwards."""
|
|
2350
|
+
try:
|
|
2351
|
+
yield
|
|
2352
|
+
except RederiveObligationNotDurable as exc:
|
|
2353
|
+
lost.append(exc)
|
|
2354
|
+
|
|
2355
|
+
|
|
2356
|
+
def _obligation_lost(stage, preview, batch_id, exc):
|
|
2357
|
+
"""The rebuild published; only its residual obligation was lost."""
|
|
2358
|
+
return RederiveObligationNotDurable(stage, preview, batch_id, exc)
|
|
2111
2359
|
|
|
2112
2360
|
|
|
2113
2361
|
def _high_water_dict(high_water):
|
|
@@ -2316,6 +2564,15 @@ def cmd_db_rederive(args) -> int:
|
|
|
2316
2564
|
else:
|
|
2317
2565
|
print(f"cctally: db rederive: {message}", file=sys.stderr)
|
|
2318
2566
|
return 2
|
|
2567
|
+
if apply and _journal.account_bound_cutover_enforced():
|
|
2568
|
+
# #825/#832 spec 5.5: a prefix-bound administrative operation
|
|
2569
|
+
# establishes the account-bound cutover before mutable planning, so
|
|
2570
|
+
# the plan's prefix already contains the boundary. A reviewed manifest
|
|
2571
|
+
# cut from a prefix without it then fails its existing drift check.
|
|
2572
|
+
try:
|
|
2573
|
+
_journal.ensure_account_bound_cutover()
|
|
2574
|
+
except _journal.AccountBoundCutoverDeferred:
|
|
2575
|
+
pass
|
|
2319
2576
|
try:
|
|
2320
2577
|
reviewed_path = getattr(args, "reviewed_weekly_decisions", None)
|
|
2321
2578
|
reviewed_op = (
|
|
@@ -2400,6 +2657,28 @@ def cmd_db_rederive(args) -> int:
|
|
|
2400
2657
|
else:
|
|
2401
2658
|
print(f"cctally: db rederive conflict: {exc}", file=sys.stderr)
|
|
2402
2659
|
return 2
|
|
2660
|
+
except RederiveObligationNotDurable as exc:
|
|
2661
|
+
# Fix round K12 (K11R-2): the corrected index IS published (and its
|
|
2662
|
+
# holder wake posted); only the residual obligation was lost.
|
|
2663
|
+
payload = _command_payload(
|
|
2664
|
+
status="failed",
|
|
2665
|
+
preview=exc.preview,
|
|
2666
|
+
batch_id=exc.batch_id,
|
|
2667
|
+
errors=(str(exc),),
|
|
2668
|
+
)
|
|
2669
|
+
payload["indexPublished"] = True
|
|
2670
|
+
if as_json:
|
|
2671
|
+
_emit_command_payload(payload, as_json=True)
|
|
2672
|
+
else:
|
|
2673
|
+
print(
|
|
2674
|
+
"cctally: the corrected stats.db was published, but the "
|
|
2675
|
+
"record that the inputs written during the rebuild still need "
|
|
2676
|
+
f"deriving could not be saved ({exc.cause}). Those inputs stay "
|
|
2677
|
+
"in the journal and the next ingest derives them; check that "
|
|
2678
|
+
"the data directory is writable.",
|
|
2679
|
+
file=sys.stderr,
|
|
2680
|
+
)
|
|
2681
|
+
return 3
|
|
2403
2682
|
except RederiveApplyError as exc:
|
|
2404
2683
|
payload = _command_payload(
|
|
2405
2684
|
status="failed",
|