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
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
"""The shared, durable derivation-completion step (#825/#832 fix round A,
|
|
2
|
+
Codex fix-round-1 Q1/Q2).
|
|
3
|
+
|
|
4
|
+
A stats rebuild is apply-only: it replays journaled decisions and never
|
|
5
|
+
derives a raw observation. Whenever an admitted current input reached the
|
|
6
|
+
journal without a current binary deriving it, an apply-only rebuild consumes
|
|
7
|
+
it without materializing it — permanently, because no decision exists for any
|
|
8
|
+
later rebuild to replay. Three ways in:
|
|
9
|
+
|
|
10
|
+
* the epoch transition: the tick whose ingest found another epoch appended its
|
|
11
|
+
reading and deferred (spec C3/F1);
|
|
12
|
+
* a previous-release process whose ingest, at its own epoch, derived current
|
|
13
|
+
readings UNSTAMPED: admission excludes those decisions (review P1-1);
|
|
14
|
+
* the corrupt-index heal and any interrupted recovery, whose rebuild consumed
|
|
15
|
+
a reading a deferred ingest never reached.
|
|
16
|
+
|
|
17
|
+
`complete_and_rebuild` runs before every production rebuild of the live index
|
|
18
|
+
(the caller holds stats maintenance EXCLUSIVE and `journal.ingest.lock`, so no
|
|
19
|
+
current decision can be appended concurrently). It
|
|
20
|
+
|
|
21
|
+
1. starts from a PROVEN complete predecessor state — the derivation frontier
|
|
22
|
+
of a trusted current index (`journal_derivation_frontier`) — or, absent that
|
|
23
|
+
proof, from the ORIGIN of the retained journal, bootstrap facts included
|
|
24
|
+
(fix round E, spec 4.7 revision 1, Codex fix-round-2 Q1): the cutover is no
|
|
25
|
+
proof, because an admitted decision for a pre-cutover input lands after
|
|
26
|
+
it. The replaced index's applied cursor
|
|
27
|
+
is never proof: an old process advances it past inputs it derived
|
|
28
|
+
unstamped, and a rebuild advances it past inputs it never derived. The
|
|
29
|
+
frontier counts consumed inputs; the cycle that consumed them journaled
|
|
30
|
+
its decisions beyond it, through the proof's output end, so the
|
|
31
|
+
predecessor is the rebuild through the frontier PLUS those owed decisions
|
|
32
|
+
(fix round C, P1-1) — without them a window input is derived against a
|
|
33
|
+
state uninterrupted ingest never had;
|
|
34
|
+
2. builds a scratch index at that point and replays the admitted inputs of the
|
|
35
|
+
window in journal order through the ONE shared scratch derivation
|
|
36
|
+
(`_cctally_rederive.replay_scratch_records`: the live pipeline hooks,
|
|
37
|
+
harvest included), evaluating the outcome quarantine at each input's
|
|
38
|
+
original position, while a `_cctally_journal.ScratchRetention` keeps the
|
|
39
|
+
projection on the retained decisions (historical facts, corrections,
|
|
40
|
+
reviewed holds and tombstones), never on fresh candidates in their place;
|
|
41
|
+
3. finds the GAP inputs (an input with a derived decision no admitted
|
|
42
|
+
decision answers; an input a previous release decided is history, never a
|
|
43
|
+
gap), replays the window without them as the reference, and once more
|
|
44
|
+
with them, holding a fresh candidate only where it differs from the
|
|
45
|
+
reference, so that the difference between the two is exactly the gap's
|
|
46
|
+
causal effect and its descendants — never an unrelated repricing of
|
|
47
|
+
decisions already made;
|
|
48
|
+
4. journals that difference by the binding append policy: no physical event
|
|
49
|
+
under the id → an ordinary current-stamped revision-0 `evt`; an admitted
|
|
50
|
+
identical decision → nothing; only an excluded legacy decision, or an
|
|
51
|
+
admitted decision the gap changes → one completed correction batch whose
|
|
52
|
+
action is above every retained revision of the id; an admitted decision the
|
|
53
|
+
gap removes → a tombstone in that batch;
|
|
54
|
+
5. rebuilds the live index over the result. The consumed-input frontier stays
|
|
55
|
+
distinct from output positions: inputs another process appended between
|
|
56
|
+
the window and the appended outputs are completed in a further round, and
|
|
57
|
+
an index published with inputs still unproven records the frontier where
|
|
58
|
+
proof stops, so the next completion picks them up. The published proof's
|
|
59
|
+
output end is the journal end, sound only because nothing between the two
|
|
60
|
+
derives from a later input (`_certify`); when that cannot be shown, the
|
|
61
|
+
proof completion started from stands.
|
|
62
|
+
|
|
63
|
+
6. owes ONE post-unlock ingest when the published prefix stops before an
|
|
64
|
+
admitted derivable input (fix round J4, Codex cycle 5 P1-1). The
|
|
65
|
+
recorder that appended such an input waited a bounded time for the
|
|
66
|
+
locks this step holds and may have exited, and a statusline wake finds
|
|
67
|
+
no intake for it, so nothing else is bound to derive it. A durable
|
|
68
|
+
obligation file of its own (`residual_dir`, named by a nonce) records
|
|
69
|
+
the obligation before the locks are released; every publisher's
|
|
70
|
+
completion wake then dispatches the statusline continuation worker,
|
|
71
|
+
which reads every obligation and runs exactly one bounded authoritative
|
|
72
|
+
ingest (`run_owed_residual_derivation`) holding no accounting or
|
|
73
|
+
selected-state lock. Each obligation names the journal end it was
|
|
74
|
+
written at, and the attempt discharges it only when the cursor its
|
|
75
|
+
ingest left applied covers that residual — or nothing derivable remains
|
|
76
|
+
before it (fix round K5, Codex cycle 6 P1-1) — by removing exactly that
|
|
77
|
+
file, so a completion that records its obligation meanwhile (another
|
|
78
|
+
file) is never discharged by an attempt that predates it (fix round K6,
|
|
79
|
+
Codex cycle 6 verification P1-1). A returned lock-contention stop (a
|
|
80
|
+
busy maintenance, ingest or cache writer lock) keeps the obligation and
|
|
81
|
+
hands the lock that refused it to the statusline's one-shot release
|
|
82
|
+
waiter, whose wake runs the next attempt; any other failure keeps it
|
|
83
|
+
too, recording the failure and a backoff in it, so the continuation
|
|
84
|
+
never retries because of its own failure and a persistently failing
|
|
85
|
+
ingest costs one attempt per backoff interval. A worker killed inside
|
|
86
|
+
the ingest leaves the obligation for the next activating event (review
|
|
87
|
+
cycle 6).
|
|
88
|
+
|
|
89
|
+
Maintenance never replays a notification: the scratch replay has no alert
|
|
90
|
+
configuration and the live index is only ever folded, never derived. A retry
|
|
91
|
+
recomputes the same plan from journal truth, reuses an interrupted batch's
|
|
92
|
+
`at`, and so re-emits byte-identical lines; a completed batch makes the gap
|
|
93
|
+
disappear, so a further run appends nothing.
|
|
94
|
+
"""
|
|
95
|
+
from __future__ import annotations
|
|
96
|
+
|
|
97
|
+
import contextlib
|
|
98
|
+
import copy
|
|
99
|
+
import dataclasses
|
|
100
|
+
import datetime as dt
|
|
101
|
+
import errno
|
|
102
|
+
import hashlib
|
|
103
|
+
import json
|
|
104
|
+
import os
|
|
105
|
+
import pathlib
|
|
106
|
+
import shutil
|
|
107
|
+
import sqlite3
|
|
108
|
+
import tempfile
|
|
109
|
+
import uuid
|
|
110
|
+
|
|
111
|
+
import _cctally_core
|
|
112
|
+
import _cctally_journal as _journal
|
|
113
|
+
import _lib_account_protocol
|
|
114
|
+
import _lib_accounts
|
|
115
|
+
import _lib_journal
|
|
116
|
+
import _lib_journal_router
|
|
117
|
+
import _lib_rederive
|
|
118
|
+
|
|
119
|
+
#: The correction-batch family this step (and the live collision check)
|
|
120
|
+
#: journals under.
|
|
121
|
+
FAMILY = _journal.DERIVATION_COMPLETION_FAMILY
|
|
122
|
+
|
|
123
|
+
#: Completion rounds per rebuild. A round only repeats when another process
|
|
124
|
+
#: appended a derivable input while the previous round was appending its own
|
|
125
|
+
#: outputs; past this bound the index is published with the frontier where
|
|
126
|
+
#: proof stops and the next completion continues from there.
|
|
127
|
+
MAX_ROUNDS = 3
|
|
128
|
+
|
|
129
|
+
#: Op kinds the pipeline derives decisions from (manual credits, cost syncs
|
|
130
|
+
#: and reviewed weekly decisions). Account-registry ops only fold, which an
|
|
131
|
+
#: apply-only rebuild already does, so they alone never open a window.
|
|
132
|
+
_DERIVING_OP_KINDS = frozenset({
|
|
133
|
+
"weekly_credit_floor",
|
|
134
|
+
"sync_week",
|
|
135
|
+
"claude_weekly_observation_decision",
|
|
136
|
+
_lib_account_protocol.WEEKLY_OBSERVATION_DECISION_V2_KIND,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _order(coordinate) -> tuple:
|
|
141
|
+
return _journal.journal_coordinate_key(coordinate)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _iso_now() -> str:
|
|
145
|
+
return (dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
|
|
146
|
+
.isoformat().replace("+00:00", "Z"))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_deriving_input(record) -> bool:
|
|
150
|
+
"""Whether the live pipeline derives decisions from ``record``."""
|
|
151
|
+
if not isinstance(record, dict):
|
|
152
|
+
return False
|
|
153
|
+
if record.get("t") == "obs":
|
|
154
|
+
import _cctally_rederive
|
|
155
|
+
return bool(_cctally_rederive._rederivable_raw_records([record]))
|
|
156
|
+
if record.get("t") == "op":
|
|
157
|
+
return (record.get("payload") or {}).get("kind") in _DERIVING_OP_KINDS
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def window_has_derivable_inputs(lo, hi, boundary) -> bool:
|
|
162
|
+
"""Whether the journal holds an admitted deriving input after ``lo`` and
|
|
163
|
+
at or before ``hi`` (both line-boundary coordinates)."""
|
|
164
|
+
if lo is None or hi is None or _order(hi) <= _order(lo):
|
|
165
|
+
return False
|
|
166
|
+
for segment, offset, raw in _journal.iter_range(lo, hi):
|
|
167
|
+
record = _lib_journal.decode_line(raw)
|
|
168
|
+
if (record is not None and is_deriving_input(record)
|
|
169
|
+
and _journal._is_admitted(record, segment, offset, boundary)):
|
|
170
|
+
return True
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------
|
|
175
|
+
# entry point
|
|
176
|
+
# --------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def complete_and_rebuild(*, context, high_water=None, update_quota_cache=True,
|
|
179
|
+
before_swap=None):
|
|
180
|
+
"""Complete derivation through the rebuild's prefix, then rebuild the
|
|
181
|
+
live index over it (see the module docstring). Returns the final
|
|
182
|
+
rebuild's `RebuildResult`."""
|
|
183
|
+
import _cctally_retention
|
|
184
|
+
|
|
185
|
+
dest = pathlib.Path(_cctally_core.DB_PATH)
|
|
186
|
+
boundary = _journal._whole_prefix_admission_boundary()
|
|
187
|
+
# #946 (spec 4.7 revision 2): the SAFE endpoint of the prefix, so the
|
|
188
|
+
# proof this step publishes never names a torn suffix as consumed.
|
|
189
|
+
target = (_journal._safe_pinned_high_water(high_water)
|
|
190
|
+
if high_water is not None
|
|
191
|
+
else _journal.journal_safe_high_water())
|
|
192
|
+
if boundary is None or target is None:
|
|
193
|
+
# No account-bound cutover yet: nothing is fenced and there is no
|
|
194
|
+
# baseline to replay from, so the rebuild stays apply-only and
|
|
195
|
+
# carries whatever frontier the replaced index proves.
|
|
196
|
+
with _cctally_retention.retention_shared(label="stats rebuild"):
|
|
197
|
+
return _journal._rebuild_stats_index_locked(
|
|
198
|
+
context=context, high_water=high_water,
|
|
199
|
+
update_quota_cache=update_quota_cache,
|
|
200
|
+
before_swap=before_swap)
|
|
201
|
+
trusted = _journal.read_trusted_derivation_state(dest)
|
|
202
|
+
baseline = (boundary.segment, boundary.end_offset)
|
|
203
|
+
if trusted is not None and _order(trusted.frontier) < _order(baseline):
|
|
204
|
+
trusted = None
|
|
205
|
+
frontier, target = _complete(context, trusted, target, boundary)
|
|
206
|
+
with _cctally_retention.retention_shared(label="stats rebuild"):
|
|
207
|
+
result = _journal._rebuild_stats_index_locked(
|
|
208
|
+
context=context, high_water=target,
|
|
209
|
+
update_quota_cache=update_quota_cache, before_swap=before_swap,
|
|
210
|
+
derivation_frontier=frontier,
|
|
211
|
+
whole_prefix=high_water is None)
|
|
212
|
+
# Step 6 (fix round J4): read the journal end AFTER the publication, so
|
|
213
|
+
# an input appended while the final rebuild ran is owed too. Still under
|
|
214
|
+
# the caller's locks, so no ingest can consume it in between.
|
|
215
|
+
end = _journal.journal_safe_high_water()
|
|
216
|
+
if window_has_derivable_inputs(target, end, boundary):
|
|
217
|
+
try:
|
|
218
|
+
owe_residual_derivation(target, residual_through=end)
|
|
219
|
+
except ResidualObligationNotDurable as exc:
|
|
220
|
+
# Fix round K11 (K9K10-R7): the index IS published. The caller
|
|
221
|
+
# still posts the publication wake once its locks are released,
|
|
222
|
+
# and reports the obligation failure rather than a failed
|
|
223
|
+
# rebuild; ``result`` is the published index.
|
|
224
|
+
exc.result = result
|
|
225
|
+
raise
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def post_publication_wake() -> None:
|
|
230
|
+
"""#825/#832 spec 4.3 revision 2: the completion wake a live index
|
|
231
|
+
publication posts once its locks are released. Fix round K11
|
|
232
|
+
(K9K10-R7): a completion whose residual obligation could not be made
|
|
233
|
+
durable has still published, so it posts this wake before reporting
|
|
234
|
+
that failure. Never raises, never waits for the work. File-only unless
|
|
235
|
+
every retained head waits: then the event compares their dependency
|
|
236
|
+
tokens without waiting (fix round K13, 825-832-CD7-V02), which a
|
|
237
|
+
publishing process may read.
|
|
238
|
+
|
|
239
|
+
Fix round K12 (K11R-11): the one helper every live publication uses.
|
|
240
|
+
It also swallows `StatsRebuildDeferred`, a `BaseException` a wake's own
|
|
241
|
+
reservation can meet, which no publication may surface."""
|
|
242
|
+
import _cctally_db
|
|
243
|
+
try:
|
|
244
|
+
import _cctally_statusline
|
|
245
|
+
_cctally_statusline.post_wake("index-publication")
|
|
246
|
+
except (Exception, _cctally_db.StatsRebuildDeferred): # noqa: BLE001
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------
|
|
251
|
+
# the residual-derivation continuation (fix round J4, Codex cycle 5 P1-1)
|
|
252
|
+
# --------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
#: The backoff after a failed residual attempt that no lock caused (fix
|
|
255
|
+
#: round K6, Codex cycle 6 verification P1-1): the first retry waits this
|
|
256
|
+
#: long, each further failure doubles it, up to `_RESIDUAL_RETRY_MAX_S`.
|
|
257
|
+
_RESIDUAL_RETRY_BASE_S = 60
|
|
258
|
+
_RESIDUAL_RETRY_MAX_S = 3600
|
|
259
|
+
|
|
260
|
+
#: A marker document larger than this is not one this module wrote.
|
|
261
|
+
_RESIDUAL_MARKER_MAX_BYTES = 64 * 1024
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _now() -> float:
|
|
265
|
+
"""The wall clock the residual backoff is judged against (a test seam)."""
|
|
266
|
+
import time
|
|
267
|
+
return time.time()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def residual_dir() -> pathlib.Path:
|
|
271
|
+
"""The durable obligations: one file per completion that owes ONE
|
|
272
|
+
post-unlock ingest, named by its nonce (fix round K6). A completion only
|
|
273
|
+
ever creates its own file, and a discharge only ever removes a file it
|
|
274
|
+
read and proved covered, so no replacement can race a removal."""
|
|
275
|
+
return pathlib.Path(_cctally_core.APP_DIR) / "derivation-residual"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _obligation_paths() -> "list[pathlib.Path]":
|
|
279
|
+
"""One listing; a missing directory is no obligation. A dot-file is an
|
|
280
|
+
unpublished temporary and never an obligation.
|
|
281
|
+
|
|
282
|
+
Fix round K11 (K9K10-R8): only a MISSING directory is empty. Any other
|
|
283
|
+
listing failure raises `OSError`, because a directory that cannot be
|
|
284
|
+
listed can hold owed work: the callers count it as owed
|
|
285
|
+
(`residual_derivation_owed`, `residual_derivation_due`) and answer it
|
|
286
|
+
with a bounded attempt (`run_owed_residual_derivation`)."""
|
|
287
|
+
try:
|
|
288
|
+
with os.scandir(residual_dir()) as entries:
|
|
289
|
+
names = [entry.name for entry in entries]
|
|
290
|
+
except FileNotFoundError:
|
|
291
|
+
return []
|
|
292
|
+
return sorted(residual_dir() / name for name in names
|
|
293
|
+
if name.endswith(".json") and not name.startswith("."))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclasses.dataclass(frozen=True)
|
|
297
|
+
class _Obligation:
|
|
298
|
+
path: pathlib.Path
|
|
299
|
+
document: dict
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _read_obligation(path: pathlib.Path) -> "_Obligation | None":
|
|
303
|
+
"""The obligation recorded at ``path``, or None once it is gone. A file
|
|
304
|
+
that cannot be parsed still owes its ingest: it reads as an obligation
|
|
305
|
+
whose residual end is unknown (only a clean cycle covers it) and that
|
|
306
|
+
has never failed."""
|
|
307
|
+
try:
|
|
308
|
+
with open(path, "rb") as handle:
|
|
309
|
+
raw = handle.read(_RESIDUAL_MARKER_MAX_BYTES + 1)
|
|
310
|
+
except FileNotFoundError:
|
|
311
|
+
return None
|
|
312
|
+
except OSError:
|
|
313
|
+
raw = b""
|
|
314
|
+
try:
|
|
315
|
+
document = json.loads(raw.decode("utf-8"))
|
|
316
|
+
if not isinstance(document, dict):
|
|
317
|
+
document = {}
|
|
318
|
+
except (ValueError, UnicodeDecodeError):
|
|
319
|
+
document = {}
|
|
320
|
+
return _Obligation(path, document)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _read_obligations() -> "list[_Obligation]":
|
|
324
|
+
found = []
|
|
325
|
+
for path in _obligation_paths():
|
|
326
|
+
obligation = _read_obligation(path)
|
|
327
|
+
if obligation is not None:
|
|
328
|
+
found.append(obligation)
|
|
329
|
+
return found
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def residual_obligations() -> "list[dict]":
|
|
333
|
+
"""Every recorded obligation's document, oldest file name first
|
|
334
|
+
(diagnostics and tests)."""
|
|
335
|
+
return [obligation.document for obligation in _read_obligations()]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def residual_derivation_owed() -> bool:
|
|
339
|
+
"""File-only and lock-free (one listing): does any completion owe a
|
|
340
|
+
residual ingest, whatever its backoff? An unreadable obligation
|
|
341
|
+
directory is owed work, never none (fix round K11, K9K10-R8)."""
|
|
342
|
+
try:
|
|
343
|
+
return bool(_obligation_paths())
|
|
344
|
+
except OSError:
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _unreadable_backoff_path() -> pathlib.Path:
|
|
349
|
+
"""Fix round K11 (K9K10-R8): the backoff of the attempts made while the
|
|
350
|
+
obligation directory cannot be listed. It lives beside that directory,
|
|
351
|
+
never in it, so it stays writable when only the directory is not."""
|
|
352
|
+
return (pathlib.Path(_cctally_core.APP_DIR)
|
|
353
|
+
/ "residual-obligations-unlistable.json")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _read_unreadable_backoff() -> dict:
|
|
357
|
+
try:
|
|
358
|
+
with open(_unreadable_backoff_path(), "rb") as handle:
|
|
359
|
+
document = json.loads(
|
|
360
|
+
handle.read(_RESIDUAL_MARKER_MAX_BYTES + 1).decode("utf-8"))
|
|
361
|
+
except (OSError, ValueError, UnicodeDecodeError):
|
|
362
|
+
return {}
|
|
363
|
+
return document if isinstance(document, dict) else {}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _unreadable_due(now: float) -> bool:
|
|
367
|
+
"""Whether an attempt over an unlistable obligation directory is owed
|
|
368
|
+
now: never attempted, or its non-sliding backoff ended (spec 4.3
|
|
369
|
+
*Waiting heads*: 60 s doubling to 3,600 s)."""
|
|
370
|
+
return _due(_Obligation(_unreadable_backoff_path(),
|
|
371
|
+
_read_unreadable_backoff()), now)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _clear_unreadable_backoff() -> None:
|
|
375
|
+
"""End the unlistable directory's backoff once it lists again. A stat
|
|
376
|
+
in the steady state: there is nothing to remove."""
|
|
377
|
+
path = _unreadable_backoff_path()
|
|
378
|
+
if not os.path.lexists(path):
|
|
379
|
+
return
|
|
380
|
+
try:
|
|
381
|
+
path.unlink()
|
|
382
|
+
except OSError:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _retry_at(document: dict):
|
|
387
|
+
value = document.get("retryAt")
|
|
388
|
+
return value if isinstance(value, (int, float)) and not isinstance(
|
|
389
|
+
value, bool) else None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _due(obligation: _Obligation, now: float) -> bool:
|
|
393
|
+
"""Whether an attempt is owed now. A retry instant further away than the
|
|
394
|
+
longest backoff was recorded before the clock stepped back, and is due
|
|
395
|
+
rather than stalling the obligation for as long as the step."""
|
|
396
|
+
retry_at = _retry_at(obligation.document)
|
|
397
|
+
return (retry_at is None or retry_at <= now
|
|
398
|
+
or retry_at - now > _RESIDUAL_RETRY_MAX_S)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def residual_derivation_due(now: "float | None" = None) -> bool:
|
|
402
|
+
"""File-only and lock-free: is an attempt owed NOW — an obligation exists
|
|
403
|
+
that is not waiting out the backoff of its own failed attempt (fix round
|
|
404
|
+
K6)? The statusline counts only this as pending work, so an obligation
|
|
405
|
+
in backoff costs no dispatch until an activating event after it ends.
|
|
406
|
+
One listing when nothing is owed."""
|
|
407
|
+
now = _now() if now is None else now
|
|
408
|
+
try:
|
|
409
|
+
paths = _obligation_paths()
|
|
410
|
+
except OSError:
|
|
411
|
+
# Fix round K11 (K9K10-R8): unknown obligations are owed, bounded by
|
|
412
|
+
# the unreadable directory's own backoff.
|
|
413
|
+
return _unreadable_due(now)
|
|
414
|
+
if not paths:
|
|
415
|
+
return False
|
|
416
|
+
for path in paths:
|
|
417
|
+
obligation = _read_obligation(path)
|
|
418
|
+
if obligation is not None and _due(obligation, now):
|
|
419
|
+
return True
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _point(coordinate):
|
|
424
|
+
return (None if coordinate is None
|
|
425
|
+
else [str(coordinate[0]), int(coordinate[1])])
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class ResidualObligationNotDurable(OSError):
|
|
429
|
+
"""A completion published its capped index but could not durably record
|
|
430
|
+
the residual-derivation obligation it owes (review cycle 7 P1-1, spec
|
|
431
|
+
4.7). The completion must not report success: nothing would resume its
|
|
432
|
+
residual automatically. The residual input itself stays in the journal,
|
|
433
|
+
past the published cursor, for the next ingest."""
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _fsync_path(path) -> None:
|
|
437
|
+
fd = os.open(path, os.O_RDONLY)
|
|
438
|
+
try:
|
|
439
|
+
os.fsync(fd)
|
|
440
|
+
finally:
|
|
441
|
+
os.close(fd)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _write_obligation(path: pathlib.Path, document: dict) -> None:
|
|
445
|
+
"""Durably publish ``document`` at ``path`` (temporary, fsync, atomic
|
|
446
|
+
rename, directory fsync), or raise `OSError`. Only a completion creating
|
|
447
|
+
its own nonce file, and the one reservation-holding attempt recording its
|
|
448
|
+
failure in a file it read, ever write one, so the rename never replaces
|
|
449
|
+
another writer's obligation.
|
|
450
|
+
|
|
451
|
+
Review cycle 7 P1-1: EVERY writer fsyncs the residual directory's parent
|
|
452
|
+
after ensuring the directory exists, not only the writer that created
|
|
453
|
+
it. A creator that died between its `mkdir` and that barrier left a
|
|
454
|
+
directory a host crash can still lose — with every obligation a later
|
|
455
|
+
writer put in it."""
|
|
456
|
+
directory = path.parent
|
|
457
|
+
temp = directory / f".{path.name}.{os.getpid()}.tmp"
|
|
458
|
+
try:
|
|
459
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
_fsync_path(directory.parent)
|
|
461
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
462
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
463
|
+
json.dump(document, handle, separators=(",", ":"))
|
|
464
|
+
handle.flush()
|
|
465
|
+
os.fsync(handle.fileno())
|
|
466
|
+
os.replace(temp, path)
|
|
467
|
+
_fsync_path(directory)
|
|
468
|
+
except OSError:
|
|
469
|
+
try:
|
|
470
|
+
temp.unlink()
|
|
471
|
+
except OSError:
|
|
472
|
+
pass
|
|
473
|
+
raise
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def owe_residual_derivation(published, *, residual_through=None) -> None:
|
|
477
|
+
"""Record, durably, that the index this completion published stops at
|
|
478
|
+
``published`` before an admitted derivable input, and that the inputs
|
|
479
|
+
owed end at ``residual_through`` (the journal end the completion read;
|
|
480
|
+
read here when not given). Every completion records its OWN obligation
|
|
481
|
+
file, named by a fresh nonce: one attempt answers all of them (one
|
|
482
|
+
ingest consumes every input past the cursor) and removes only the files
|
|
483
|
+
it read and proved covered.
|
|
484
|
+
|
|
485
|
+
Fail-closed (review cycle 7 P1-1, spec 4.7): when the obligation cannot
|
|
486
|
+
be made durable this raises `ResidualObligationNotDurable`, so the
|
|
487
|
+
completion — which has already published its capped index — reports
|
|
488
|
+
failure instead of a success whose wake finds nothing to resume. The
|
|
489
|
+
caller's own failure handling (an exit 3, a retained heal marker, a
|
|
490
|
+
rederive apply error) then stands, and the residual input stays in the
|
|
491
|
+
journal for the next ingest."""
|
|
492
|
+
if residual_through is None:
|
|
493
|
+
try:
|
|
494
|
+
residual_through = _journal.journal_safe_high_water()
|
|
495
|
+
except Exception: # noqa: BLE001 — best effort, like the write
|
|
496
|
+
residual_through = None
|
|
497
|
+
nonce = uuid.uuid4().hex
|
|
498
|
+
path = residual_dir() / f"{nonce}.json"
|
|
499
|
+
try:
|
|
500
|
+
_write_obligation(path, {
|
|
501
|
+
"schemaVersion": 3,
|
|
502
|
+
"publishedThrough": _point(published),
|
|
503
|
+
"residualThrough": _point(residual_through),
|
|
504
|
+
"recordedAt": _iso_now(), "nonce": nonce})
|
|
505
|
+
except OSError as exc:
|
|
506
|
+
raise ResidualObligationNotDurable(
|
|
507
|
+
getattr(exc, "errno", None) or errno.EIO,
|
|
508
|
+
"the derivation completion published a capped index but could "
|
|
509
|
+
f"not durably record its residual obligation ({exc})",
|
|
510
|
+
str(path)) from exc
|
|
511
|
+
# Fix round K7 (Codex round 14 P1-1): the new obligation advances
|
|
512
|
+
# the statusline's pending generation, as a render capture does, so
|
|
513
|
+
# a continuation pass that scanned the disk before this file existed
|
|
514
|
+
# cannot clear pending past it and lose the wake that follows.
|
|
515
|
+
# Fix round K13 (review cycle 7 P1-1): a failed registration is not
|
|
516
|
+
# absorbed — `register_durable_work` falls back to the durable dirty
|
|
517
|
+
# notification, and an older pass keeps pending for the publication
|
|
518
|
+
# wake this completion posts after it releases its locks.
|
|
519
|
+
try:
|
|
520
|
+
import _cctally_statusline
|
|
521
|
+
_cctally_statusline.register_durable_work()
|
|
522
|
+
except Exception: # noqa: BLE001 — the obligation itself is durable
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclasses.dataclass(frozen=True)
|
|
527
|
+
class ResidualAttempt:
|
|
528
|
+
"""What one `run_owed_residual_derivation` call did (fix rounds K5, K6).
|
|
529
|
+
|
|
530
|
+
``disposition`` is ``none`` (nothing was owed), ``discharged`` (the
|
|
531
|
+
applied cursor covers every obligation it read), ``blocked`` (a
|
|
532
|
+
lock-contention stop: the uncovered obligations stay and ``blocked_by``
|
|
533
|
+
names the lock whose release resumes them), ``failed`` (any other
|
|
534
|
+
failure: the uncovered obligations stay, each recording the failure and
|
|
535
|
+
its backoff, and ``reason`` says why) or ``backoff`` (every obligation is
|
|
536
|
+
still waiting out a failed attempt's backoff: no ingest ran)."""
|
|
537
|
+
|
|
538
|
+
owed: bool
|
|
539
|
+
disposition: str
|
|
540
|
+
blocked_by: object = None
|
|
541
|
+
reason: str = ""
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _residual_through(document: dict):
|
|
545
|
+
"""The journal end an obligation's residual inputs reach, or None when
|
|
546
|
+
it does not say (unreadable, or written without one)."""
|
|
547
|
+
through = document.get("residualThrough")
|
|
548
|
+
if (isinstance(through, list) and len(through) == 2
|
|
549
|
+
and isinstance(through[0], str) and type(through[1]) is int
|
|
550
|
+
and through[1] >= 0):
|
|
551
|
+
return (through[0], through[1])
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _residual_covered(result, through) -> bool:
|
|
556
|
+
"""Whether a returned ingest consumed the residual inputs a marker owes:
|
|
557
|
+
its applied cursor reaches ``through``, or no admitted derivable input
|
|
558
|
+
lies between the two. A marker that names no end is covered only by a
|
|
559
|
+
clean cycle, which consumed through a high water it read after the
|
|
560
|
+
marker was written."""
|
|
561
|
+
if not getattr(result, "ran", False) or getattr(result, "error", None):
|
|
562
|
+
return False
|
|
563
|
+
applied = getattr(result, "applied_through", None)
|
|
564
|
+
if through is None:
|
|
565
|
+
return getattr(result, "blocked_by", None) is None
|
|
566
|
+
if applied is not None and _order(applied) >= _order(through):
|
|
567
|
+
return True
|
|
568
|
+
start = applied if applied is not None else journal_origin()
|
|
569
|
+
return not window_has_derivable_inputs(
|
|
570
|
+
start, through, _journal._whole_prefix_admission_boundary())
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _discharge(obligation: _Obligation) -> None:
|
|
574
|
+
"""Remove exactly the obligation file this attempt read and proved
|
|
575
|
+
covered. No other writer replaces a file under its name, so a newer
|
|
576
|
+
completion's obligation — another file — can never be removed here."""
|
|
577
|
+
try:
|
|
578
|
+
obligation.path.unlink()
|
|
579
|
+
except OSError:
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _record_failure(obligation: _Obligation, reason: str, now: float) -> float:
|
|
584
|
+
"""Record one failed attempt in the obligation it was made for: the
|
|
585
|
+
count, when and why, and the backoff before the next attempt. Returns
|
|
586
|
+
the retry instant."""
|
|
587
|
+
document = dict(obligation.document)
|
|
588
|
+
failures = document.get("failures")
|
|
589
|
+
failures = (failures if type(failures) is int and failures >= 0 else 0) + 1
|
|
590
|
+
interval = min(_RESIDUAL_RETRY_MAX_S,
|
|
591
|
+
_RESIDUAL_RETRY_BASE_S * 2 ** min(failures - 1, 16))
|
|
592
|
+
document.update({
|
|
593
|
+
"failures": failures,
|
|
594
|
+
"lastFailure": reason,
|
|
595
|
+
"lastFailureAt": _iso_now(),
|
|
596
|
+
"retryAt": now + interval,
|
|
597
|
+
})
|
|
598
|
+
try:
|
|
599
|
+
_write_obligation(obligation.path, document)
|
|
600
|
+
except OSError:
|
|
601
|
+
# The obligation itself is already durable and unchanged; only its
|
|
602
|
+
# backoff is lost, so the next activating event retries sooner.
|
|
603
|
+
pass
|
|
604
|
+
return now + interval
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def run_owed_residual_derivation() -> ResidualAttempt:
|
|
608
|
+
"""Answer the recorded obligations with exactly ONE bounded
|
|
609
|
+
authoritative ingest attempt. Called by the statusline continuation
|
|
610
|
+
worker, which holds no accounting or selected-state lock, so the
|
|
611
|
+
ingest's bounded waits respect the lock-order law. The attempt reads
|
|
612
|
+
every obligation file first; one written after that (a completion that
|
|
613
|
+
published another capped prefix meanwhile) is not answered by this
|
|
614
|
+
attempt and stays owed. Once the ingest RETURNS (fix round K5, Codex
|
|
615
|
+
cycle 6 P1-1; fix round K6, its verification P1-1):
|
|
616
|
+
|
|
617
|
+
* each obligation its applied cursor covers is discharged — its own
|
|
618
|
+
file removed, nothing else;
|
|
619
|
+
* it lost a lock another holder held (a contended ``blocked_by``: the
|
|
620
|
+
maintenance or ingest lock refused it, or the cache leg prefix-stopped
|
|
621
|
+
on a cache writer flock) -> the uncovered obligations stay, and the
|
|
622
|
+
caller registers that lock with the one-shot release waiter, whose
|
|
623
|
+
wake runs the next attempt; nothing here retries;
|
|
624
|
+
* anything else (a raised error, an I/O failure on a lock, a cycle that
|
|
625
|
+
returned short of the residual with no lock to wait for) -> the
|
|
626
|
+
uncovered obligations stay, durable, each recording the failure and a
|
|
627
|
+
backoff. The attempt never re-arms itself: until the backoff ends no
|
|
628
|
+
attempt runs an ingest and the statusline counts none of them as
|
|
629
|
+
pending work, so a persistently failing ingest costs at most one
|
|
630
|
+
attempt per backoff interval, run by an activating event after it.
|
|
631
|
+
|
|
632
|
+
When every obligation is still in its backoff, nothing runs. A worker
|
|
633
|
+
killed inside the ingest leaves every obligation for the next
|
|
634
|
+
activating event (review cycle 6)."""
|
|
635
|
+
try:
|
|
636
|
+
obligations = _read_obligations()
|
|
637
|
+
except OSError as exc:
|
|
638
|
+
return _attempt_unreadable_obligations(exc)
|
|
639
|
+
_clear_unreadable_backoff()
|
|
640
|
+
if not obligations:
|
|
641
|
+
return ResidualAttempt(False, "none")
|
|
642
|
+
now = _now()
|
|
643
|
+
if not any(_due(obligation, now) for obligation in obligations):
|
|
644
|
+
return ResidualAttempt(True, "backoff",
|
|
645
|
+
reason="every obligation is in its backoff")
|
|
646
|
+
import _cctally_db
|
|
647
|
+
result = None
|
|
648
|
+
failure = None
|
|
649
|
+
try:
|
|
650
|
+
result = _journal.run_stats_ingest(mode="authoritative")
|
|
651
|
+
except (Exception, _cctally_db.StatsRebuildDeferred) as exc:
|
|
652
|
+
failure = exc
|
|
653
|
+
block = getattr(result, "blocked_by", None)
|
|
654
|
+
uncovered = []
|
|
655
|
+
for obligation in obligations:
|
|
656
|
+
if failure is None and _residual_covered(
|
|
657
|
+
result, _residual_through(obligation.document)):
|
|
658
|
+
_discharge(obligation)
|
|
659
|
+
else:
|
|
660
|
+
uncovered.append(obligation)
|
|
661
|
+
if not uncovered:
|
|
662
|
+
return ResidualAttempt(True, "discharged")
|
|
663
|
+
if failure is None and block is not None and getattr(
|
|
664
|
+
block, "contended", False):
|
|
665
|
+
return ResidualAttempt(
|
|
666
|
+
True, "blocked", blocked_by=block,
|
|
667
|
+
reason=f"{block.lock} lock busy ({block.mode})")
|
|
668
|
+
if failure is not None:
|
|
669
|
+
reason = f"ingest raised {type(failure).__name__}: {failure}"
|
|
670
|
+
elif result is None or not isinstance(result, _journal.IngestResult):
|
|
671
|
+
reason = "ingest returned no result"
|
|
672
|
+
elif getattr(result, "error", None) is not None:
|
|
673
|
+
error = result.error
|
|
674
|
+
reason = f"ingest error {type(error).__name__}: {error}"
|
|
675
|
+
elif block is not None:
|
|
676
|
+
reason = f"{block.lock} lock failed with an I/O error"
|
|
677
|
+
else:
|
|
678
|
+
reason = "the cycle returned short of the residual inputs"
|
|
679
|
+
retry_at = max(_record_failure(obligation, reason, now)
|
|
680
|
+
for obligation in uncovered)
|
|
681
|
+
retry = (dt.datetime.fromtimestamp(retry_at, tz=dt.timezone.utc)
|
|
682
|
+
.replace(microsecond=0).isoformat().replace("+00:00", "Z"))
|
|
683
|
+
_cctally_core.eprint(
|
|
684
|
+
f"[stats] residual derivation attempt failed: {reason}; "
|
|
685
|
+
f"{len(uncovered)} obligation(s) kept in {residual_dir()}, "
|
|
686
|
+
f"retried by the first activating event after {retry}")
|
|
687
|
+
return ResidualAttempt(True, "failed", reason=reason)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _attempt_unreadable_obligations(listing_error) -> ResidualAttempt:
|
|
691
|
+
"""Fix round K11 (K9K10-R8): the obligation directory cannot be listed,
|
|
692
|
+
so whatever it holds is owed and unknown. When the directory's own
|
|
693
|
+
backoff allows, run the ONE ingest every obligation is answered by —
|
|
694
|
+
nothing can be discharged, because nothing was read. The attempt is
|
|
695
|
+
recorded (with the next, doubled, backoff) BEFORE the ingest runs, so an
|
|
696
|
+
attempt the ingest's own wake starts meanwhile waits it out instead of
|
|
697
|
+
ingesting again."""
|
|
698
|
+
now = _now()
|
|
699
|
+
reason = f"the obligation directory cannot be listed ({listing_error})"
|
|
700
|
+
if not _unreadable_due(now):
|
|
701
|
+
return ResidualAttempt(True, "backoff", reason=reason)
|
|
702
|
+
retry_at = _record_failure(
|
|
703
|
+
_Obligation(_unreadable_backoff_path(), _read_unreadable_backoff()),
|
|
704
|
+
reason, now)
|
|
705
|
+
import _cctally_db
|
|
706
|
+
result = None
|
|
707
|
+
try:
|
|
708
|
+
result = _journal.run_stats_ingest(mode="authoritative")
|
|
709
|
+
except (Exception, _cctally_db.StatsRebuildDeferred):
|
|
710
|
+
result = None
|
|
711
|
+
block = getattr(result, "blocked_by", None)
|
|
712
|
+
if block is not None and getattr(block, "contended", False):
|
|
713
|
+
return ResidualAttempt(
|
|
714
|
+
True, "blocked", blocked_by=block,
|
|
715
|
+
reason=f"{block.lock} lock busy ({block.mode})")
|
|
716
|
+
retry = (dt.datetime.fromtimestamp(retry_at, tz=dt.timezone.utc)
|
|
717
|
+
.replace(microsecond=0).isoformat().replace("+00:00", "Z"))
|
|
718
|
+
_cctally_core.eprint(
|
|
719
|
+
f"[stats] residual derivation obligations unknown: {reason}; "
|
|
720
|
+
f"retried by the first activating event after {retry}")
|
|
721
|
+
return ResidualAttempt(True, "failed", reason=reason)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _certify(proven, entry, boundary, fallback):
|
|
725
|
+
"""The derivation proof to publish once every admitted derivable input
|
|
726
|
+
through ``proven`` has its decisions journaled (fix round C, P1-1).
|
|
727
|
+
|
|
728
|
+
The output end is the journal end now. That is sound only when no
|
|
729
|
+
admitted decision in between derives from an input after ``proven``: the
|
|
730
|
+
caller holds `journal.ingest.lock`, so nothing appended since ``entry``
|
|
731
|
+
(the journal end when completion started) is a live decision — only this
|
|
732
|
+
step's own outputs, for inputs at or before ``proven``, and recorders'
|
|
733
|
+
inputs. Before ``entry`` a live cycle may have derived a later input, so
|
|
734
|
+
that stretch must hold no derivable input — or no admitted decision at
|
|
735
|
+
all: a decision derived from an input is journaled after it, so a stretch
|
|
736
|
+
holding inputs but no decision (a recorder appended after the caller's
|
|
737
|
+
pinned prefix, its ingest waiting on the lock this step holds) holds
|
|
738
|
+
nothing derived from a later input either. Withholding the proof there
|
|
739
|
+
left it behind the published cursor, where live ingest can never extend
|
|
740
|
+
it, so drop classification (which requires the proof at the cursor,
|
|
741
|
+
review cycle 3 P1-1) stayed unknown until some later rebuild. Otherwise
|
|
742
|
+
``fallback`` (the proof completion started from, still a journal fact, or
|
|
743
|
+
None) stands."""
|
|
744
|
+
if proven is None:
|
|
745
|
+
return fallback
|
|
746
|
+
if (entry is None or _order(proven) >= _order(entry)
|
|
747
|
+
or not window_has_derivable_inputs(proven, entry, boundary)
|
|
748
|
+
or not _window_has_admitted_decisions(proven, entry, boundary)):
|
|
749
|
+
# The output end is the verified complete-line endpoint (revision
|
|
750
|
+
# 2), never the raw size: a torn suffix past it holds no output.
|
|
751
|
+
return _journal.DerivationFrontier(
|
|
752
|
+
proven, _journal.journal_safe_high_water())
|
|
753
|
+
return fallback
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _window_has_admitted_decisions(lo, hi, boundary) -> bool:
|
|
757
|
+
"""Whether the journal holds an admitted decision record (an event, a
|
|
758
|
+
correction or a correction-batch marker) after ``lo`` and at or before
|
|
759
|
+
``hi``. An excluded record is never part of the admitted state a proof
|
|
760
|
+
describes."""
|
|
761
|
+
if lo is None or hi is None or _order(hi) <= _order(lo):
|
|
762
|
+
return False
|
|
763
|
+
for segment, offset, raw in _journal.iter_range(lo, hi):
|
|
764
|
+
record = _lib_journal.decode_line(raw)
|
|
765
|
+
if (isinstance(record, dict)
|
|
766
|
+
and record.get("t") in ("evt", "correction", "correction_batch")
|
|
767
|
+
and _journal._is_admitted(record, segment, offset, boundary)):
|
|
768
|
+
return True
|
|
769
|
+
return False
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def journal_origin():
|
|
773
|
+
"""The coordinate before the first retained journal line: where
|
|
774
|
+
completion reconstructs from without a trusted frontier (fix round E)."""
|
|
775
|
+
segments = _journal.list_segments()
|
|
776
|
+
return (segments[0], 0) if segments else None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _complete(context, trusted, target, boundary):
|
|
780
|
+
"""Returns ``(proof, target)``: the derivation proof to publish (a
|
|
781
|
+
`DerivationFrontier` or None), and the prefix the live index must cover
|
|
782
|
+
(at least the original target, plus any outputs this step appended).
|
|
783
|
+
|
|
784
|
+
Without a trusted frontier the predecessor is the empty index at the
|
|
785
|
+
journal's origin and the window is the whole retained journal, bootstrap
|
|
786
|
+
facts included (fix round E, Codex fix-round-2 Q1): the cutover is no
|
|
787
|
+
proof that the decisions it precedes are all a later input needs — an
|
|
788
|
+
admitted decision for a pre-cutover input lands after it."""
|
|
789
|
+
entry = _journal.journal_safe_high_water()
|
|
790
|
+
start = trusted.frontier if trusted is not None else journal_origin()
|
|
791
|
+
if start is None:
|
|
792
|
+
return _certify(target, entry, boundary, None), target
|
|
793
|
+
if _order(start) >= _order(target):
|
|
794
|
+
# The proof already reaches past this prefix: a journal fact, kept.
|
|
795
|
+
return (trusted if trusted is not None
|
|
796
|
+
else _certify(target, entry, boundary, None)), target
|
|
797
|
+
if not window_has_derivable_inputs(start, target, boundary):
|
|
798
|
+
return _certify(target, entry, boundary, trusted), target
|
|
799
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
800
|
+
with tempfile.TemporaryDirectory(
|
|
801
|
+
prefix="cctally-completion-", dir=str(_cctally_core.APP_DIR)) as tmp:
|
|
802
|
+
base = pathlib.Path(tmp) / "predecessor.db"
|
|
803
|
+
_journal.rebuild_stats_index(
|
|
804
|
+
context=_journal.RebuildContext(trigger=context.trigger),
|
|
805
|
+
target_path=str(base), high_water=start,
|
|
806
|
+
update_quota_cache=False)
|
|
807
|
+
owed = frozenset()
|
|
808
|
+
if trusted is not None and trusted.output_end is not None:
|
|
809
|
+
owed = _apply_owed_outputs(
|
|
810
|
+
base, start, trusted.output_end, boundary)
|
|
811
|
+
for round_index in range(MAX_ROUNDS):
|
|
812
|
+
appended = _complete_window(
|
|
813
|
+
base, pathlib.Path(tmp), start, target, boundary, round_index,
|
|
814
|
+
owed=owed)
|
|
815
|
+
if not appended:
|
|
816
|
+
return _certify(target, entry, boundary, trusted), target
|
|
817
|
+
after = _journal.journal_safe_high_water()
|
|
818
|
+
if not window_has_derivable_inputs(target, after, boundary):
|
|
819
|
+
return _certify(after, entry, boundary, trusted), after
|
|
820
|
+
if round_index + 1 < MAX_ROUNDS:
|
|
821
|
+
target = after
|
|
822
|
+
# Review cycle 3 P1-1 (Codex fix-round-4 Q1): the rounds ran out
|
|
823
|
+
# while another process kept appending derivable inputs. Publish
|
|
824
|
+
# ONLY through the proven input endpoint: the residual inputs past it
|
|
825
|
+
# stay outside both applied-cursor fields and the admission and
|
|
826
|
+
# selector coverage, so ordinary ingest resumes there. The proof's
|
|
827
|
+
# output end (the journal end now) is kept separate, so the decisions
|
|
828
|
+
# this last round journaled beyond the residual inputs are owed, not
|
|
829
|
+
# discarded: ingest applies them before it derives those inputs, and
|
|
830
|
+
# advances the frontier with the cursor in one transaction. Advancing
|
|
831
|
+
# the published prefix to the journal end instead consumed the
|
|
832
|
+
# residual inputs without deriving them, permanently.
|
|
833
|
+
return _certify(target, entry, boundary, trusted), target
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _apply_owed_outputs(base, start, output_end, boundary) -> frozenset:
|
|
837
|
+
"""Complete the predecessor at the consumed-input frontier ``start``
|
|
838
|
+
(fix round C, P1-1): apply the admitted decisions journaled after it, up
|
|
839
|
+
to its output end, which were derived from inputs at or before it.
|
|
840
|
+
|
|
841
|
+
A rebuild through the frontier alone lacks them, because the cycle that
|
|
842
|
+
consumed those inputs journaled its decisions beyond its captured high
|
|
843
|
+
water, and the window replay derives only inputs — so a later window
|
|
844
|
+
input would be derived against a state uninterrupted ingest never had.
|
|
845
|
+
The proof guarantees nothing in that stretch derives from a later input,
|
|
846
|
+
so these are exactly the outputs owed. Each is applied as its effective
|
|
847
|
+
revision through that output end, in the rebuild's fold order (structural
|
|
848
|
+
folds, the open 5h block projection, then milestones). Returns the ids
|
|
849
|
+
applied, which the predecessor then holds."""
|
|
850
|
+
prefix = _read_prefix(start, output_end, boundary)
|
|
851
|
+
if prefix.cutover_claude is _journal._CUTOVER_UNSEEN:
|
|
852
|
+
prefix.cutover_claude = _journal._resolve_cutover_for_rebuild(
|
|
853
|
+
_journal._CUTOVER_UNSEEN, output_end, _journal.list_segments(),
|
|
854
|
+
{"lines": 0, "bytes": 0, "decodes": 0}, boundary=boundary)
|
|
855
|
+
elif prefix.cutover_claude is None:
|
|
856
|
+
prefix.cutover_claude = _lib_accounts.UNATTRIBUTED
|
|
857
|
+
for record in prefix.slots:
|
|
858
|
+
if record is not None:
|
|
859
|
+
_journal._normalize_legacy_account_stamp(
|
|
860
|
+
record, prefix.cutover_claude)
|
|
861
|
+
selection = _lib_journal.resolve_effective_events(
|
|
862
|
+
prefix.slots, protocol_prefix_evidence=tuple(prefix.evidence))
|
|
863
|
+
owed: dict = {}
|
|
864
|
+
for position, record in enumerate(prefix.window):
|
|
865
|
+
if record.get("t") not in ("evt", "correction"):
|
|
866
|
+
continue
|
|
867
|
+
selected = selection.by_id.get(record.get("id"))
|
|
868
|
+
if selected is not None and selected.status == "active":
|
|
869
|
+
owed.setdefault(record["id"], (position, selected.record))
|
|
870
|
+
if not owed:
|
|
871
|
+
return frozenset()
|
|
872
|
+
stream = sorted(owed.values(), key=lambda item: (
|
|
873
|
+
_journal._fold_order(item[1]), item[0]))
|
|
874
|
+
conn = _cctally_core.open_db(_target_path=str(base))
|
|
875
|
+
try:
|
|
876
|
+
for phase in ("structural", "tail"):
|
|
877
|
+
if phase == "tail":
|
|
878
|
+
import sys
|
|
879
|
+
backfill = getattr(sys.modules.get("cctally"),
|
|
880
|
+
"_backfill_five_hour_blocks", None)
|
|
881
|
+
if backfill is not None:
|
|
882
|
+
# Fix round J5: the first writer is read over the WHOLE
|
|
883
|
+
# prefix in journal order, never over the owed decisions
|
|
884
|
+
# alone, which may start after a window's first decision.
|
|
885
|
+
backfill(conn, only_missing=True,
|
|
886
|
+
journaled_first=(
|
|
887
|
+
_journal.journaled_block_first_observations(
|
|
888
|
+
prefix.slots, selection.by_id)))
|
|
889
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
890
|
+
try:
|
|
891
|
+
for _position, record in stream:
|
|
892
|
+
structural = (_journal._fold_order(record)
|
|
893
|
+
< _journal._REBUILD_MILESTONE_ORDER)
|
|
894
|
+
if structural == (phase == "structural"):
|
|
895
|
+
_journal._apply_evt(conn, record)
|
|
896
|
+
conn.commit()
|
|
897
|
+
except BaseException:
|
|
898
|
+
conn.rollback()
|
|
899
|
+
raise
|
|
900
|
+
finally:
|
|
901
|
+
conn.close()
|
|
902
|
+
return frozenset(owed)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# --------------------------------------------------------------------------
|
|
906
|
+
# one round
|
|
907
|
+
# --------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
class _Prefix:
|
|
910
|
+
"""One pass over the journal through the round's target."""
|
|
911
|
+
|
|
912
|
+
def __init__(self):
|
|
913
|
+
self.slots: list = [] # selector slots, one per decoded record
|
|
914
|
+
self.evidence: list = [] # protocol-resolution prefix evidence
|
|
915
|
+
self.window: list = [] # window records (placeholders kept)
|
|
916
|
+
self.coordinates: list = [] # parallel to `window`
|
|
917
|
+
self.excluded_revisions: dict = {}
|
|
918
|
+
self.inert_revisions: dict = {}
|
|
919
|
+
self.begin_at: dict = {}
|
|
920
|
+
self.batch_family: dict = {}
|
|
921
|
+
self.cutover_claude = _journal._CUTOVER_UNSEEN
|
|
922
|
+
# Admitted decision ids before the window (the predecessor holds
|
|
923
|
+
# them), and ids whose admitted revision-0 event a previous release
|
|
924
|
+
# wrote (fix round E).
|
|
925
|
+
self.before_ids: set = set()
|
|
926
|
+
self.legacy_origin_ids: set = set()
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _bump(table: dict, record) -> None:
|
|
930
|
+
event_id = record.get("id")
|
|
931
|
+
rev = record.get("rev", 0)
|
|
932
|
+
if isinstance(event_id, str) and type(rev) is int and rev >= 0:
|
|
933
|
+
table[event_id] = max(rev, table.get(event_id, rev))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _read_prefix(start, target, boundary) -> _Prefix:
|
|
937
|
+
out = _Prefix()
|
|
938
|
+
segments = _journal.list_segments()
|
|
939
|
+
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
940
|
+
prior = {"hw": None}
|
|
941
|
+
placeholder = _lib_account_protocol.excluded_placeholder()
|
|
942
|
+
start_key = _order(start)
|
|
943
|
+
for segment, offset, raw in _journal._iter_range_with_segments(
|
|
944
|
+
None, target, segments,
|
|
945
|
+
on_segment=lambda name: hasher.begin_segment(name, prior["hw"]),
|
|
946
|
+
on_bytes=hasher.extend,
|
|
947
|
+
):
|
|
948
|
+
end = (segment, offset + len(raw) + 1)
|
|
949
|
+
record = _lib_journal.decode_line(raw)
|
|
950
|
+
if record is None:
|
|
951
|
+
prior["hw"] = end
|
|
952
|
+
continue
|
|
953
|
+
in_window = _order((segment, offset)) >= start_key
|
|
954
|
+
if not _journal._is_admitted(record, segment, offset, boundary):
|
|
955
|
+
out.slots.append(None)
|
|
956
|
+
if record.get("t") in ("evt", "correction"):
|
|
957
|
+
_bump(out.excluded_revisions, record)
|
|
958
|
+
if in_window:
|
|
959
|
+
out.window.append({**placeholder, "quarantine": {
|
|
960
|
+
"record": record, "segment": segment, "offset": offset}})
|
|
961
|
+
out.coordinates.append((segment, offset))
|
|
962
|
+
prior["hw"] = end
|
|
963
|
+
continue
|
|
964
|
+
t = record.get("t")
|
|
965
|
+
if t == "op":
|
|
966
|
+
_journal._capture_protocol_prefix_evidence(
|
|
967
|
+
record, prior["hw"], out.evidence, hasher=hasher)
|
|
968
|
+
if (out.cutover_claude is _journal._CUTOVER_UNSEEN
|
|
969
|
+
and record.get("id") == _journal.CUTOVER_OP_ID):
|
|
970
|
+
out.cutover_claude = _journal._cutover_value_of(record)
|
|
971
|
+
if t in ("evt", "correction") and isinstance(record.get("id"), str):
|
|
972
|
+
if not in_window:
|
|
973
|
+
out.before_ids.add(record["id"])
|
|
974
|
+
if (t == "evt" and record.get("rev", 0) == 0
|
|
975
|
+
and not _lib_account_protocol.is_stamped(record)):
|
|
976
|
+
out.legacy_origin_ids.add(record["id"])
|
|
977
|
+
if t == "correction_batch" and record.get("phase") == "begin":
|
|
978
|
+
out.begin_at.setdefault(record.get("id"), record.get("at"))
|
|
979
|
+
out.batch_family.setdefault(record.get("id"), record.get("family"))
|
|
980
|
+
out.slots.append(
|
|
981
|
+
record if t in _lib_journal_router.RETAINED_RECORD_TYPES else None)
|
|
982
|
+
if in_window:
|
|
983
|
+
out.window.append(record)
|
|
984
|
+
out.coordinates.append((segment, offset))
|
|
985
|
+
prior["hw"] = end
|
|
986
|
+
return out
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _held_observation_ids(slots) -> frozenset:
|
|
990
|
+
"""The observations a retained reviewed weekly decision holds (latest
|
|
991
|
+
decision per observation wins, as the rederive planner reads them)."""
|
|
992
|
+
import _cctally_rederive
|
|
993
|
+
effective: dict = {}
|
|
994
|
+
for record in slots:
|
|
995
|
+
if (not isinstance(record, dict) or record.get("t") != "op"
|
|
996
|
+
or (record.get("payload") or {}).get("kind")
|
|
997
|
+
not in _cctally_rederive._REVIEWED_WEEKLY_KINDS):
|
|
998
|
+
continue
|
|
999
|
+
payload = record["payload"]
|
|
1000
|
+
decisions = (payload.get("decisions")
|
|
1001
|
+
if payload.get("schema_version") == 1
|
|
1002
|
+
else payload.get("weekly_axis_decisions")) or []
|
|
1003
|
+
for item in decisions:
|
|
1004
|
+
if isinstance(item, dict) and isinstance(
|
|
1005
|
+
item.get("observationId"), str):
|
|
1006
|
+
effective[item["observationId"]] = item.get("disposition")
|
|
1007
|
+
return frozenset(obs for obs, disposition in effective.items()
|
|
1008
|
+
if disposition == "hold")
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@contextlib.contextmanager
|
|
1012
|
+
def _cache_read_view():
|
|
1013
|
+
"""A stable read-only view of cache.db for the replay's cost reads, or
|
|
1014
|
+
None when there is no readable cache. Takes no cache flock (the caller
|
|
1015
|
+
holds the stats locks); a read transaction pins one snapshot for both
|
|
1016
|
+
replays, so the causal difference never reflects a cache change."""
|
|
1017
|
+
conn = None
|
|
1018
|
+
path = _cctally_core.CACHE_DB_PATH
|
|
1019
|
+
if pathlib.Path(path).exists():
|
|
1020
|
+
try:
|
|
1021
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True,
|
|
1022
|
+
timeout=5.0)
|
|
1023
|
+
conn.execute("BEGIN")
|
|
1024
|
+
conn.execute("SELECT 1 FROM session_entries LIMIT 1").fetchall()
|
|
1025
|
+
except sqlite3.Error:
|
|
1026
|
+
if conn is not None:
|
|
1027
|
+
conn.close()
|
|
1028
|
+
conn = None
|
|
1029
|
+
try:
|
|
1030
|
+
yield conn
|
|
1031
|
+
finally:
|
|
1032
|
+
if conn is not None:
|
|
1033
|
+
conn.close()
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
@contextlib.contextmanager
|
|
1037
|
+
def _no_cache_read_adapters():
|
|
1038
|
+
"""The replay's readers with no cache: no retained usage entries and no
|
|
1039
|
+
alert configuration — the same for both replays, so the causal
|
|
1040
|
+
difference is unaffected; only cost facts go unpriced."""
|
|
1041
|
+
import sys
|
|
1042
|
+
cctally = sys.modules["cctally"]
|
|
1043
|
+
replacements = {
|
|
1044
|
+
"get_entries": lambda *args, **kwargs: [],
|
|
1045
|
+
"get_claude_session_entries": lambda *args, **kwargs: [],
|
|
1046
|
+
"load_config": lambda *args, **kwargs: {},
|
|
1047
|
+
}
|
|
1048
|
+
prior = {name: getattr(cctally, name) for name in replacements}
|
|
1049
|
+
try:
|
|
1050
|
+
for name, value in replacements.items():
|
|
1051
|
+
setattr(cctally, name, value)
|
|
1052
|
+
yield
|
|
1053
|
+
finally:
|
|
1054
|
+
for name, value in prior.items():
|
|
1055
|
+
setattr(cctally, name, value)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _replay(base, tmp, name, prefix, held_ids, cache_conn, *, retention,
|
|
1059
|
+
skip=frozenset()):
|
|
1060
|
+
"""One scratch replay of the window from the predecessor state, the
|
|
1061
|
+
projection kept on the retained decisions by ``retention``. Returns
|
|
1062
|
+
``(events, by_input)``: every derived candidate, and per raw input index
|
|
1063
|
+
its own candidates."""
|
|
1064
|
+
import _cctally_quarantine
|
|
1065
|
+
import _cctally_rederive
|
|
1066
|
+
copy_path = tmp / name
|
|
1067
|
+
shutil.copyfile(base, copy_path)
|
|
1068
|
+
records = copy.deepcopy(prefix.window)
|
|
1069
|
+
for record in records:
|
|
1070
|
+
if record.get("t") != _lib_account_protocol.EXCLUDED_PLACEHOLDER_TYPE:
|
|
1071
|
+
_journal._normalize_legacy_account_stamp(
|
|
1072
|
+
record, prefix.cutover_claude)
|
|
1073
|
+
by_input: dict = {}
|
|
1074
|
+
conn = _cctally_core.open_db(_target_path=str(copy_path))
|
|
1075
|
+
try:
|
|
1076
|
+
fold = _cctally_quarantine.QuarantineFold.load(conn, held_ids=held_ids)
|
|
1077
|
+
events = _cctally_rederive.replay_scratch_records(
|
|
1078
|
+
conn, records, cache_conn, fold=fold, held_ids=held_ids,
|
|
1079
|
+
coordinates=prefix.coordinates, skip_indexes=skip,
|
|
1080
|
+
on_input=lambda index, derived: by_input.__setitem__(
|
|
1081
|
+
index, list(derived)),
|
|
1082
|
+
read_adapters=(None if cache_conn is not None
|
|
1083
|
+
else _no_cache_read_adapters()),
|
|
1084
|
+
retention=retention)
|
|
1085
|
+
finally:
|
|
1086
|
+
conn.close()
|
|
1087
|
+
return events, by_input
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _owned_by_id(events) -> dict:
|
|
1091
|
+
"""First candidate per owned id, in derivation order (a later divergent
|
|
1092
|
+
re-derivation of one id is what live ingest withholds as a conflict)."""
|
|
1093
|
+
out: dict = {}
|
|
1094
|
+
for event in events:
|
|
1095
|
+
if _lib_rederive._is_owned_event(event):
|
|
1096
|
+
out.setdefault(event["id"], event)
|
|
1097
|
+
return out
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _effective_records(selection) -> dict:
|
|
1101
|
+
"""Every selected id's effective record (None for a tombstone)."""
|
|
1102
|
+
return {event_id: (item.record if item.status == "active" else None)
|
|
1103
|
+
for event_id, item in selection.by_id.items()
|
|
1104
|
+
if item.status in ("active", "tombstone")}
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _retention(effective, prefix, owed, *, legacy, reference=None):
|
|
1108
|
+
retention = _journal.ScratchRetention(
|
|
1109
|
+
effective, reference=reference, retained_only=legacy)
|
|
1110
|
+
retention.seed(prefix.before_ids)
|
|
1111
|
+
retention.seed(owed)
|
|
1112
|
+
return retention
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
#: The snapshot decision id the live pipeline mints for a Claude usage
|
|
1116
|
+
#: observation (`_cctally_record._pipeline_claude_usage`).
|
|
1117
|
+
_SNAPSHOT_ID_PREFIX = "sa:"
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _is_legacy_input(record) -> bool:
|
|
1121
|
+
"""Whether a window input was itself written by a previous release:
|
|
1122
|
+
unstamped, or stamped by a writer generation other than this one."""
|
|
1123
|
+
return (isinstance(record, dict) and record.get("t") == "obs"
|
|
1124
|
+
and not _lib_account_protocol.is_stamped(record))
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _legacy_decided_inputs(prefix, by_input=None) -> frozenset:
|
|
1128
|
+
"""The window inputs a previous release decided (fix round E): a LEGACY
|
|
1129
|
+
input (`_is_legacy_input`) one of whose decisions is an admitted
|
|
1130
|
+
UNSTAMPED revision-0 event. Its history is exactly what that release
|
|
1131
|
+
journaled, which the current pipeline may spell or extend differently,
|
|
1132
|
+
so it is never a gap and its extra fresh candidates are never
|
|
1133
|
+
materialized. Before a replay only the snapshot linkage is known;
|
|
1134
|
+
``by_input`` adds every derived id.
|
|
1135
|
+
|
|
1136
|
+
Both conditions are required (revision 2, Codex design-fix-round-3 Q6,
|
|
1137
|
+
P1-1). Legacy provenance alone never exempts a missing decision, and a
|
|
1138
|
+
CURRENT-stamped input never becomes history because one of its derived
|
|
1139
|
+
candidates collides with a retained legacy event identity: a same-second
|
|
1140
|
+
current reading re-emits the retained closure of a five-hour block a
|
|
1141
|
+
previous-release reading closed at that second, and treating that retry
|
|
1142
|
+
as the reading's own history left the reading's decision unjournaled."""
|
|
1143
|
+
legacy = prefix.legacy_origin_ids
|
|
1144
|
+
out = {index for index, record in enumerate(prefix.window)
|
|
1145
|
+
if _is_legacy_input(record)
|
|
1146
|
+
and _SNAPSHOT_ID_PREFIX + str(record.get("id")) in legacy}
|
|
1147
|
+
for index, derived in (by_input or {}).items():
|
|
1148
|
+
if not (0 <= index < len(prefix.window)
|
|
1149
|
+
and _is_legacy_input(prefix.window[index])):
|
|
1150
|
+
continue
|
|
1151
|
+
if any(event["id"] in legacy for event in derived
|
|
1152
|
+
if _lib_rederive._is_owned_event(event)):
|
|
1153
|
+
out.add(index)
|
|
1154
|
+
return frozenset(out)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _gap_inputs(by_input, selection, legacy) -> frozenset:
|
|
1158
|
+
"""The inputs with a derived decision no admitted decision answers,
|
|
1159
|
+
previous-release inputs (``legacy``) excepted. Only the with-gap /
|
|
1160
|
+
without-gap comparison, never an absent id alone, says what a gap
|
|
1161
|
+
owes."""
|
|
1162
|
+
return frozenset(
|
|
1163
|
+
index for index, derived in by_input.items()
|
|
1164
|
+
if index not in legacy
|
|
1165
|
+
and any(_lib_rederive._is_owned_event(event)
|
|
1166
|
+
and event["id"] not in selection.by_id
|
|
1167
|
+
for event in derived))
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _complete_window(base, tmp, start, target, boundary, round_index, *,
|
|
1171
|
+
owed=frozenset()) -> bool:
|
|
1172
|
+
"""Plan and journal one round. Returns whether anything was appended.
|
|
1173
|
+
|
|
1174
|
+
Three replays from the predecessor, each keeping the projection on the
|
|
1175
|
+
retained decisions (`_journal.ScratchRetention`): one over every input
|
|
1176
|
+
finds the gap; one without the gap is the reference; one with the gap
|
|
1177
|
+
holds the fresh candidate only where it differs from the reference, so a
|
|
1178
|
+
gap's effect reaches its descendants while every unaffected decision
|
|
1179
|
+
stays the retained one. Their difference is the causal suffix."""
|
|
1180
|
+
prefix = _read_prefix(start, target, boundary)
|
|
1181
|
+
if prefix.cutover_claude is _journal._CUTOVER_UNSEEN:
|
|
1182
|
+
prefix.cutover_claude = _journal._resolve_cutover_for_rebuild(
|
|
1183
|
+
_journal._CUTOVER_UNSEEN, target, _journal.list_segments(),
|
|
1184
|
+
{"lines": 0, "bytes": 0, "decodes": 0}, boundary=boundary)
|
|
1185
|
+
elif prefix.cutover_claude is None:
|
|
1186
|
+
prefix.cutover_claude = _lib_accounts.UNATTRIBUTED
|
|
1187
|
+
for record in prefix.slots:
|
|
1188
|
+
if record is not None:
|
|
1189
|
+
_journal._normalize_legacy_account_stamp(
|
|
1190
|
+
record, prefix.cutover_claude)
|
|
1191
|
+
selection = _lib_journal.resolve_effective_events(
|
|
1192
|
+
prefix.slots, protocol_prefix_evidence=tuple(prefix.evidence))
|
|
1193
|
+
# Inert (incomplete or tainted) batch actions of OTHER families still
|
|
1194
|
+
# occupy their revision: a later retry of that batch must not collide.
|
|
1195
|
+
for record in prefix.slots:
|
|
1196
|
+
if (isinstance(record, dict) and record.get("t") == "correction"
|
|
1197
|
+
and record.get("batch") not in selection.completed_batches
|
|
1198
|
+
and prefix.batch_family.get(record.get("batch")) != FAMILY):
|
|
1199
|
+
_bump(prefix.inert_revisions, record)
|
|
1200
|
+
held_ids = _held_observation_ids(prefix.slots)
|
|
1201
|
+
effective = _effective_records(selection)
|
|
1202
|
+
|
|
1203
|
+
legacy = _legacy_decided_inputs(prefix)
|
|
1204
|
+
|
|
1205
|
+
with _cache_read_view() as cache_conn:
|
|
1206
|
+
_events, by_input = _replay(
|
|
1207
|
+
base, tmp, f"all-{round_index}.db", prefix, held_ids, cache_conn,
|
|
1208
|
+
retention=_retention(effective, prefix, owed, legacy=legacy))
|
|
1209
|
+
legacy = _legacy_decided_inputs(prefix, by_input)
|
|
1210
|
+
gap = _gap_inputs(by_input, selection, legacy)
|
|
1211
|
+
if not gap:
|
|
1212
|
+
return False
|
|
1213
|
+
events_a, _ = _replay(
|
|
1214
|
+
base, tmp, f"without-{round_index}.db", prefix, held_ids,
|
|
1215
|
+
cache_conn, skip=gap,
|
|
1216
|
+
retention=_retention(effective, prefix, owed, legacy=legacy))
|
|
1217
|
+
reference: dict = {}
|
|
1218
|
+
for event in events_a:
|
|
1219
|
+
reference.setdefault(
|
|
1220
|
+
event["id"], _lib_journal.event_content_hash(event))
|
|
1221
|
+
events_b, _ = _replay(
|
|
1222
|
+
base, tmp, f"with-{round_index}.db", prefix, held_ids, cache_conn,
|
|
1223
|
+
retention=_retention(effective, prefix, owed, legacy=legacy,
|
|
1224
|
+
reference=reference))
|
|
1225
|
+
with_gap = _owned_by_id(events_b)
|
|
1226
|
+
without_gap = _owned_by_id(events_a)
|
|
1227
|
+
|
|
1228
|
+
plain: list = []
|
|
1229
|
+
actions: list = []
|
|
1230
|
+
for event_id in sorted(set(with_gap) | set(without_gap)):
|
|
1231
|
+
desired = with_gap.get(event_id)
|
|
1232
|
+
reference = without_gap.get(event_id)
|
|
1233
|
+
if (desired is not None and reference is not None
|
|
1234
|
+
and _lib_rederive._semantic_event(desired)
|
|
1235
|
+
== _lib_rederive._semantic_event(reference)):
|
|
1236
|
+
# Not caused by the gap: an unrelated difference against the
|
|
1237
|
+
# journal (a repricing, a previous release's input the current
|
|
1238
|
+
# pipeline extends) is never acted on.
|
|
1239
|
+
continue
|
|
1240
|
+
selected = selection.by_id.get(event_id)
|
|
1241
|
+
current = (selected.record if selected is not None
|
|
1242
|
+
and selected.status == "active" else None)
|
|
1243
|
+
physical = [rev for rev in (
|
|
1244
|
+
prefix.excluded_revisions.get(event_id),
|
|
1245
|
+
prefix.inert_revisions.get(event_id),
|
|
1246
|
+
None if selected is None else selected.rev,
|
|
1247
|
+
) if rev is not None]
|
|
1248
|
+
if desired is None:
|
|
1249
|
+
if current is not None:
|
|
1250
|
+
actions.append({"action": "tombstone", "id": event_id,
|
|
1251
|
+
"rev": max(physical) + 1,
|
|
1252
|
+
"at": str(current["at"]), "payload": None})
|
|
1253
|
+
continue
|
|
1254
|
+
if current is not None:
|
|
1255
|
+
desired = _lib_rederive._preserve_non_derivable_state(
|
|
1256
|
+
current, desired)
|
|
1257
|
+
if (_lib_rederive._semantic_event(current)
|
|
1258
|
+
== _lib_rederive._semantic_event(desired)):
|
|
1259
|
+
continue # an admitted identical decision: nothing
|
|
1260
|
+
if not physical:
|
|
1261
|
+
plain.append(desired)
|
|
1262
|
+
continue
|
|
1263
|
+
actions.append({"action": "replace", "id": event_id,
|
|
1264
|
+
"rev": max(physical) + 1,
|
|
1265
|
+
"at": str(desired["at"]),
|
|
1266
|
+
"payload": dict(desired.get("payload") or {})})
|
|
1267
|
+
|
|
1268
|
+
records: list = []
|
|
1269
|
+
if actions:
|
|
1270
|
+
digest = hashlib.sha256(json.dumps(
|
|
1271
|
+
actions, sort_keys=True, separators=(",", ":"),
|
|
1272
|
+
ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
1273
|
+
batch_id = f"{FAMILY}:{digest}"
|
|
1274
|
+
if batch_id not in selection.completed_batches:
|
|
1275
|
+
records.extend(_lib_journal.make_correction_batch(
|
|
1276
|
+
batch_id=batch_id, family=FAMILY,
|
|
1277
|
+
# An interrupted earlier attempt's `at`, so the retry's lines
|
|
1278
|
+
# are byte-identical and the selector keeps each once.
|
|
1279
|
+
at=str(prefix.begin_at.get(batch_id) or _iso_now()),
|
|
1280
|
+
actions=actions))
|
|
1281
|
+
order = {id(event): index for index, event in enumerate(events_b)}
|
|
1282
|
+
plain.sort(key=lambda event: order.get(id(event), 0))
|
|
1283
|
+
records.extend(plain)
|
|
1284
|
+
if not records:
|
|
1285
|
+
return False
|
|
1286
|
+
_journal.append_records(records)
|
|
1287
|
+
return True
|