cctally 1.115.0 → 1.116.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/_cctally_cache.py +85 -4
  3. package/bin/_cctally_completion.py +80 -12
  4. package/bin/_cctally_core.py +106 -9
  5. package/bin/_cctally_dashboard.py +6 -9
  6. package/bin/_cctally_dashboard_envelope.py +26 -32
  7. package/bin/_cctally_db.py +85 -4
  8. package/bin/_cctally_doctor.py +38 -5
  9. package/bin/_cctally_five_hour.py +83 -1017
  10. package/bin/_cctally_journal.py +1307 -180
  11. package/bin/_cctally_journal_repair.py +9 -7
  12. package/bin/_cctally_oauth_samples.py +280 -0
  13. package/bin/_cctally_parser.py +5 -0
  14. package/bin/_cctally_quarantine.py +14 -4
  15. package/bin/_cctally_quota.py +70 -8
  16. package/bin/_cctally_quota_model.py +35 -4
  17. package/bin/_cctally_record.py +414 -123
  18. package/bin/_cctally_rederive.py +632 -235
  19. package/bin/_cctally_refresh.py +29 -0
  20. package/bin/_cctally_statusline.py +984 -47
  21. package/bin/_cctally_store.py +972 -58
  22. package/bin/_cctally_tui.py +62 -7
  23. package/bin/_cctally_update.py +25 -0
  24. package/bin/_lib_account_protocol.py +201 -18
  25. package/bin/_lib_clone_copy.py +46 -11
  26. package/bin/_lib_dashboard_sources.py +57 -15
  27. package/bin/_lib_detector_access_registry.py +273 -52
  28. package/bin/_lib_doctor.py +302 -1
  29. package/bin/_lib_journal.py +145 -25
  30. package/bin/_lib_journal_router.py +166 -48
  31. package/bin/_lib_oauth_samples.py +238 -0
  32. package/bin/_lib_pricing_check.py +0 -12
  33. package/bin/_lib_provider_activity.py +770 -0
  34. package/bin/_lib_quarantine.py +3 -2
  35. package/bin/_lib_quota_model.py +96 -6
  36. package/bin/_lib_rebuild_progress.py +146 -0
  37. package/bin/_lib_record.py +22 -0
  38. package/bin/_lib_rederive.py +299 -3
  39. package/bin/_lib_snapshot_cache.py +43 -1
  40. package/bin/_lib_stale_proof.py +708 -0
  41. package/bin/_lib_statusline_candidates.py +65 -5
  42. package/bin/_lib_worker_launches.py +315 -0
  43. package/bin/cctally +6 -11
  44. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  45. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  46. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  47. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  48. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  49. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  50. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  51. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  52. package/dashboard/static/dashboard.html +1 -1
  53. package/package.json +7 -2
  54. package/bin/_lib_merge_gate.py +0 -452
  55. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
@@ -12,6 +12,8 @@ import math
12
12
  import re
13
13
  from typing import Callable, Mapping
14
14
 
15
+ import _lib_provider_activity
16
+
15
17
 
16
18
  #: Candidate and tombstone documents. Every session on this machine writes
17
19
  #: candidates, so this version may only move when peer sessions running the
@@ -59,6 +61,11 @@ class Candidate:
59
61
  #: The immutable `AccountBinding` captured with this candidate (#825/#832
60
62
  #: spec 4.2). The reducer partitions by its account; it is never inferred.
61
63
  binding: object | None = None
64
+ #: The provider-activity witness (#797 spec 4.2,
65
+ #: `_lib_provider_activity.ActivityWitness`) captured once at this
66
+ #: arrival, before its intake commit. Required in every v2 candidate
67
+ #: document, so the intake identity hashes it; never recomputed.
68
+ activity: object | None = None
62
69
 
63
70
 
64
71
  @dataclasses.dataclass(frozen=True)
@@ -307,6 +314,16 @@ def _require_ascii_source(value: object) -> str:
307
314
  return value
308
315
 
309
316
 
317
+ def validate_activity(value: object):
318
+ """The candidate's activity witness, or ``StateValidationError``: a
319
+ missing or malformed witness makes the v2 document malformed and never
320
+ silently reads as ``unknown`` (#797 spec 4.2)."""
321
+ try:
322
+ return _lib_provider_activity.witness_from_json(value)
323
+ except _lib_provider_activity.MalformedWitness as exc:
324
+ raise StateValidationError(str(exc)) from exc
325
+
326
+
310
327
  def _axis_value(
311
328
  value: object,
312
329
  *,
@@ -1118,7 +1135,14 @@ def _reduce_axis(
1118
1135
  scope=None,
1119
1136
  drop_allowed: bool = True,
1120
1137
  evidence: AxisEvidence | None = None,
1138
+ member_facts: Mapping[str, object] | None = None,
1121
1139
  ) -> _AxisDecision:
1140
+ # #797 spec 4.4 (Q3): the per-member refusal facts
1141
+ # (`_lib_stale_proof.MemberFacts`, keyed by contributor token) are
1142
+ # resolved before the kernel. None, or no refused member, reduces
1143
+ # exactly as before.
1144
+ refused = frozenset(
1145
+ token for token, facts in (member_facts or {}).items() if facts.refused)
1122
1146
  reduced_data = _reduced_candidate(
1123
1147
  candidates, axis=axis, tombstone=tombstone, db_axis=db_axis
1124
1148
  )
@@ -1137,8 +1161,11 @@ def _reduce_axis(
1137
1161
  # ordinary first post-floor reading; one captured before it is
1138
1162
  # stale and is never relabelled as post-floor. The reading opens
1139
1163
  # no consensus and spends no attempt, under either gate.
1164
+ # #797 spec 4.4: a refused member is excluded from the post-floor
1165
+ # set; every other member keeps the receipt-floor test.
1140
1166
  post = tuple((candidate, value) for candidate, value in in_window
1141
- if candidate.received_at >= int(evidence.floor_epoch))
1167
+ if candidate.token not in refused
1168
+ and candidate.received_at >= int(evidence.floor_epoch))
1142
1169
  if not post:
1143
1170
  verdict = CLASS_STALE
1144
1171
  else:
@@ -1184,7 +1211,13 @@ def _reduce_axis(
1184
1211
  # drop. The maximum decides what is displayed; it may not erase the
1185
1212
  # evidence that a drop is pending, which is why the low is read separately
1186
1213
  # here and why a rise is a publication rather than a branch of its own.
1187
- rising = reduced.percent > db_axis.percent
1214
+ # #797 spec 4.4 (Q3): `rising` and the published value come from the
1215
+ # members that are not refused; low evidence, supporter retraction,
1216
+ # unanimity, pending membership, `reduced_percent`, deadlines and attempt
1217
+ # accounting keep the unfiltered set. The eligible value applies at every
1218
+ # rising return (plain, armed, cancelled and reconciled).
1219
+ rise = _eligible_rise(reduced, in_window, refused, db_axis)
1220
+ rising = rise is not None and rise.percent > db_axis.percent
1188
1221
  low = min(value.percent for _, value in in_window)
1189
1222
  armed = (
1190
1223
  pending is not None
@@ -1196,7 +1229,7 @@ def _reduce_axis(
1196
1229
  if rising:
1197
1230
  # The rise moves the baseline and no drop is pending against the old
1198
1231
  # one, so there is nothing to preserve.
1199
- return _AxisDecision("PUBLISH_DB", reduced, None)
1232
+ return _AxisDecision("PUBLISH_DB", rise, None)
1200
1233
  if low >= db_axis.percent:
1201
1234
  return _AxisDecision(
1202
1235
  "WRITE_CONTROL" if pending is not None else "NOOP", None, None
@@ -1212,7 +1245,7 @@ def _reduce_axis(
1212
1245
  # A cancelled or already-recorded drop stops being pending, and a rise still
1213
1246
  # publishes on the way out.
1214
1247
  cancelled = _AxisDecision(
1215
- "PUBLISH_DB" if rising else "WRITE_CONTROL", reduced if rising else None, None
1248
+ "PUBLISH_DB" if rising else "WRITE_CONTROL", rise if rising else None, None
1216
1249
  )
1217
1250
  if _supporters_retracted(pending, in_window, db_axis):
1218
1251
  return cancelled
@@ -1245,7 +1278,7 @@ def _reduce_axis(
1245
1278
  # reporting a higher number erased another session's real reset. The
1246
1279
  # retained low keeps its meaning, because the baseline only moved UP —
1247
1280
  # a low armed below the old baseline is still a drop below the new one.
1248
- return _AxisDecision("PUBLISH_DB", reduced, updated)
1281
+ return _AxisDecision("PUBLISH_DB", rise, updated)
1249
1282
 
1250
1283
  unanimous = (
1251
1284
  reduced.percent < db_axis.percent
@@ -1289,6 +1322,25 @@ def _reduce_axis(
1289
1322
  )
1290
1323
 
1291
1324
 
1325
+ def _eligible_rise(
1326
+ reduced: AxisValue,
1327
+ in_window: tuple[tuple[Candidate, AxisValue], ...],
1328
+ refused: frozenset,
1329
+ db_axis: AxisProjection,
1330
+ ) -> AxisValue | None:
1331
+ """The same-window maximum over members that are not refused (#797 spec
1332
+ 4.4), or None when every member is refused. Without a refused member it
1333
+ is ``reduced`` itself."""
1334
+ if not refused or not any(c.token in refused for c, _ in in_window):
1335
+ return reduced
1336
+ eligible = [value.percent for candidate, value in in_window
1337
+ if candidate.token not in refused]
1338
+ if not eligible:
1339
+ return None
1340
+ return AxisValue(max(eligible), db_axis.raw_resets_at,
1341
+ canonical_key=reduced.canonical_key)
1342
+
1343
+
1292
1344
  def reduce_candidates(
1293
1345
  candidates: tuple[Candidate, ...] | list[Candidate],
1294
1346
  *,
@@ -1298,6 +1350,7 @@ def reduce_candidates(
1298
1350
  now_epoch: int,
1299
1351
  account_key: str | None = None,
1300
1352
  drop_consensus_allowed: bool = True,
1353
+ member_facts: Mapping[str, Mapping[str, object]] | None = None,
1301
1354
  ) -> ReductionDecision:
1302
1355
  """Reduce active candidates independently for 5h and 7d axes.
1303
1356
 
@@ -1319,7 +1372,12 @@ def reduce_candidates(
1319
1372
  reading after a retiring floor); a stale reading takes the older-window
1320
1373
  disposition, and an unclassifiable one is named in ``unknown_axes`` (fix
1321
1374
  rounds K13 and K14c).
1375
+
1376
+ ``member_facts`` maps an axis to its per-member refusal facts (#797 spec
1377
+ 4.4, `_lib_stale_proof.MemberFacts` by contributor token). None, or an
1378
+ axis without facts, means every member is eligible.
1322
1379
  """
1380
+ facts = member_facts or {}
1323
1381
  active = tuple(
1324
1382
  candidate
1325
1383
  for candidate in candidates
@@ -1335,6 +1393,7 @@ def reduce_candidates(
1335
1393
  scope=None if account_key is None else (PROTOCOL_GENERATION, account_key, "fiveHour"),
1336
1394
  drop_allowed=drop_consensus_allowed,
1337
1395
  evidence=axis_evidence(db, "fiveHour"),
1396
+ member_facts=facts.get("fiveHour"),
1338
1397
  )
1339
1398
  seven = _reduce_axis(
1340
1399
  "sevenDay",
@@ -1346,6 +1405,7 @@ def reduce_candidates(
1346
1405
  scope=None if account_key is None else (PROTOCOL_GENERATION, account_key, "sevenDay"),
1347
1406
  drop_allowed=drop_consensus_allowed,
1348
1407
  evidence=axis_evidence(db, "sevenDay"),
1408
+ member_facts=facts.get("sevenDay"),
1349
1409
  )
1350
1410
  next_control = ControlState(
1351
1411
  db_projection=db,
@@ -0,0 +1,315 @@
1
+ """The launch ledger of the detached stats rebuild workers (#997, spec D8).
2
+
3
+ On 2026-10-22 an epoch-rebuild worker ran for hours and nothing recorded
4
+ whether one worker ran throughout or one died and another replaced it: the log
5
+ held only returned outcomes. Each worker (the epoch rebuild and the corruption
6
+ heal) now keeps two private records in ``APP_DIR``: a run state, written only
7
+ by the holder of the worker flock, and this launch ledger, written only under
8
+ the worker's admission flock. This module is the pure kernel that decides what
9
+ became of each launch; spawners, workers, ``doctor`` and ``db status`` all
10
+ classify through it, and only spawners and workers persist the result.
11
+
12
+ The central rule: **a death is settled by evidence, never by elapsed time.**
13
+ An unsettled entry is settled when
14
+
15
+ (a) the run state shows its ``launchId`` finished, which transfers that
16
+ outcome as ``finished:<outcome>``, or records its in-run death; or
17
+ (b) its process is confirmed gone by identity — absent, a zombie, or a live
18
+ pid whose ``birth`` differs — which settles ``died-pre-start`` without a
19
+ start acknowledgement and ``died-in-run`` with one. The caller probes the
20
+ identities BEFORE it reads the files it passes here, so a re-read after the
21
+ probe is what this function sees.
22
+
23
+ A process that is alive, or whose liveness is unknown (``ps`` failing, a live
24
+ pid with a null ``birth``), stays unresolved however old the entry is: a slow
25
+ child is never declared dead.
26
+
27
+ Ledger shape (``schemaVersion`` 1)::
28
+
29
+ {"schemaVersion": 1,
30
+ "entries": [{"launchId", "at", "pid", "birth",
31
+ "spawn": "reserved"|"ok"|"failed",
32
+ "ack": None | {"kind": "started", "runId", "at"}
33
+ | {"kind": "busy", "at"},
34
+ "outcome": None | <terminal>, "settledAt"}],
35
+ "counts": {<terminal>: int}, # cumulative, survives pruning
36
+ "lastDeathAt": str | None}
37
+
38
+ ``pid`` and ``birth`` are null while ``spawn`` is ``reserved``; ``birth`` may
39
+ stay null after it (``ps`` could not see the child). Unresolved entries are
40
+ never dropped: the spawner admits no launch while ``LAUNCH_CAPACITY`` are
41
+ unresolved (``has_launch_capacity``), which is what bounds them.
42
+
43
+ ``<terminal>`` is ``finished:<worker outcome>``, ``busy``, ``spawn-failed``,
44
+ ``died-pre-start`` or ``died-in-run``. Every function returns a new ledger and
45
+ never mutates its input. Stdlib only, and importable on its own.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import copy
50
+
51
+ SCHEMA_VERSION = 1
52
+ #: Unresolved entries at which the spawner withholds further launches.
53
+ LAUNCH_CAPACITY = 100
54
+ #: Settled entries kept (the most recent); ``counts`` keeps the totals.
55
+ SETTLED_KEEP = 10
56
+ DEATHS = ("died-pre-start", "died-in-run")
57
+
58
+
59
+ def empty_ledger() -> dict:
60
+ return {
61
+ "schemaVersion": SCHEMA_VERSION,
62
+ "entries": [],
63
+ "counts": {},
64
+ "lastDeathAt": None,
65
+ }
66
+
67
+
68
+ def normalize(ledger) -> dict:
69
+ """A validated deep copy of ``ledger``; ``ValueError`` when malformed."""
70
+ if not isinstance(ledger, dict):
71
+ raise ValueError("launch ledger is not an object")
72
+ if ledger.get("schemaVersion") != SCHEMA_VERSION:
73
+ raise ValueError("unsupported launch ledger schemaVersion")
74
+ entries = ledger.get("entries", [])
75
+ if not isinstance(entries, list) or not all(
76
+ isinstance(e, dict) and isinstance(e.get("launchId"), str)
77
+ for e in entries):
78
+ raise ValueError("launch ledger entries are malformed")
79
+ counts = ledger.get("counts") or {}
80
+ if not isinstance(counts, dict) or not all(
81
+ isinstance(k, str) and isinstance(v, int)
82
+ for k, v in counts.items()):
83
+ raise ValueError("launch ledger counts are malformed")
84
+ last_death = ledger.get("lastDeathAt")
85
+ if last_death is not None and not isinstance(last_death, str):
86
+ raise ValueError("launch ledger lastDeathAt is malformed")
87
+ out = empty_ledger()
88
+ out["entries"] = [_entry(**{k: e.get(k) for k in _ENTRY_KEYS})
89
+ for e in copy.deepcopy(entries)]
90
+ out["counts"] = dict(counts)
91
+ out["lastDeathAt"] = last_death
92
+ return out
93
+
94
+
95
+ _ENTRY_KEYS = ("launchId", "at", "pid", "birth", "spawn", "ack", "outcome",
96
+ "settledAt")
97
+
98
+
99
+ def _entry(launchId, at, pid, birth, spawn="ok", ack=None, outcome=None,
100
+ settledAt=None) -> dict:
101
+ return {
102
+ "launchId": launchId, "at": at, "pid": pid, "birth": birth,
103
+ "spawn": spawn or "ok", "ack": ack, "outcome": outcome,
104
+ "settledAt": settledAt,
105
+ }
106
+
107
+
108
+ def find(ledger: dict, launch_id: str) -> "dict | None":
109
+ for entry in ledger.get("entries", ()):
110
+ if entry.get("launchId") == launch_id:
111
+ return entry
112
+ return None
113
+
114
+
115
+ def unresolved(ledger: dict) -> list:
116
+ return [e for e in ledger.get("entries", ()) if not e.get("outcome")]
117
+
118
+
119
+ def newest(ledger: dict) -> "dict | None":
120
+ """The newest launch in ledger (launch) order."""
121
+ entries = ledger.get("entries") or []
122
+ return entries[-1] if entries else None
123
+
124
+
125
+ def _settle_in_place(ledger: dict, entry: dict, terminal: str, at) -> bool:
126
+ if entry.get("outcome"):
127
+ return False
128
+ entry["outcome"] = terminal
129
+ entry["settledAt"] = at
130
+ counts = ledger.setdefault("counts", {})
131
+ counts[terminal] = int(counts.get(terminal, 0)) + 1
132
+ if terminal in DEATHS:
133
+ ledger["lastDeathAt"] = at or entry.get("at")
134
+ return True
135
+
136
+
137
+ def append_entry(ledger: dict, *, launch_id: str, at, pid, birth,
138
+ spawn: str = "ok") -> dict:
139
+ """Record a launch. A launch id already present is left as it is (the
140
+ worker's own acknowledgement may have recorded it first). A failed spawn
141
+ is settled at once as ``spawn-failed``."""
142
+ out = copy.deepcopy(ledger)
143
+ if find(out, launch_id) is not None:
144
+ return out
145
+ entry = _entry(launch_id, at, pid, birth, spawn=spawn)
146
+ out.setdefault("entries", []).append(entry)
147
+ if spawn == "failed":
148
+ _settle_in_place(out, entry, "spawn-failed", at)
149
+ return out
150
+
151
+
152
+ def reserve(ledger: dict, *, launch_id: str, at) -> dict:
153
+ """The spawner's reservation, persisted BEFORE the spawn (revision 6): a
154
+ spawner killed after it leaves an entry with no pid, which stays
155
+ unresolved with unknown liveness until the child acknowledges."""
156
+ out = copy.deepcopy(ledger)
157
+ if find(out, launch_id) is None:
158
+ out.setdefault("entries", []).append(
159
+ _entry(launch_id, at, None, None, spawn="reserved"))
160
+ return out
161
+
162
+
163
+ def fill_spawn(ledger: dict, launch_id: str, *, pid, birth, at,
164
+ spawn: str = "ok") -> dict:
165
+ """Complete a reservation with the spawn's result. A failed spawn is
166
+ settled at once as ``spawn-failed``. An identity the worker already
167
+ recorded at acknowledgement is never overwritten."""
168
+ out = copy.deepcopy(ledger)
169
+ entry = find(out, launch_id)
170
+ if entry is None:
171
+ out.setdefault("entries", []).append(
172
+ _entry(launch_id, at, None, None, spawn="reserved"))
173
+ entry = out["entries"][-1]
174
+ if spawn == "failed":
175
+ if entry.get("spawn") == "reserved":
176
+ entry["spawn"] = "failed"
177
+ _settle_in_place(out, entry, "spawn-failed", at)
178
+ return out
179
+ if entry.get("pid") is None:
180
+ entry["pid"] = pid
181
+ entry["birth"] = birth
182
+ if entry.get("spawn") == "reserved":
183
+ entry["spawn"] = "ok"
184
+ return out
185
+
186
+
187
+ def acknowledge(ledger: dict, launch_id: str, kind: str, run_id=None, *,
188
+ at, pid=None, birth=None) -> dict:
189
+ """A worker's acknowledgement: ``started`` (with its ``runId``) or
190
+ ``busy`` (which is also the entry's terminal). Fills the worker's own
191
+ ``pid`` and ``birth`` into a reservation the spawner never completed, and
192
+ appends the entry when there is none (a ledger lost to the unreadable
193
+ archive path)."""
194
+ out = copy.deepcopy(ledger)
195
+ entry = find(out, launch_id)
196
+ if entry is None:
197
+ entry = _entry(launch_id, at, pid, birth)
198
+ out.setdefault("entries", []).append(entry)
199
+ elif entry.get("pid") is None and pid is not None:
200
+ entry["pid"] = pid
201
+ entry["birth"] = birth
202
+ if entry.get("spawn") == "reserved":
203
+ entry["spawn"] = "ok"
204
+ if kind == "started":
205
+ entry["ack"] = {"kind": "started", "runId": run_id, "at": at}
206
+ elif kind == "busy":
207
+ entry["ack"] = {"kind": "busy", "at": at}
208
+ _settle_in_place(out, entry, "busy", at)
209
+ else:
210
+ raise ValueError(f"unknown acknowledgement kind {kind!r}")
211
+ return out
212
+
213
+
214
+ def settle(ledger: dict, launch_id: str, terminal: str, *, at) -> dict:
215
+ """Give an entry its terminal outcome; the first settlement wins."""
216
+ out = copy.deepcopy(ledger)
217
+ entry = find(out, launch_id)
218
+ if entry is not None:
219
+ _settle_in_place(out, entry, terminal, at)
220
+ return out
221
+
222
+
223
+ def liveness(entry: dict, identities: dict) -> str:
224
+ """``alive`` | ``gone`` | ``unknown`` for a ledger entry or a run (both
225
+ carry ``pid`` and ``birth``), from ``process_identity`` results."""
226
+ pid = entry.get("pid")
227
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
228
+ return "unknown"
229
+ identity = identities.get(pid)
230
+ if not isinstance(identity, dict):
231
+ return "unknown"
232
+ status = identity.get("status")
233
+ if status == "gone":
234
+ return "gone"
235
+ if status != "alive":
236
+ return "unknown"
237
+ entry_birth, live_birth = entry.get("birth"), identity.get("birth")
238
+ if entry_birth and live_birth and entry_birth != live_birth:
239
+ return "gone" # the pid now names a different process
240
+ if not entry_birth or not live_birth:
241
+ return "unknown"
242
+ return "alive"
243
+
244
+
245
+ def classify(ledger: dict, state, identities: dict, *, now=None) -> dict:
246
+ """Settle what the evidence settles. Returns ``{"ledger", "settled"}``,
247
+ ``settled`` being the newly settled entries (copies). Never settles by
248
+ age; see the module docstring for rules (a) and (b)."""
249
+ out = copy.deepcopy(ledger)
250
+ run = state.get("run") if isinstance(state, dict) else None
251
+ run = run if isinstance(run, dict) else None
252
+ deaths = state.get("deaths") if isinstance(state, dict) else None
253
+ # A death whose ``launchId`` is not a string (a corrupt record in a
254
+ # state that still validates) settles nothing.
255
+ died_in_run = {d.get("launchId") for d in (deaths or ())
256
+ if isinstance(d, dict) and isinstance(d.get("launchId"), str)
257
+ and d.get("launchId")}
258
+ settled = []
259
+ for entry in out.get("entries", []):
260
+ if entry.get("outcome"):
261
+ continue
262
+ launch_id = entry.get("launchId")
263
+ terminal = None
264
+ if (run is not None and run.get("launchId") == launch_id
265
+ and run.get("status") == "finished"):
266
+ terminal = f"finished:{run.get('outcome')}"
267
+ elif launch_id in died_in_run:
268
+ terminal = "died-in-run"
269
+ elif liveness(entry, identities) == "gone":
270
+ ack = entry.get("ack")
271
+ started = isinstance(ack, dict) and ack.get("kind") == "started"
272
+ terminal = "died-in-run" if started else "died-pre-start"
273
+ if terminal is not None and _settle_in_place(out, entry, terminal,
274
+ now):
275
+ settled.append(copy.deepcopy(entry))
276
+ return {"ledger": out, "settled": settled}
277
+
278
+
279
+ def _protected_ids(protected_launch_ids) -> frozenset:
280
+ if isinstance(protected_launch_ids, str):
281
+ return frozenset({protected_launch_ids})
282
+ if not isinstance(protected_launch_ids, (list, tuple, set, frozenset)):
283
+ return frozenset()
284
+ return frozenset(i for i in protected_launch_ids if isinstance(i, str))
285
+
286
+
287
+ def prune(ledger: dict, protected_launch_ids=None) -> dict:
288
+ """Bound the settled entries to the newest ``SETTLED_KEEP``, in ledger
289
+ order. Unresolved entries are never dropped (``has_launch_capacity``
290
+ bounds them); ``counts`` and ``lastDeathAt`` are kept.
291
+
292
+ ``protected_launch_ids`` (one id, or an iterable of them; a non-string
293
+ member protects nothing) names the entries a run state references or is
294
+ about to: the current ``run.launchId`` and, for a worker whose own state
295
+ write has not landed yet, its own launch as well. Each is exempt from the
296
+ bound, because the slot's entry is the record that makes the next
297
+ worker's death accounting exactly-once, and the settled bound then counts
298
+ them plus ``SETTLED_KEEP`` minus their number of others."""
299
+ out = copy.deepcopy(ledger)
300
+ entries = out.get("entries", [])
301
+ wanted = _protected_ids(protected_launch_ids)
302
+ protected = {id(e) for e in entries if e.get("launchId") in wanted}
303
+ done_ids = [id(e) for e in entries
304
+ if e.get("outcome") and id(e) not in protected]
305
+ settled_room = max(0, SETTLED_KEEP - sum(
306
+ 1 for e in entries if id(e) in protected and e.get("outcome")))
307
+ dropped = set(done_ids[:max(0, len(done_ids) - settled_room)])
308
+ out["entries"] = [e for e in entries if id(e) not in dropped]
309
+ return out
310
+
311
+
312
+ def has_launch_capacity(ledger: dict) -> bool:
313
+ """Whether the spawner may admit another launch: fewer than
314
+ ``LAUNCH_CAPACITY`` entries are unresolved."""
315
+ return len(unresolved(ledger)) < LAUNCH_CAPACITY
package/bin/cctally CHANGED
@@ -404,7 +404,6 @@ diff_pricing = _lib_pricing_check.diff_pricing
404
404
  stale_allowlist_entries = _lib_pricing_check.stale_allowlist_entries
405
405
  expired_allowlist_entries = _lib_pricing_check.expired_allowlist_entries
406
406
  check_table_shapes = _lib_pricing_check.check_table_shapes
407
- pricing_issue_action = _lib_pricing_check.pricing_issue_action
408
407
  CoverageGap = _lib_pricing_check.CoverageGap
409
408
  DriftRow = _lib_pricing_check.DriftRow
410
409
  DriftResult = _lib_pricing_check.DriftResult
@@ -1258,13 +1257,11 @@ _delete_doomed_snapshot_rows = _cctally_record._delete_doomed_snapshot_rows
1258
1257
  # #834 S1 (#835) Gate A R4: the band is a connection-taking builder now,
1259
1258
  # because the held predicate is omitted on a store predating the column.
1260
1259
  _stale_replica_band_sql = _cctally_record._stale_replica_band_sql
1261
- # #834 S1 (#835): the read-side twin of the band above — which weekly
1262
- # values a week's credits retired, and the predicate that recognizes one.
1263
- # `_latest_seven_day_and_window` reaches both through this namespace.
1260
+ # #834 S1 (#835): a week's credits and their floors.
1261
+ # `_latest_seven_day_and_window` reaches both through this namespace for its
1262
+ # credit-floor bound (the retirement band itself was removed by #840).
1264
1263
  _credit_retirement_bands = _cctally_record._credit_retirement_bands
1265
1264
  _latest_credit_floor_instant = _cctally_record._latest_credit_floor_instant
1266
- _weekly_value_is_retired_replica = (
1267
- _cctally_record._weekly_value_is_retired_replica)
1268
1265
  _stale_replay_candidates = _cctally_record._stale_replay_candidates
1269
1266
  _count_stale_replays = _cctally_record._count_stale_replays
1270
1267
  _credit_preview_text = _cctally_record._credit_preview_text
@@ -1606,11 +1603,9 @@ _format_block_start = _cctally_five_hour._format_block_start
1606
1603
  _format_hhmm_in_tz = _cctally_five_hour._format_hhmm_in_tz
1607
1604
  _block_is_active = _cctally_five_hour._block_is_active
1608
1605
  _latest_seven_day_and_window = _cctally_five_hour._latest_seven_day_and_window
1609
- # #834 S1 (#835) Gate A S1: the ONE read-time filter for a block's two
1610
- # weekly axes. Both five-hour commands reach it through this namespace.
1611
- _parsed_block_instant = _cctally_five_hour._parsed_block_instant
1612
- _block_weekly_start_instant = _cctally_five_hour._block_weekly_start_instant
1613
- _block_weekly_end_instant = _cctally_five_hour._block_weekly_end_instant
1606
+ # #834 S1 (#835) Gate A S1: the ONE read of a block's two weekly axes (the
1607
+ # stored values since #840). Both five-hour commands reach it through this
1608
+ # namespace.
1614
1609
  _credit_aware_block_weekly_axes = (
1615
1610
  _cctally_five_hour._credit_aware_block_weekly_axes)
1616
1611
  # #834 S1 (#836): the ONE five-hour milestone read, shared by the CLI