cctally 1.112.0 → 1.114.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 (58) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -5
  3. package/bin/_cctally_cache.py +1683 -485
  4. package/bin/_cctally_core.py +223 -1
  5. package/bin/_cctally_dashboard.py +96 -18
  6. package/bin/_cctally_dashboard_perf.py +128 -9
  7. package/bin/_cctally_dashboard_sources.py +267 -204
  8. package/bin/_cctally_db.py +861 -18
  9. package/bin/_cctally_diagnosis_sources.py +22 -4
  10. package/bin/_cctally_doctor.py +136 -18
  11. package/bin/_cctally_five_hour.py +2 -2
  12. package/bin/_cctally_forecast.py +27 -6
  13. package/bin/_cctally_journal.py +67 -5
  14. package/bin/_cctally_milestone_history.py +82 -9
  15. package/bin/_cctally_percent_breakdown.py +3 -2
  16. package/bin/_cctally_quota.py +201 -33
  17. package/bin/_cctally_record.py +14 -0
  18. package/bin/_cctally_rederive.py +23 -9
  19. package/bin/_cctally_source_analytics.py +60 -15
  20. package/bin/_cctally_statusline.py +17 -0
  21. package/bin/_cctally_store.py +80 -5
  22. package/bin/_cctally_tui.py +4 -3
  23. package/bin/_cctally_weekrefs.py +128 -21
  24. package/bin/_lib_cache_report.py +35 -6
  25. package/bin/_lib_codex_conversation.py +32 -0
  26. package/bin/_lib_codex_conversation_query.py +164 -30
  27. package/bin/_lib_conversation_query.py +95 -56
  28. package/bin/_lib_conversation_retention.py +1605 -640
  29. package/bin/_lib_cost_provenance.py +53 -2
  30. package/bin/_lib_doctor.py +263 -69
  31. package/bin/_lib_fmt.py +11 -0
  32. package/bin/_lib_merge_gate.py +469 -51
  33. package/bin/_lib_perf.py +27 -1
  34. package/bin/_lib_pricing.py +209 -8
  35. package/bin/_lib_pricing_check.py +20 -4
  36. package/bin/_lib_reclaim_planner.py +792 -0
  37. package/bin/_lib_rederive.py +225 -77
  38. package/bin/_lib_snapshot_cache.py +21 -0
  39. package/bin/_lib_sqlite_close.py +81 -0
  40. package/bin/_lib_subscription_weeks.py +100 -17
  41. package/bin/_lib_tick_stats.py +217 -17
  42. package/bin/_lib_wal_checkpoint.py +1077 -0
  43. package/bin/_lib_write_budget.py +483 -0
  44. package/bin/_lib_write_io.py +703 -0
  45. package/bin/cctally +8 -0
  46. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  47. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  48. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  49. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  50. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  51. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  52. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  53. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  54. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  55. package/dashboard/static/dashboard.html +2 -2
  56. package/package.json +6 -1
  57. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  58. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -25,12 +25,15 @@ Bounds: at most ``RING_CAPACITY`` retained records and at most
