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
@@ -764,6 +764,7 @@ from _lib_snapshot_cache import (
764
764
  _reset_sig as _snapshot_reset_sig,
765
765
  )
766
766
  import _lib_tick_stats
767
+ import _lib_write_io
767
768
 
768
769
 
769
770
  # === F22a: count the silent Group A cache-open failures (#583 S1 §1.6) =====
@@ -2585,9 +2586,9 @@ def _dashboard_maybe_prune_retention() -> None:
2585
2586
  crash the sync thread)."""
2586
2587
  try:
2587
2588
  c = sys.modules["cctally"]
2588
- from _cctally_config import resolve_retention_days
2589
+ from _cctally_config import resolve_prune_retention_days
2589
2590
  import _lib_conversation_retention as retention
2590
- retention_days = resolve_retention_days(c.load_config())
2591
+ retention_days = resolve_prune_retention_days(c.load_config())
2591
2592
  if retention_days <= 0:
2592
2593
  return
2593
2594
  conn = c.open_conversations_db(attach_cache=False)
@@ -2855,11 +2856,13 @@ def _conversation_sync_loop(
2855
2856
  while not stop.is_set():
2856
2857
  t0 = monotonic()
2857
2858
  cpu0 = thread_time_ns()
2859
+ written = _lib_write_io.begin_interval("conversation")
2858
2860
  try:
2859
2861
  status = run_iteration() or "ok"
2860
2862
  except Exception: # noqa: BLE001 — the worker must outlive one bad pass
2861
2863
  _log_sync_iteration_failure()
2862
2864
  status = "error"
2865
+ observation = _lib_write_io.end_interval(written)
2863
2866
  work = max(0.0, monotonic() - t0)
2864
2867
  cpu_ns = max(0, thread_time_ns() - cpu0)
2865
2868
  seq += 1
@@ -2882,6 +2885,12 @@ def _conversation_sync_loop(
2882
2885
  "codex", "not_observed"),
2883
2886
  claude_files=getattr(status, "files", {}).get("claude", 0),
2884
2887
  codex_files=getattr(status, "files", {}).get("codex", 0),
2888
+ # #901 §5.5: the pass's process write delta. It includes the
2889
+ # main loop's concurrent work, which `write_overlap` flags; it
2890
+ # is never summed with a tick's.
2891
+ process_write_bytes=observation.process_write_bytes,
2892
+ write_status=observation.status,
2893
+ write_overlap=observation.overlap,
2885
2894
  )
2886
2895
  deadline = _conversation_next_deadline(t0, interval, work)
2887
2896
  remaining = deadline - monotonic()
@@ -6894,6 +6903,38 @@ def _assemble_projects_via_cache(
6894
6903
  return buckets, total_cost_by_week, key_by_bucket
6895
6904
 
6896
6905
 
6906
+ def _projects_week_usage_rows(
6907
+ conn: "sqlite3.Connection", since_date: str, end_date: str,
6908
+ ) -> list:
6909
+ """The anchored, non-NULL weekly readings of the projects window, oldest
6910
+ capture first: ``(week_start_date, week_start_at, weekly_percent,
6911
+ captured_at_utc, id)`` for every week date in ``[since_date, end_date)``.
6912
+
6913
+ The window holds ``weeks_back`` weeks of status-line readings, which is
6914
+ not a bound on a sorter, so the rows are ordered here rather than by an
6915
+ ``ORDER BY`` that built a temp b-tree over all of them on every build
6916
+ (#901 Q11). ``(captured_at_utc, id)`` is a total order — ``id`` is the
6917
+ primary key and ``captured_at_utc`` is NOT NULL TEXT, which Python
6918
+ compares in the same code-point order as SQLite's BINARY collation — so
6919
+ the result is exactly the former ``ORDER BY captured_at_utc ASC, id ASC``.
6920
+ """
6921
+ rows = conn.execute(
6922
+ " SELECT week_start_date, week_start_at, weekly_percent,"
6923
+ " captured_at_utc, id"
6924
+ " FROM weekly_usage_snapshots"
6925
+ # Held rows excluded: these rows feed `latest_usage_by_segment`, which
6926
+ # is a weekly read, and the pre-fetch must select the same population
6927
+ # the reducer would select for itself (#769 S11, #824).
6928
+ " WHERE week_start_date >= ? AND week_start_date < ?"
6929
+ " AND date(week_start_date) IS NOT NULL"
6930
+ " AND weekly_percent IS NOT NULL"
6931
+ + _cctally_core.weekly_held_exclusion(conn),
6932
+ (since_date, end_date),
6933
+ ).fetchall()
6934
+ rows.sort(key=lambda row: (row[3], row[4]))
6935
+ return rows
6936
+
6937
+
6897
6938
  def _build_projects_envelope(
6898
6939
  conn: "sqlite3.Connection",
6899
6940
  *,
@@ -7165,24 +7206,8 @@ def _build_projects_envelope(
7165
7206
  # valid rendered-week row (#620 S1 A3).
7166
7207
  weekly_pct_by_week: dict[dt.datetime, float] = {}
7167
7208
  try:
7168
- cur = conn.execute(
7169
- " SELECT week_start_date, week_start_at, weekly_percent,"
7170
- " captured_at_utc, id"
7171
- " FROM weekly_usage_snapshots"
7172
- # Held rows excluded: these rows feed `latest_usage_by_segment`,
7173
- # which is a weekly read, and the pre-fetch must select the same
7174
- # population the reducer would select for itself (#769 S11, #824).
7175
- " WHERE week_start_date >= ? AND week_start_date < ?"
7176
- " AND date(week_start_date) IS NOT NULL"
7177
- " AND weekly_percent IS NOT NULL"
7178
- + _cctally_core.weekly_held_exclusion(conn) +
7179
- " ORDER BY captured_at_utc ASC, id ASC",
7180
- (
7181
- since_dt.date().isoformat(),
7182
- cw_end.date().isoformat(),
7183
- ),
7184
- )
7185
- rows = cur.fetchall()
7209
+ rows = _projects_week_usage_rows(
7210
+ conn, since_dt.date().isoformat(), cw_end.date().isoformat())
7186
7211
  except sqlite3.OperationalError:
7187
7212
  # No weekly_usage_snapshots table — leaves attributed_pct = None
7188
7213
  # throughout (acceptable per spec §2.7).
@@ -8086,6 +8111,15 @@ class _DiagnosisSelectorError(Exception):
8086
8111
 
8087
8112
 
8088
8113
  # ── /api/debug/backend on-demand cache-state helpers (issue #276, Session A) ──
8114
+
8115
+ def _debug_write_io() -> dict:
8116
+ """`writeIo` for the debug surfaces (#901 §5.5). A telemetry fault never
8117
+ takes the diagnostic down, and is never reported as a zero."""
8118
+ try:
8119
+ return _lib_write_io.write_io_payload()
8120
+ except Exception: # noqa: BLE001 — diagnostics fail closed
8121
+ return {"status": "unavailable", "reason": "counter_error"}
8122
+
8089
8123
  # All read-only, cheap, and privacy-safe: they leak ONLY row counts, signature
8090
8124
  # legs (ints/tuples), pending-flag names, and the tool version — never prompt /
8091
8125
  # prose / paths. Computed on demand so cache_state is available even with
@@ -8494,6 +8528,7 @@ _GET_ROUTES = (
8494
8528
  ("exact", "/api/diagnosis", "_handle_get_diagnosis",
8495
8529
  ("scope", "endpoint.diagnosis"), False),
8496
8530
  ("exact", "/api/debug/backend", "_handle_get_debug_backend", None, False),
8531
+ ("exact", "/api/debug/backend/write-io", "_handle_get_debug_backend_write_io", None, False),
8497
8532
  ("exact", "/api/conversations/facets", "_handle_get_conversations_facets",
8498
8533
  ("scope", "endpoint.conversations_facets"), False),
8499
8534
  ("exact", "/api/conversations", "_handle_get_conversations",
@@ -9224,11 +9259,26 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9224
9259
  # stats failure reported as a cache one sends the user to
9225
9260
  # `cctally cache-sync --rebuild`.
9226
9261
  "stats_faults": stats_faults,
9262
+ # #901 §5.5: kernel-accounted process writes, the trailing
9263
+ # five-minute statistic and its budget verdict. Additive camelCase.
9264
+ "writeIo": _debug_write_io(),
9227
9265
  }
9228
9266
  if body["phases"] is None:
9229
9267
  body["note"] = "tracing_disabled"
9230
9268
  self._respond_json(200, body)
9231
9269
 
9270
+ def _handle_get_debug_backend_write_io(self) -> None:
9271
+ """GET ``/api/debug/backend/write-io`` — the ``writeIo`` object alone
9272
+ (#901 §5.6): what ``cctally doctor`` reads from a running dashboard
9273
+ inside its bounded timeout, without ``/api/debug/backend``'s
9274
+ on-demand cache-table counts. Same gates, same order: the bearer
9275
+ (``_require_api_auth``, before dispatch), then the loopback TCP peer
9276
+ and the IP-literal ``Host``. No bypass exists."""
9277
+ if not self._require_debug_backend_allowed():
9278
+ return
9279
+ self._respond_json(200, {"schemaVersion": 1,
9280
+ "writeIo": _debug_write_io()})
9281
+
9232
9282
  def _handle_post_debug_backend_trace(self) -> None:
9233
9283
  """POST ``/api/debug/backend/trace`` — arm the deep phase trace (§3.2).
9234
9284
 
@@ -12485,6 +12535,13 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12485
12535
  **_make_sync_loop_collaborators(ref=ref, hub=hub),
12486
12536
  )
12487
12537
 
12538
+ # #901 §5.6: this process's write-telemetry identity, registered before
12539
+ # the first build so the in-process doctor reads its own snapshot from the
12540
+ # first tick; the bound port replaces the requested one once known.
12541
+ write_instance_id = _lib_write_io.new_instance_id()
12542
+ _lib_write_io.register_local_instance(
12543
+ instance_id=write_instance_id, host=args.host,
12544
+ port=_resolve_dashboard_port(args.port))
12488
12545
  sync_thread = (
12489
12546
  None if args.no_sync
12490
12547
  else _DashboardSyncThread(
@@ -12566,6 +12623,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12566
12623
  http_thread = threading.Thread(target=srv.serve_forever, daemon=True,
12567
12624
  name="dashboard-http")
12568
12625
  http_thread.start()
12626
+ # The owner-readable descriptor `cctally doctor` discovers this instance
12627
+ # through: written once now, removed at clean shutdown. A crash leaves it
12628
+ # for the next startup to remove; doctor ignores a dead or reused pid's.
12629
+ _lib_write_io.register_local_instance(
12630
+ instance_id=write_instance_id, host=bind_host, port=bind_port)
12631
+ write_descriptor_written = False
12632
+ try:
12633
+ _lib_write_io.remove_stale_descriptors(_cctally_core.APP_DIR)
12634
+ _lib_write_io.write_instance_descriptor(
12635
+ _cctally_core.APP_DIR, instance_id=write_instance_id,
12636
+ host=bind_host, port=bind_port)
12637
+ write_descriptor_written = True
12638
+ except OSError as exc:
12639
+ eprint(f"[dashboard] instance descriptor not written ({exc})")
12569
12640
 
12570
12641
  # Surface any pending migration errors at server startup. Browser is a
12571
12642
  # separate UI surface (out of scope for this fix); this prints once to
@@ -12609,6 +12680,13 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12609
12680
  finally:
12610
12681
  # First, so a shutdown can never be followed by an automatic restart.
12611
12682
  _stop_install_watch()
12683
+ if write_descriptor_written:
12684
+ try:
12685
+ _lib_write_io.remove_instance_descriptor(
12686
+ _cctally_core.APP_DIR, write_instance_id)
12687
+ except OSError:
12688
+ pass
12689
+ _lib_write_io.clear_local_instance()
12612
12690
  if sync_thread is not None:
12613
12691
  sync_thread.stop()
12614
12692
  conversation_sync_stop.set()
@@ -65,6 +65,7 @@ from _lib_dashboard_json import (
65
65
  encode_dashboard_json_bytes_capped,
66
66
  )
67
67
  from _lib_retained_size import retained_size_bytes
68
+ import _lib_codex_rollout_container as _rc
68
69
 
69
70
 
70
71
  def _advance_live_tail_accounting(handler, changed, sync, label):
@@ -443,40 +444,280 @@ def _cached_file_sigs(conn, paths):
443
444
  return out
444
445
 
445
446
 
447
+ #: The cursor columns a Codex live-tail signature is computed from (#968 5.9).
448
+ _CODEX_WATCH_SIG_COLUMNS = (
449
+ "path, size_bytes, container_kind, container_size_bytes, mtime_ns, "
450
+ "device_id, inode, pending_container_fingerprint")
451
+
452
+
453
+ def _codex_watch_kernel():
454
+ return sys.modules["cctally"]._load_sibling("_lib_codex_conversation_watch")
455
+
456
+
457
+ def _codex_acknowledged_sig(row):
458
+ """The live-tail signature one ``codex_conversation_source_files`` row
459
+ acknowledges (#968 spec 5.9).
460
+
461
+ A container already recorded as pending (or unverified) work is
462
+ acknowledged at its recorded fingerprint: the verification executor owns
463
+ it, and rescheduling it on every poll is the non-convergence I9 forbids.
464
+ Otherwise the accepted container's signature: ``("plain", size)`` for
465
+ plain, which is today's committed size, or the container identity for a
466
+ zstd container.
467
+ """
468
+ _path, size, kind, csize, mtime_ns, device, inode, pending = row
469
+ if pending:
470
+ fingerprint = _rc.ContainerFingerprint.from_json(pending)
471
+ if fingerprint is not None:
472
+ return fingerprint.watch_sig()
473
+ return _codex_watch_kernel().committed_watch_sig(
474
+ kind, size, csize, mtime_ns, device, inode)
475
+
476
+
446
477
  def _codex_cached_file_sigs(conn, paths):
447
- """{path: size_bytes} from ``codex_conversation_source_files`` for the given paths — the
448
- Codex analogue of ``_cached_file_sigs`` (spec §5.2). Size-only, matching the
449
- watch kernel's size-only signature and ``sync_codex_cache``'s size-only
450
- delta. Paths with no row are absent → treated as changed. Baselines the
451
- Codex live-tail watch against the cache's own committed cursor so growth
452
- during an ingest is re-detected next cycle (the per-path committed-cursor
453
- invariant — the S6 physical mutation sequence is at most an extra
454
- certificate, never this cursor's replacement)."""
478
+ """{path: acknowledged signature} from ``codex_conversation_source_files``
479
+ for the given paths — the Codex analogue of ``_cached_file_sigs`` (spec
480
+ §5.2), in the container-aware shape the Codex observation seam returns
481
+ (#968 spec 5.9). Paths with no row are absent → treated as changed.
482
+ Baselines the Codex live-tail watch against the cache's own committed
483
+ cursor so growth during an ingest is re-detected next cycle (the per-path
484
+ committed-cursor invariant — the S6 physical mutation sequence is at most
485
+ an extra certificate, never this cursor's replacement)."""
455
486
  out = {}
456
487
  if not paths:
457
488
  return out
458
489
  placeholders = ",".join("?" for _ in paths)
459
490
  try:
460
491
  rows = conn.execute(
461
- f"SELECT path, size_bytes FROM codex_conversation_source_files "
492
+ f"SELECT {_CODEX_WATCH_SIG_COLUMNS} "
493
+ f"FROM codex_conversation_source_files "
462
494
  f"WHERE path IN ({placeholders})", list(paths)).fetchall()
463
495
  except sqlite3.OperationalError:
464
496
  return out
465
- for p, size in rows:
466
- out[p] = size
497
+ for row in rows:
498
+ out[row[0]] = _codex_acknowledged_sig(row)
467
499
  return out
468
500
 
469
501
 
470
502
  def _codex_all_committed_sizes(conn):
471
- """{path: size_bytes} for EVERY tracked Codex file — fed to the frontier as
472
- both the ``known_paths`` diff set and the ``committed_sizes`` growth
473
- baseline. A plain SELECT (no lock), so it never widens the SSE lock scope."""
503
+ """{path: acknowledged signature} for EVERY tracked Codex file — fed to the
504
+ frontier as both the ``known_paths`` diff set and the ``committed_sizes``
505
+ growth baseline, which the kernel compares with ``codex_sig_advanced``
506
+ (#968 spec 5.9). A plain SELECT (no lock), so it never widens the SSE lock
507
+ scope."""
474
508
  try:
475
509
  rows = conn.execute(
476
- "SELECT path, size_bytes FROM codex_conversation_source_files").fetchall()
510
+ f"SELECT {_CODEX_WATCH_SIG_COLUMNS} "
511
+ "FROM codex_conversation_source_files").fetchall()
477
512
  except sqlite3.OperationalError:
478
513
  return {}
479
- return {p: size for (p, size) in rows}
514
+ return {row[0]: _codex_acknowledged_sig(row) for row in rows}
515
+
516
+
517
+ def _codex_seal_token(conn):
518
+ """The publication barrier's seal token (Task 11's ``SEAL_SEQ_KEY``), read
519
+ through the attached cache; ``None`` when unreadable, which never re-arms
520
+ anything."""
521
+ barrier = sys.modules["cctally"]._load_sibling(
522
+ "_lib_codex_publication_barrier")
523
+ try:
524
+ row = conn.execute(
525
+ "SELECT value FROM cache_db.cache_meta WHERE key=?",
526
+ (barrier.SEAL_SEQ_KEY,)).fetchone()
527
+ except sqlite3.Error:
528
+ return None
529
+ return None if row is None else row[0]
530
+
531
+
532
+ @dataclasses.dataclass
533
+ class _CodexWatchIngest:
534
+ """Stream-local observations, separate from the durable ingest result.
535
+
536
+ ``stats.targeted_clean`` remains authoritative for ingest completion. A
537
+ held container can acknowledge its observed signature without certifying
538
+ any accounting or transcript work as complete (T12-INTEG01).
539
+ """
540
+
541
+ stats: object
542
+ accounting_blocked: bool = False
543
+ acknowledged: dict = dataclasses.field(default_factory=dict)
544
+ observation_visible: bool = False
545
+
546
+ def __getattr__(self, name):
547
+ return getattr(self.stats, name)
548
+
549
+
550
+ def _codex_observation_hold_only(stats, *, executor_owned=False):
551
+ """Recognize container holds without overlooking an actual ingest loss.
552
+
553
+ This is only admission to a stream-local observation acknowledgment,
554
+ never a replacement for ``targeted_clean`` or a frontier certificate.
555
+ Unknown skipped reasons and every failed/contended ingest stay dirty.
556
+ """
557
+ if any(getattr(stats, name, False) for name in (
558
+ "lock_contended", "deferred_reason", "files_failed",
559
+ "files_deferred_torn", "files_deferred_reset", "maintenance_failed",
560
+ "budget_exhausted", "drain_yielded", "rebuild_refused")):
561
+ return False
562
+ if getattr(stats, "barrier_retry", False) and not executor_owned:
563
+ return False
564
+ skipped = getattr(stats, "container_skipped", {})
565
+ return (bool(getattr(stats, "container_pending", 0) or any(skipped.values()))
566
+ and set(skipped) <= {
567
+ "decoder-unavailable", "decode-error", "read-error",
568
+ "unstable-stat", "verification-pending", "verification-unverified"})
569
+
570
+
571
+ def _codex_watch_probes(conn):
572
+ """Container observations and committed cursors for both Codex polls.
573
+
574
+ Pending fingerprints acknowledge executor-owned work. A container held
575
+ without a recorded fingerprint acknowledges the pre-ingest observation,
576
+ so replacement during ingest is still detected. These are observation
577
+ acknowledgments only: durable pending work remains dirty.
578
+
579
+ A scoped accounting hold cannot run transcript ingest. It acknowledges
580
+ the observation quietly and retries once accounting accepts that exact
581
+ container. Failed or contended accounting remains unacknowledged. Plain
582
+ barrier-deferred files wait at their pre-ingest observation until a real
583
+ file change or a readable new seal token re-arms them once.
584
+ """
585
+ observed: dict = {}
586
+ deferred: dict = {} # path -> (pre-ingest signature, seal token)
587
+ accounting_wait: dict = {} # path -> observed container awaiting accounting
588
+ rearmed: set = set()
589
+
590
+ def accounting_accepted(path, sig):
591
+ try:
592
+ row = conn.execute(
593
+ f"SELECT {_CODEX_WATCH_SIG_COLUMNS} "
594
+ "FROM cache_db.codex_session_files "
595
+ "WHERE path=? AND container_state='accepted'", (path,)).fetchone()
596
+ except sqlite3.Error:
597
+ return False
598
+ return row is not None and _codex_acknowledged_sig(row) == sig
599
+
600
+ def executor_owns_observations(paths):
601
+ # Publication-unit planning can encounter the same pending container
602
+ # as phase one and set barrier_retry. Acknowledge only when EVERY
603
+ # changed path has durable executor ownership of the exact observation;
604
+ # a failed plain/accepted contributor must keep the whole poll retrying.
605
+ if not paths:
606
+ return False
607
+ marks = ",".join("?" for _ in paths)
608
+ try:
609
+ rows = conn.execute(
610
+ "SELECT path,pending_container_fingerprint "
611
+ "FROM codex_conversation_source_files "
612
+ f"WHERE path IN ({marks}) AND container_state='pending'",
613
+ list(paths)).fetchall()
614
+ except sqlite3.Error:
615
+ return False
616
+ if len(rows) != len(paths):
617
+ return False
618
+ for path, pending in rows:
619
+ fingerprint = _rc.ContainerFingerprint.from_json(pending)
620
+ before = observed.get(path)
621
+ if (fingerprint is None or before is None
622
+ or before[0] != _rc.ZSTD
623
+ or fingerprint.watch_sig() != before):
624
+ return False
625
+ return True
626
+
627
+ def stat_fn(path):
628
+ sig = _rc.observe_watch_sig(path)
629
+ observed[path] = sig
630
+ waiting = accounting_wait.get(path)
631
+ if waiting is not None:
632
+ if sig != waiting:
633
+ del accounting_wait[path]
634
+ elif accounting_accepted(path, sig):
635
+ del accounting_wait[path]
636
+ rearmed.add(path)
637
+ held = deferred.get(path)
638
+ if held is not None:
639
+ token = _codex_seal_token(conn)
640
+ if sig != held[0] or (token is not None and token != held[1]):
641
+ del deferred[path]
642
+ if sig == held[0]:
643
+ rearmed.add(path)
644
+ if path in rearmed and sig is not None:
645
+ return ("rearm",) + tuple(sig)
646
+ return sig
647
+
648
+ def committed_sig(path):
649
+ rearmed.discard(path) # reached only after a clean ingest
650
+ held = deferred.get(path)
651
+ if held is not None:
652
+ return held[0]
653
+ acknowledged = _codex_cached_file_sigs(conn, [path]).get(path)
654
+ before = observed.get(path)
655
+ if (before is not None and before[0] == _rc.ZSTD
656
+ and acknowledged != before):
657
+ return before
658
+ return acknowledged
659
+
660
+ def settle_ingest(ingest):
661
+ def settled(changed):
662
+ token = _codex_seal_token(conn)
663
+ result = ingest(changed)
664
+ if isinstance(result, _CodexWatchIngest):
665
+ stats = result.stats
666
+ else:
667
+ stats = result
668
+ result = _CodexWatchIngest(stats)
669
+ clean = getattr(stats, "targeted_clean", False)
670
+ executor_owned = (
671
+ getattr(stats, "barrier_retry", False)
672
+ and not result.accounting_blocked
673
+ and not getattr(stats, "barrier_files_deferred", 0)
674
+ and executor_owns_observations(changed))
675
+ held_only = _codex_observation_hold_only(
676
+ stats, executor_owned=executor_owned)
677
+ if not clean and not held_only:
678
+ return result
679
+ acknowledged = _codex_cached_file_sigs(conn, list(changed))
680
+ plain_deferred = 0
681
+ for path in changed:
682
+ before = observed.get(path)
683
+ if before is None:
684
+ continue
685
+ if before[0] == _rc.ZSTD and held_only:
686
+ # The pass actually observed this container, even if the
687
+ # verification executor still owes its durable work.
688
+ result.acknowledged[path] = before
689
+ rearmed.discard(path)
690
+ if result.accounting_blocked:
691
+ accounting_wait[path] = before
692
+ elif (not result.accounting_blocked
693
+ and getattr(stats, "barrier_files_deferred", 0)
694
+ and before[0] != _rc.ZSTD
695
+ and _codex_watch_kernel().codex_sig_advanced(
696
+ before, acknowledged.get(path))):
697
+ # Phase-one deferrals can publish in phase two. Only a
698
+ # committed cursor behind the probe still owes this work;
699
+ # ingestion may already have consumed a later append.
700
+ deferred[path] = (before, token)
701
+ result.acknowledged[path] = before
702
+ plain_deferred += 1
703
+ elif not result.accounting_blocked and not clean:
704
+ # A mixed pass may have committed a plain contributor
705
+ # while holding a container. Keep its committed cursor.
706
+ sig = acknowledged.get(path)
707
+ if sig is not None:
708
+ result.acknowledged[path] = sig
709
+ if (plain_deferred == len(changed)
710
+ and not getattr(stats, "targeted_visible", False)):
711
+ stats.targeted_visible = False
712
+ result.observation_visible = (
713
+ not clean and bool(result.acknowledged)
714
+ and not result.accounting_blocked
715
+ and not getattr(stats, "barrier_retry", False)
716
+ and bool(getattr(stats, "targeted_visible", True)))
717
+ return result
718
+ return settled
719
+
720
+ return stat_fn, committed_sig, settle_ingest
480
721
 
481
722
 
482
723
  def _codex_classified_paths(conn, paths):
@@ -1220,7 +1461,7 @@ def _send_sse_headers(handler) -> None:
1220
1461
 
1221
1462
  def _run_conversation_events_stream(
1222
1463
  handler, conn, *, passive, tail_data, resolve, ingest, cached_sigs,
1223
- discovery_step=None,
1464
+ discovery_step=None, stat_fn=None, committed_sig=None,
1224
1465
  ) -> None:
1225
1466
  """Shared per-conversation live-tail SSE loop (spec §5.2), driving both the
1226
1467
  bare (``sessionId``) and qualified (``conversationKey``) streams from ONE
@@ -1236,9 +1477,18 @@ def _run_conversation_events_stream(
1236
1477
  ~10-cycle Codex child-discovery frontier (``None`` for Claude — a bare or
1237
1478
  qualified-Claude stream never runs it). The cache lock is held ONLY inside
1238
1479
  ``ingest`` / ``discovery_step``, never across a sleep (the #297 WAL
1239
- discipline)."""
1480
+ discipline).
1481
+
1482
+ ``stat_fn`` / ``committed_sig`` are the provider's observation seam
1483
+ (#968 spec 5.9): Codex passes ``_codex_watch_probes`` so both poll sites
1484
+ compare one container-aware signature shape; ``None`` keeps the kernel's
1485
+ literal-path size probe and the committed-cursor lookup through
1486
+ ``cached_sigs`` (Claude, unchanged).
1487
+ """
1240
1488
  import time as _time
1241
1489
  watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
1490
+ probe = stat_fn or watch.file_sig
1491
+ committed = committed_sig or (lambda p: cached_sigs([p]).get(p))
1242
1492
  tail_frame = (
1243
1493
  "event: tail\ndata: " + encode_dashboard_json(tail_data) + "\n\n"
1244
1494
  ).encode("utf-8")
@@ -1286,12 +1536,26 @@ def _run_conversation_events_stream(
1286
1536
  if _request_peer_gone(handler, timeout=_LIVE_TAIL_POLL_INTERVAL):
1287
1537
  return
1288
1538
  cycles += 1
1289
- changed = watch.changed_paths(files, seen)
1539
+ changed = watch.changed_paths(files, seen, probe)
1290
1540
  if changed:
1291
1541
  _time.sleep(_LIVE_TAIL_DEBOUNCE)
1542
+ observation = None
1543
+
1544
+ def poll_ingest(changed):
1545
+ nonlocal observation
1546
+ observation = ingest(changed)
1547
+ return observation
1548
+
1292
1549
  new_seen, emitted = watch.watch_step(
1293
- files, seen, ingest_fn=ingest,
1294
- committed_sig_fn=lambda p: cached_sigs([p]).get(p))
1550
+ files, seen, stat_fn=probe, ingest_fn=poll_ingest,
1551
+ committed_sig_fn=committed)
1552
+ if isinstance(observation, _CodexWatchIngest):
1553
+ # A held container was observed, not successfully ingested.
1554
+ # Keep that distinction out of the generic watch kernel and
1555
+ # out of every durable cursor/certification decision.
1556
+ new_seen = dict(new_seen)
1557
+ new_seen.update(observation.acknowledged)
1558
+ emitted = emitted or observation.observation_visible
1295
1559
  seen = new_seen
1296
1560
  if emitted:
1297
1561
  handler.wfile.write(tail_frame)
@@ -1643,7 +1907,8 @@ def _qualified_conversation_events(
1643
1907
  finally:
1644
1908
  accounting.close()
1645
1909
  if not accounting_stats.targeted_clean:
1646
- return accounting_stats
1910
+ return _CodexWatchIngest(
1911
+ accounting_stats, accounting_blocked=True)
1647
1912
  writer = sys.modules["_cctally_dashboard"].open_conversations_db()
1648
1913
  try:
1649
1914
  stats = sync_codex_conversations(writer, only_paths=set(changed))
@@ -1658,6 +1923,8 @@ def _qualified_conversation_events(
1658
1923
  return stats
1659
1924
 
1660
1925
  cached = lambda paths: _codex_cached_file_sigs(conn, paths)
1926
+ stat_fn, committed_sig, settle_ingest = _codex_watch_probes(conn)
1927
+ _ingest = settle_ingest(_ingest)
1661
1928
  discovery = (
1662
1929
  None if account_key is not None
1663
1930
  else _make_codex_discovery_step(handler, conn, key, cq_codex)
@@ -1694,6 +1961,7 @@ def _qualified_conversation_events(
1694
1961
  return stats
1695
1962
 
1696
1963
  cached = lambda paths: _cached_file_sigs(conn, paths)
1964
+ stat_fn = committed_sig = None
1697
1965
  discovery = None
1698
1966
 
1699
1967
  try:
@@ -1701,7 +1969,8 @@ def _qualified_conversation_events(
1701
1969
  handler, conn, passive=passive,
1702
1970
  tail_data={"conversationKey": key},
1703
1971
  resolve=_resolve, ingest=_ingest, cached_sigs=cached,
1704
- discovery_step=discovery)
1972
+ discovery_step=discovery, stat_fn=stat_fn,
1973
+ committed_sig=committed_sig)
1705
1974
  except (BrokenPipeError, ConnectionResetError,
1706
1975
  ConnectionAbortedError, socket.timeout):
1707
1976
  pass
@@ -96,6 +96,7 @@ from _cctally_dashboard_cache_report import _cache_report_snapshot_to_dict
96
96
  # copy was already exactly this — collapse to the single definition).
97
97
  _ensure_sibling_loaded("_lib_json_envelope")
98
98
  from _lib_json_envelope import _iso_z
99
+ from _lib_five_hour import latest_five_hour_reading
99
100
 
100
101
 
101
102
  def _model_breakdowns_to_models(model_breakdowns: list[dict[str, Any]],
@@ -235,9 +236,17 @@ def _select_current_block_for_envelope(
235
236
  # statement of that key. Its `week_start_at` is a byte copy of the
236
237
  # non-held row it was derived from, so scoping the post-reset lookup
237
238
  # below by it is unaffected.
239
+ #
240
+ # #825/#832 spec 4.7 revision 7: the current window is the newest five-hour
241
+ # READING's, through the one shared selection under the same as-of and
242
+ # account restrictions. A retired replica (its five-hour fields cleared in
243
+ # place by a five-hour credit) or a weekly-only reading can be the newest
244
+ # row and names no window. The newest row still supplies `week_start_at`
245
+ # for the post-reset lookup below: that is weekly evidence, selected
246
+ # independently.
238
247
  snap = conn.execute(
239
248
  """
240
- SELECT five_hour_window_key, week_start_at
249
+ SELECT week_start_at
241
250
  FROM weekly_usage_snapshots
242
251
  WHERE captured_at_utc <= ?
243
252
  AND (? IS NULL OR account_key = ?)
@@ -246,7 +255,11 @@ def _select_current_block_for_envelope(
246
255
  """,
247
256
  (_iso_z(now_utc), account_key, account_key),
248
257
  ).fetchone()
249
- if snap is None or snap["five_hour_window_key"] is None:
258
+ if snap is None:
259
+ return None
260
+ reading = latest_five_hour_reading(
261
+ conn, account_key=account_key, as_of_utc=_iso_z(now_utc))
262
+ if reading is None:
250
263
  return None
251
264
 
252
265
  # The account supplied by `_tui_build_current_week` is the owner of the
@@ -298,7 +311,7 @@ def _select_current_block_for_envelope(
298
311
  ORDER BY id ASC
299
312
  LIMIT 1
300
313
  """,
301
- (snap["five_hour_window_key"], account_key, account_key,
314
+ (reading.window_key, account_key, account_key,
302
315
  _iso_z(now_utc)),
303
316
  ).fetchone()
304
317
  if block is None: