cctally 1.115.0 → 1.116.1
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 +21 -0
- package/bin/_cctally_cache.py +85 -4
- package/bin/_cctally_completion.py +80 -12
- package/bin/_cctally_core.py +106 -9
- package/bin/_cctally_dashboard.py +6 -9
- package/bin/_cctally_dashboard_envelope.py +26 -32
- package/bin/_cctally_db.py +85 -4
- package/bin/_cctally_doctor.py +38 -5
- package/bin/_cctally_five_hour.py +83 -1017
- package/bin/_cctally_journal.py +1307 -180
- package/bin/_cctally_journal_repair.py +9 -7
- package/bin/_cctally_oauth_samples.py +280 -0
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_quarantine.py +14 -4
- package/bin/_cctally_quota.py +70 -8
- package/bin/_cctally_quota_model.py +35 -4
- package/bin/_cctally_record.py +414 -123
- package/bin/_cctally_rederive.py +632 -235
- package/bin/_cctally_refresh.py +29 -0
- package/bin/_cctally_statusline.py +984 -47
- package/bin/_cctally_store.py +972 -58
- package/bin/_cctally_tui.py +62 -7
- package/bin/_cctally_update.py +25 -0
- package/bin/_lib_account_protocol.py +201 -18
- package/bin/_lib_clone_copy.py +46 -11
- package/bin/_lib_dashboard_sources.py +57 -15
- package/bin/_lib_detector_access_registry.py +273 -52
- package/bin/_lib_doctor.py +302 -1
- package/bin/_lib_journal.py +145 -25
- package/bin/_lib_journal_router.py +166 -48
- package/bin/_lib_oauth_samples.py +238 -0
- package/bin/_lib_pricing_check.py +0 -12
- package/bin/_lib_provider_activity.py +770 -0
- package/bin/_lib_quarantine.py +3 -2
- package/bin/_lib_quota_model.py +96 -6
- package/bin/_lib_rebuild_progress.py +146 -0
- package/bin/_lib_record.py +22 -0
- package/bin/_lib_rederive.py +299 -3
- package/bin/_lib_snapshot_cache.py +43 -1
- package/bin/_lib_stale_proof.py +708 -0
- package/bin/_lib_statusline_candidates.py +65 -5
- package/bin/_lib_worker_launches.py +315 -0
- package/bin/cctally +6 -11
- package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
- package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
- package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
- package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
- package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +7 -2
- package/bin/_lib_merge_gate.py +0 -452
- package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
package/bin/_cctally_rederive.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import contextlib
|
|
12
12
|
import copy
|
|
13
|
+
import dataclasses
|
|
13
14
|
import datetime as dt
|
|
14
15
|
import fcntl
|
|
15
16
|
import hashlib
|
|
@@ -28,9 +29,10 @@ import _cctally_core
|
|
|
28
29
|
import _cctally_journal as _journal
|
|
29
30
|
import _cctally_record as _record
|
|
30
31
|
import _lib_account_protocol
|
|
32
|
+
import _lib_clone_copy
|
|
31
33
|
import _lib_journal
|
|
32
|
-
import _lib_journal_router
|
|
33
34
|
import _lib_json_envelope
|
|
35
|
+
import _lib_rebuild_progress
|
|
34
36
|
import _lib_rederive
|
|
35
37
|
import _cctally_store
|
|
36
38
|
from _cctally_store import StatsIndexNotCurrent
|
|
@@ -77,6 +79,10 @@ class RederivePreview:
|
|
|
77
79
|
reviewed_decision_id: "str | None" = None
|
|
78
80
|
baseline_plan: "_lib_rederive.RederivePlan | None" = None
|
|
79
81
|
baseline_plan_guard: "dict | None" = None
|
|
82
|
+
# D4: the empty correction batch an unfinished reviewed decision whose
|
|
83
|
+
# plan has no actions completes with. Never reported as `batch_id`, so a
|
|
84
|
+
# preview's output is unchanged; None when there is no such decision.
|
|
85
|
+
empty_decision_batch_id: "str | None" = None
|
|
80
86
|
|
|
81
87
|
|
|
82
88
|
@dataclass(frozen=True)
|
|
@@ -128,6 +134,15 @@ def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -
|
|
|
128
134
|
The caller must have validated the cache contract first, including
|
|
129
135
|
``validate_codex_cache_contract`` when ``include_codex`` is set (#874):
|
|
130
136
|
a missing column here is a raw sqlite error, not a typed data gap.
|
|
137
|
+
|
|
138
|
+
#997 D16: the digest is `_fingerprint` of
|
|
139
|
+
``{"codexEntries"?, "sessionEntries", "sessionFiles"}`` (each a list of
|
|
140
|
+
row lists), but the canonical JSON is fed to SHA-256 row by row as the
|
|
141
|
+
cursor yields it, in sorted key order with `_canonical_bytes`' encoder
|
|
142
|
+
settings, instead of materializing every row and one document of them
|
|
143
|
+
(+0.7 GB transient on the maintainer's cache). The bytes hashed, and so
|
|
144
|
+
the digest, are identical, so a preview's plan guard and an apply's
|
|
145
|
+
recheck agree across the change.
|
|
131
146
|
"""
|
|
132
147
|
entry_columns = (
|
|
133
148
|
"source_path", "line_offset", "timestamp_utc", "model", "input_tokens",
|
|
@@ -135,27 +150,39 @@ def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -
|
|
|
135
150
|
"cache_create_1h_tokens", "cost_usd_raw", "speed", "account_key",
|
|
136
151
|
)
|
|
137
152
|
file_columns = ("path", "session_id", "project_path")
|
|
138
|
-
|
|
139
|
-
|
|
153
|
+
sections = {
|
|
154
|
+
"sessionEntries": (
|
|
140
155
|
"SELECT " + ",".join(entry_columns)
|
|
141
|
-
+ " FROM session_entries ORDER BY source_path, line_offset")
|
|
142
|
-
|
|
143
|
-
files = [
|
|
144
|
-
list(row) for row in cache_conn.execute(
|
|
156
|
+
+ " FROM session_entries ORDER BY source_path, line_offset"),
|
|
157
|
+
"sessionFiles": (
|
|
145
158
|
"SELECT " + ",".join(file_columns)
|
|
146
|
-
+ " FROM session_files ORDER BY path")
|
|
147
|
-
|
|
148
|
-
basis = {"sessionEntries": entries, "sessionFiles": files}
|
|
159
|
+
+ " FROM session_files ORDER BY path"),
|
|
160
|
+
}
|
|
149
161
|
if include_codex:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
162
|
+
sections["codexEntries"] = (
|
|
163
|
+
"SELECT source_path,line_offset,timestamp_utc,session_id,model,"
|
|
164
|
+
"input_tokens,cached_input_tokens,output_tokens,"
|
|
165
|
+
"reasoning_output_tokens,account_key "
|
|
166
|
+
"FROM codex_session_entries ORDER BY source_path,line_offset"
|
|
167
|
+
)
|
|
168
|
+
encode = json.JSONEncoder(
|
|
169
|
+
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
170
|
+
).encode
|
|
171
|
+
digest = hashlib.sha256()
|
|
172
|
+
digest.update(b"{")
|
|
173
|
+
for index, key in enumerate(sorted(sections)):
|
|
174
|
+
if index:
|
|
175
|
+
digest.update(b",")
|
|
176
|
+
digest.update(encode(key).encode("utf-8") + b":[")
|
|
177
|
+
first = True
|
|
178
|
+
for row in cache_conn.execute(sections[key]):
|
|
179
|
+
if not first:
|
|
180
|
+
digest.update(b",")
|
|
181
|
+
first = False
|
|
182
|
+
digest.update(encode(list(row)).encode("utf-8"))
|
|
183
|
+
digest.update(b"]")
|
|
184
|
+
digest.update(b"}")
|
|
185
|
+
return "sha256:" + digest.hexdigest()
|
|
159
186
|
|
|
160
187
|
|
|
161
188
|
def _split_matches(parts, total) -> bool:
|
|
@@ -544,24 +571,30 @@ def _scratch_read_adapters(cache_conn: sqlite3.Connection):
|
|
|
544
571
|
setattr(cctally, name, value)
|
|
545
572
|
|
|
546
573
|
|
|
574
|
+
#: Op kinds the scratch replay runs through the pipeline hooks.
|
|
575
|
+
_REDERIVABLE_OP_KINDS = frozenset({
|
|
576
|
+
"weekly_credit_floor", "account_observe", "account_label",
|
|
577
|
+
"accounts_cutover", "sync_week",
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _is_rederivable_raw(record: dict) -> bool:
|
|
582
|
+
"""Whether the scratch replay runs ``record`` through the pipeline hooks:
|
|
583
|
+
a Claude reading from a recording source, or a deriving/registry op
|
|
584
|
+
(#997 spec D5: a per-record predicate, so the replay can test a record it
|
|
585
|
+
prepared one at a time)."""
|
|
586
|
+
t = record.get("t")
|
|
587
|
+
if t == "obs":
|
|
588
|
+
return (record.get("provider") == "claude"
|
|
589
|
+
and record.get("src") in _record._CLAUDE_OBS_SRCS)
|
|
590
|
+
if t != "op":
|
|
591
|
+
return False
|
|
592
|
+
kind = (record.get("payload") or {}).get("kind")
|
|
593
|
+
return kind in _REDERIVABLE_OP_KINDS or kind in _REVIEWED_WEEKLY_KINDS
|
|
594
|
+
|
|
595
|
+
|
|
547
596
|
def _rederivable_raw_records(records: list[dict]) -> list[dict]:
|
|
548
|
-
|
|
549
|
-
for record in records:
|
|
550
|
-
if record.get("t") == "obs":
|
|
551
|
-
if (
|
|
552
|
-
record.get("provider") == "claude"
|
|
553
|
-
and record.get("src") in _record._CLAUDE_OBS_SRCS
|
|
554
|
-
):
|
|
555
|
-
out.append(record)
|
|
556
|
-
continue
|
|
557
|
-
if record.get("t") != "op":
|
|
558
|
-
continue
|
|
559
|
-
kind = (record.get("payload") or {}).get("kind")
|
|
560
|
-
if kind in {"weekly_credit_floor", "account_observe",
|
|
561
|
-
"account_label", "accounts_cutover", "sync_week",
|
|
562
|
-
*_REVIEWED_WEEKLY_KINDS}:
|
|
563
|
-
out.append(record)
|
|
564
|
-
return out
|
|
597
|
+
return [record for record in records if _is_rederivable_raw(record)]
|
|
565
598
|
|
|
566
599
|
|
|
567
600
|
#: #825/#832 spec 5.1: the previous release validates a reviewed weekly
|
|
@@ -596,6 +629,16 @@ _REVIEWED_OP_KEYS_V2 = frozenset({
|
|
|
596
629
|
"journal_prefix_hash", "reason", "weekly_axis_decisions",
|
|
597
630
|
"snapshot_identity_decisions",
|
|
598
631
|
}) | _REVIEWED_OP_EXPECTATION_KEYS
|
|
632
|
+
#: #797/#823/#840 spec 4.7 (Q5): payload schema 3 adds the canonical
|
|
633
|
+
#: `weekly_axis_retractions` list beside the hold projection. Manifest schema 3
|
|
634
|
+
#: keeps schema 2's keys and admits the `retract` disposition.
|
|
635
|
+
_REVIEWED_OP_KEYS_V3 = _REVIEWED_OP_KEYS_V2 | frozenset({
|
|
636
|
+
"weekly_axis_retractions",
|
|
637
|
+
})
|
|
638
|
+
_REVIEWED_MANIFEST_DISPOSITIONS = {
|
|
639
|
+
1: ("hold", "accept"), 2: ("hold", "accept"),
|
|
640
|
+
3: ("hold", "accept", "retract"),
|
|
641
|
+
}
|
|
599
642
|
|
|
600
643
|
|
|
601
644
|
def _reviewed_weekly_op_from_manifest(path) -> dict:
|
|
@@ -612,9 +655,11 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
|
|
|
612
655
|
version = manifest.get("schemaVersion")
|
|
613
656
|
expected_keys = (
|
|
614
657
|
_REVIEWED_MANIFEST_KEYS_V1 if version == 1
|
|
615
|
-
else _REVIEWED_MANIFEST_KEYS_V2 if version
|
|
658
|
+
else _REVIEWED_MANIFEST_KEYS_V2 if version in (2, 3)
|
|
616
659
|
else None
|
|
617
660
|
)
|
|
661
|
+
if type(version) is not int:
|
|
662
|
+
expected_keys = None
|
|
618
663
|
actual_keys = set(manifest)
|
|
619
664
|
if (expected_keys is None
|
|
620
665
|
or actual_keys not in (
|
|
@@ -622,7 +667,8 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
|
|
|
622
667
|
expected_keys | _REVIEWED_MANIFEST_EXPECTATION_KEYS,
|
|
623
668
|
)):
|
|
624
669
|
raise _lib_rederive.RederiveConflict(
|
|
625
|
-
"reviewed weekly decision manifest requires schemaVersion 1
|
|
670
|
+
"reviewed weekly decision manifest requires schemaVersion 1, 2 "
|
|
671
|
+
"or 3 with exactly its fields"
|
|
626
672
|
)
|
|
627
673
|
for key in _REVIEWED_MANIFEST_EXPECTATION_KEYS & actual_keys:
|
|
628
674
|
value = manifest[key]
|
|
@@ -684,7 +730,8 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
|
|
|
684
730
|
or set(item) != {"observationId", "disposition"}
|
|
685
731
|
or not isinstance(item["observationId"], str)
|
|
686
732
|
or not item["observationId"]
|
|
687
|
-
or item["disposition"]
|
|
733
|
+
or item["disposition"]
|
|
734
|
+
not in _REVIEWED_MANIFEST_DISPOSITIONS[version]):
|
|
688
735
|
raise _lib_rederive.RederiveConflict(
|
|
689
736
|
"reviewed weekly decision entry is malformed"
|
|
690
737
|
)
|
|
@@ -723,19 +770,18 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
|
|
|
723
770
|
item["acceptedObservationId"], item["replayObservationId"],
|
|
724
771
|
))
|
|
725
772
|
normalized_identity.append(dict(item))
|
|
726
|
-
|
|
773
|
+
base = {
|
|
727
774
|
"kind": _REVIEWED_WEEKLY_KIND,
|
|
728
775
|
"schema_version": version,
|
|
729
776
|
"journal_high_water": dict(hw),
|
|
730
777
|
"journal_prefix_hash": digest,
|
|
731
778
|
"reason": reason,
|
|
732
779
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
normalized, key=lambda item: item["observationId"])
|
|
780
|
+
# #797/#823/#840 spec 4.7: a retraction persists as an ordinary `hold` in
|
|
781
|
+
# the decision list plus the canonical `weekly_axis_retractions` list.
|
|
782
|
+
payload = _lib_rederive.reviewed_weekly_payload(
|
|
783
|
+
version=version, decisions=normalized, base=base)
|
|
784
|
+
if version != 1:
|
|
739
785
|
payload["snapshot_identity_decisions"] = sorted(
|
|
740
786
|
normalized_identity,
|
|
741
787
|
key=lambda item: (
|
|
@@ -776,7 +822,7 @@ def _reviewed_weekly_expected_hashes(path, *, required: bool):
|
|
|
776
822
|
"reviewed weekly decision manifest has unexpected fields"
|
|
777
823
|
)
|
|
778
824
|
version = manifest.get("schemaVersion")
|
|
779
|
-
if version not in (1, 2):
|
|
825
|
+
if version not in (1, 2, 3) or type(version) is not int:
|
|
780
826
|
return None
|
|
781
827
|
present = _REVIEWED_MANIFEST_EXPECTATION_KEYS & set(manifest)
|
|
782
828
|
if not present:
|
|
@@ -835,7 +881,13 @@ def _reviewed_op_expected_hashes(record, *, required: bool):
|
|
|
835
881
|
return values
|
|
836
882
|
|
|
837
883
|
|
|
838
|
-
def
|
|
884
|
+
def _reviewed_weekly_decisions(records):
|
|
885
|
+
"""``(effective, identity_pairs, ops)`` of the retained reviewed weekly
|
|
886
|
+
decisions of ``records``: the effective disposition per observation
|
|
887
|
+
(``hold``, ``accept`` or ``retract``; the latest logical decision wins),
|
|
888
|
+
the preserved snapshot identity pairs, and the ops in journal order.
|
|
889
|
+
Every payload goes through the one strict decoder
|
|
890
|
+
(`_lib_rederive.decode_reviewed_weekly_payload`)."""
|
|
839
891
|
raw_ids = {
|
|
840
892
|
record.get("id") for record in records
|
|
841
893
|
if record.get("t") == "obs"
|
|
@@ -845,88 +897,54 @@ def _reviewed_weekly_state(records):
|
|
|
845
897
|
and "resets_at" in (record.get("payload") or {})
|
|
846
898
|
}
|
|
847
899
|
effective = {}
|
|
900
|
+
identity_effective = {}
|
|
848
901
|
ops = []
|
|
849
902
|
for record in records:
|
|
850
903
|
if (record.get("t") != "op" or
|
|
851
904
|
(record.get("payload") or {}).get("kind")
|
|
852
905
|
not in _REVIEWED_WEEKLY_KINDS):
|
|
853
906
|
continue
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
else payload.get("weekly_axis_decisions") if version == 2
|
|
859
|
-
else None
|
|
860
|
-
)
|
|
861
|
-
identity_decisions = (
|
|
862
|
-
[] if version == 1 else payload.get("snapshot_identity_decisions")
|
|
863
|
-
)
|
|
864
|
-
if (not isinstance(decisions, list)
|
|
865
|
-
or not isinstance(identity_decisions, list)
|
|
866
|
-
or not decisions and not identity_decisions):
|
|
867
|
-
raise _lib_rederive.RederiveConflict(
|
|
868
|
-
"malformed retained reviewed weekly decision op"
|
|
869
|
-
)
|
|
870
|
-
seen = set()
|
|
871
|
-
for item in decisions:
|
|
872
|
-
if (not isinstance(item, dict)
|
|
873
|
-
or set(item) != {"observationId", "disposition"}
|
|
874
|
-
or not isinstance(item["observationId"], str)
|
|
875
|
-
or item["disposition"] not in ("hold", "accept")):
|
|
876
|
-
raise _lib_rederive.RederiveConflict(
|
|
877
|
-
"malformed retained reviewed weekly decision entry"
|
|
878
|
-
)
|
|
879
|
-
obs_id = item["observationId"]
|
|
880
|
-
if obs_id not in raw_ids or obs_id in seen:
|
|
907
|
+
decoded = _lib_rederive.decode_reviewed_weekly_payload(
|
|
908
|
+
record["payload"])
|
|
909
|
+
for obs_id, disposition in decoded.dispositions.items():
|
|
910
|
+
if obs_id not in raw_ids:
|
|
881
911
|
raise _lib_rederive.RederiveConflict(
|
|
882
912
|
f"unknown or duplicate reviewed weekly observation: {obs_id}"
|
|
883
913
|
)
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
identity_ids = set()
|
|
887
|
-
for item in identity_decisions:
|
|
888
|
-
if (not isinstance(item, dict)
|
|
889
|
-
or set(item) != {
|
|
890
|
-
"acceptedObservationId", "replayObservationId",
|
|
891
|
-
"disposition",
|
|
892
|
-
}
|
|
893
|
-
or not all(
|
|
894
|
-
isinstance(item.get(key), str) and item[key]
|
|
895
|
-
for key in (
|
|
896
|
-
"acceptedObservationId", "replayObservationId",
|
|
897
|
-
)
|
|
898
|
-
)
|
|
899
|
-
or item.get("disposition") not in (
|
|
900
|
-
"preserve", "rederive",
|
|
901
|
-
)
|
|
902
|
-
or item["acceptedObservationId"]
|
|
903
|
-
== item["replayObservationId"]
|
|
904
|
-
or item["acceptedObservationId"] in identity_ids
|
|
905
|
-
or item["replayObservationId"] in identity_ids):
|
|
906
|
-
raise _lib_rederive.RederiveConflict(
|
|
907
|
-
"malformed retained snapshot identity decision"
|
|
908
|
-
)
|
|
909
|
-
identity_ids.update((
|
|
910
|
-
item["acceptedObservationId"], item["replayObservationId"],
|
|
911
|
-
))
|
|
912
|
-
ops.append(record)
|
|
913
|
-
identity_effective = {}
|
|
914
|
-
for op in ops:
|
|
915
|
-
payload = op["payload"]
|
|
916
|
-
for item in payload.get("snapshot_identity_decisions", []):
|
|
914
|
+
effective[obs_id] = disposition
|
|
915
|
+
for item in decoded.identity_decisions:
|
|
917
916
|
identity_effective[
|
|
918
917
|
(item["acceptedObservationId"], item["replayObservationId"])
|
|
919
918
|
] = item["disposition"]
|
|
919
|
+
ops.append(record)
|
|
920
|
+
return effective, tuple(
|
|
921
|
+
pair for pair, disposition in identity_effective.items()
|
|
922
|
+
if disposition == "preserve"
|
|
923
|
+
), ops
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _reviewed_weekly_state(records):
|
|
927
|
+
"""``(held, accepted, identity_pairs, ops)``. ``held`` is every
|
|
928
|
+
observation whose weekly detection the reviewed replay suppresses: an
|
|
929
|
+
effective `hold` or `retract` (spec 4.7: a retraction is suppressed
|
|
930
|
+
exactly as a hold; only hold's dependency preservation tells them
|
|
931
|
+
apart, `_reviewed_weekly_retractions`)."""
|
|
932
|
+
effective, identity_pairs, ops = _reviewed_weekly_decisions(records)
|
|
920
933
|
return (frozenset(
|
|
921
934
|
obs_id for obs_id, disposition in effective.items()
|
|
922
|
-
if disposition
|
|
935
|
+
if disposition in ("hold", "retract")
|
|
923
936
|
), frozenset(
|
|
924
937
|
obs_id for obs_id, disposition in effective.items()
|
|
925
938
|
if disposition == "accept"
|
|
926
|
-
),
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
939
|
+
), identity_pairs, ops)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _reviewed_weekly_retractions(records) -> frozenset:
|
|
943
|
+
"""The observations whose effective reviewed weekly decision is
|
|
944
|
+
`retract`."""
|
|
945
|
+
effective, _pairs, _ops = _reviewed_weekly_decisions(records)
|
|
946
|
+
return frozenset(obs_id for obs_id, disposition in effective.items()
|
|
947
|
+
if disposition == "retract")
|
|
930
948
|
|
|
931
949
|
|
|
932
950
|
class _PinnedPrefixDigest:
|
|
@@ -952,6 +970,7 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
|
|
|
952
970
|
expected_keys = (
|
|
953
971
|
_REVIEWED_OP_KEYS_V1 if version == 1
|
|
954
972
|
else _REVIEWED_OP_KEYS_V2 if version == 2
|
|
973
|
+
else _REVIEWED_OP_KEYS_V3 if version == 3
|
|
955
974
|
else None
|
|
956
975
|
)
|
|
957
976
|
if (expected_keys is None or set(payload) != expected_keys
|
|
@@ -965,10 +984,16 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
|
|
|
965
984
|
or record != _lib_journal.make_op(
|
|
966
985
|
at=record.get("at"), src=record.get("src"), payload=payload,
|
|
967
986
|
writer_generation=record.get("wpg"),
|
|
987
|
+
# #797/#823/#840 (spec 4.6 B1): the protocol revision is part
|
|
988
|
+
# of a current decision's content-id core.
|
|
989
|
+
writer_revision=record.get("wpr"),
|
|
968
990
|
)):
|
|
969
991
|
raise _lib_rederive.RederiveConflict(
|
|
970
992
|
"malformed retained reviewed weekly decision op"
|
|
971
993
|
)
|
|
994
|
+
# #797/#823/#840 spec 4.7: the one strict decoder (schema 3's
|
|
995
|
+
# retractions must each name a same-op projected hold).
|
|
996
|
+
_lib_rederive.decode_reviewed_weekly_payload(payload)
|
|
972
997
|
_reviewed_op_expected_hashes(record, required=True)
|
|
973
998
|
expected = (bound["segment"], bound["offset"])
|
|
974
999
|
if prior_high_water != expected or hasher.digest_at(expected) != (
|
|
@@ -980,7 +1005,10 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
|
|
|
980
1005
|
)
|
|
981
1006
|
|
|
982
1007
|
|
|
983
|
-
def
|
|
1008
|
+
def _planner_cutover_claude(records) -> str:
|
|
1009
|
+
"""The journal-wide Claude legacy account the planner normalizes under:
|
|
1010
|
+
every `accounts_cutover` value validated first (missing or conflicting
|
|
1011
|
+
values refuse), `unattributed` when the prefix has no cutover."""
|
|
984
1012
|
import _lib_accounts
|
|
985
1013
|
|
|
986
1014
|
cutover_values = {
|
|
@@ -997,15 +1025,58 @@ def _normalize_legacy_accounts(records: list[dict]) -> None:
|
|
|
997
1025
|
raise _lib_rederive.RederiveConflict(
|
|
998
1026
|
"conflicting accounts_cutover Claude account values"
|
|
999
1027
|
)
|
|
1000
|
-
|
|
1028
|
+
return (
|
|
1001
1029
|
next(iter(cutover_values))
|
|
1002
1030
|
if cutover_values
|
|
1003
1031
|
else _lib_accounts.UNATTRIBUTED
|
|
1004
1032
|
)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _normalize_legacy_accounts(records: list[dict]) -> None:
|
|
1036
|
+
"""Normalize every record of ``records`` IN PLACE (the pre-#997 planner
|
|
1037
|
+
input; kept for callers that own a private copy)."""
|
|
1038
|
+
cutover_claude = _planner_cutover_claude(records)
|
|
1005
1039
|
for record in records:
|
|
1006
1040
|
_journal._normalize_legacy_account_stamp(record, cutover_claude)
|
|
1007
1041
|
|
|
1008
1042
|
|
|
1043
|
+
def _normalized_planning_view(records, cutover_claude=None) -> list:
|
|
1044
|
+
"""#997 spec D5: the planner's normalized records, copy-on-write.
|
|
1045
|
+
|
|
1046
|
+
The same global pass as `_normalize_legacy_accounts` (every cutover value
|
|
1047
|
+
validated first), but a record normalization leaves unchanged is SHARED
|
|
1048
|
+
with the caller, and a changed one is a normalized deep copy. Changed
|
|
1049
|
+
copies are memoized by source-object identity, so one shared object at
|
|
1050
|
+
many positions (a D4 stub) yields one shared normalized object. Order,
|
|
1051
|
+
multiplicity and positions are preserved and the caller's records, which
|
|
1052
|
+
`apply_db_rederive` reads afterwards, are never mutated. Peak memory loses
|
|
1053
|
+
the up-front deep copy of every record normalization leaves alone."""
|
|
1054
|
+
if cutover_claude is None:
|
|
1055
|
+
cutover_claude = _planner_cutover_claude(records)
|
|
1056
|
+
changed: dict = {}
|
|
1057
|
+
view = []
|
|
1058
|
+
for record in records:
|
|
1059
|
+
if not isinstance(record, dict):
|
|
1060
|
+
view.append(record)
|
|
1061
|
+
continue
|
|
1062
|
+
hit = changed.get(id(record))
|
|
1063
|
+
if hit is not None:
|
|
1064
|
+
view.append(hit)
|
|
1065
|
+
continue
|
|
1066
|
+
probe = dict(record)
|
|
1067
|
+
if isinstance(record.get("payload"), dict):
|
|
1068
|
+
probe["payload"] = dict(record["payload"])
|
|
1069
|
+
_journal._normalize_legacy_account_stamp(probe, cutover_claude)
|
|
1070
|
+
if probe == record:
|
|
1071
|
+
view.append(record)
|
|
1072
|
+
continue
|
|
1073
|
+
normalized = copy.deepcopy(record)
|
|
1074
|
+
_journal._normalize_legacy_account_stamp(normalized, cutover_claude)
|
|
1075
|
+
changed[id(record)] = normalized
|
|
1076
|
+
view.append(normalized)
|
|
1077
|
+
return view
|
|
1078
|
+
|
|
1079
|
+
|
|
1009
1080
|
def _derive_desired_events(records: list[dict], cache_conn: sqlite3.Connection,
|
|
1010
1081
|
scratch_dir: Path, *,
|
|
1011
1082
|
held_weekly_observation_ids=frozenset(),
|
|
@@ -1047,24 +1118,35 @@ def _derive_desired_events_into(
|
|
|
1047
1118
|
+ ", ".join(sorted(unknown_ids))
|
|
1048
1119
|
)
|
|
1049
1120
|
conn = _cctally_core.open_db(_target_path=str(scratch_path))
|
|
1121
|
+
# #997 (spec D1): a disposable scratch, never published or trusted.
|
|
1122
|
+
import _cctally_store
|
|
1123
|
+
_cctally_store.apply_disposable_scratch_policy(conn)
|
|
1050
1124
|
# #825/#832 spec 5.6 item 8: the scratch replay re-evaluates the same
|
|
1051
1125
|
# triggers and certificates over the corrected history in journal order,
|
|
1052
1126
|
# so it can never plan an outcome ordinary processing withholds.
|
|
1053
1127
|
import _cctally_quarantine
|
|
1054
1128
|
fold = _cctally_quarantine.QuarantineFold.empty(held_ids=held_ids)
|
|
1055
1129
|
try:
|
|
1130
|
+
# #997 spec D5: the planner's view shares unchanged records with the
|
|
1131
|
+
# caller, so the replay prepares a private copy of each record.
|
|
1056
1132
|
return replay_scratch_records(
|
|
1057
1133
|
conn, records, cache_conn, fold=fold, held_ids=held_ids,
|
|
1058
|
-
block_sources=block_sources)
|
|
1134
|
+
block_sources=block_sources, prepare=copy.deepcopy)
|
|
1059
1135
|
finally:
|
|
1060
1136
|
conn.close()
|
|
1061
1137
|
|
|
1062
1138
|
|
|
1139
|
+
#: #997 (spec D2): modifying inputs per scratch replay transaction — raw inputs
|
|
1140
|
+
#: plus retained `evt`/`correction` offers. One input per commit made every
|
|
1141
|
+
#: input pay a rollback-journal create/fsync/unlink and a database fsync.
|
|
1142
|
+
_SCRATCH_REPLAY_COMMIT_EVERY = 4096
|
|
1143
|
+
|
|
1144
|
+
|
|
1063
1145
|
def replay_scratch_records(
|
|
1064
1146
|
conn, records, cache_conn, *, fold, held_ids=frozenset(),
|
|
1065
1147
|
block_sources: "dict | None" = None, coordinates=None,
|
|
1066
1148
|
skip_indexes=frozenset(), on_input=None, read_adapters=None,
|
|
1067
|
-
retention=None,
|
|
1149
|
+
retention=None, prepare: "Callable[[dict], dict] | None" = None,
|
|
1068
1150
|
) -> list[dict]:
|
|
1069
1151
|
"""THE scratch derivation: run every rederivable raw record of
|
|
1070
1152
|
``records`` through the live pipeline hooks and harvest, in journal
|
|
@@ -1085,9 +1167,10 @@ def replay_scratch_records(
|
|
|
1085
1167
|
`_cctally_journal.ScratchRetention`) keeps the scratch projection on the
|
|
1086
1168
|
retained effective decisions: the emitters consult it, and every
|
|
1087
1169
|
``evt``/``correction`` line of ``records`` is offered to it at its own
|
|
1088
|
-
position.
|
|
1089
|
-
|
|
1090
|
-
|
|
1170
|
+
position. ``prepare`` (#997 spec D5) maps each record to the one the
|
|
1171
|
+
fold, the retention and the hooks see, immediately before it is offered
|
|
1172
|
+
(a per-record copy instead of a whole-list one); the raw-input test runs
|
|
1173
|
+
on the prepared record. Returns every candidate in derivation order."""
|
|
1091
1174
|
events: list[dict] = []
|
|
1092
1175
|
reviewed_weekly_basis_by_account = {}
|
|
1093
1176
|
hooks = (
|
|
@@ -1099,66 +1182,98 @@ def replay_scratch_records(
|
|
|
1099
1182
|
import _lib_account_protocol as _protocol
|
|
1100
1183
|
adapters = (_scratch_read_adapters(cache_conn) if read_adapters is None
|
|
1101
1184
|
else read_adapters)
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
if (retention is not None
|
|
1114
|
-
and record.get("t") in ("evt", "correction")):
|
|
1115
|
-
conn.execute("BEGIN IMMEDIATE")
|
|
1116
|
-
try:
|
|
1117
|
-
retention.on_journaled(conn, record)
|
|
1118
|
-
conn.commit()
|
|
1119
|
-
except BaseException:
|
|
1120
|
-
conn.rollback()
|
|
1121
|
-
raise
|
|
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
|
|
1185
|
+
# #997 (spec D2): one transaction holds up to
|
|
1186
|
+
# `_SCRATCH_REPLAY_COMMIT_EVERY` modifying inputs. Only the commit boundary
|
|
1187
|
+
# moves: each raw input keeps its own `IngestContext` and `_harvest`, in
|
|
1188
|
+
# journal order, and every reader shares this connection, which sees its
|
|
1189
|
+
# own uncommitted writes. On any exception the open batch rolls back and
|
|
1190
|
+
# the caller discards the scratch.
|
|
1191
|
+
bound = max(1, int(_SCRATCH_REPLAY_COMMIT_EVERY))
|
|
1192
|
+
pending = 0
|
|
1193
|
+
|
|
1194
|
+
def _open_batch():
|
|
1195
|
+
if not conn.in_transaction:
|
|
1148
1196
|
conn.execute("BEGIN IMMEDIATE")
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1197
|
+
|
|
1198
|
+
inputs = 0
|
|
1199
|
+
commits = 0
|
|
1200
|
+
|
|
1201
|
+
def _count_input():
|
|
1202
|
+
nonlocal pending, inputs, commits
|
|
1203
|
+
pending += 1
|
|
1204
|
+
inputs += 1
|
|
1205
|
+
if pending >= bound:
|
|
1206
|
+
conn.commit()
|
|
1207
|
+
pending = 0
|
|
1208
|
+
commits += 1
|
|
1209
|
+
# #997 D9: every commit batch (throttled to one write per ten
|
|
1210
|
+
# seconds; a no-op unless a worker installed a progress sink).
|
|
1211
|
+
_lib_rebuild_progress.tick(inputs=inputs, commits=commits)
|
|
1212
|
+
elif not inputs & 1023:
|
|
1213
|
+
_lib_rebuild_progress.tick(inputs=inputs, commits=commits)
|
|
1214
|
+
|
|
1215
|
+
try:
|
|
1216
|
+
with adapters:
|
|
1217
|
+
for index, record in enumerate(records):
|
|
1218
|
+
if not isinstance(record, dict):
|
|
1219
|
+
continue
|
|
1220
|
+
if prepare is not None:
|
|
1221
|
+
record = prepare(record)
|
|
1222
|
+
if record.get("t") == _protocol.EXCLUDED_PLACEHOLDER_TYPE:
|
|
1223
|
+
carried = record.get("quarantine") or {}
|
|
1224
|
+
fold.on_excluded(
|
|
1225
|
+
carried.get("record") or {},
|
|
1226
|
+
segment=carried.get("segment"),
|
|
1227
|
+
offset=carried.get("offset"))
|
|
1228
|
+
continue
|
|
1229
|
+
if (retention is not None
|
|
1230
|
+
and record.get("t") in ("evt", "correction")):
|
|
1231
|
+
_open_batch()
|
|
1232
|
+
retention.on_journaled(conn, record)
|
|
1233
|
+
_count_input()
|
|
1234
|
+
coordinate = (None if coordinates is None
|
|
1235
|
+
else coordinates[index])
|
|
1236
|
+
if coordinate is None:
|
|
1237
|
+
gate = fold.on_record(record)
|
|
1238
|
+
else:
|
|
1239
|
+
gate = fold.on_record(
|
|
1240
|
+
record, segment=coordinate[0], offset=coordinate[1])
|
|
1241
|
+
if not _is_rederivable_raw(record) or index in skip_indexes:
|
|
1242
|
+
continue
|
|
1243
|
+
before = len(events)
|
|
1244
|
+
ctx = _journal.IngestContext(
|
|
1245
|
+
conn=conn,
|
|
1246
|
+
batch=[record],
|
|
1247
|
+
config={},
|
|
1248
|
+
event_sink=events,
|
|
1249
|
+
projection_writes=False,
|
|
1250
|
+
held_weekly_observation_ids=held_ids,
|
|
1251
|
+
reviewed_weekly_basis_by_account=(
|
|
1252
|
+
reviewed_weekly_basis_by_account),
|
|
1253
|
+
block_source_evidence=block_sources,
|
|
1254
|
+
quarantine=fold,
|
|
1255
|
+
outcome_gate=gate,
|
|
1256
|
+
scratch_retention=retention,
|
|
1257
|
+
)
|
|
1158
1258
|
if retention is not None:
|
|
1159
|
-
retention.current_input =
|
|
1160
|
-
|
|
1161
|
-
|
|
1259
|
+
retention.current_input = index
|
|
1260
|
+
_open_batch()
|
|
1261
|
+
try:
|
|
1262
|
+
for hook in hooks:
|
|
1263
|
+
hook(ctx, record)
|
|
1264
|
+
_journal._harvest(ctx)
|
|
1265
|
+
finally:
|
|
1266
|
+
if retention is not None:
|
|
1267
|
+
retention.current_input = None
|
|
1268
|
+
_count_input()
|
|
1269
|
+
if on_input is not None:
|
|
1270
|
+
on_input(index, events[before:])
|
|
1271
|
+
if conn.in_transaction:
|
|
1272
|
+
conn.commit()
|
|
1273
|
+
except BaseException:
|
|
1274
|
+
if conn.in_transaction:
|
|
1275
|
+
conn.rollback()
|
|
1276
|
+
raise
|
|
1162
1277
|
return events
|
|
1163
1278
|
|
|
1164
1279
|
|
|
@@ -1196,8 +1311,9 @@ def plan_claude_usage(
|
|
|
1196
1311
|
read view. Task C owns lock/snapshot orchestration; Task B owns the pure
|
|
1197
1312
|
replay and comparison contract.
|
|
1198
1313
|
"""
|
|
1199
|
-
|
|
1200
|
-
|
|
1314
|
+
# #997 spec D5: a copy-on-write normalized view, not a whole-list deep
|
|
1315
|
+
# copy. Every planner consumer below reads this view.
|
|
1316
|
+
records = _normalized_planning_view(list(records))
|
|
1201
1317
|
report = _lib_rederive.validate_family_registry(
|
|
1202
1318
|
evt_kinds=set(_journal._EVT_SPECS),
|
|
1203
1319
|
op_kinds=_planner_op_kinds(),
|
|
@@ -1228,6 +1344,10 @@ def plan_claude_usage(
|
|
|
1228
1344
|
held_ids, _accepted_ids, identity_pairs, reviewed_ops = (
|
|
1229
1345
|
_reviewed_weekly_state(records)
|
|
1230
1346
|
)
|
|
1347
|
+
# #797/#823/#840 spec 4.7 (D4): a retraction is suppressed in the replay
|
|
1348
|
+
# like a hold (it is in `held_ids`) but is not in hold's
|
|
1349
|
+
# dependency-preservation set.
|
|
1350
|
+
retracted_ids = _reviewed_weekly_retractions(records)
|
|
1231
1351
|
config_basis = {
|
|
1232
1352
|
"historicalConfig": "not-retained",
|
|
1233
1353
|
"projectionPolicy": "retire-and-rematerialize",
|
|
@@ -1244,8 +1364,12 @@ def plan_claude_usage(
|
|
|
1244
1364
|
key: value for key, value in op["payload"].items()
|
|
1245
1365
|
if key not in _REVIEWED_OP_EXPECTATION_KEYS
|
|
1246
1366
|
}
|
|
1367
|
+
# #797/#823/#840 spec 4.6 B1: the logical identity stays the
|
|
1368
|
+
# revision-1 one, so a plan an earlier writer pinned keeps its
|
|
1369
|
+
# config fingerprint across the revision-2 stamp.
|
|
1247
1370
|
logical = _lib_journal.make_op(
|
|
1248
1371
|
at=op["at"], src=op["src"], payload=payload,
|
|
1372
|
+
writer_revision=None,
|
|
1249
1373
|
)
|
|
1250
1374
|
config_basis["reviewedWeeklyDecisionOps"].append({
|
|
1251
1375
|
"id": logical["id"], "at": logical["at"],
|
|
@@ -1306,7 +1430,8 @@ def plan_claude_usage(
|
|
|
1306
1430
|
conflicted_event_ids=owned_conflicted_event_ids(selection),
|
|
1307
1431
|
raw_observations=raw_records,
|
|
1308
1432
|
snapshot_identity_decisions=identity_pairs,
|
|
1309
|
-
reviewed_weekly_hold_ids=held_ids,
|
|
1433
|
+
reviewed_weekly_hold_ids=held_ids - retracted_ids,
|
|
1434
|
+
reviewed_weekly_retraction_ids=retracted_ids,
|
|
1310
1435
|
uncertain_cost_fact_ids=(
|
|
1311
1436
|
_uncertain_cost_facts(selection) | codex_uncertain),
|
|
1312
1437
|
historical_close_markers=historical_markers,
|
|
@@ -1353,6 +1478,51 @@ def owned_conflicted_event_ids(selection) -> frozenset:
|
|
|
1353
1478
|
return frozenset(owned)
|
|
1354
1479
|
|
|
1355
1480
|
|
|
1481
|
+
def _is_inert_codex_obs(record) -> bool:
|
|
1482
|
+
"""An admitted Codex quota observation: always admitted (its family is
|
|
1483
|
+
Codex-only), never a rederivable raw input, never a Claude reading."""
|
|
1484
|
+
return (record.get("t") == "obs"
|
|
1485
|
+
and record.get("provider") == "codex"
|
|
1486
|
+
and (record.get("payload") or {}).get("kind")
|
|
1487
|
+
== "quota_window_snapshot")
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _inert_codex_stub(record: dict, table: "dict | None" = None) -> dict:
|
|
1491
|
+
"""#997 spec D4: the read-only stand-in for an inert Codex quota
|
|
1492
|
+
observation on the rederive path — `t`, `provider`, `src`, `account` and
|
|
1493
|
+
a `payload` of `kind` plus `account_key`/`vendor` when present, which is
|
|
1494
|
+
everything a consumer on this path reads (admission, positional protocol
|
|
1495
|
+
selection, `_is_rederivable_raw`, `is_claude_reading`, legacy account
|
|
1496
|
+
classification and normalization, the Codex pricing scan, preservation
|
|
1497
|
+
and the reviewed-decision suffix checks). Equal key tuples
|
|
1498
|
+
`(src, account, payload.account_key, payload.vendor)` share one object
|
|
1499
|
+
from ``table`` (the reader's per-read intern table), so 2.19 M positions
|
|
1500
|
+
cost a pointer each. A stub is never mutated: the planner's normalized
|
|
1501
|
+
view copies it once (`_normalized_planning_view`)."""
|
|
1502
|
+
payload = record.get("payload") or {}
|
|
1503
|
+
key = (record.get("src"),
|
|
1504
|
+
"account" in record, record.get("account"),
|
|
1505
|
+
"account_key" in payload, payload.get("account_key"),
|
|
1506
|
+
"vendor" in payload, payload.get("vendor"))
|
|
1507
|
+
try:
|
|
1508
|
+
stub = None if table is None else table.get(key)
|
|
1509
|
+
except TypeError: # an unhashable stamp: keep the record as it is
|
|
1510
|
+
return record
|
|
1511
|
+
if stub is not None:
|
|
1512
|
+
return stub
|
|
1513
|
+
stub_payload = {"kind": payload.get("kind")}
|
|
1514
|
+
for name in ("account_key", "vendor"):
|
|
1515
|
+
if name in payload:
|
|
1516
|
+
stub_payload[name] = payload[name]
|
|
1517
|
+
stub = {"t": "obs", "provider": "codex", "src": record.get("src")}
|
|
1518
|
+
if "account" in record:
|
|
1519
|
+
stub["account"] = record["account"]
|
|
1520
|
+
stub["payload"] = stub_payload
|
|
1521
|
+
if table is not None:
|
|
1522
|
+
table[key] = stub
|
|
1523
|
+
return stub
|
|
1524
|
+
|
|
1525
|
+
|
|
1356
1526
|
def read_rederive_journal_prefix(
|
|
1357
1527
|
high_water: "tuple[str, int] | None" = None,
|
|
1358
1528
|
):
|
|
@@ -1373,6 +1543,11 @@ def read_rederive_journal_prefix(
|
|
|
1373
1543
|
`records` in parallel with `record_ends`. Both lists stay complete and
|
|
1374
1544
|
aligned, so the win in this file is the removed double materialization and
|
|
1375
1545
|
the removed hash traversals, not reduced retention.
|
|
1546
|
+
|
|
1547
|
+
#997 spec D4: positions are kept, but an admitted Codex quota observation
|
|
1548
|
+
is retained as an interned read-only stub (`_inert_codex_stub`) rather
|
|
1549
|
+
than decoded in full; no consumer on this path reads its quota payload,
|
|
1550
|
+
`id` or `at`.
|
|
1376
1551
|
"""
|
|
1377
1552
|
if high_water is None:
|
|
1378
1553
|
high_water = _journal.journal_high_water()
|
|
@@ -1389,7 +1564,7 @@ def read_rederive_journal_prefix(
|
|
|
1389
1564
|
evidence_owners: list = []
|
|
1390
1565
|
malformed = 0
|
|
1391
1566
|
prior_high_water = None
|
|
1392
|
-
hasher =
|
|
1567
|
+
hasher = _journal._new_prefix_hasher()
|
|
1393
1568
|
# #825/#832 (spec 5.3): admission before interpretation, resolved within
|
|
1394
1569
|
# this one pass. A reviewed decision's validation needs the prefix digest
|
|
1395
1570
|
# at its own position, so the digest is taken inline and the validation
|
|
@@ -1397,19 +1572,32 @@ def read_rederive_journal_prefix(
|
|
|
1397
1572
|
pass_boundary = _journal._PassBoundary()
|
|
1398
1573
|
coordinates: list = []
|
|
1399
1574
|
pending_reviewed: list = []
|
|
1575
|
+
inert_stubs: dict = {}
|
|
1576
|
+
lines = 0
|
|
1400
1577
|
for segment, offset, raw in _journal._iter_range_with_segments(
|
|
1401
1578
|
None,
|
|
1402
1579
|
high_water,
|
|
1403
1580
|
segments,
|
|
1404
|
-
on_segment=lambda name: hasher.begin_segment(
|
|
1581
|
+
on_segment=lambda name, size: hasher.begin_segment(
|
|
1582
|
+
name, prior_high_water, size=size),
|
|
1405
1583
|
on_bytes=hasher.extend,
|
|
1406
1584
|
):
|
|
1585
|
+
lines += 1
|
|
1586
|
+
if not lines & 4095:
|
|
1587
|
+
# #997 D9 (throttled; a no-op without a progress sink).
|
|
1588
|
+
_lib_rebuild_progress.tick(lines=lines)
|
|
1407
1589
|
record = _lib_journal.decode_line(raw)
|
|
1408
1590
|
record_end = (segment, offset + len(raw) + 1)
|
|
1409
1591
|
if record is None:
|
|
1410
1592
|
malformed += 1
|
|
1411
1593
|
prior_high_water = record_end
|
|
1412
1594
|
continue
|
|
1595
|
+
inert = _is_inert_codex_obs(record)
|
|
1596
|
+
if not inert:
|
|
1597
|
+
# #997 D15: every record retained in full is compacted before
|
|
1598
|
+
# anything holds a reference to it, so the identity checks below
|
|
1599
|
+
# (`records[index] is record`) see only the compacted object.
|
|
1600
|
+
record = _lib_journal.compact_retained(record)
|
|
1413
1601
|
pass_boundary.observe(record)
|
|
1414
1602
|
if record.get("t") == "op":
|
|
1415
1603
|
if (record.get("payload") or {}).get("kind") in _REVIEWED_WEEKLY_KINDS:
|
|
@@ -1426,6 +1614,8 @@ def read_rederive_journal_prefix(
|
|
|
1426
1614
|
)
|
|
1427
1615
|
if len(evidence) > len(evidence_owners):
|
|
1428
1616
|
evidence_owners.append(len(records))
|
|
1617
|
+
elif inert:
|
|
1618
|
+
record = _inert_codex_stub(record, inert_stubs)
|
|
1429
1619
|
records.append(record)
|
|
1430
1620
|
record_ends.append(record_end)
|
|
1431
1621
|
coordinates.append((segment, offset))
|
|
@@ -1462,8 +1652,9 @@ def read_rederive_journal_prefix(
|
|
|
1462
1652
|
record, prior, _PinnedPrefixDigest(prior, digest),
|
|
1463
1653
|
)
|
|
1464
1654
|
# Released before the raise below, so a malformed prefix does not pin the
|
|
1465
|
-
# accumulator
|
|
1466
|
-
#
|
|
1655
|
+
# accumulator (its hash states, registered boundaries and segment opener;
|
|
1656
|
+
# since #997 D12 it streams and retains no segment bytes) on the traceback
|
|
1657
|
+
# while the exception unwinds. On the success path the frame
|
|
1467
1658
|
# dies two statements later, so this mirrors the repair reader, where the
|
|
1468
1659
|
# release genuinely precedes a loop over every decoded record.
|
|
1469
1660
|
hasher = None
|
|
@@ -1496,6 +1687,22 @@ def _batch_id_for_plan(plan: _lib_rederive.RederivePlan) -> "str | None":
|
|
|
1496
1687
|
return f"rederive:{plan.family}:{digest}"
|
|
1497
1688
|
|
|
1498
1689
|
|
|
1690
|
+
def _empty_decision_batch_id(family: str, decision_id: str) -> str:
|
|
1691
|
+
"""D4: the id of the empty batch that completes reviewed decision
|
|
1692
|
+
``decision_id`` when its plan has no actions.
|
|
1693
|
+
|
|
1694
|
+
The plain path reads a reviewed decision as finished only when its own
|
|
1695
|
+
completed correction batch follows it, so a decision that changes
|
|
1696
|
+
nothing (a later split manifest whose observations an earlier decision
|
|
1697
|
+
already holds) still gets a batch: begin and commit, ``action_count``
|
|
1698
|
+
0. Keyed by the decision, because every empty action set is the same
|
|
1699
|
+
and each decision needs its own completion.
|
|
1700
|
+
"""
|
|
1701
|
+
body = {"family": family, "actions": [], "reviewedDecision": decision_id}
|
|
1702
|
+
digest = hashlib.sha256(_canonical_bytes(body)).hexdigest()
|
|
1703
|
+
return f"rederive:{family}:{digest}"
|
|
1704
|
+
|
|
1705
|
+
|
|
1499
1706
|
def _iso_now() -> str:
|
|
1500
1707
|
return (
|
|
1501
1708
|
dt.datetime.now(dt.timezone.utc)
|
|
@@ -1505,45 +1712,111 @@ def _iso_now() -> str:
|
|
|
1505
1712
|
)
|
|
1506
1713
|
|
|
1507
1714
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1715
|
+
#: How each read-only snapshot's main file was taken, ``"clone"`` or
|
|
1716
|
+
#: ``"copy"`` (#997 D13). Per process; tests clear and read it.
|
|
1717
|
+
SNAPSHOT_COUNTS: dict = {}
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _device_of(path) -> int:
|
|
1721
|
+
"""`st_dev` of ``path`` (a seam: tests report another device)."""
|
|
1722
|
+
return os.stat(path).st_dev
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def _snapshot_clone_parent(path: Path, tmpdir: str) -> "str | None":
|
|
1726
|
+
"""Where a clonable snapshot's directory goes, or None off Darwin.
|
|
1727
|
+
|
|
1728
|
+
A clone shares blocks only within one volume, so the directory is under
|
|
1729
|
+
`TMPDIR` when `TMPDIR` is on the source's device and otherwise in the
|
|
1730
|
+
source's own directory (a data folder on an external volume with an
|
|
1731
|
+
internal `TMPDIR`)."""
|
|
1732
|
+
if _lib_clone_copy._PLATFORM != "darwin":
|
|
1733
|
+
return None
|
|
1734
|
+
try:
|
|
1735
|
+
same = _device_of(tmpdir) == _device_of(path.parent)
|
|
1736
|
+
except OSError:
|
|
1737
|
+
return tmpdir
|
|
1738
|
+
return tmpdir if same else str(path.parent)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _take_sqlite_snapshot(path: Path, snapshot: Path, *, clone: bool):
|
|
1742
|
+
"""Write a consistent snapshot of ``path`` (and its WAL prefix) to
|
|
1743
|
+
``snapshot``; return ``"clone"`` or ``"copy"``, or None when ``clone``
|
|
1744
|
+
was asked for and the filesystem refused it."""
|
|
1745
|
+
wal = path.with_name(path.name + "-wal")
|
|
1746
|
+
snapshot_wal = snapshot.with_name(snapshot.name + "-wal")
|
|
1747
|
+
# Copy the append-only WAL prefix before the main file. If a checkpoint
|
|
1748
|
+
# races this read, a main-file identity change makes us retry rather
|
|
1749
|
+
# than combining generations. SQLite validates the copied WAL frames.
|
|
1750
|
+
for attempt in range(3):
|
|
1751
|
+
before = path.stat()
|
|
1752
|
+
snapshot_wal.unlink(missing_ok=True)
|
|
1753
|
+
try:
|
|
1754
|
+
wal_size = wal.stat().st_size
|
|
1755
|
+
with wal.open("rb") as source, snapshot_wal.open("wb") as target:
|
|
1756
|
+
shutil.copyfileobj(source, target, length=1024 * 1024)
|
|
1757
|
+
target.truncate(wal_size)
|
|
1758
|
+
except FileNotFoundError:
|
|
1522
1759
|
snapshot_wal.unlink(missing_ok=True)
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1760
|
+
if clone:
|
|
1761
|
+
# A clone needs a new path, so a retry's previous snapshot goes
|
|
1762
|
+
# first (#997 D13).
|
|
1763
|
+
snapshot.unlink(missing_ok=True)
|
|
1764
|
+
if not _lib_clone_copy.try_clone(str(path), str(snapshot)):
|
|
1765
|
+
return None
|
|
1766
|
+
method = "clone"
|
|
1767
|
+
else:
|
|
1530
1768
|
shutil.copyfile(path, snapshot)
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1769
|
+
method = "copy"
|
|
1770
|
+
after = path.stat()
|
|
1771
|
+
if (
|
|
1772
|
+
before.st_ino,
|
|
1773
|
+
before.st_size,
|
|
1774
|
+
before.st_mtime_ns,
|
|
1775
|
+
) == (
|
|
1776
|
+
after.st_ino,
|
|
1777
|
+
after.st_size,
|
|
1778
|
+
after.st_mtime_ns,
|
|
1779
|
+
):
|
|
1780
|
+
return method
|
|
1781
|
+
if attempt == 2:
|
|
1782
|
+
raise RederiveBusy(
|
|
1783
|
+
"cache.db changed during the read-only preview snapshot; "
|
|
1784
|
+
"retry shortly"
|
|
1785
|
+
)
|
|
1786
|
+
raise AssertionError("unreachable")
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
@contextlib.contextmanager
|
|
1790
|
+
def _open_sqlite_snapshot(path: Path, *, prefix: str):
|
|
1791
|
+
"""Yield a query-only SQLite family copy without touching the source.
|
|
1792
|
+
|
|
1793
|
+
#997 D13: on Darwin the main file is a clone sharing the source's blocks
|
|
1794
|
+
(the cache snapshot was a 2.54 GB copy per preview), its directory placed
|
|
1795
|
+
by `_snapshot_clone_parent`. A refused clone removes that directory and
|
|
1796
|
+
takes the real copy under `TMPDIR`, so a volume that cannot clone never
|
|
1797
|
+
receives a copy beside the data. Elsewhere the snapshot is the real copy
|
|
1798
|
+
under `TMPDIR`. The WAL prefix is always a byte copy truncated to the
|
|
1799
|
+
size read. The directory is removed on exit; a crash leaves it behind.
|
|
1800
|
+
"""
|
|
1801
|
+
tmpdir = tempfile.gettempdir()
|
|
1802
|
+
parent = _snapshot_clone_parent(path, tmpdir)
|
|
1803
|
+
holder = None
|
|
1804
|
+
try:
|
|
1805
|
+
method = None
|
|
1806
|
+
if parent is not None:
|
|
1807
|
+
holder = tempfile.TemporaryDirectory(prefix=prefix, dir=parent)
|
|
1808
|
+
snapshot = Path(holder.name) / path.name
|
|
1809
|
+
method = _take_sqlite_snapshot(path, snapshot, clone=True)
|
|
1810
|
+
if method is None and parent != tmpdir:
|
|
1811
|
+
holder.cleanup()
|
|
1812
|
+
holder = None
|
|
1813
|
+
if method is None:
|
|
1814
|
+
if holder is None:
|
|
1815
|
+
holder = tempfile.TemporaryDirectory(prefix=prefix, dir=tmpdir)
|
|
1816
|
+
snapshot = Path(holder.name) / path.name
|
|
1817
|
+
snapshot.unlink(missing_ok=True)
|
|
1818
|
+
method = _take_sqlite_snapshot(path, snapshot, clone=False)
|
|
1819
|
+
SNAPSHOT_COUNTS[method] = SNAPSHOT_COUNTS.get(method, 0) + 1
|
|
1547
1820
|
conn = sqlite3.connect(snapshot)
|
|
1548
1821
|
try:
|
|
1549
1822
|
conn.execute("PRAGMA query_only=ON")
|
|
@@ -1551,6 +1824,9 @@ def _open_sqlite_snapshot(path: Path, *, prefix: str):
|
|
|
1551
1824
|
yield conn
|
|
1552
1825
|
finally:
|
|
1553
1826
|
conn.close()
|
|
1827
|
+
finally:
|
|
1828
|
+
if holder is not None:
|
|
1829
|
+
holder.cleanup()
|
|
1554
1830
|
|
|
1555
1831
|
|
|
1556
1832
|
@contextlib.contextmanager
|
|
@@ -1719,13 +1995,40 @@ def _preview_from_snapshot(
|
|
|
1719
1995
|
batch_id = _batch_id_for_plan(plan)
|
|
1720
1996
|
generated_at = _iso_now()
|
|
1721
1997
|
incomplete = False
|
|
1722
|
-
|
|
1723
|
-
|
|
1998
|
+
|
|
1999
|
+
def _begins_of(identity):
|
|
2000
|
+
return [
|
|
1724
2001
|
record for record in records
|
|
1725
2002
|
if record.get("t") == "correction_batch"
|
|
1726
2003
|
and record.get("phase") == "begin"
|
|
1727
|
-
and record.get("id") ==
|
|
2004
|
+
and record.get("id") == identity
|
|
1728
2005
|
]
|
|
2006
|
+
|
|
2007
|
+
empty_decision_batch_id = None
|
|
2008
|
+
if (batch_id is None and decision_op is not None
|
|
2009
|
+
and not decision_batch_completed):
|
|
2010
|
+
# D4: an unfinished reviewed decision whose plan is empty completes
|
|
2011
|
+
# with an empty batch, so the plain path reads it as finished. A
|
|
2012
|
+
# retry after an interrupted append reuses the begin's `at`, so its
|
|
2013
|
+
# lines are byte-identical.
|
|
2014
|
+
empty_id = _empty_decision_batch_id(family, decision_op["id"])
|
|
2015
|
+
if empty_id not in selection.completed_batches:
|
|
2016
|
+
empty_id = _lib_journal.revision_batch_id(
|
|
2017
|
+
empty_id, _begins_of(empty_id))
|
|
2018
|
+
if empty_id not in selection.completed_batches:
|
|
2019
|
+
empty_decision_batch_id = empty_id
|
|
2020
|
+
begins = _begins_of(empty_id)
|
|
2021
|
+
if begins:
|
|
2022
|
+
generated_at = str(begins[0]["at"])
|
|
2023
|
+
incomplete = True
|
|
2024
|
+
if batch_id is not None:
|
|
2025
|
+
# #797/#823/#840 spec 4.6 B1: an incomplete batch an earlier writer
|
|
2026
|
+
# began without the revision-2 stamp is retried under a derived id,
|
|
2027
|
+
# never restamped.
|
|
2028
|
+
if batch_id not in selection.completed_batches:
|
|
2029
|
+
batch_id = _lib_journal.revision_batch_id(
|
|
2030
|
+
batch_id, _begins_of(batch_id))
|
|
2031
|
+
begins = _begins_of(batch_id)
|
|
1729
2032
|
if begins:
|
|
1730
2033
|
generated_at = str(begins[0]["at"])
|
|
1731
2034
|
incomplete = batch_id not in selection.completed_batches
|
|
@@ -1759,6 +2062,7 @@ def _preview_from_snapshot(
|
|
|
1759
2062
|
None if decision_op is None else decision_op["id"]),
|
|
1760
2063
|
baseline_plan=baseline_plan,
|
|
1761
2064
|
baseline_plan_guard=baseline_plan_guard,
|
|
2065
|
+
empty_decision_batch_id=empty_decision_batch_id,
|
|
1762
2066
|
)
|
|
1763
2067
|
if decision_op is not None and not decision_batch_completed:
|
|
1764
2068
|
expected_hashes = _reviewed_op_expected_hashes(
|
|
@@ -1991,6 +2295,16 @@ def _stats_has_batch(batch_id: str) -> bool:
|
|
|
1991
2295
|
"WHERE batch_id = ? LIMIT 1",
|
|
1992
2296
|
(batch_id,),
|
|
1993
2297
|
).fetchone()
|
|
2298
|
+
if row is None:
|
|
2299
|
+
# D4: an empty batch (a reviewed decision whose plan had no
|
|
2300
|
+
# actions) leaves no effective event; the index has folded
|
|
2301
|
+
# it when its selector row is completed.
|
|
2302
|
+
row = conn.execute(
|
|
2303
|
+
"SELECT 1 FROM journal_selector_batches "
|
|
2304
|
+
"WHERE batch_id = ? AND status = 'completed' "
|
|
2305
|
+
"AND action_count = 0",
|
|
2306
|
+
(batch_id,),
|
|
2307
|
+
).fetchone()
|
|
1994
2308
|
return row is not None
|
|
1995
2309
|
except (OSError, sqlite3.Error, StatsIndexNotCurrent):
|
|
1996
2310
|
return False
|
|
@@ -2123,6 +2437,35 @@ def _assert_reviewed_plan_hashes(preview, expected_plan_hashes) -> None:
|
|
|
2123
2437
|
)
|
|
2124
2438
|
|
|
2125
2439
|
|
|
2440
|
+
def _compact_preview(preview: RederivePreview) -> RederivePreview:
|
|
2441
|
+
"""#997 D18: the preview without its journal records.
|
|
2442
|
+
|
|
2443
|
+
`records` (every retained record of the prefix) and `record_ends` (one
|
|
2444
|
+
commit coordinate per record) are read only by planning: the exact-retry
|
|
2445
|
+
and materialization checks run before any rebuild starts. Every other
|
|
2446
|
+
field (both plans and their hashes, `batch_id`, `incomplete_batch`, the
|
|
2447
|
+
high-water marks) is kept, so the status choice, the result, the
|
|
2448
|
+
renderers and every error read what they read before. The one release
|
|
2449
|
+
seam: T18 disables the release by patching it to the identity.
|
|
2450
|
+
"""
|
|
2451
|
+
return dataclasses.replace(preview, records=(), record_ends=())
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def _release_preview_records(*previews):
|
|
2455
|
+
"""#997 D18: compact copies of `previews`, in order, for the apply to
|
|
2456
|
+
rebind immediately before a rebuild, so no full preview stays reachable
|
|
2457
|
+
through any name. A preview passed under two names comes back as ONE
|
|
2458
|
+
copy, so an alias stays an alias; `None` (no such preview) stays
|
|
2459
|
+
`None`."""
|
|
2460
|
+
copies: dict = {}
|
|
2461
|
+
released = []
|
|
2462
|
+
for preview in previews:
|
|
2463
|
+
if preview is not None and id(preview) not in copies:
|
|
2464
|
+
copies[id(preview)] = _compact_preview(preview)
|
|
2465
|
+
released.append(None if preview is None else copies[id(preview)])
|
|
2466
|
+
return tuple(released)
|
|
2467
|
+
|
|
2468
|
+
|
|
2126
2469
|
def apply_db_rederive(
|
|
2127
2470
|
family: str,
|
|
2128
2471
|
*,
|
|
@@ -2136,6 +2479,9 @@ def apply_db_rederive(
|
|
|
2136
2479
|
with _rederive_locks(apply=True, timeout=lock_timeout), \
|
|
2137
2480
|
_held_past_unlock(lost):
|
|
2138
2481
|
appended_reviewed_op = False
|
|
2482
|
+
# A reviewed exact retry's preview at the decision's bound; D18
|
|
2483
|
+
# releases it with the others.
|
|
2484
|
+
original = None
|
|
2139
2485
|
if reviewed_op is not None:
|
|
2140
2486
|
durable_hashes = _reviewed_op_expected_hashes(
|
|
2141
2487
|
reviewed_op, required=True,
|
|
@@ -2168,12 +2514,15 @@ def apply_db_rederive(
|
|
|
2168
2514
|
if record.get("t") == "op"
|
|
2169
2515
|
and record.get("id") == reviewed_op["id"]
|
|
2170
2516
|
)
|
|
2517
|
+
# D4: an empty decision's own batch is its empty one.
|
|
2518
|
+
original_batch_id = (
|
|
2519
|
+
original.batch_id or original.empty_decision_batch_id)
|
|
2171
2520
|
if any(
|
|
2172
2521
|
not (
|
|
2173
2522
|
(record.get("t") == "correction_batch"
|
|
2174
|
-
and record.get("id") ==
|
|
2523
|
+
and record.get("id") == original_batch_id)
|
|
2175
2524
|
or (record.get("t") == "correction"
|
|
2176
|
-
and record.get("batch") ==
|
|
2525
|
+
and record.get("batch") == original_batch_id)
|
|
2177
2526
|
)
|
|
2178
2527
|
for record in preview.records[op_index + 1:]
|
|
2179
2528
|
):
|
|
@@ -2224,13 +2573,22 @@ def apply_db_rederive(
|
|
|
2224
2573
|
preview.latest_completed_batch is not None
|
|
2225
2574
|
and not _stats_has_batch(preview.latest_completed_batch)
|
|
2226
2575
|
)
|
|
2227
|
-
|
|
2228
|
-
|
|
2576
|
+
# D4: an unfinished reviewed decision whose plan is empty appends its
|
|
2577
|
+
# empty completed batch here, exactly like a batch with actions, so
|
|
2578
|
+
# the plain path reads the decision as finished. It reports what the
|
|
2579
|
+
# decision reported before (the latest earlier batch), so the output
|
|
2580
|
+
# is unchanged.
|
|
2581
|
+
batch_id = preview.batch_id
|
|
2582
|
+
reported_batch_id = preview.batch_id
|
|
2583
|
+
if not plan.actions and preview.empty_decision_batch_id is not None:
|
|
2584
|
+
batch_id = preview.empty_decision_batch_id
|
|
2585
|
+
reported_batch_id = preview.latest_completed_batch
|
|
2586
|
+
if batch_id is not None:
|
|
2229
2587
|
batch = _lib_journal.make_correction_batch(
|
|
2230
|
-
batch_id=
|
|
2588
|
+
batch_id=batch_id,
|
|
2231
2589
|
family=family,
|
|
2232
2590
|
at=preview.generated_at,
|
|
2233
|
-
actions=plan.to_correction_actions(),
|
|
2591
|
+
actions=plan.to_correction_actions() if plan.actions else [],
|
|
2234
2592
|
)
|
|
2235
2593
|
try:
|
|
2236
2594
|
batch_high_water = _journal.append_records(
|
|
@@ -2242,8 +2600,11 @@ def apply_db_rederive(
|
|
|
2242
2600
|
)
|
|
2243
2601
|
except Exception as exc:
|
|
2244
2602
|
raise RederiveApplyError(
|
|
2245
|
-
"correction append", preview,
|
|
2603
|
+
"correction append", preview, batch_id, exc
|
|
2246
2604
|
) from exc
|
|
2605
|
+
# #997 D18: nothing past planning reads the records.
|
|
2606
|
+
preview, report_preview, original = _release_preview_records(
|
|
2607
|
+
preview, report_preview, original)
|
|
2247
2608
|
try:
|
|
2248
2609
|
_call_crash_hook("after-batch-commit")
|
|
2249
2610
|
result = _journal.rebuild_stats_index(
|
|
@@ -2258,21 +2619,25 @@ def apply_db_rederive(
|
|
|
2258
2619
|
_call_crash_hook("after-rebuild")
|
|
2259
2620
|
except _cctally_completion.ResidualObligationNotDurable as exc:
|
|
2260
2621
|
raise _obligation_lost(
|
|
2261
|
-
"stats rebuild", preview,
|
|
2622
|
+
"stats rebuild", preview, batch_id, exc
|
|
2262
2623
|
) from exc
|
|
2263
2624
|
except Exception as exc:
|
|
2264
2625
|
raise RederiveApplyError(
|
|
2265
|
-
"stats rebuild", preview,
|
|
2626
|
+
"stats rebuild", preview, batch_id, exc
|
|
2266
2627
|
) from exc
|
|
2267
2628
|
status = (
|
|
2268
2629
|
"recovered"
|
|
2269
2630
|
if preview.incomplete_batch or recovering_prior
|
|
2631
|
+
# D4: as before, completing an empty decision this
|
|
2632
|
+
# invocation did not append (an interrupted apply resumed by
|
|
2633
|
+
# an exact retry or a plain rederive) is a recovery.
|
|
2634
|
+
or (not plan.actions and not appended_reviewed_op)
|
|
2270
2635
|
else "applied"
|
|
2271
2636
|
)
|
|
2272
2637
|
return RederiveCommandResult(
|
|
2273
2638
|
preview=report_preview,
|
|
2274
2639
|
status=status,
|
|
2275
|
-
batch_id=
|
|
2640
|
+
batch_id=reported_batch_id,
|
|
2276
2641
|
rebuild=result,
|
|
2277
2642
|
)
|
|
2278
2643
|
|
|
@@ -2282,6 +2647,9 @@ def apply_db_rederive(
|
|
|
2282
2647
|
raise RederiveBusy(
|
|
2283
2648
|
f"completed correction batch {latest} has no commit high-water"
|
|
2284
2649
|
)
|
|
2650
|
+
# #997 D18: nothing past planning reads the records.
|
|
2651
|
+
preview, report_preview, original = _release_preview_records(
|
|
2652
|
+
preview, report_preview, original)
|
|
2285
2653
|
try:
|
|
2286
2654
|
result = _journal.rebuild_stats_index(
|
|
2287
2655
|
context=_journal.RebuildContext(
|
|
@@ -2310,6 +2678,9 @@ def apply_db_rederive(
|
|
|
2310
2678
|
rebuild=result,
|
|
2311
2679
|
)
|
|
2312
2680
|
if appended_reviewed_op or _reviewed_op_needs_materialization(preview):
|
|
2681
|
+
# #997 D18: released only after the check above has read them.
|
|
2682
|
+
preview, report_preview, original = _release_preview_records(
|
|
2683
|
+
preview, report_preview, original)
|
|
2313
2684
|
try:
|
|
2314
2685
|
result = _journal.rebuild_stats_index(
|
|
2315
2686
|
context=_journal.RebuildContext(trigger="rederive-recovery"),
|
|
@@ -2390,6 +2761,7 @@ def _command_payload(
|
|
|
2390
2761
|
guarded_plan: "_lib_rederive.RederivePlan | None" = None,
|
|
2391
2762
|
plan_guard=None,
|
|
2392
2763
|
reviewed_decision_id=None,
|
|
2764
|
+
reviewed_decision_counts=None,
|
|
2393
2765
|
):
|
|
2394
2766
|
plan = guarded_plan if preview is None else preview.plan
|
|
2395
2767
|
counts = (
|
|
@@ -2434,6 +2806,10 @@ def _command_payload(
|
|
|
2434
2806
|
}
|
|
2435
2807
|
if reviewed_decision_id is not None:
|
|
2436
2808
|
body["reviewedWeeklyDecisionId"] = reviewed_decision_id
|
|
2809
|
+
if reviewed_decision_counts is not None:
|
|
2810
|
+
# #797/#823/#840 spec 4.7: the manifest's decisions by disposition,
|
|
2811
|
+
# retractions counted on their own (they persist as holds).
|
|
2812
|
+
body["reviewedWeeklyDecisionCounts"] = reviewed_decision_counts
|
|
2437
2813
|
if plan is not None and plan.uncertain_cost_fact_count:
|
|
2438
2814
|
body["uncertainCostFactCount"] = plan.uncertain_cost_fact_count
|
|
2439
2815
|
if preview is not None and preview.baseline_plan is not None:
|
|
@@ -2511,6 +2887,23 @@ def _emit_command_payload(payload: dict, *, as_json: bool) -> None:
|
|
|
2511
2887
|
"decision action counts: "
|
|
2512
2888
|
+ _counts_text(payload["decisionActionCounts"])
|
|
2513
2889
|
)
|
|
2890
|
+
if "reviewedWeeklyDecisionCounts" in payload:
|
|
2891
|
+
counts = payload["reviewedWeeklyDecisionCounts"]
|
|
2892
|
+
print("reviewed decisions: " + ", ".join(
|
|
2893
|
+
f"{name}={counts[name]}" for name in ("hold", "accept", "retract")))
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
def _reviewed_decision_counts(reviewed_op) -> "dict | None":
|
|
2897
|
+
"""A reviewed manifest's decisions per disposition (`hold`, `accept`,
|
|
2898
|
+
`retract`), or None without a manifest."""
|
|
2899
|
+
if reviewed_op is None:
|
|
2900
|
+
return None
|
|
2901
|
+
decoded = _lib_rederive.decode_reviewed_weekly_payload(
|
|
2902
|
+
reviewed_op["payload"])
|
|
2903
|
+
counts = dict.fromkeys(_lib_rederive.REVIEWED_WEEKLY_DISPOSITIONS, 0)
|
|
2904
|
+
for disposition in decoded.dispositions.values():
|
|
2905
|
+
counts[disposition] += 1
|
|
2906
|
+
return counts
|
|
2514
2907
|
|
|
2515
2908
|
|
|
2516
2909
|
def cmd_db_rederive(args) -> int:
|
|
@@ -2598,6 +2991,7 @@ def cmd_db_rederive(args) -> int:
|
|
|
2598
2991
|
rebuild=result.rebuild,
|
|
2599
2992
|
reviewed_decision_id=(
|
|
2600
2993
|
None if reviewed_op is None else reviewed_op["id"]),
|
|
2994
|
+
reviewed_decision_counts=_reviewed_decision_counts(reviewed_op),
|
|
2601
2995
|
)
|
|
2602
2996
|
else:
|
|
2603
2997
|
preview = preview_db_rederive(family, reviewed_op=reviewed_op)
|
|
@@ -2605,6 +2999,8 @@ def cmd_db_rederive(args) -> int:
|
|
|
2605
2999
|
"preview"
|
|
2606
3000
|
if reviewed_op is not None or preview.plan.actions
|
|
2607
3001
|
or preview.recovery_required
|
|
3002
|
+
# D4: the apply completes an unfinished empty decision.
|
|
3003
|
+
or preview.empty_decision_batch_id is not None
|
|
2608
3004
|
else "no-op"
|
|
2609
3005
|
)
|
|
2610
3006
|
payload = _command_payload(
|
|
@@ -2616,6 +3012,7 @@ def cmd_db_rederive(args) -> int:
|
|
|
2616
3012
|
else preview.batch_id
|
|
2617
3013
|
),
|
|
2618
3014
|
reviewed_decision_id=preview.reviewed_decision_id,
|
|
3015
|
+
reviewed_decision_counts=_reviewed_decision_counts(reviewed_op),
|
|
2619
3016
|
)
|
|
2620
3017
|
except _lib_rederive.RederiveDataGap as exc:
|
|
2621
3018
|
payload = _command_payload(
|