cctally 1.117.2 → 1.118.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 +30 -0
  2. package/README.md +7 -3
  3. package/bin/_cctally_cache.py +2783 -175
  4. package/bin/_cctally_completion.py +106 -33
  5. package/bin/_cctally_core.py +69 -4
  6. package/bin/_cctally_dashboard.py +188 -13
  7. package/bin/_cctally_dashboard_conversation.py +5 -1
  8. package/bin/_cctally_dashboard_envelope.py +50 -19
  9. package/bin/_cctally_dashboard_sources.py +23 -1
  10. package/bin/_cctally_db.py +193 -153
  11. package/bin/_cctally_diagnosis_sources.py +63 -7
  12. package/bin/_cctally_doctor.py +865 -93
  13. package/bin/_cctally_five_hour.py +67 -50
  14. package/bin/_cctally_forecast.py +35 -13
  15. package/bin/_cctally_journal.py +186 -18
  16. package/bin/_cctally_milestone_history.py +859 -62
  17. package/bin/_cctally_oauth_samples.py +286 -15
  18. package/bin/_cctally_parser.py +2 -2
  19. package/bin/_cctally_quota.py +104 -12
  20. package/bin/_cctally_record.py +153 -29
  21. package/bin/_cctally_rederive.py +608 -68
  22. package/bin/_cctally_statusline.py +108 -81
  23. package/bin/_cctally_store.py +179 -38
  24. package/bin/_cctally_tui.py +892 -181
  25. package/bin/_cctally_update.py +32 -13
  26. package/bin/_lib_codex_conversation_query.py +52 -14
  27. package/bin/_lib_codex_drain_admission.py +224 -6
  28. package/bin/_lib_codex_metadata.py +39 -0
  29. package/bin/_lib_conversation_anon.py +15 -8
  30. package/bin/_lib_conversation_dispatch.py +7 -0
  31. package/bin/_lib_conversation_query.py +289 -70
  32. package/bin/_lib_dashboard_sources.py +37 -1
  33. package/bin/_lib_detector_access_registry.py +497 -26
  34. package/bin/_lib_doctor.py +85 -20
  35. package/bin/_lib_doctor_scan_index.py +321 -0
  36. package/bin/_lib_ingest_frontier.py +20 -1
  37. package/bin/_lib_oauth_samples.py +23 -7
  38. package/bin/_lib_pricing.py +146 -16
  39. package/bin/_lib_quota.py +19 -0
  40. package/bin/_lib_rederive.py +11 -6
  41. package/bin/_lib_retained_records.py +516 -0
  42. package/bin/_lib_snapshot_cache.py +10 -0
  43. package/bin/_lib_stale_proof.py +272 -43
  44. package/bin/_lib_stats_content_revision.py +831 -0
  45. package/bin/_lib_write_io.py +11 -4
  46. package/bin/cctally +4 -1
  47. package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
  48. package/dashboard/static/assets/{DoctorModal-B0DnU9d9.js → DoctorModal-D4QSq6uz.js} +1 -1
  49. package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
  50. package/dashboard/static/assets/{ProjectsDrillPanel-DSxXQSa-.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
  51. package/dashboard/static/assets/{SourceDetailModal-BleDpQqG.js → SourceDetailModal-NyyEfXmc.js} +1 -1
  52. package/dashboard/static/assets/{UpdateModal-CMqaAkSK.js → UpdateModal-CFLq9pT1.js} +1 -1
  53. package/dashboard/static/assets/{index-BjQV-0-D.js → index-BKTWH9JE.js} +2 -2
  54. package/dashboard/static/assets/{outlineNavigation-CO-VWBm7.js → outlineNavigation-BnGUwCS3.js} +3 -3
  55. package/dashboard/static/dashboard.html +1 -1
  56. package/package.json +4 -1
  57. package/dashboard/static/assets/ConversationsView-oqNsWzWV.js +0 -72
  58. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +0 -1
@@ -292,8 +292,8 @@ def cmd_statusline(args: argparse.Namespace) -> int:
292
292
  # writer (spec 2026-07-17-usage-statusline-fallback). This is a pure
293
293
  # side effect that runs AFTER the line is printed and is FULLY guarded:
294
294
  # persistence must NEVER break or slow rendering, so the whole call is
295
- # wrapped here (the feeder itself forks detached + swallows its own
296
- # errors, but this is belt-and-suspenders). Absence of rate_limits or a
295
+ # wrapped here (the feeder itself dispatches a detached worker + swallows
296
+ # its own errors, but this is belt-and-suspenders). Absence of rate_limits or a
297
297
  # lost persist lock degrades to a clean no-op; the authoritative OAuth
298
298
  # confirmation below covers the missing/stale-payload case.
299
299
  try:
@@ -458,7 +458,7 @@ def _try_acquire_persist_lock() -> "int | None":
458
458
 
459
459
  Returns the open fd on success; ``None`` when another render already
460
460
  holds it (EWOULDBLOCK) or the lock file can't be opened/locked — in
461
- which case the caller renders without forking."""
461
+ which case the caller renders without dispatching."""
462
462
  try:
463
463
  _durable_mkdir(_bound_root())
464
464
  fd = os.open(_selected_lock_path(), os.O_WRONLY | os.O_CREAT, 0o600)
@@ -1009,7 +1009,24 @@ def _db_file_fingerprint() -> dict[str, "_candidates.FileFingerprint | None"]:
1009
1009
 
1010
1010
 
1011
1011
  def _epoch_from_iso(value: object) -> int:
1012
- return int(_cctally_core.parse_iso_datetime(str(value), "statusline projection").timestamp())
1012
+ """The epoch second of a stored projection timestamp.
1013
+
1014
+ #1064: an aware value's instant does not depend on the host zone, so it
1015
+ skips ``parse_iso_datetime``'s final conversion to local time. That is
1016
+ one libc local-time call per value, about 60 µs in a bare-fork child on
1017
+ macOS, and the projection sorts every snapshot row by it: the hook's
1018
+ OAuth publication held the selected state 4.3 s instead of 1.6 s. A
1019
+ naive, unparsable or edge-of-range value takes the shared parser
1020
+ unchanged, so every result and every error stays the same.
1021
+ """
1022
+ text = str(value) # once, as before: the fallback reuses the same string
1023
+ try:
1024
+ parsed = dt.datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
1025
+ except ValueError:
1026
+ parsed = None
1027
+ if parsed is not None and parsed.tzinfo is not None and 1 < parsed.year < 9999:
1028
+ return int(parsed.timestamp())
1029
+ return int(_cctally_core.parse_iso_datetime(text, "statusline projection").timestamp())
1013
1030
 
1014
1031
 
1015
1032
  def _statusline_active_account() -> "str | None":
@@ -1341,6 +1358,21 @@ class _StaleInputs:
1341
1358
  five_credits: tuple = ()
1342
1359
  five_samples: tuple = ()
1343
1360
 
1361
+ # #1064 (spec 5.1.5): each sample tuple is prepared once, on its first
1362
+ # kernel use, and every kernel call that reads it passes this view. The
1363
+ # view lives exactly as long as this snapshot (`dataclasses.replace` and
1364
+ # a new classification make a new owner); the tuples stay untouched for
1365
+ # every other reader.
1366
+ @functools.cached_property
1367
+ def sample_view(self):
1368
+ import _lib_stale_proof
1369
+ return _lib_stale_proof.prepare(self.samples)
1370
+
1371
+ @functools.cached_property
1372
+ def five_sample_view(self):
1373
+ import _lib_stale_proof
1374
+ return _lib_stale_proof.prepare(self.five_samples)
1375
+
1344
1376
 
1345
1377
  #: #797 spec 4.4: ``(samples_after, receipts_held, drop_work)`` for the
1346
1378
  #: partition the detached reducer is working on — the earliest instant any
@@ -2895,6 +2927,18 @@ class _StaleCache:
2895
2927
  #: an eligible one and every render dispatches the detached reducer.
2896
2928
  refused: frozenset = frozenset()
2897
2929
 
2930
+ # #1064 (spec 5.1.5): the render's kernel calls read the cached samples
2931
+ # through one view per decoded cache, exactly as `_StaleInputs` does.
2932
+ @functools.cached_property
2933
+ def sample_view(self):
2934
+ import _lib_stale_proof
2935
+ return _lib_stale_proof.prepare(self.samples)
2936
+
2937
+ @functools.cached_property
2938
+ def five_sample_view(self):
2939
+ import _lib_stale_proof
2940
+ return _lib_stale_proof.prepare(self.five_samples)
2941
+
2898
2942
 
2899
2943
  @dataclasses.dataclass(frozen=True)
2900
2944
  class AccountResult:
@@ -4252,9 +4296,11 @@ def _all_members(candidates) -> list:
4252
4296
  def _stale_axes(stale: "_StaleInputs") -> tuple:
4253
4297
  """Per axis: ``(axis, window, samples, credits, supports)``. The
4254
4298
  five-hour axis checks no supports (its accounted value is detector
4255
- state)."""
4256
- return (("sevenDay", stale.window, stale.samples, stale.credits, stale.supports),
4257
- ("fiveHour", stale.five_window, stale.five_samples, stale.five_credits, None))
4299
+ state). ``samples`` is the snapshot's prepared view (#1064), which every
4300
+ consumer passes to the kernel readers only."""
4301
+ return (("sevenDay", stale.window, stale.sample_view, stale.credits, stale.supports),
4302
+ ("fiveHour", stale.five_window, stale.five_sample_view, stale.five_credits,
4303
+ None))
4258
4304
 
4259
4305
 
4260
4306
  def _stale_member_facts(stale: "_StaleInputs | None", receipts, candidates):
@@ -4340,11 +4386,12 @@ def _completion_receipts(transition, classified, members):
4340
4386
  if accounted is None or window is None:
4341
4387
  continue
4342
4388
  # The five-hour axis earns by exact equality alone (path (i)); its
4343
- # samples feed refusal, never earning.
4389
+ # samples feed refusal, never earning. (#1064: the empty tuple of an
4390
+ # uncovered or five-hour earn holds no sample, so it needs no view.)
4344
4391
  earned.extend(_lib_stale_proof.earn_receipts(
4345
4392
  [m for m in _axis_members(members, axis) if m.sigma.window == window],
4346
4393
  accounted,
4347
- stale.samples if axis == "sevenDay" and stale.samples_covered else (),
4394
+ stale.sample_view if axis == "sevenDay" and stale.samples_covered else (),
4348
4395
  completed_at_utc=completed, index_identity=stale.index_identity))
4349
4396
  receipts = _lib_stale_proof.latest_per_token(
4350
4397
  _lib_stale_proof.merge_receipts(kept, earned))
@@ -4359,12 +4406,12 @@ def _completion_receipts(transition, classified, members):
4359
4406
  cache = _StaleCache(
4360
4407
  stale.window, stale.accounted,
4361
4408
  () if stale.window is None else _lib_stale_proof.compact_samples(
4362
- stale.samples, window=stale.window, floor_epoch=floor),
4409
+ stale.sample_view, window=stale.window, floor_epoch=floor),
4363
4410
  stale.samples_after, stale.samples_covered,
4364
4411
  stale.five_window,
4365
4412
  None if stale.five_accounted is None else stale.five_accounted.percent,
4366
4413
  five_samples=() if stale.five_window is None else _lib_stale_proof.compact_samples(
4367
- stale.five_samples, window=stale.five_window, floor_epoch=None,
4414
+ stale.five_sample_view, window=stale.five_window, floor_epoch=None,
4368
4415
  axis="fiveHour"),
4369
4416
  refused=refused)
4370
4417
  return receipts, cache
@@ -4474,7 +4521,7 @@ def _receipt_work(transition: _Transition, members) -> bool:
4474
4521
  if cache.samples_after is None or bound is None or cache.samples_after > bound:
4475
4522
  return True
4476
4523
  if _lib_stale_proof.earn_receipts(
4477
- [member], cache.accounted, cache.samples,
4524
+ [member], cache.accounted, cache.sample_view,
4478
4525
  completed_at_utc="1970-01-01T00:00:00.000000Z",
4479
4526
  index_identity="render"):
4480
4527
  return True
@@ -4578,8 +4625,8 @@ def _cached_drop_proof(cache, member) -> tuple:
4578
4625
 
4579
4626
  if cache is None or not cache.samples_covered:
4580
4627
  return False, None
4581
- window, samples = ((cache.window, cache.samples) if member.axis == "sevenDay"
4582
- else (cache.five_window, cache.five_samples))
4628
+ window, samples = ((cache.window, cache.sample_view) if member.axis == "sevenDay"
4629
+ else (cache.five_window, cache.five_sample_view))
4583
4630
  if window is None or member.window != window:
4584
4631
  return False, None
4585
4632
  bound = _lib_stale_proof.floor_microsecond(member.bound_utc)
@@ -4816,6 +4863,8 @@ def _plan_drop_facts(account_key: str, plan: dict, stale) -> "dict | None":
4816
4863
  bound.append(member)
4817
4864
  if len(bound) != 1:
4818
4865
  continue
4866
+ # #1064: ``samples`` is the snapshot's view; the empty tuple of a
4867
+ # window-less axis holds no sample, so it needs none.
4819
4868
  facts = _lib_stale_proof.refusal_facts(
4820
4869
  axis, [], samples if window is not None else (), (), (),
4821
4870
  index_identity=stale.index_identity,
@@ -5396,7 +5445,7 @@ def _publish_plan(transition, plan: dict, binding, classified: "_Classified", *,
5396
5445
 
5397
5446
  account_key = transition.account_key
5398
5447
  _crash_point("before-append")
5399
- # 6f mode wiring (P2 exit-code fix): the statusline persist fork's publish
5448
+ # 6f mode wiring (P2 exit-code fix): the statusline reducer's publish
5400
5449
  # step ingests OPPORTUNISTICALLY. A render tick must NEVER crash on a
5401
5450
  # derivation error — the opportunistic path swallows a cycle exception
5402
5451
  # (loud log only) instead of propagating.
@@ -8134,17 +8183,21 @@ def _spawn_continuation(kind: str, fd: int, argument: "str | None" = None) -> bo
8134
8183
  """Detach one continuation process (`cctally _statusline-continue`) that
