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.
Files changed (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -504,6 +504,105 @@ def subscription_window_ending_at(
504
504
  return window
505
505
 
506
506
 
507
+ #: The pre-#901 grouped anchor read, kept as the fallback for a stats index
508
+ #: without the epoch-1017 anchor indexes (it selects the same rows, but sorts
509
+ #: one group per subscription week ever recorded through a temp b-tree).
510
+ _SUBSCRIPTION_ANCHOR_GROUPED_SQL = (
511
+ "SELECT "
512
+ " MIN(week_start_at) AS week_start_at, "
513
+ " MIN(week_end_at) AS week_end_at, "
514
+ " week_start_date, "
515
+ " MIN(week_end_date) AS week_end_date "
516
+ "FROM weekly_usage_snapshots "
517
+ "WHERE week_start_at IS NOT NULL "
518
+ " AND week_end_at IS NOT NULL "
519
+ " AND week_start_date IS NOT NULL "
520
+ " {account} "
521
+ "GROUP BY week_start_date "
522
+ "ORDER BY MIN(week_start_at) ASC"
523
+ )
524
+
525
+
526
+ def _subscription_anchor_eligibility(prefix: str) -> str:
527
+ return (
528
+ f"{prefix}week_start_at IS NOT NULL "
529
+ f"AND {prefix}week_end_at IS NOT NULL "
530
+ f"AND {prefix}week_start_date IS NOT NULL"
531
+ )
532
+
533
+
534
+ def _subscription_anchor_sql(scoped: bool) -> str:
535
+ """The ordered representative-row anchor read (#901 Amendment 1 item 9).
536
+
537
+ The outer scan walks every qualifying row in ``(week_start_at,
538
+ week_start_date)`` order through ``idx_usage_subscription_anchor_order``
539
+ and keeps one row per date: the date's first qualifying row by
540
+ ``(week_start_at, id)``, found with one seek of
541
+ ``idx_usage_subscription_anchor_pick``. That row's ``week_start_at`` is the
542
+ date's lexical ``MIN(week_start_at)``, so rows leave in the old ``ORDER BY
543
+ MIN(week_start_at)`` order, with equal starts on two dates in date order.
544
+ ``MIN(week_end_at)`` and ``MIN(week_end_date)`` are INDEPENDENT correlated
545
+ aggregates over the date's complete qualifying population, because the
546
+ three minima can come from three different rows. The eligibility and the
547
+ optional account predicate repeat in the outer scan and in every lookup.
548
+ """
549
+ def account(alias: str) -> str:
550
+ return f" AND {alias}.account_key = ?" if scoped else ""
551
+
552
+ def lookup(alias: str) -> str:
553
+ return (
554
+ f"FROM weekly_usage_snapshots AS {alias} "
555
+ "INDEXED BY idx_usage_subscription_anchor_pick "
556
+ f"WHERE {alias}.week_start_date = o.week_start_date "
557
+ f"AND {_subscription_anchor_eligibility(alias + '.')}"
558
+ f"{account(alias)}"
559
+ )
560
+
561
+ return (
562
+ "SELECT o.week_start_at AS week_start_at, "
563
+ f"(SELECT MIN(e.week_end_at) {lookup('e')}) AS week_end_at, "
564
+ "o.week_start_date AS week_start_date, "
565
+ f"(SELECT MIN(d.week_end_date) {lookup('d')}) AS week_end_date "
566
+ "FROM weekly_usage_snapshots AS o "
567
+ "INDEXED BY idx_usage_subscription_anchor_order "
568
+ f"WHERE {_subscription_anchor_eligibility('o.')}{account('o')} "
569
+ f"AND o.id = (SELECT p.id {lookup('p')} "
570
+ "ORDER BY p.week_start_at ASC, p.id ASC LIMIT 1) "
571
+ "ORDER BY o.week_start_at ASC, o.week_start_date ASC"
572
+ )
573
+
574
+
575
+ def _subscription_anchor_rows(
576
+ conn: sqlite3.Connection, account_key: "str | None",
577
+ ) -> list:
578
+ """One ``(start_at, end_at, start_date, end_date)`` row per subscription
579
+ week, ascending by the week's minimum start (#901 Amendment 1 item 9).
580
+
581
+ The pre-#901 statement grouped every qualifying snapshot by date and
582
+ sorted the groups by ``MIN(week_start_at)`` through temp b-trees, with no
583
+ bound on retained weeks; ``_subscription_anchor_sql`` returns the same
584
+ rows by streaming two epoch-1017 indexes (``c901-design-2``). A store
585
+ without both indexes keeps the grouped statement, which is correct.
586
+ """
587
+ present = {
588
+ str(row[0]) for row in conn.execute(
589
+ "SELECT name FROM sqlite_master WHERE type='index' AND name IN "
590
+ "('idx_usage_subscription_anchor_order', "
591
+ "'idx_usage_subscription_anchor_pick')")
592
+ }
593
+ params: tuple = () if account_key is None else (account_key,)
594
+ if len(present) == 2:
595
+ # The account placeholder appears four times: the two aggregates, the
596
+ # outer scan and the representative pick, in that textual order.
597
+ return conn.execute(
598
+ _subscription_anchor_sql(account_key is not None), params * 4,
599
+ ).fetchall()
600
+ account = "" if account_key is None else " AND account_key = ?"
601
+ return conn.execute(
602
+ _SUBSCRIPTION_ANCHOR_GROUPED_SQL.format(account=account), params,
603
+ ).fetchall()
604
+
605
+
507
606
  def _compute_subscription_weeks(
508
607
  conn: sqlite3.Connection,
509
608
  range_start: dt.datetime,
@@ -545,23 +644,7 @@ def _compute_subscription_weeks(
545
644
  """
546
645
  # Case A: snapshots exist. Account scoping (#341): a real key filters the
547
646
  # reset anchors to that account; None is the merged (all-accounts) read.
548
- acct_pred = "" if account_key is None else " AND account_key = ?"
549
- acct_params: tuple = () if account_key is None else (account_key,)
550
- snap_rows = conn.execute(
551
- "SELECT "
552
- " MIN(week_start_at) AS week_start_at, "
553
- " MIN(week_end_at) AS week_end_at, "
554
- " week_start_date, "
555
- " MIN(week_end_date) AS week_end_date "
556
- "FROM weekly_usage_snapshots "
557
- "WHERE week_start_at IS NOT NULL "
558
- " AND week_end_at IS NOT NULL "
559
- " AND week_start_date IS NOT NULL "
560
- f" {acct_pred} "
561
- "GROUP BY week_start_date "
562
- "ORDER BY MIN(week_start_at) ASC",
563
- acct_params,
564
- ).fetchall()
647
+ snap_rows = _subscription_anchor_rows(conn, account_key)
565
648
 
566
649
  weeks: list[SubWeek] = []
567
650
 
@@ -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