25
25
  """
26
26
  from __future__ import annotations
27
27
 
28
+ import array
28
29
  import dataclasses
29
30
  import sys
30
31
  import threading
31
32
  import time
32
33
  import types
33
34
 
35
+ import _lib_write_io
36
+
34
37
  RING_CAPACITY = 64
35
38
  MEMORY_BUDGET_BYTES = 65536
36
39
 
@@ -58,6 +61,10 @@ PUBLICATIONS = ("final", "partial", "seed", "degraded")
58
61
  #: frozen field typed `str` enforces nothing on its own.
59
62
  CONVERSATION_STATUSES = ("ok", "store_unavailable", "error")
60
63
  CONVERSATION_MODES = ("caught_up", "targeted", "full", "not_observed")
64
+ #: The write status of an interval (#901 §5.5): `ok`, a typed unavailable
65
+ #: reason, or `not_sampled` when the recorder was given no observation. A
66
+ #: number is published only with `ok`; it is never fabricated as zero.
67
+ WRITE_STATUSES = _lib_write_io.WRITE_STATUSES
61
68
 
62
69
  _INGEST = "ingest"
63
70
  _BUILD = "build"
@@ -84,6 +91,9 @@ class TickRecord:
84
91
  period_ns: "int | None"
85
92
  cache_pin_ns: int
86
93
  cpu_ns: int
94
+ process_write_bytes: "int | None"
95
+ write_status: str
96
+ write_overlap: bool
87
97
 
88
98
  def as_wire(self) -> dict:
89
99
  return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
@@ -125,6 +135,9 @@ class ConversationSyncRecord:
125
135
  codex_mode: str
126
136
  claude_files: int
127
137
  codex_files: int
138
+ process_write_bytes: "int | None"
139
+ write_status: str
140
+ write_overlap: bool
128
141
 
129
142
  def as_wire(self) -> dict:
130
143
  return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
@@ -147,16 +160,42 @@ MAINTENANCE_PHASES = ("delete", "reclaim", "checkpoint", "other")
147
160
  #: maintenance history at the throttled cadence, and many continuation passes
148
161
  #: at the escalated one.
149
162
  MAINTENANCE_RING_CAPACITY = 16
163
+ #: #901: one record per maintenance OPERATION (a deletion group or a reclaim
164
+ #: chunk) carries its own outcome. `gated` means the allowance was in debt,
165
+ #: `skipped` that reclaim could not run without checkpointing.
166
+ MAINTENANCE_OUTCOMES = ("ok", "deadline", "no_progress", "busy", "gated",
167
+ "skipped")
168
+ MAINTENANCE_MODES = ("", "spill_free", "fallback", "reclaim")
169
+ STARTED_AT_MAX_CHARS = 32
170
+ #: #901 Q9: the closed set of reclaim skip reasons and reader statuses a
171
+ #: record may carry (`_lib_conversation_retention.RECLAIM_SKIP_REASONS`,
172
+ #: pinned equal by `tests/test_tick_stats_write_io.py`); anything else is
173
+ #: published as "unknown".
174
+ MAINTENANCE_SKIP_REASONS = (
175
+ "no_checkpoint_on_close_unavailable", "connection_settings_unavailable",
176
+ "sqlite_error", "state_record_unnormalized", "state_record_oversize",
177
+ "helper_failed", "helper_timeout", "wal_index_unreadable",
178
+ "wal_checksum_mismatch", "wal_endpoint_mismatch", "read_budget_exhausted",
179
+ "unsupported_geometry", "malformed_page", "cyclic_freelist",
180
+ "identity_changed", "sqlite_unaudited")
181
+ SQLITE_SOURCE_ID_MAX_CHARS = 96
182
+ PLAN_DIGEST_MAX_CHARS = 16
183
+ #: One shared object per distinct SQLite source id, so sixteen records of
184
+ #: one runtime hold one string (the 65,536-byte budget).
185
+ _SOURCE_IDS: "dict[str, str]" = {}
150
186
 
151
187
 
152
188
  @dataclasses.dataclass(frozen=True, slots=True)
153
189
  class MaintenancePhaseRecord:
154
- """One timed maintenance phase.
155
-
156
- `phase` is normalized against `MAINTENANCE_PHASES` in the recorder, for the
157
- same reason `ConversationSyncRecord.status` is: a frozen field typed `str`
158
- would accept `str(exc)` or a filesystem path and publish it through the
159
- debug endpoint.
190
+ """One timed maintenance phase or operation (#780, #901).
191
+
192
+ `phase`, `outcome`, `mode` and `write_status` are normalized against
193
+ closed sets in the recorder, for the same reason
194
+ `ConversationSyncRecord.status` is: a frozen field typed `str` would
195
+ accept `str(exc)` or a filesystem path and publish it through the debug
196
+ endpoint. The operation fields default so a legacy phase record stays
197
+ constructible; `charged_bytes` is the operation's reservation and
198
+ `process_write_bytes` is telemetry only.
160
199
  """
161
200
 
162
201
  seq: int
@@ -167,9 +206,63 @@ class MaintenancePhaseRecord:
167
206
  pages_reclaimed: int = 0
168
207
  bytes_returned: int = 0
169
208
  pending: bool = False