8135
8184
  inherits ``fd``, the reservation it runs under. The parent's copy is
8136
8185
  closed here without unlocking, so the child alone holds it. The child
8137
- is told the store explicitly, so it can only act on this one."""
8138
- command = [sys.executable,
8139
- str(pathlib.Path(__file__).resolve().with_name("cctally")),
8140
- _CONTINUE_COMMAND, "--reservation-fd", str(fd)]
8141
- if kind == "wait":
8142
- command += ["--wait", str(argument)]
8143
- env = dict(os.environ)
8144
- env["CCTALLY_DATA_DIR"] = str(_cctally_core.APP_DIR)
8186
+ is told the store explicitly, so it can only act on this one. The
8187
+ command is built inside the ``try`` (#1064 review F4), so a failure to
8188
+ build it still closes ``fd`` and releases the reservation rather than
8189
+ leaving it with the caller (the render, whose OAuth child would inherit
8190
+ it)."""
8145
8191
  import subprocess
8146
8192
 
8147
8193
  try:
8194
+ command = [sys.executable,
8195
+ str(pathlib.Path(__file__).resolve().with_name("cctally")),
8196
+ _CONTINUE_COMMAND, "--reservation-fd", str(fd)]
8197
+ if kind == "wait":
8198
+ command += ["--wait", str(argument)]
8199
+ env = dict(os.environ)
8200
+ env["CCTALLY_DATA_DIR"] = str(_cctally_core.APP_DIR)
8148
8201
  subprocess.Popen(
8149
8202
  command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
8150
8203
  stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True,
@@ -8685,12 +8738,13 @@ def _preliminary_decision() -> "_candidates.ReductionDecision | None":
8685
8738
 
8686
8739
 
8687
8740
  def _finalize_wal_checkpoints() -> None:
8688
- """The W9 finalizer before a forked child's ``os._exit`` (#901 Q14).
8741
+ """The W9 finalizer before a detached process exits (#901 Q14).
8689
8742
 
8690
- `os._exit` skips `atexit`. The persist child records usage and the OAuth
8691
- child may too, and either can reach a cache sync that arms a store; the
8692
- finalizer then attempts one PASSIVE per armed store and closes the
8693
- keepers. With nothing armed it does nothing."""
8743
+ `os._exit` skips `atexit`. The continuation worker records usage (it runs
8744
+ this before returning from `_statusline-continue`) and the OAuth child,
8745
+ which leaves through `os._exit`, may too; either can reach a cache sync
8746
+ that arms a store. The finalizer then attempts one PASSIVE per armed
8747
+ store and closes the keepers. With nothing armed it does nothing."""
8694
8748
  try:
8695
8749
  import _lib_wal_checkpoint
8696
8750
 
@@ -8699,55 +8753,22 @@ def _finalize_wal_checkpoints() -> None:
8699
8753
  pass
8700
8754
 
8701
8755
 
8702
- def _fork_persist(parent_lock_fd: int, reservation_fd: int) -> None:
8703
- """Run the revalidated spool reducer in a detached serialized child.
8704
-
8705
- Spec 4.3 revision 5: the render's dispatch goes through the ONE reducer
8706
- reservation, ``reservation_fd``, which the child inherits and runs the
8707
- continuation worker under — the pass under the selected-state lock it
8708
- takes once the render releases it, the dashboard nudge after that lock
8709
- is released (#583 S2), the hand-off of what it could not finish (a
8710
- dependency waiter per held lock, a frontier wake, the epoch rebuild no
8711
- worker owns) and the final release check — so a render's child and a
8712
- dispatched worker never overlap. The parent's copy is closed without
8713
- unlocking."""
8714
- try:
8715
- pid = os.fork()
8716
- except OSError:
8717
- _release_reservation(reservation_fd)
8718
- return
8719
- if pid > 0:
8720
- try:
8721
- os.close(reservation_fd)
8722
- except OSError:
8723
- pass
8724
- return
8756
+ def _dispatch_render_worker(reservation_fd: int) -> None:
8757
+ """Hand the render's ONE reducer reservation to an exec'd continuation
8758
+ worker (`cctally _statusline-continue --reservation-fd <fd>`).
8725
8759
 
8726
- # --- child ---
8727
- try:
8728
- try:
8729
- os.close(parent_lock_fd)
8730
- except OSError:
8731
- pass
8732
- try:
8733
- os.setsid()
8734
- except OSError:
8735
- pass
8736
- try:
8737
- devnull = os.open(os.devnull, os.O_RDWR)
8738
- os.dup2(devnull, 0)
8739
- os.dup2(devnull, 1)
8740
- os.dup2(devnull, 2)
8741
- if devnull > 2:
8742
- os.close(devnull)
8743
- except OSError:
8744
- pass
8745
- _continuation_worker(reservation_fd)
8746
- except BaseException:
8747
- pass
8748
- finally:
8749
- _finalize_wal_checkpoints()
8750
- os._exit(0)
8760
+ #1064 spec 5.3: the reducer never runs in a bare-fork child of the
8761
+ render. On macOS a child of a bare `os.fork()` reloads the zone file on
8762
+ every local-time conversion (50-340x slower), which held the selected
8763
+ state for up to an hour per pass. The caller has already released the
8764
+ selected state, so only the reservation reaches the worker
8765
+ (`close_fds=True`, `pass_fds=(fd,)`); it stays held across the hand-off,
8766
+ by the render until the spawn and by the worker after it, so a render's
8767
+ worker and a dispatched worker never overlap. `_spawn_continuation` owns
8768
+ the descriptor in every outcome: a failed spawn closes it, releasing the
8769
+ reservation, the arrival stays durable in the intake and the next
8770
+ activating event reclaims it. Never raises."""
8771
+ _spawn_owned("worker", reservation_fd)
8751
8772
 
8752
8773
 
8753
8774
  def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
@@ -8756,9 +8777,10 @@ def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
8756
8777
  This is the RENDER path (C4): it captures one binding, logs the arrival
8757
8778
  with the evaluation it actually saw in the account's durable intake and
8758
8779
  only then installs its spool slot (spec 4.2 and 4.3 revision 3, Codex
8759
- design-fix-round-4 Q4), and decides from files alone whether to fork the
8760
- detached reducer. Capture precedes the selected-state try-lock, so a
8761
- render that loses it leaves a durable arrival and registers its wake for
8780
+ design-fix-round-4 Q4), and decides from files alone whether to dispatch
8781
+ the detached reducer (an exec'd continuation worker, #1064). Capture
8782
+ precedes the selected-state try-lock, so a render that loses it leaves a
8783
+ durable arrival and registers its wake for
8762
8784
  the running holder or a worker — it never returns silently on a busy
8763
8785
  lock. It never scans the journal, never establishes the cutover and
8764
8786
  never touches the index."""
@@ -8801,15 +8823,20 @@ def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
8801
8823
  _register_capture()
8802
8824
  return
8803
8825
  if sync_for_test:
8804
- # The synchronous twin of the detached child: the worker body,
8826
+ # The synchronous twin of the detached worker: the worker body,
8805
8827
  # once this frame no longer holds the selected state.
8806
8828
  c._release_persist_lock(lock_fd)
8807
8829
  lock_fd = None
8808
8830
  fd, reservation = reservation, None
8809
8831
  _continuation_worker(fd)
8810
8832
  return
8833
+ # #1064 spec 5.3: the selected state is released BEFORE the dispatch,
8834
+ # so the worker inherits only the reservation, which this frame still
8835
+ # holds and hands over without releasing it.
8836
+ c._release_persist_lock(lock_fd)
8837
+ lock_fd = None
8811
8838
  fd, reservation = reservation, None
8812
- _fork_persist(lock_fd, fd)
8839
+ _dispatch_render_worker(fd)
8813
8840
  finally:
8814
8841
  if lock_fd is not None:
8815
8842
  c._release_persist_lock(lock_fd)
@@ -277,15 +277,27 @@ _REPLAY_SCRATCH_INDEXES = (
277
277
  )
278
278
 
279
279
 
280
+ #: #1003 D2: the page cache (KiB) of a full replay's disposable scratch. A
281
+ #: replay batch dirties more distinct pages than SQLite's 2 MiB default holds,
282
+ #: so each overflow spilled pages the batch kept re-dirtying and wrote them
283
+ #: again; 64 MiB removes every spill (256 MiB writes the same bytes). SQLite
284
+ #: allocates cache pages only as a transaction touches them, and completion
285
+ #: closes each replay copy before opening the next.
286
+ DISPOSABLE_SCRATCH_CACHE_SIZE_KIB = 65536
287
+
288
+
280
289
  def apply_disposable_scratch_policy(conn: sqlite3.Connection) -> None:
281
290
  """Put a full replay's disposable scratch connection on the #997 policy:
282
- rollback journal in memory, no fsync, and the replay indexes when
283
- `weekly_usage_snapshots` exists. Called right after the scratch is opened,
284
- outside any transaction; commits before returning."""
291
+ rollback journal in memory, no fsync, a 64 MiB page cache (#1003 D2), and
292
+ the replay indexes when `weekly_usage_snapshots` exists. Called right
293
+ after the scratch is opened, outside any transaction; commits before
294
+ returning. The live rebuild's `.rebuilding-*` scratch never comes here."""
285
295
  if conn.in_transaction:
286
296
  conn.commit()
287
297
  conn.execute("PRAGMA journal_mode=MEMORY")
288
298
  conn.execute("PRAGMA synchronous=OFF")
299
+ conn.execute(
300
+ f"PRAGMA cache_size=-{int(DISPOSABLE_SCRATCH_CACHE_SIZE_KIB)}")
289
301
  has_usage = conn.execute(
290
302
  "SELECT 1 FROM sqlite_schema WHERE type = 'table' "
291
303
  "AND name = 'weekly_usage_snapshots'").fetchone()
@@ -609,6 +621,7 @@ _INGEST_LOCK_HELD = _cctally_core._STATS_INGEST_LOCK_HELD
609
621
  _INTERRUPTED_RECOVERY_SUPPRESSED = (
610
622
  _cctally_core._STATS_INTERRUPTED_RECOVERY_SUPPRESSED
611
623
  )
624
+ _DIAGNOSTIC_ALLOWANCE = _cctally_core._STATS_DIAGNOSTIC_WAIT_ALLOWANCE
612
625
 
613
626
 
614
627
  def in_stats_write_scope() -> bool:
@@ -627,6 +640,42 @@ def holds_ingest_lock() -> bool:
627
640
  return _INGEST_LOCK_HELD.get() > 0
628
641
 
629
642
 
643
+ @dataclass
644
+ class _DiagnosticWaitAllowance:
645
+ """The one mutable record a ``bounded_diagnostic_waits`` scope shares."""
646
+
647
+ remaining_s: float
648
+
649
+
650
+ @contextlib.contextmanager
651
+ def bounded_diagnostic_waits(allowance_s: float):
652
+ """Bound every maintenance-lock wait in this context by one allowance (#1005).
653
+
654
+ Doctor's gather opens stats.db through several independent guarded opens,
655
+ and each one used to pay the opener's full 5 s wait while maintenance held
656
+ the lock: 8 × 5 s ≈ 40 s for one report. Inside this scope every wait made
657
+ through ``diagnostic_flock`` (the opener's shared, re-acquisition and
658
+ exclusive-upgrade waits, ``stats_open_time_guard``'s loop, and Doctor's own
659
+ cache-maintenance admission) draws from ONE cumulative allowance. Elapsed
660
+ acquisition time is charged on success, expiry and exception and never
661
+ replenished; once it is spent each acquisition still makes exactly one
662
+ non-blocking attempt, so a released lock is still taken and nothing latches.
663
+
664
+ Nested scopes share the outer record rather than minting a fresh one, and
665
+ exit restores the prior context, also on an exception. Outside any scope
666
+ every caller keeps its own timeout, byte for byte.
667
+ """
668
+ if _DIAGNOSTIC_ALLOWANCE.get() is not None:
669
+ yield
670
+ return
671
+ token = _DIAGNOSTIC_ALLOWANCE.set(
672
+ _DiagnosticWaitAllowance(max(0.0, float(allowance_s))))
673
+ try:
674
+ yield
675
+ finally:
676
+ _DIAGNOSTIC_ALLOWANCE.reset(token)
677
+
678
+
630
679
  @contextlib.contextmanager
631
680
  def suppress_interrupted_stats_recovery():
632
681
  """Keep every nested Doctor stats opener read-only in this context."""
@@ -896,22 +945,16 @@ def stats_open_time_guard(
896
945
  )
897
946
  acquired = False
898
947
  try:
899
- deadline = time.monotonic() + (
948
+ # #1005: the same poll as before outside a Doctor scope; inside one,
949
+ # `diagnostic_flock` caps it by the remaining allowance (the smaller
950
+ # of that and any `wait_seconds` override) and charges it.
951
+ if not diagnostic_flock(
952
+ fd, fcntl.LOCK_EX,
900
953
  _STATS_OPEN_TIME_MAINTENANCE_WAIT_S if wait_seconds is None
901
- else float(wait_seconds)
902
- )
903
- while True:
904
- try:
905
- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
906
- acquired = True
907
- break
908
- except (BlockingIOError, OSError) as exc:
909
- if time.monotonic() >= deadline:
910
- _GUARD_FAILURE.set(
911
- "contention" if isinstance(exc, BlockingIOError)
912
- else "error")
913
- raise _maintenance_busy_error("exclusive")
914
- time.sleep(0.02)
954
+ else float(wait_seconds),
955
+ ):
956
+ raise _maintenance_busy_error("exclusive")
957
+ acquired = True
915
958
  _cctally_core.note_stats_maintenance_acquired()
916
959
  try:
917
960
  with stats_write_scope("open-time"):
@@ -990,18 +1033,45 @@ _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG = (
990
1033
  )
991
1034
 
992
1035
 
993
- def _stats_open_maintenance_timeout_message() -> str:
994
- """Name a confirmed detached rebuild when it owns the maintenance wait."""
1036
+ def _confirmed_stats_heal_active() -> bool:
1037
+ """A confirmed corruption heal whose detached worker may be running."""
995
1038
  request = _read_stats_heal_request()
996
- if (
1039
+ return bool(
997
1040
  request
998
1041
  and request.get("forensicsDisposition") == "confirmed"
999
1042
  and _stats_heal_worker_active()
1000
- ):
1043
+ )
1044
+
1045
+
1046
+ def _stats_open_maintenance_timeout_message() -> str:
1047
+ """Name a confirmed detached rebuild when it owns the maintenance wait."""
1048
+ if _confirmed_stats_heal_active():
1001
1049
  return str(_cctally_db.StatsHealDeferred("pending"))
1002
1050
  return _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
1003
1051
 
1004
1052
 
1053
+ def active_stats_recovery_deferral() -> "_cctally_db.StatsRebuildDeferred | None":
1054
+ """The pending deferral a running detached stats recovery stands for (#932).
1055
+
1056
+ Both detached recoveries hold the maintenance lock EXCLUSIVE for their
1057
+ whole rebuild, so an open during one times out with a plain
1058
+ `StatsDbMaintenanceError` rather than a deferral. A caller that already
1059
+ holds an explicit deferral frame uses this to tell "the recovery is still
1060
+ running" from a genuine maintenance conflict: a confirmed heal with a live
1061
+ worker is ``StatsHealDeferred("pending")``, else a positively owned epoch
1062
+ rebuild is ``StatsEpochRebuildDeferred("pending")``, else ``None``. Reads
1063
+ the workers' records only; takes no lock, writes nothing, never raises.
1064
+ """
1065
+ try:
1066
+ if _confirmed_stats_heal_active():
1067
+ return _cctally_db.StatsHealDeferred("pending")
1068
+ if _stats_epoch_rebuild_worker_active():
1069
+ return _cctally_db.StatsEpochRebuildDeferred("pending")
1070
+ except Exception: # noqa: BLE001 — unknown recovery state is no recovery
1071
+ return None
1072
+ return None
1073
+
1074
+
1005
1075
  #: Why this context's most recent bounded maintenance wait expired
1006
1076
  #: (#825/#832 Codex fix-round-4 Q3): ``contention`` when the lock stayed
1007
1077
  #: held, ``error`` for an OS error no holder caused.
@@ -1023,6 +1093,56 @@ def _maintenance_busy_error(mode: str) -> "_cctally_db.StatsDbMaintenanceError":
1023
1093
  return exc
1024
1094
 
1025
1095
 
1096
+ def diagnostic_flock(fh, operation: int, timeout_s: float) -> bool:
1097
+ """Poll for ``operation`` on ``fh`` (a file object or fd), bounded (#1005).
1098
+
1099
+ ``True`` when the lock is held, ``False`` on expiry (nothing acquired, so
1100
+ the caller must not release). Outside a ``bounded_diagnostic_waits`` scope
1101
+ this is the opener's historical loop unchanged: poll every 0.02 s until
1102
+ ``timeout_s``. Inside one it is the single place the shared allowance is
1103
+ charged: the deadline is ``now + min(timeout_s, remaining)``, no sleep
1104
+ passes that deadline or follows the last attempt, and the call's elapsed
1105
+ time is subtracted (floored at 0) however it ends. With nothing left it
1106
+ makes exactly one ``LOCK_NB`` attempt.
1107
+
1108
+ ``_GUARD_FAILURE`` is set exactly as before, so ``_maintenance_busy_error``
1109
+ still tells contention from an OS error.
1110
+ """
1111
+ allowance = _DIAGNOSTIC_ALLOWANCE.get()
1112
+ _GUARD_FAILURE.set(None)
1113
+ if allowance is None:
1114
+ deadline = time.monotonic() + timeout_s
1115
+ while True:
1116
+ try:
1117
+ fcntl.flock(fh, operation | fcntl.LOCK_NB)
1118
+ return True
1119
+ except (BlockingIOError, OSError) as exc:
1120
+ if time.monotonic() >= deadline:
1121
+ _GUARD_FAILURE.set(
1122
+ "contention" if isinstance(exc, BlockingIOError)
1123
+ else "error")
1124
+ return False
1125
+ time.sleep(0.02)
1126
+ start = time.monotonic()
1127
+ deadline = start + min(float(timeout_s), allowance.remaining_s)
1128
+ try:
1129
+ while True:
1130
+ try:
1131
+ fcntl.flock(fh, operation | fcntl.LOCK_NB)
1132
+ return True
1133
+ except (BlockingIOError, OSError) as exc:
1134
+ now = time.monotonic()
1135
+ if now >= deadline:
1136
+ _GUARD_FAILURE.set(
1137
+ "contention" if isinstance(exc, BlockingIOError)
1138
+ else "error")
1139
+ return False
1140
+ time.sleep(min(0.02, deadline - now))
1141
+ finally:
1142
+ elapsed = max(0.0, time.monotonic() - start)
1143
+ allowance.remaining_s = max(0.0, allowance.remaining_s - elapsed)
1144
+
1145
+
1026
1146
  def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
1027
1147
  """Poll for ``operation`` on ``lock_fh`` until ``timeout_s`` expires.
1028
1148
 
@@ -1030,20 +1150,9 @@ def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
1030
1150
  the caller must not release). Same shape as ``_heal_flock_bounded``, but it
1031
1151
  operates on an already-open file object rather than minting an fd, because
1032
1152
  the opener holds one file object across its whole marker/pending/connect
1033
- sequence.
1153
+ sequence. Delegates to ``diagnostic_flock`` so a Doctor scope bounds it.
1034
1154
  """
1035
- deadline = time.monotonic() + timeout_s
1036
- _GUARD_FAILURE.set(None)
1037
- while True:
1038
- try:
1039
- fcntl.flock(lock_fh, operation | fcntl.LOCK_NB)
1040
- return True
1041
- except (BlockingIOError, OSError) as exc:
1042
- if time.monotonic() >= deadline:
1043
- _GUARD_FAILURE.set(
1044
- "contention" if isinstance(exc, BlockingIOError) else "error")
1045
- return False
1046
- time.sleep(0.02)
1155
+ return diagnostic_flock(lock_fh, operation, timeout_s)
1047
1156
 
1048
1157
 
1049
1158
  def _stats_repair_marker(db_path) -> pathlib.Path:
@@ -1416,6 +1525,33 @@ def _has_completed_stats_quarantine_incident(
1416
1525
  return False
1417
1526
 
1418
1527
 
1528
+ def _lock_free_journal_high_water() -> "tuple[str, int] | None":
1529
+ """``(latest segment, size)`` read without ``journal.lock`` (#1005).
1530
+
1531
+ The value ``journal_high_water()`` returns, but with no leaf lock and no
1532
+ created file, as ``_cctally_rederive._read_only_journal_high_water`` does.
1533
+ A writer may remove an empty future segment under ``journal.lock``
1534
+ between the listing and the size read, so a ``FileNotFoundError`` there
1535
+ re-lists once; a second failure, or any other ``OSError``, propagates.
1536
+ """
1537
+ import _cctally_journal
1538
+
1539
+ for attempt in range(2):
1540
+ segments = _cctally_journal.list_segments()
1541
+ if not segments:
1542
+ return None
1543
+ latest = segments[-1]
1544
+ try:
1545
+ return (
1546
+ latest,
1547
+ os.path.getsize(_cctally_core.JOURNAL_DIR / latest),
1548
+ )
1549
+ except FileNotFoundError:
1550
+ if attempt:
1551
+ raise
1552
+ raise AssertionError("unreachable")
1553
+
1554
+
1419
1555
  def stats_interrupted_rebuild_evidence(
1420
1556
  db_path: pathlib.Path,
1421
1557
  ) -> "dict | None":
@@ -1429,9 +1565,7 @@ def stats_interrupted_rebuild_evidence(
1429
1565
  or not _has_completed_stats_quarantine_incident(db_path, artifacts)
1430
1566
  ):
1431
1567
  return None
1432
- import _cctally_journal
1433
-
1434
- high_water = _cctally_journal.journal_high_water()
1568
+ high_water = _lock_free_journal_high_water()
1435
1569
  if high_water is None or high_water[1] == 0:
1436
1570
  return None
1437
1571
  lock_fh = open(lock_path, "r+")
@@ -1648,6 +1782,13 @@ def stats_open_guarded(
1648
1782
  # deadlock while upgrading; recheck under exclusive because a
1649
1783
  # live owner may have completed it in the gap.
1650
1784
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1785
+ if _DIAGNOSTIC_ALLOWANCE.get() is not None:
1786
+ # #1005 C3: a diagnostic (Doctor) open never resumes a
1787
+ # pending quarantine — handle discovery and the family
1788
+ # quarantine are mutations. Report maintenance instead,
1789
+ # with no fabricated `blocked_by`: nothing was contended.
1790
+ raise _cctally_db.StatsDbMaintenanceError(
1791
+ _stats_open_maintenance_timeout_message())
1651
1792
  if not _flock_bounded(
1652
1793
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1653
1794
  ):