cctally 1.88.2 → 1.89.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 +32 -0
- package/bin/_cctally_cache.py +827 -37
- package/bin/_cctally_config.py +125 -0
- package/bin/_cctally_core.py +86 -2
- package/bin/_cctally_dashboard_cache_report.py +31 -0
- package/bin/_cctally_dashboard_conversation.py +26 -7
- package/bin/_cctally_dashboard_sources.py +51 -0
- package/bin/_cctally_db.py +626 -0
- package/bin/_cctally_doctor.py +84 -1
- package/bin/_cctally_journal.py +2 -1
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_quota.py +1358 -112
- package/bin/_cctally_record.py +249 -9
- package/bin/_cctally_setup.py +14 -5
- package/bin/_cctally_store.py +16 -1
- package/bin/_cctally_tui.py +16 -2
- package/bin/_cctally_update.py +9 -2
- package/bin/_lib_background_mcp.py +168 -0
- package/bin/_lib_cache_report.py +19 -2
- package/bin/_lib_codex_conversation.py +8 -0
- package/bin/_lib_codex_conversation_query.py +8 -7
- package/bin/_lib_conversation.py +105 -5
- package/bin/_lib_conversation_dispatch.py +15 -4
- package/bin/_lib_conversation_query.py +294 -2
- package/bin/_lib_dashboard_sources.py +5 -1
- package/bin/_lib_doctor.py +202 -1
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_quota_alert_axes.py +188 -0
- package/bin/_lib_quota_ledger.py +274 -0
- package/bin/_lib_snapshot_cache.py +36 -0
- package/bin/cctally +6 -3
- package/dashboard/static/assets/index-BgoYXdus.js +92 -0
- package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
- package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
|
@@ -29,7 +29,11 @@ CapabilityStatus = Literal[
|
|
|
29
29
|
# (`UpdateRunningModal.tsx`, `store/sse.ts`), so an old client demonstrably
|
|
30
30
|
# does meet a new server; `docs/cli-contract.md` calls changing a value's
|
|
31
31
|
# meaning breaking. Version-aware client reaction is deliberately deferred.
|
|
32
|
-
|
|
32
|
+
# 2 -> 3 (public #5): the Codex source gained the additive `ingest_backlog`
|
|
33
|
+
# field. Additive and omitted-when-zero, so the normal payload is byte-identical
|
|
34
|
+
# — but the same `execvp` transition applies, so the bump ships as the signal it
|
|
35
|
+
# has always been rather than as a mechanism the client branches on.
|
|
36
|
+
SOURCE_SCHEMA_VERSION = 3
|
|
33
37
|
DEFAULT_SOURCE = "claude"
|
|
34
38
|
SOURCE_ORDER = ("claude", "codex", "all")
|
|
35
39
|
SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -189,6 +189,13 @@ class DoctorState:
|
|
|
189
189
|
# by the next whole-tree sync that defers none. None = key absent (the
|
|
190
190
|
# normal state) or cache unreadable — the check degrades OK.
|
|
191
191
|
codex_torn_deferred: Optional[dict] = None
|
|
192
|
+
# public #5 spec §5: what the hook's BUDGETED ingest leg has left to read.
|
|
193
|
+
# Mirrors the `codex_ingest_backlog` cache_meta record — `{files, bytes,
|
|
194
|
+
# since}` — or None when the backlog is empty (the record is deleted, never
|
|
195
|
+
# zeroed). A non-zero backlog is NORMAL right after a heavy burst, so the
|
|
196
|
+
# leg distinguishes draining from stuck on the `since` stamp rather than on
|
|
197
|
+
# the counts.
|
|
198
|
+
codex_ingest_backlog: Optional[dict] = None
|
|
192
199
|
# The byte-zero Codex replay's stall signal. `codex_replay_pending` mirrors
|
|
193
200
|
# the `codex_replay_from_zero_pending` cache_meta marker; `codex_replay_blocked`
|
|
194
201
|
# is `{"at": iso, "files_failed": N, "files_deferred_torn": N}`, written by a
|
|
@@ -197,6 +204,12 @@ class DoctorState:
|
|
|
197
204
|
# state) or cache unreadable — the check degrades OK.
|
|
198
205
|
codex_replay_pending: Optional[bool] = None
|
|
199
206
|
codex_replay_blocked: Optional[dict] = None
|
|
207
|
+
# `codex_replay_deferred` is `{"since": iso, "at": iso}`, written when a
|
|
208
|
+
# BUDGETED tick (the Codex hook) declined the replay outright. A hook-only
|
|
209
|
+
# install produces this and nothing else — the decline returns before the
|
|
210
|
+
# walk, so `files_failed`/`files_deferred_torn` are both zero, no `blocked`
|
|
211
|
+
# record is ever written, and the ingest-backlog leg reads a drained store.
|
|
212
|
+
codex_replay_deferred: Optional[dict] = None
|
|
200
213
|
# #279 S2 (F5b): PRAGMA quick_check(1) results, gathered ONLY under
|
|
201
214
|
# doctor_gather_state(deep=True) (CLI cmd_doctor) — the dashboard
|
|
202
215
|
# rebuild loop calls the gather every rebuild and quick_check on a
|
|
@@ -229,6 +242,13 @@ class DoctorState:
|
|
|
229
242
|
codex_quota_windows: Optional[list[dict]] = None
|
|
230
243
|
codex_hook_roots: Optional[list[dict]] = None
|
|
231
244
|
codex_lifecycle_activity_24h: Optional[dict] = None
|
|
245
|
+
# public #5: 24h outcome counts for the detached `_codex-quota-verify`
|
|
246
|
+
# worker, parsed from the same bounded log. The lifecycle parser above
|
|
247
|
+
# cannot supply these — worker lines carry no `source_root_key`, so its
|
|
248
|
+
# root filter drops every one. Shape:
|
|
249
|
+
# {"success_count_24h", "error_count_24h", "spawn_failure_count_24h",
|
|
250
|
+
# "last_success_at"}. None/absent degrades the check to OK.
|
|
251
|
+
codex_quota_verify_activity: Optional[dict] = None
|
|
232
252
|
# #311: precomputed five-state classification of settings.json's
|
|
233
253
|
# statusLine.refreshInterval (unavailable/absent/foreign/present/missing),
|
|
234
254
|
# computed by doctor_gather_state via the setup I/O-layer classifier so the
|
|
@@ -1115,6 +1135,26 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
|
|
|
1115
1135
|
)
|
|
1116
1136
|
|
|
1117
1137
|
|
|
1138
|
+
def _age_seconds(stamp: object, now_utc: dt.datetime) -> Optional[int]:
|
|
1139
|
+
"""Seconds between an ISO-8601 UTC stamp and ``now_utc``; None if unusable."""
|
|
1140
|
+
if not stamp:
|
|
1141
|
+
return None
|
|
1142
|
+
try:
|
|
1143
|
+
parsed = dt.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
|
|
1144
|
+
except (ValueError, TypeError):
|
|
1145
|
+
return None
|
|
1146
|
+
if parsed.tzinfo is None:
|
|
1147
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
1148
|
+
return int((now_utc - parsed.astimezone(dt.timezone.utc)).total_seconds())
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
#: How long a budgeted decline may stand before it stops being self-healing.
|
|
1152
|
+
#: The hook hands the drain to a detached worker on every decline (throttled
|
|
1153
|
+
#: hourly), so a deferral older than this means the hand-off is not landing.
|
|
1154
|
+
#: Same hour as `CODEX_INGEST_BACKLOG_STUCK_SECONDS`, for the same reason.
|
|
1155
|
+
CODEX_REPLAY_DEFERRED_WARN_SECONDS = 3600
|
|
1156
|
+
|
|
1157
|
+
|
|
1118
1158
|
def _check_data_codex_replay(s: DoctorState) -> CheckResult:
|
|
1119
1159
|
"""WARN while a byte-zero Codex replay is STALLED rather than merely pending.
|
|
1120
1160
|
|
|
@@ -1131,14 +1171,27 @@ def _check_data_codex_replay(s: DoctorState) -> CheckResult:
|
|
|
1131
1171
|
between the migration and the next sync, and it clears on its own. The WARN
|
|
1132
1172
|
needs the durable `blocked` record, which only a completed-but-unsuccessful
|
|
1133
1173
|
whole-tree walk writes.
|
|
1174
|
+
|
|
1175
|
+
The `deferred` record is the second stall shape (public #5). A BUDGETED tick
|
|
1176
|
+
cannot perform the replay at all, so the Codex hook declines it — and on a
|
|
1177
|
+
hook-only install no unbudgeted caller exists, which froze every Codex sync
|
|
1178
|
+
while `files_failed` and `files_deferred_torn` both stayed zero and no
|
|
1179
|
+
`blocked` record was ever written. The hook now hands the drain to a
|
|
1180
|
+
detached worker, so a RECENT deferral is the ordinary self-healing state and
|
|
1181
|
+
stays `ok`; one that has stood for over an hour means the hand-off is not
|
|
1182
|
+
landing and is the only signal that the install is frozen.
|
|
1134
1183
|
"""
|
|
1135
1184
|
blocked = s.codex_replay_blocked or {}
|
|
1185
|
+
deferred = s.codex_replay_deferred or {}
|
|
1136
1186
|
at = blocked.get("at")
|
|
1187
|
+
deferred_since = deferred.get("since")
|
|
1137
1188
|
details = {
|
|
1138
1189
|
"pending": bool(s.codex_replay_pending),
|
|
1139
1190
|
"blocked_at": at,
|
|
1140
1191
|
"files_failed": blocked.get("files_failed"),
|
|
1141
1192
|
"files_deferred_torn": blocked.get("files_deferred_torn"),
|
|
1193
|
+
"deferred_since": deferred_since,
|
|
1194
|
+
"deferred_at": deferred.get("at"),
|
|
1142
1195
|
}
|
|
1143
1196
|
if s.codex_replay_pending and isinstance(at, str) and at:
|
|
1144
1197
|
return CheckResult(
|
|
@@ -1150,15 +1203,105 @@ def _check_data_codex_replay(s: DoctorState) -> CheckResult:
|
|
|
1150
1203
|
"is truncated), then `cctally cache-sync --source codex`"),
|
|
1151
1204
|
details=details,
|
|
1152
1205
|
)
|
|
1206
|
+
if s.codex_replay_pending and isinstance(deferred_since, str) and deferred_since:
|
|
1207
|
+
age_s = _age_seconds(deferred_since, s.now_utc)
|
|
1208
|
+
if age_s is not None and age_s > CODEX_REPLAY_DEFERRED_WARN_SECONDS:
|
|
1209
|
+
return CheckResult(
|
|
1210
|
+
id="data.codex_replay", title="Codex transcript replay",
|
|
1211
|
+
severity="warn",
|
|
1212
|
+
summary=(f"the Codex hook has deferred it since "
|
|
1213
|
+
f"{deferred_since} — a budgeted tick cannot run the "
|
|
1214
|
+
"replay, and all Codex ingest is frozen until an "
|
|
1215
|
+
"unbudgeted sync does"),
|
|
1216
|
+
remediation="Run `cctally cache-sync --source codex`",
|
|
1217
|
+
details=details,
|
|
1218
|
+
)
|
|
1219
|
+
return CheckResult(
|
|
1220
|
+
id="data.codex_replay", title="Codex transcript replay",
|
|
1221
|
+
severity="ok",
|
|
1222
|
+
summary=(f"pending; the Codex hook deferred it at "
|
|
1223
|
+
f"{deferred.get('at') or deferred_since} and handed the "
|
|
1224
|
+
"drain to a background worker"),
|
|
1225
|
+
remediation=None, details=details,
|
|
1226
|
+
)
|
|
1153
1227
|
return CheckResult(
|
|
1154
1228
|
id="data.codex_replay", title="Codex transcript replay",
|
|
1155
1229
|
severity="ok",
|
|
1156
|
-
|
|
1230
|
+
# NOT "clears on the next Codex sync": a budgeted tick — the Codex
|
|
1231
|
+
# hook — declines the replay rather than running it, so on a hook-only
|
|
1232
|
+
# install the next sync is precisely what does not clear it.
|
|
1233
|
+
summary="pending (clears on the next unbudgeted Codex sync)"
|
|
1157
1234
|
if s.codex_replay_pending else "none pending",
|
|
1158
1235
|
remediation=None, details=details,
|
|
1159
1236
|
)
|
|
1160
1237
|
|
|
1161
1238
|
|
|
1239
|
+
def _check_data_codex_quota_verification(s: DoctorState) -> CheckResult:
|
|
1240
|
+
"""WARN when the detached Codex quota verification is not landing.
|
|
1241
|
+
|
|
1242
|
+
Public #5 moved every whole-history projection pass off the blocking hook
|
|
1243
|
+
path, so on a hook-only install ALL of it now depends on the
|
|
1244
|
+
`_codex-quota-verify` worker. Two of its routes make that dependency sharp
|
|
1245
|
+
rather than cosmetic: the catch-all (a rebuilt stats index, an
|
|
1246
|
+
interpretation bump, a reset ledger, a ledgerless cache) performs no
|
|
1247
|
+
projection work at all, which leaves the projection transiently MISSING —
|
|
1248
|
+
no blocks, no milestones, no alerts — until the worker converges it.
|
|
1249
|
+
Nothing else observes that. `data.codex_quota` is a different question
|
|
1250
|
+
entirely: it reports the freshness of the local rollout OBSERVATIONS, which
|
|
1251
|
+
stay perfectly fresh while the projection derived from them is absent.
|
|
1252
|
+
|
|
1253
|
+
The worker's own streams are `/dev/null` and its exit code is observed by
|
|
1254
|
+
nobody, so it writes its outcome to `hook-tick.log` for exactly this leg to
|
|
1255
|
+
read. The predicate is "failures with no success in 24h": one failed
|
|
1256
|
+
hand-off is ordinary (a contended spawn, a transient lock) and self-heals on
|
|
1257
|
+
the next throttle window, while a worker that has not landed once in a day
|
|
1258
|
+
is the persistent condition — and because the deadline only moves when a
|
|
1259
|
+
pass COMMITS, such a worker re-spawns forever with nothing to show for it.
|
|
1260
|
+
|
|
1261
|
+
Silence is OK, not a failure: an install with no Codex hooks never hands off
|
|
1262
|
+
at all, and every non-hook caller runs the pass inline.
|
|
1263
|
+
"""
|
|
1264
|
+
activity = s.codex_quota_verify_activity or {}
|
|
1265
|
+
successes = int(activity.get("success_count_24h") or 0)
|
|
1266
|
+
errors = int(activity.get("error_count_24h") or 0)
|
|
1267
|
+
spawn_failures = int(activity.get("spawn_failure_count_24h") or 0)
|
|
1268
|
+
last_success = activity.get("last_success_at")
|
|
1269
|
+
details = {
|
|
1270
|
+
"success_count_24h": successes,
|
|
1271
|
+
"error_count_24h": errors,
|
|
1272
|
+
"spawn_failure_count_24h": spawn_failures,
|
|
1273
|
+
"last_success_at": (
|
|
1274
|
+
_iso_z(last_success) if isinstance(last_success, dt.datetime)
|
|
1275
|
+
else (last_success if isinstance(last_success, str) else None)
|
|
1276
|
+
),
|
|
1277
|
+
}
|
|
1278
|
+
failures = errors + spawn_failures
|
|
1279
|
+
if failures and not successes:
|
|
1280
|
+
return CheckResult(
|
|
1281
|
+
id="data.codex_quota_verification",
|
|
1282
|
+
title="Codex quota verification",
|
|
1283
|
+
severity="warn",
|
|
1284
|
+
summary=(
|
|
1285
|
+
f"{failures} failed hand-off(s) and no completed pass in 24h — "
|
|
1286
|
+
"the Codex quota projection is not being verified"),
|
|
1287
|
+
remediation=(
|
|
1288
|
+
"Run `cctally cache-sync --source codex` (every non-hook caller "
|
|
1289
|
+
"runs the pass inline), then check hook-tick.log for "
|
|
1290
|
+
"`op=quota-verify`"),
|
|
1291
|
+
details=details,
|
|
1292
|
+
)
|
|
1293
|
+
return CheckResult(
|
|
1294
|
+
id="data.codex_quota_verification",
|
|
1295
|
+
title="Codex quota verification",
|
|
1296
|
+
severity="ok",
|
|
1297
|
+
summary=(
|
|
1298
|
+
f"{successes} completed, {failures} failed in 24h"
|
|
1299
|
+
if successes or failures else "no hand-off in the last 24h"),
|
|
1300
|
+
remediation=None,
|
|
1301
|
+
details=details,
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1162
1305
|
def _check_data_codex_project_metadata(s: DoctorState) -> CheckResult:
|
|
1163
1306
|
"""Report the identity-safe all-history Codex metadata partition."""
|
|
1164
1307
|
if s.codex_project_metadata_error is not None:
|
|
@@ -2621,6 +2764,61 @@ def _check_accounts_codex_identity(s: DoctorState) -> CheckResult:
|
|
|
2621
2764
|
)
|
|
2622
2765
|
|
|
2623
2766
|
|
|
2767
|
+
#: A backlog younger than this is draining, not stuck. A heavy Codex burst
|
|
2768
|
+
#: legitimately leaves work for the next few ticks; only a backlog that has
|
|
2769
|
+
#: stayed non-zero CONTINUOUSLY past this is worth an operator's attention.
|
|
2770
|
+
CODEX_INGEST_BACKLOG_STUCK_SECONDS = 3600
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
def _check_data_codex_ingest_backlog(s: DoctorState) -> CheckResult:
|
|
2774
|
+
"""WARN when the hook's budgeted Codex ingest has been behind for over an hour.
|
|
2775
|
+
|
|
2776
|
+
The hook's ingest leg is bounded in wall clock (public #5 spec §4), so it
|
|
2777
|
+
can legitimately leave a backlog — that is the mechanism working, not a
|
|
2778
|
+
fault. What is worth reporting is a backlog that never drains: a store
|
|
2779
|
+
whose per-tick budget is smaller than its per-tick growth, or a walk that
|
|
2780
|
+
has stopped making progress.
|
|
2781
|
+
|
|
2782
|
+
Per the CLI contract a WARN alone does not change `doctor`'s exit code.
|
|
2783
|
+
"""
|
|
2784
|
+
record = s.codex_ingest_backlog or {}
|
|
2785
|
+
try:
|
|
2786
|
+
files = int(record.get("files") or 0)
|
|
2787
|
+
except (TypeError, ValueError):
|
|
2788
|
+
files = 0
|
|
2789
|
+
try:
|
|
2790
|
+
pending_bytes = int(record.get("bytes") or 0)
|
|
2791
|
+
except (TypeError, ValueError):
|
|
2792
|
+
pending_bytes = 0
|
|
2793
|
+
since_text = record.get("since")
|
|
2794
|
+
age_s = _age_seconds(since_text, s.now_utc)
|
|
2795
|
+
details = {"files": files, "bytes": pending_bytes, "since": since_text,
|
|
2796
|
+
"age_s": age_s}
|
|
2797
|
+
if files <= 0:
|
|
2798
|
+
return CheckResult(
|
|
2799
|
+
id="data.codex_ingest_backlog", title="Codex ingest backlog",
|
|
2800
|
+
severity="ok", summary="no Codex ingest backlog",
|
|
2801
|
+
remediation=None, details=details,
|
|
2802
|
+
)
|
|
2803
|
+
if age_s is not None and age_s > CODEX_INGEST_BACKLOG_STUCK_SECONDS:
|
|
2804
|
+
return CheckResult(
|
|
2805
|
+
id="data.codex_ingest_backlog", title="Codex ingest backlog",
|
|
2806
|
+
severity="warn",
|
|
2807
|
+
summary=(f"{files} Codex rollout(s) / {pending_bytes} byte(s) still "
|
|
2808
|
+
f"unread after {age_s // 3600}h — the hook's budget is not "
|
|
2809
|
+
"draining them"),
|
|
2810
|
+
remediation="Run `cctally cache-sync --source codex`",
|
|
2811
|
+
details=details,
|
|
2812
|
+
)
|
|
2813
|
+
return CheckResult(
|
|
2814
|
+
id="data.codex_ingest_backlog", title="Codex ingest backlog",
|
|
2815
|
+
severity="ok",
|
|
2816
|
+
summary=(f"{files} Codex rollout(s) / {pending_bytes} byte(s) queued — "
|
|
2817
|
+
"draining"),
|
|
2818
|
+
remediation=None, details=details,
|
|
2819
|
+
)
|
|
2820
|
+
|
|
2821
|
+
|
|
2624
2822
|
def _check_accounts_attribution(s: DoctorState) -> CheckResult:
|
|
2625
2823
|
"""WARN when Claude usage is flowing but landing in `unattributed` despite a
|
|
2626
2824
|
resolved active account (the stamping pipeline is broken), or while the
|
|
@@ -2738,7 +2936,10 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
2738
2936
|
# after the pair rather than between them.
|
|
2739
2937
|
("data.codex_project_metadata", "_check_data_codex_project_metadata"),
|
|
2740
2938
|
("data.codex_replay", "_check_data_codex_replay"),
|
|
2939
|
+
("data.codex_ingest_backlog", "_check_data_codex_ingest_backlog"),
|
|
2741
2940
|
("data.codex_quota", "_check_data_codex_quota"),
|
|
2941
|
+
("data.codex_quota_verification",
|
|
2942
|
+
"_check_data_codex_quota_verification"),
|
|
2742
2943
|
("data.parse_health", "_check_data_parse_health"),
|
|
2743
2944
|
("data.forked_buckets", "_check_data_forked_buckets"),
|
|
2744
2945
|
("data.post_credit_milestones", "_check_data_post_credit_milestones"),
|
package/bin/_lib_jsonl.py
CHANGED
|
@@ -986,12 +986,21 @@ def _iter_codex_fused_records_with_offsets(
|
|
|
986
986
|
initial_total_tokens: int = 0,
|
|
987
987
|
source_root_key: str | None = None,
|
|
988
988
|
state: _CodexIterState | None = None,
|
|
989
|
+
stop_before=None,
|
|
989
990
|
):
|
|
990
991
|
"""Yield typed facts for every complete, valid Codex JSONL object.
|
|
991
992
|
|
|
992
993
|
Binary readers retain physical byte offsets and strictly reject invalid UTF-8,
|
|
993
994
|
non-finite JSON constants, and non-object JSON. The mutable state carries
|
|
994
995
|
the shipped accounting resume contract alongside the latest thread facts.
|
|
996
|
+
|
|
997
|
+
``stop_before`` (public #5) is an optional predicate called with the byte
|
|
998
|
+
offset of each line BEFORE it is read; returning True ends the iteration
|
|
999
|
+
there, leaving ``fh`` positioned exactly at that offset so the caller can
|
|
1000
|
+
persist it as a resume point. It sits inside the reader rather than in the
|
|
1001
|
+
consuming loop because most lines yield nothing at all — a long run of
|
|
1002
|
+
non-emitting records would otherwise never hand control back, which is
|
|
1003
|
+
precisely the case a wall-clock budget has to survive.
|
|
995
1004
|
"""
|
|
996
1005
|
if state is None:
|
|
997
1006
|
state = _CodexIterState()
|
|
@@ -1003,6 +1012,9 @@ def _iter_codex_fused_records_with_offsets(
|
|
|
1003
1012
|
|
|
1004
1013
|
while True:
|
|
1005
1014
|
line_offset = fh.tell()
|
|
1015
|
+
if stop_before is not None and stop_before(line_offset):
|
|
1016
|
+
fh.seek(line_offset)
|
|
1017
|
+
return
|
|
1006
1018
|
line = fh.readline()
|
|
1007
1019
|
if not line:
|
|
1008
1020
|
return
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Pure kernel deciding what an alert pass has to look at (public #5 §3).
|
|
2
|
+
|
|
3
|
+
Alert state is NOT a function of window dirtiness, so a projector bounded by the
|
|
4
|
+
change ledger would silently stop honouring four of the five things that can
|
|
5
|
+
make an alert eligible. Each axis below is a way the answer changes with no row
|
|
6
|
+
in ``quota_window_snapshots`` moving at all:
|
|
7
|
+
|
|
8
|
+
1. **Physical-window dirtiness** — the ledger. The only axis the ledger sees.
|
|
9
|
+
2. **Policy scope** — the resolved rule changed. ``quota_rule_fingerprint``
|
|
10
|
+
already hashes the resolved thresholds, gates, root and logical limit, and
|
|
11
|
+
is persisted per identity in ``quota_alert_arming``; comparing it against a
|
|
12
|
+
freshly resolved one detects the change. An exact-rule change is scoped to
|
|
13
|
+
its identities, a default/global change marks everything.
|
|
14
|
+
3. **Delivery gate** — the global or quota switch flipped. DISABLING must
|
|
15
|
+
enumerate every Codex arming row, delete it and journal a disarm, and must do
|
|
16
|
+
so even with zero dirty windows and zero lifecycle-eligible roots; that is
|
|
17
|
+
why it needs no observation load and why the caller runs it before the
|
|
18
|
+
eligibility fast path. ENABLING is the opposite: it needs a semantic pass
|
|
19
|
+
over the affected identities regardless of physical dirtiness, because
|
|
20
|
+
activation has to write ``suppressed_backfill`` terminal rows for
|
|
21
|
+
already-satisfied thresholds instead of dispatching history.
|
|
22
|
+
4. **Scheduled time** — an observation captured in the future is skipped today,
|
|
23
|
+
and becomes eligible when wall time passes it with no mutation to observe.
|
|
24
|
+
Persisting that boundary and treating ``now >= boundary`` as dirty is what
|
|
25
|
+
closes it. Unlike axes 2 and 3 this one fires on WALL CLOCK rather than on a
|
|
26
|
+
configuration change, which is why the hook path defers it
|
|
27
|
+
(``defer_scheduled``) instead of paying an unannounced whole-history pass on
|
|
28
|
+
a blocking tick.
|
|
29
|
+
5. **Durable lifecycle state** — the existing arming rows and terminal events,
|
|
30
|
+
unchanged. Represented here only as the fingerprints axis 2 compares.
|
|
31
|
+
|
|
32
|
+
The scope this returns is deliberately a SUPERSET decision: widening a bounded
|
|
33
|
+
pass is always safe, missing an identity is not.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import datetime as dt
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Iterable, Mapping
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
#: Ordered weakest to strongest; a later value absorbs an earlier one.
|
|
43
|
+
SCOPE_NONE = "none"
|
|
44
|
+
SCOPE_GROUPS = "groups"
|
|
45
|
+
SCOPE_ROOTS = "roots"
|
|
46
|
+
SCOPE_ALL = "all"
|
|
47
|
+
|
|
48
|
+
_ORDER = {SCOPE_NONE: 0, SCOPE_GROUPS: 1, SCOPE_ROOTS: 2, SCOPE_ALL: 3}
|
|
49
|
+
|
|
50
|
+
#: Recorded in place of ``"scheduled"`` when axis 4 came due but the caller
|
|
51
|
+
#: cannot honour it inline. A caller that sees it MUST carry the stored
|
|
52
|
+
#: boundary through untouched — the axis is postponed, not satisfied.
|
|
53
|
+
REASON_SCHEDULED_DEFERRED = "scheduled_deferred"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class AlertDirtyScope:
|
|
58
|
+
"""What the pass has to load, and whether it has to disarm first."""
|
|
59
|
+
|
|
60
|
+
disarm_all: bool
|
|
61
|
+
scope: str
|
|
62
|
+
roots: frozenset[str]
|
|
63
|
+
reasons: tuple[str, ...]
|
|
64
|
+
|
|
65
|
+
def widens(self, ledger_scope: str) -> bool:
|
|
66
|
+
"""True when the alert axes demand more than the ledger already gives."""
|
|
67
|
+
return _ORDER[self.scope] > _ORDER[ledger_scope]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _strongest(a: str, b: str) -> str:
|
|
71
|
+
return a if _ORDER[a] >= _ORDER[b] else b
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def alert_dirty_scope(
|
|
75
|
+
*,
|
|
76
|
+
ledger_groups: Iterable[object],
|
|
77
|
+
stored_fingerprints: Mapping[tuple, str],
|
|
78
|
+
resolved_fingerprints: Mapping[tuple, str],
|
|
79
|
+
gate_before: "bool | None",
|
|
80
|
+
gate_after: bool,
|
|
81
|
+
now: dt.datetime,
|
|
82
|
+
next_evaluation_at: "dt.datetime | None",
|
|
83
|
+
defer_scheduled: bool = False,
|
|
84
|
+
) -> AlertDirtyScope:
|
|
85
|
+
"""Resolve the five axes into one decision.
|
|
86
|
+
|
|
87
|
+
``stored_fingerprints`` / ``resolved_fingerprints`` are keyed by the arming
|
|
88
|
+
identity tuple ``(source, source_root_key, account_key, logical_limit_key,
|
|
89
|
+
observed_slot, window_minutes)``; the ROOT is element 1, which is what an
|
|
90
|
+
exact-rule change is scoped to.
|
|
91
|
+
|
|
92
|
+
``defer_scheduled`` is the hook path's (``full_pass="defer"``). Axes 2 and 3
|
|
93
|
+
are driven by a configuration change the user just made, so widening for
|
|
94
|
+
them is bounded and expected; axis 4 is driven by WALL CLOCK, which makes it
|
|
95
|
+
the one route into a whole-history pass that can land on a blocking hook
|
|
96
|
+
tick with nothing to have predicted it. Under this flag it is recorded as
|
|
97
|
+
``REASON_SCHEDULED_DEFERRED`` and does NOT strengthen the scope — and the
|
|
98
|
+
caller owes the stored boundary a carry-through, because a deferral that
|
|
99
|
+
lets the boundary be recomputed is a silent drop.
|
|
100
|
+
"""
|
|
101
|
+
reasons: list[str] = []
|
|
102
|
+
if not gate_after:
|
|
103
|
+
# Nothing to evaluate: delivery is off, so the only work is making sure
|
|
104
|
+
# no arming boundary survives to turn disabled-period evidence into a
|
|
105
|
+
# later alert. It needs no observations at all.
|
|
106
|
+
return AlertDirtyScope(
|
|
107
|
+
disarm_all=True, scope=SCOPE_NONE, roots=frozenset(),
|
|
108
|
+
reasons=("gate_disabled",),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
scope = SCOPE_NONE
|
|
112
|
+
roots: set[str] = set()
|
|
113
|
+
|
|
114
|
+
if any(True for _ in ledger_groups):
|
|
115
|
+
scope = _strongest(scope, SCOPE_GROUPS)
|
|
116
|
+
reasons.append("window_dirty")
|
|
117
|
+
|
|
118
|
+
if gate_before is not True:
|
|
119
|
+
# Enabling — or a first pass with no recorded gate state, which cannot
|
|
120
|
+
# be distinguished from one and must not be assumed to be a no-op.
|
|
121
|
+
scope = _strongest(scope, SCOPE_ALL)
|
|
122
|
+
reasons.append("gate_enabled")
|
|
123
|
+
|
|
124
|
+
changed_roots = {
|
|
125
|
+
identity[1]
|
|
126
|
+
for identity, fingerprint in resolved_fingerprints.items()
|
|
127
|
+
if stored_fingerprints.get(identity) != fingerprint
|
|
128
|
+
}
|
|
129
|
+
if changed_roots:
|
|
130
|
+
scope = _strongest(scope, SCOPE_ROOTS)
|
|
131
|
+
roots |= changed_roots
|
|
132
|
+
reasons.append("rule_changed")
|
|
133
|
+
|
|
134
|
+
if next_evaluation_at is not None and now >= next_evaluation_at:
|
|
135
|
+
# A future-clocked observation just became eligible. Which identity it
|
|
136
|
+
# belongs to is not recorded — only the instant — so the honest scope is
|
|
137
|
+
# everything, and on the hook path "everything" is precisely what may
|
|
138
|
+
# not run.
|
|
139
|
+
if defer_scheduled:
|
|
140
|
+
reasons.append(REASON_SCHEDULED_DEFERRED)
|
|
141
|
+
else:
|
|
142
|
+
scope = _strongest(scope, SCOPE_ALL)
|
|
143
|
+
reasons.append("scheduled")
|
|
144
|
+
|
|
145
|
+
return AlertDirtyScope(
|
|
146
|
+
disarm_all=False, scope=scope, roots=frozenset(roots),
|
|
147
|
+
reasons=tuple(reasons),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def next_evaluation_boundary(
|
|
152
|
+
*, capture_times: Iterable[dt.datetime], now: dt.datetime,
|
|
153
|
+
stored: "dt.datetime | None", retain_due: bool = False,
|
|
154
|
+
) -> "dt.datetime | None":
|
|
155
|
+
"""The earliest still-future capture the projector must come back for.
|
|
156
|
+
|
|
157
|
+
A bounded pass only sees the dirty windows, so the STORED boundary is
|
|
158
|
+
retained whenever it is still in the future: dropping it would forget a
|
|
159
|
+
future-clocked observation sitting in a window this pass never loaded. Once
|
|
160
|
+
wall time passes it the axis fires, the pass widens to everything, and the
|
|
161
|
+
boundary is recomputed from complete evidence — so a retained value can only
|
|
162
|
+
ever cost one extra pass, never a missed one.
|
|
163
|
+
|
|
164
|
+
``retain_due`` keeps a boundary that is ALREADY due, which is the case where
|
|
165
|
+
"recomputed from complete evidence" is a lie: a reporting-only pass never
|
|
166
|
+
reaches a threshold decision at all, and a BOUNDED hook tick that declined
|
|
167
|
+
the widening deliberately did not look. Either would otherwise retire the
|
|
168
|
+
axis on behalf of an evaluation nobody performed.
|
|
169
|
+
|
|
170
|
+
A due value sorts before every future candidate, so it stays until a pass
|
|
171
|
+
that genuinely looked at everything retires it — in practice a hook tick
|
|
172
|
+
that widened to whole-history for axis 2 or 3, since carrying alert
|
|
173
|
+
eligibility is what separates such a pass from a reporting-only one and the
|
|
174
|
+
hook is the only production caller that carries it.
|
|
175
|
+
|
|
176
|
+
It does NOT stay "until a pass that can act on it does", and that gap is
|
|
177
|
+
open rather than closed: on a hook-only install with a steady enabled gate,
|
|
178
|
+
unchanged rules and a quiet ledger, no qualifying pass ever runs and the
|
|
179
|
+
instant is retained indefinitely. The cost is bounded — the tick stays
|
|
180
|
+
bounded and fast, and the window is re-evaluated as soon as it goes
|
|
181
|
+
ledger-dirty again, which for a live window is continuous — so the exposure
|
|
182
|
+
is a future-clocked capture in a window that then goes permanently quiet
|
|
183
|
+
never qualifying a threshold. Under-alerting, never a stall or a burst.
|
|
184
|
+
"""
|
|
185
|
+
candidates = [value for value in capture_times if value > now]
|
|
186
|
+
if stored is not None and (retain_due or stored > now):
|
|
187
|
+
candidates.append(stored)
|
|
188
|
+
return min(candidates) if candidates else None
|