209
+ op_id: int = 0
210
+ started_at: str = ""
211
+ rows: int = 0
212
+ charged_bytes: int = 0
213
+ process_write_bytes: "int | None" = None
214
+ write_status: str = "not_sampled"
215
+ write_overlap: bool = False
216
+ balance_before_bytes: "int | None" = None
217
+ balance_after_bytes: "int | None" = None
218
+ mode: str = ""
219
+ # #901 Q9: a reclaim chunk's skip, its plan's identity and its reader.
220
+ skip_reason: "str | None" = None
221
+ sqlite_source_id: "str | None" = None
222
+ plan_digest: "str | None" = None
223
+ reader_status: "str | None" = None
224
+ # #901 Q8 (a deletion's reservation inputs) and Q9 (a reclaim chunk's
225
+ # plan and outcome, §5.5): `OPERATION_INT_FIELDS` packed into ONE
226
+ # array, -1 for "not recorded", because sixteen records of twenty
227
+ # separate int objects do not fit the frozen 65,536-byte budget. Read
228
+ # them by name (`record.page_count`); `as_wire` publishes them by name.
229
+ numbers: "array.array | None" = None # typecode "i"
170
230
 
171
231
  def as_wire(self) -> dict:
172
- return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
232
+ wire = {f.name: getattr(self, f.name)
233
+ for f in dataclasses.fields(self) if f.name != "numbers"}
234
+ for name in OPERATION_INT_FIELDS:
235
+ wire[name] = getattr(self, name)
236
+ return wire
237
+
238
+
239
+ #: Every packed value is a page count, a byte count bounded by the
240
+ #: planner's read budget, or a small count: a C int holds each (a larger
241
+ #: value is clamped rather than wrapped).
242
+ _PACKED_MAX = 2 ** 31 - 1
243
+ #: The packed numeric operation fields, in their fixed array order.
244
+ OPERATION_INT_FIELDS = (
245
+ "page_count", "usable_size", "page_size", "pointer_map_cap",
246
+ "reservation_version", "planner_version", "freelist_count", "steps",
247
+ "kind_free", "kind_overflow", "kind_leaf", "kind_interior",
248
+ "identified_pages", "unidentified_pages", "fixed_bytes",
249
+ "inspected_bytes", "inspection_ms", "freelist_reduction",
250
+ "page_count_reduction")
251
+
252
+
253
+ def _packed_field(index: int):
254
+ def read(record):
255
+ numbers = record.numbers
256
+ if numbers is None:
257
+ return None
258
+ value = numbers[index]
259
+ return None if value < 0 else value
260
+ return property(read)
261
+
262
+
263
+ for _index, _name in enumerate(OPERATION_INT_FIELDS):
264
+ setattr(MaintenancePhaseRecord, _name, _packed_field(_index))
265
+ del _index, _name
173
266
 
174
267
 
175
268
  @dataclasses.dataclass(frozen=True, slots=True)
@@ -286,7 +379,8 @@ class TickContext:
286
379
  __slots__ = ("_now", "_cpu_now", "_standalone", "_started_ns",
287
380
  "_started_cpu_ns", "_spans", "_finished",
288
381
  "_ingest_ns", "_builder_ns", "_ingest_ran", "_dispatch",
289
- "_codex_regime", "_publication", "_cold", "_cache_pin_ns")
382
+ "_codex_regime", "_publication", "_cold", "_cache_pin_ns",
383
+ "_write")
290
384
 
291
385
  def __init__(self, *, monotonic_ns, thread_time_ns, standalone: bool):
292
386
  self._now = monotonic_ns
@@ -308,6 +402,7 @@ class TickContext:
308
402
  self._publication = "final"
309
403
  self._cold = False
310
404
  self._cache_pin_ns = 0
405
+ self._write = _lib_write_io.begin_interval("tick")
311
406
 
312
407
  # ── measurement ──────────────────────────────────────────────────────
313
408
 
@@ -444,6 +539,9 @@ class TickContext:
444
539
  ended_cpu_ns = self._cpu_now()
445
540
  if getattr(_tls, "tick", None) is self:
446
541
  _tls.tick = None
542
+ # #901: the interval's process write delta. It includes concurrent
543
+ # work (`write_overlap` says so) and is never summed across loops.
544
+ written = _lib_write_io.end_interval(self._write)
447
545
 
448
546
  global _STATE
449
547
  with _LOCK:
@@ -474,6 +572,9 @@ class TickContext:
474
572
  period_ns=period,
475
573
  cache_pin_ns=self._cache_pin_ns,
476
574
  cpu_ns=max(0, ended_cpu_ns - self._started_cpu_ns),
575
+ process_write_bytes=written.process_write_bytes,
576
+ write_status=written.status,
577
+ write_overlap=written.overlap,
477
578
  )
478
579
  if self._standalone:
479
580
  _STATE = dataclasses.replace(prior, standalone=record)
@@ -498,12 +599,30 @@ class TickContext:
498
599
  # wiped it 128 times and the suite reported one record.
499
600
  maintenance_records=prior.maintenance_records,
500
601
  )
602
+ # A completed main publication: the per-publication denominator,
603
+ # warm admission and the hysteresis step (outside this module's lock).
604
+ _lib_write_io.note_publication(cold=self._cold)
605
+
606
+
607
+ def _safe_write(process_write_bytes, write_status) -> "tuple[int | None, str]":
608
+ """Coerce an interval's write fields: a number only with `ok`."""
609
+ status = write_status if write_status in WRITE_STATUSES else "not_sampled"
610
+ if status != "ok":
611
+ return None, status
612
+ try:
613
+ value = int(process_write_bytes)
614
+ except (TypeError, ValueError):
615
+ return None, "not_sampled"
616
+ if value < 0:
617
+ return None, "not_sampled"
618
+ return value, "ok"
501
619
 
502
620
 
503
621
  def record_conversation_pass(
504
622
  *, seq, started_ns, ended_ns, duration_ns, cpu_ns, status,
505
623
  claude_mode="not_observed", codex_mode="not_observed",
506
624
  claude_files=0, codex_files=0,
625
+ process_write_bytes=None, write_status="not_sampled", write_overlap=False,
507
626
  ) -> None:
508
627
  """Append one conversation sync pass to its ring, under the SHARED lock.
509
628
 
@@ -534,6 +653,8 @@ def record_conversation_pass(
534
653
  claude_mode if claude_mode in CONVERSATION_MODES else "not_observed")
535
654
  safe_codex_mode = (
536
655
  codex_mode if codex_mode in CONVERSATION_MODES else "not_observed")
656
+ safe_bytes, safe_write_status = _safe_write(
657
+ process_write_bytes, write_status)
537
658
  start = int(started_ns)
538
659
  record = ConversationSyncRecord(
539
660
  seq=int(seq),
@@ -547,6 +668,9 @@ def record_conversation_pass(
547
668
  codex_mode=safe_codex_mode,
548
669
  claude_files=max(0, int(claude_files)),
549
670
  codex_files=max(0, int(codex_files)),
671
+ process_write_bytes=safe_bytes,
672
+ write_status=safe_write_status,
673
+ write_overlap=bool(write_overlap),
550
674
  )
551
675
  with _LOCK:
552
676
  prior = _STATE
@@ -565,7 +689,8 @@ def record_conversation_pass(
565
689
 
566
690
 
567
691
  def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
568
- """Append one maintenance phase to its own bounded ring (#780).
692
+ """Append one maintenance phase or operation to its own bounded ring
693
+ (#780, #901).
569
694
 
570
695
  Separate from the conversation-sync ring on purpose, so a two-second
571
696
  reclaim and a slow checkpoint each appear as their own timed phase with
@@ -592,8 +717,87 @@ def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
592
717
  duration_ns = max(0, int(float(duration_s) * 1_000_000_000))
593
718
  except (TypeError, ValueError):
594
719
  duration_ns = 0
595
- pending = bool(data.get("pending"))
596
- if safe_phase == "reclaim":
720
+
721
+ def _as_int(value):
722
+ try:
723
+ return max(0, int(value or 0))
724
+ except (TypeError, ValueError):
725
+ return 0
726
+
727
+ def _signed_or_none(value):
728
+ if value is None or isinstance(value, bool):
729
+ return None
730
+ try:
731
+ return int(value)
732
+ except (TypeError, ValueError):
733
+ return None
734
+
735
+ def _count_or_none(value):
736
+ value = _signed_or_none(value)
737
+ return None if value is None or value < 0 else value
738
+
739
+ def _reason_or_none(value):
740
+ if value is None:
741
+ return None
742
+ return value if value in MAINTENANCE_SKIP_REASONS else "unknown"
743
+
744
+ def _status_or_none(value):
745
+ return "ok" if value == "ok" else _reason_or_none(value)
746
+
747
+ def _source_id(value):
748
+ if not isinstance(value, str) or not value:
749
+ return None
750
+ value = value[:SQLITE_SOURCE_ID_MAX_CHARS]
751
+ shared = _SOURCE_IDS.get(value)
752
+ if shared is None:
753
+ if len(_SOURCE_IDS) >= 4:
754
+ _SOURCE_IDS.clear()
755
+ shared = _SOURCE_IDS.setdefault(value, value)
756
+ return shared
757
+
758
+ def _packed(data):
759
+ values = [_count_or_none(data.get(name))
760
+ for name in OPERATION_INT_FIELDS]
761
+ if all(v is None for v in values):
762
+ return None
763
+ return array.array("i", [
764
+ -1 if v is None else min(v, _PACKED_MAX) for v in values])
765
+
766
+ def _digest(value):
767
+ if not isinstance(value, str):
768
+ return None
769
+ value = value[:PLAN_DIGEST_MAX_CHARS]
770
+ return value if all(c in "0123456789abcdef" for c in value) else None
771
+
772
+ extra = {}
773
+ if "op_id" in data:
774
+ outcome = data.get("outcome")
775
+ outcome = outcome if outcome in MAINTENANCE_OUTCOMES else "ok"
776
+ mode = data.get("mode")
777
+ written, written_status = _safe_write(
778
+ data.get("process_write_bytes"), data.get("write_status"))
779
+ pending = (safe_phase == "reclaim" and outcome == "ok") or outcome in (
780
+ "gated", "skipped")
781
+ extra = dict(
782
+ op_id=_as_int(data.get("op_id")),
783
+ started_at=str(data.get("started_at") or "")[:STARTED_AT_MAX_CHARS],
784
+ rows=_as_int(data.get("rows")),
785
+ charged_bytes=_as_int(data.get("charged_bytes")),
786
+ process_write_bytes=written,
787
+ write_status=written_status,
788
+ write_overlap=bool(data.get("write_overlap")),
789
+ balance_before_bytes=_signed_or_none(
790
+ data.get("balance_before_bytes")),
791
+ balance_after_bytes=_signed_or_none(
792
+ data.get("balance_after_bytes")),
793
+ mode=mode if mode in MAINTENANCE_MODES else "",
794
+ skip_reason=_reason_or_none(data.get("skip_reason")),
795
+ sqlite_source_id=_source_id(data.get("sqlite_source_id")),
796
+ plan_digest=_digest(data.get("plan_digest")),
797
+ reader_status=_status_or_none(data.get("reader_status")),
798
+ numbers=_packed(data),
799
+ )
800
+ elif safe_phase == "reclaim":
597
801
  pending = bool(data.get("deadline_hit")) or not bool(
598
802
  data.get("made_progress", True))
599
803
  outcome = "deadline" if data.get("deadline_hit") else (
@@ -603,14 +807,9 @@ def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
603
807
  outcome = "ok" if (result and result[0] == 0) else "busy"
604
808
  pending = not (result and result[0] == 0 and result[1] == 0)
605
809
  else:
810
+ pending = bool(data.get("pending"))
606
811
  outcome = "ok"
607
812
 
608
- def _as_int(value):
609
- try:
610
- return max(0, int(value or 0))
611
- except (TypeError, ValueError):
612
- return 0
613
-
614
813
  record = MaintenancePhaseRecord(
615
814
  seq=0,
616
815
  phase=safe_phase,
@@ -620,6 +819,7 @@ def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
620
819
  pages_reclaimed=_as_int(data.get("pages_reclaimed")),
621
820
  bytes_returned=_as_int(data.get("bytes_returned")),
622
821
  pending=pending,
822
+ **extra,
623
823
  )
624
824
  with _LOCK:
625
825
  prior = _STATE