cctally 1.88.2 → 1.89.1

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 (37) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_cache.py +827 -37
  3. package/bin/_cctally_config.py +125 -0
  4. package/bin/_cctally_core.py +86 -2
  5. package/bin/_cctally_dashboard_cache_report.py +31 -0
  6. package/bin/_cctally_dashboard_conversation.py +26 -7
  7. package/bin/_cctally_dashboard_sources.py +51 -0
  8. package/bin/_cctally_db.py +626 -0
  9. package/bin/_cctally_doctor.py +84 -1
  10. package/bin/_cctally_journal.py +2 -1
  11. package/bin/_cctally_parser.py +42 -0
  12. package/bin/_cctally_quota.py +1358 -112
  13. package/bin/_cctally_record.py +249 -9
  14. package/bin/_cctally_setup.py +14 -5
  15. package/bin/_cctally_store.py +16 -1
  16. package/bin/_cctally_tui.py +16 -2
  17. package/bin/_cctally_update.py +9 -2
  18. package/bin/_lib_background_mcp.py +168 -0
  19. package/bin/_lib_cache_report.py +19 -2
  20. package/bin/_lib_codex_conversation.py +8 -0
  21. package/bin/_lib_codex_conversation_query.py +8 -7
  22. package/bin/_lib_conversation.py +105 -5
  23. package/bin/_lib_conversation_dispatch.py +15 -4
  24. package/bin/_lib_conversation_query.py +294 -2
  25. package/bin/_lib_dashboard_sources.py +5 -1
  26. package/bin/_lib_doctor.py +202 -1
  27. package/bin/_lib_jsonl.py +12 -0
  28. package/bin/_lib_quota_alert_axes.py +188 -0
  29. package/bin/_lib_quota_ledger.py +274 -0
  30. package/bin/_lib_snapshot_cache.py +36 -0
  31. package/bin/cctally +6 -3
  32. package/dashboard/static/assets/index-BgoYXdus.js +92 -0
  33. package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
  37. package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
@@ -169,6 +169,7 @@ import fcntl
169
169
  import json
170
170
  import math
171
171
  import os
172
+ import re
172
173
  import sqlite3
173
174
  import sys
174
175
  import time
@@ -4627,27 +4628,135 @@ def _hook_tick_format_log_line(
4627
4628
  )
4628
4629
 
4629
4630
 
4631
+ #: A token that is unmistakably a filesystem path: an absolute POSIX path, a
4632
+ #: `~`-relative one, or a Windows drive path. The negative lookbehind is what
4633
+ #: keeps `Input/output error` and `disk I/O error` intact — a separator with a
4634
+ #: word character in front of it is prose, not a root.
4635
+ #:
4636
+ #: It deliberately does NOT match a RELATIVE path (`.codex/sessions/…`): every
4637
+ #: separator in one is preceded by a word character, so widening the lookbehind
4638
+ #: to reach it is the same edit that starts eating prose. That is acceptable
4639
+ #: because of what the two rules are each for — a relative path carries no
4640
+ #: username and no home directory, so the only identifier it can leak is the
4641
+ #: conversation id, which the UUID rule below redacts wherever it appears.
4642
+ _HOOK_LOG_PATHISH = re.compile(r"(?<!\w)(?:[A-Za-z]:[\\/]|~?/)[^\s'\"]*")
4643
+
4644
+ #: A conversation identifier, in or out of path form. Codex names its rollouts
4645
+ #: `rollout-<timestamp>-<uuid>.jsonl`, and the `OSError` narrowing only drops
4646
+ #: the one that arrives as `filename` — any OTHER exception type quoting a
4647
+ #: rollout relatively, or a bare conversation key in one of our own
4648
+ #: `ValueError(f"… {key}")` messages, escapes the path rule entirely. A
4649
+ #: canonical UUID cannot occur in prose, so this one needs no lookbehind.
4650
+ _HOOK_LOG_UUIDISH = re.compile(
4651
+ r"(?<![0-9a-fA-F])[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
4652
+ r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?![0-9a-fA-F])")
4653
+
4654
+ #: `hook-tick.log` free text is read back by a LAST-WINS `k=v` comprehension,
4655
+ #: so the value may not contain a separator of its own.
4656
+ _HOOK_LOG_FIELD_SEPARATOR_SUBSTITUTE = ":"
4657
+
4658
+
4659
+ def _hook_log_safe_free_text(value: str, *, limit: int = 200) -> str:
4660
+ """Collapse one free-text hook-tick log value and defuse it.
4661
+
4662
+ Four transforms, in order: whitespace collapses so a multi-line message
4663
+ cannot split the record; path-shaped tokens are redacted; UUID-shaped ones
4664
+ are redacted separately, because a relative or bare conversation id never
4665
+ reaches the path rule; and ``=`` is substituted so the value cannot
4666
+ impersonate a field. See ``_hook_log_error_detail`` for why each is
4667
+ load-bearing.
4668
+ """
4669
+ collapsed = " ".join(str(value).split())
4670
+ collapsed = _HOOK_LOG_PATHISH.sub("<path>", collapsed)
4671
+ collapsed = _HOOK_LOG_UUIDISH.sub("<uuid>", collapsed)
4672
+ collapsed = collapsed.replace("=", _HOOK_LOG_FIELD_SEPARATOR_SUBSTITUTE)
4673
+ return collapsed[:limit]
4674
+
4675
+
4676
+ def _hook_log_error_detail(exc: BaseException, *, limit: int = 200) -> str:
4677
+ """One bounded, privacy-safe ``<class>: <message>`` for a hook-tick line.
4678
+
4679
+ Two things the plain ``f"{type(exc).__name__}: {exc}"`` emits that this
4680
+ durable, deliberately-bounded diagnostic must not.
4681
+
4682
+ A FILESYSTEM PATH. The whole ``OSError`` family puts ``filename`` in its
4683
+ ``str()``, so one ``PermissionError: [Errno 13] Permission denied:
4684
+ '/Users/<name>/.codex/sessions/…/rollout-…-<uuid>.jsonl'`` writes a username
4685
+ AND a conversation identifier into the log — the same exposure for which a
4686
+ traceback was already rejected, without the traceback. The family is
4687
+ narrowed to ``errno``/``strerror``, which is the diagnostic half, and
4688
+ whatever remains is additionally scrubbed of path-shaped AND UUID-shaped
4689
+ tokens: the callers are blanket ``except`` blocks that can catch anything,
4690
+ including our own ``ValueError(f"… {path}")``. The two scrubs are separate
4691
+ rules because the path one recognises only an ABSOLUTE or ``~``-relative
4692
+ root — a relative ``.codex/sessions/…`` spelling escapes it, and so does a
4693
+ bare conversation key, neither of which the ``OSError`` narrowing can reach
4694
+ on a non-``OSError`` type. Between them: a username or home directory can
4695
+ only appear under an absolute or ``~`` root, and a conversation id is a UUID
4696
+ wherever it appears.
4697
+
4698
+ An ``=``. The only real reader of these lines is a LAST-WINS
4699
+ ``{k: v for token in tokens if "=" in token}`` comprehension
4700
+ (``_codex_lifecycle_activity_24h``), so a message containing
4701
+ ``provider=claude`` or ``result=success`` overrides the true field — the
4702
+ record is dropped from doctor's view, or an errored tick is counted as a
4703
+ success. Appending the free text LAST does not protect the fixed columns
4704
+ from that parser; last-wins means it is precisely the position that loses.
4705
+ """
4706
+ detail = (
4707
+ f"[Errno {exc.errno}] {exc.strerror}"
4708
+ if isinstance(exc, OSError) and exc.strerror
4709
+ else str(exc)
4710
+ )
4711
+ return _hook_log_safe_free_text(
4712
+ f"{type(exc).__name__}: {detail}".strip(), limit=limit)
4713
+
4714
+
4630
4715
  def _codex_lifecycle_log_line(
4631
4716
  *, source_root_key: str, event: str, sync: str, result: str,
4632
4717
  blocks: int, milestones: int, alert_eligible_roots: int,
4633
- quota_alerts: int, budget_alerts: int, dur_ms: int,
4718
+ quota_alerts: int, budget_alerts: int, dur_ms: int, backlog: int = 0,
4719
+ error: str = "",
4634
4720
  ) -> str:
4635
4721
  """Render one privacy-safe root-qualified Codex lifecycle outcome.
4636
4722
 
4637
4723
  Native hook input can contain session paths and conversation identifiers;
4638
4724
  this durable diagnostic deliberately carries only the bounded event label,
4639
- opaque source root key, aggregate reconciliation counts, and duration.
4725
+ opaque source root key, aggregate reconciliation counts, duration, and — on
4726
+ an errored tick — a DEFUSED exception class and message.
4727
+
4728
+ ``error`` is the one free-text field. It carries the class and message from
4729
+ the hook's blanket except, never a traceback and never anything derived from
4730
+ hook stdin, because a `result=error` tick with nothing else recorded is
4731
+ undiagnosable and has already cost one debugging round.
4732
+
4733
+ Its position is last for readability, NOT for safety, and the comment that
4734
+ claimed otherwise had it backwards. The only real reader of this line is a
4735
+ LAST-WINS `k=v` comprehension (`_codex_lifecycle_activity_24h`), against
4736
+ which last position is the WINNING one — a message containing
4737
+ `provider=claude` dropped the whole record from doctor's view, and one
4738
+ containing `result=success` counted an errored tick as a success. Safety
4739
+ comes from `_hook_log_safe_free_text`, which is what keeps every fixed
4740
+ column authoritative and every path out of the file. A caller holding the
4741
+ exception should pass `_hook_log_error_detail(exc)`, so the `OSError`
4742
+ family's embedded `filename` is narrowed away at the source as well.
4640
4743
  """
4641
4744
  safe_event = "".join(
4642
4745
  char for char in str(event)[:40] if char.isalnum() or char in "-_"
4643
4746
  ) or "unknown"
4747
+ suffix = ""
4748
+ if error:
4749
+ collapsed = _hook_log_safe_free_text(error)
4750
+ if collapsed:
4751
+ suffix = f" error={collapsed}"
4644
4752
  return (
4645
4753
  f"{now_utc_iso()} provider=codex source_root_key={source_root_key} "
4646
4754
  f"event={safe_event} sync={sync} blocks={int(blocks)} "
4647
4755
  f"milestones={int(milestones)} "
4648
4756
  f"alert_eligible_roots={int(alert_eligible_roots)} "
4649
4757
  f"quota_alerts={int(quota_alerts)} budget_alerts={int(budget_alerts)} "
4650
- f"dur_ms={max(0, int(dur_ms))} result={result}"
4758
+ f"backlog={max(0, int(backlog))} "
4759
+ f"dur_ms={max(0, int(dur_ms))} result={result}{suffix}"
4651
4760
  )
4652
4761
 
4653
4762
 
@@ -4656,7 +4765,54 @@ def _codex_lifecycle_roots():
4656
4765
  return codex_hook_roots(_cctally()._codex_home_roots())
4657
4766
 
4658
4767
 
4659
- def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") -> int:
4768
+ def _stats_epoch_rebuild_pending() -> bool:
4769
+ """Would opening stats.db right now trigger a whole-journal rebuild?
4770
+
4771
+ Side-effect-free: a raw read-only ``PRAGMA user_version``, the same probe
4772
+ ``resolve_stats_epoch_mismatch`` re-checks under the maintenance lock.
4773
+
4774
+ Deliberately narrow. A MISSING stats.db is a fresh install, where building
4775
+ the index is cheap and skipping it would leave the hook with nothing to do
4776
+ forever. An UNREADABLE one belongs to the corruption auto-heal path, not
4777
+ here. A LEGACY index (``user_version <= LEGACY_STATS_HEAD``) takes the
4778
+ migration route rather than the epoch rebuild, and predates every epoch
4779
+ this decision is about. Only a readable, post-legacy, wrong-epoch index —
4780
+ exactly what an upgrade across a ``STATS_INDEX_EPOCH`` bump produces —
4781
+ answers True.
4782
+ """
4783
+ path = _cctally_core.DB_PATH
4784
+ try:
4785
+ if not path.exists():
4786
+ return False
4787
+ except OSError:
4788
+ return False
4789
+ try:
4790
+ import _cctally_store
4791
+ version = _cctally_store._raw_user_version(path)
4792
+ except Exception:
4793
+ return False
4794
+ if version < 0 or version <= _cctally_core.LEGACY_STATS_HEAD:
4795
+ return False
4796
+ return version != _cctally_core.STATS_INDEX_EPOCH
4797
+
4798
+
4799
+ def _defer_stats_epoch_rebuild() -> str:
4800
+ """Hand a pending stats.db epoch rebuild to the detached quota worker.
4801
+
4802
+ Reuses ``_codex-quota-verify`` rather than adding a third worker: its
4803
+ ``force_full`` pass opens stats.db, which is what performs the rebuild, and
4804
+ the whole-history pass it then runs is exactly the one the freshly rebuilt
4805
+ index needs anyway. Sharing the worker also shares its attempt-stamped
4806
+ throttle, so a rebuild that keeps dying cannot spawn one worker per tick.
4807
+ """
4808
+ from _cctally_quota import _defer_codex_quota_verification
4809
+ return _defer_codex_quota_verification()
4810
+
4811
+
4812
+ def _cmd_hook_tick_codex(
4813
+ args: argparse.Namespace, *, event: str = "unknown",
4814
+ transcript_path: str = "",
4815
+ ) -> int:
4660
4816
  """Run one quiet, foreground Codex lifecycle tick.
4661
4817
 
4662
4818
  Native Codex Stop/SubagentStop hooks may fire concurrently. Per-root
@@ -4679,6 +4835,7 @@ def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") ->
4679
4835
 
4680
4836
  def log_outcome(
4681
4837
  *, sync: str, result: str, projection=None, budget_alerts: int = 0,
4838
+ backlog: int = 0, error: str = "",
4682
4839
  ) -> None:
4683
4840
  blocks = int(getattr(projection, "blocks_upserted", 0) or 0)
4684
4841
  milestones = int(getattr(projection, "milestones_upserted", 0) or 0)
@@ -4695,10 +4852,30 @@ def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") ->
4695
4852
  alert_eligible_roots=len(due_root_keys),
4696
4853
  quota_alerts=quota_alerts,
4697
4854
  budget_alerts=budget_alerts,
4855
+ backlog=backlog,
4698
4856
  dur_ms=dur_ms,
4857
+ error=error,
4699
4858
  ))
4700
4859
  _hook_tick_log_rotate_if_needed()
4701
4860
 
4861
+ # public #5: a pending stats.db epoch rebuild is the one operation on this
4862
+ # path that neither the ingest budget nor the projection's `defer` can
4863
+ # bound, because it happens inside `open_db()` before any of this code gets
4864
+ # a say. Measured on a real 211K-observation / 1,859-rollout store, the
4865
+ # first tick after the epoch bump cost 82.05s wall — 76.45s of it the
4866
+ # journal rebuild — against Codex's 30-second hook timeout. A killed rebuild
4867
+ # commits nothing, so the next tick repeats it: a non-converging
4868
+ # 30-second-per-turn loop, which is the reported defect delivered by the
4869
+ # fix. Hand it to the same detached worker the periodic verification uses
4870
+ # (its `force_full` pass opens stats.db and therefore performs the rebuild)
4871
+ # and acknowledge this tick as a no-op. No lifecycle marker is stamped, so
4872
+ # the next Codex turn re-checks immediately; the spawn itself is throttled.
4873
+ if _stats_epoch_rebuild_pending():
4874
+ _defer_stats_epoch_rebuild()
4875
+ log_outcome(sync="deferred", result="noop")
4876
+ release_lifecycle_locks(locks)
4877
+ return 0
4878
+
4702
4879
  try:
4703
4880
  # Hook stdout/stderr is contractually silent. Cache migration and
4704
4881
  # ingest diagnostics remain available to explicit CLI operations.
@@ -4707,10 +4884,26 @@ def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") ->
4707
4884
  cache = c.open_cache_db()
4708
4885
  try:
4709
4886
  cache_mod = c._load_sibling("_cctally_cache")
4887
+ # public #5 spec §4: the hook's ingest leg is BUDGETED and
4888
+ # resumable, and it ingests the active rollout first so live
4889
+ # numbers stay correct while history lags. Only this caller
4890
+ # passes a budget — an explicit `cctally cache-sync` still runs
4891
+ # to completion.
4892
+ import _cctally_config as _cfg_codex
4893
+ budget_seconds = _cfg_codex.resolve_codex_hook_ingest_budget(
4894
+ c.load_config())
4710
4895
  stats, cache = cache_mod._run_cache_operation_with_recovery(
4711
4896
  cache,
4712
4897
  lambda active_conn: c.sync_codex_cache(
4713
- active_conn, lock_timeout=0
4898
+ active_conn, lock_timeout=0,
4899
+ budget_seconds=budget_seconds,
4900
+ active_transcript_path=transcript_path or None,
4901
+ # public #5 spec §4: ONE reconcile per tick. The
4902
+ # explicit alert-eligible reconcile below can never
4903
+ # take the certificate short-circuit (it is guarded by
4904
+ # `not alert_eligible_roots`), so the sync-internal one
4905
+ # was pure duplicated cost.
4906
+ quota_reconcile="defer",
4714
4907
  ),
4715
4908
  origin="hook.codex_quota.sync",
4716
4909
  )
@@ -4723,6 +4916,18 @@ def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") ->
4723
4916
  source_root_keys=all_root_keys,
4724
4917
  alert_eligible_root_keys=due_root_keys,
4725
4918
  now=dt.datetime.now(dt.timezone.utc),
4919
+ # public #5 spec §2/§4: the hook path NEVER runs a
4920
+ # whole-history quota pass inline — not the once-a-day
4921
+ # verification, and not a rebuilt stats index, an interpretation
4922
+ # bump, a missing reverse map, a reset ledger or a dirty-unit
4923
+ # burst either. On a hook-only install no dashboard tick or
4924
+ # `codex quota` invocation reaches any of them first, so each
4925
+ # would land here as a ~14-30s reconcile on a blocking path
4926
+ # against Codex's 30-second timeout. `defer` hands every one of
4927
+ # them to the detached `_codex-quota-verify` worker; the bounded
4928
+ # ingest above stays foreground, so fresh observations still
4929
+ # precede the turn.
4930
+ full_pass="defer",
4726
4931
  )
4727
4932
  # Vendor-scoped spend is intentionally evaluated once per
4728
4933
  # successful due-set tick, not once per root. Task 7 Item 4: the
@@ -4743,15 +4948,47 @@ def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") ->
4743
4948
  _jr_codex.run_stats_ingest(
4744
4949
  mode="authoritative", codex_apply=_codex_budget_leg)
4745
4950
  budget_alerts = _budget_holder["n"]
4951
+ if getattr(stats, "deferred_reason", None) == "replay_pending":
4952
+ # public #5: the budgeted tick declined a byte-zero replay, and on
4953
+ # a hook-only install nothing else would ever perform one — no
4954
+ # dashboard, no `codex quota`, no `cache-sync`. Every following tick
4955
+ # would return at the same decline and Codex ingest would freeze
4956
+ # permanently. Hand the unbudgeted drain to a detached worker.
4957
+ # Outside the cache flocks by construction (the sync released them
4958
+ # before returning) and after the connection closed, so the worker
4959
+ # is not racing this tick for the writer lock.
4960
+ cache_mod._defer_codex_replay_drain()
4746
4961
  mark_lifecycle_success(locks)
4747
4962
  log_outcome(
4748
- sync="ok", result="success", projection=projection,
4963
+ # A byte-zero Codex replay is not sliceable, so a budgeted tick
4964
+ # declines it outright and hands the unbudgeted drain to a detached
4965
+ # worker (above). Say so in the lifecycle line rather than reporting
4966
+ # a sync that did not walk anything as "ok".
4967
+ sync="deferred" if getattr(stats, "deferred_reason", None) else "ok",
4968
+ result="success", projection=projection,
4749
4969
  budget_alerts=budget_alerts,
4970
+ backlog=int(getattr(stats, "backlog_files", 0) or 0),
4750
4971
  )
4751
- except Exception:
4972
+ except Exception as exc:
4752
4973
  # A failed sync, projection, or budget evaluation must acknowledge no
4753
4974
  # root. Hooks are best-effort and remain a successful no-op to Codex.
4754
- log_outcome(sync="error", result="error")
4975
+ #
4976
+ # The class and message go into the lifecycle line. Discarding them
4977
+ # already cost a debugging round: a changed keyword signature raised
4978
+ # TypeError inside this block and presented as a silent `result=error`
4979
+ # tick indistinguishable from a database failure. No traceback (this is
4980
+ # a durable, privacy-bounded diagnostic and a traceback carries paths),
4981
+ # and nothing reaches stdout or stderr — those stay contractually
4982
+ # silent.
4983
+ #
4984
+ # `_hook_log_error_detail`, not a bare f-string: rejecting the traceback
4985
+ # for carrying paths and then interpolating `str(exc)` was the same leak
4986
+ # one layer down, because the whole `OSError` family embeds `filename`
4987
+ # — a rollout path is a username plus a conversation UUID.
4988
+ log_outcome(
4989
+ sync="error", result="error",
4990
+ error=_hook_log_error_detail(exc),
4991
+ )
4755
4992
  return 0
4756
4993
  finally:
4757
4994
  release_lifecycle_locks(locks)
@@ -4781,7 +5018,10 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
4781
5018
  # probes may only drain stdin. Do not turn an absent payload into a
4782
5019
  # hook failure merely because event observability is unavailable.
4783
5020
  event = meta.get("event", "unknown") if isinstance(meta, dict) else "unknown"
4784
- return _cmd_hook_tick_codex(args, event=event)
5021
+ transcript = (
5022
+ meta.get("transcript_path", "") if isinstance(meta, dict) else "")
5023
+ return _cmd_hook_tick_codex(
5024
+ args, event=event, transcript_path=str(transcript or ""))
4785
5025
  explain = bool(getattr(args, "explain", False))
4786
5026
  foreground = bool(getattr(args, "foreground", False))
4787
5027
  no_oauth = bool(getattr(args, "no_oauth", False))
@@ -1477,15 +1477,24 @@ def _setup_recent_log_stats(seconds: float = 24 * 3600) -> dict:
1477
1477
  continue
1478
1478
  if ts < cutoff:
1479
1479
  continue
1480
- counts["fires"] += 1
1481
- last_ts = max(last_ts, ts)
1482
- # event=NAME
1483
- ev = "unknown"
1480
+ # event=NAME. Its ABSENCE now means the line is not a hook fire
1481
+ # at all: `hook-tick.log` also carries the detached Codex
1482
+ # workers' outcomes (`op=quota-verify`, `op=quota-verify-spawn`,
1483
+ # `op=replay-drain`), which are `provider=codex op=… result=…`
1484
+ # and name no event. Counting them inflated `fires` and piled
1485
+ # them into `by_event["unknown"]`, which is meant to report hook
1486
+ # payloads whose `hook_event_name` cctally could not read.
1487
+ ev = None
1484
1488
  for tok in ln.split():
1485
1489
  if tok.startswith("event="):
1486
1490
  ev = tok.split("=", 1)[1]
1487
1491
  break
1488
- counts["by_event"][ev] = counts["by_event"].get(ev, 0) + 1
1492
+ if ev is None:
1493
+ continue
1494
+ counts["fires"] += 1
1495
+ last_ts = max(last_ts, ts)
1496
+ counts["by_event"][ev or "unknown"] = (
1497
+ counts["by_event"].get(ev or "unknown", 0) + 1)
1489
1498
  if "oauth=ok(" in ln:
1490
1499
  counts["oauth_ok"] += 1
1491
1500
  elif "oauth=throttled" in ln:
@@ -275,7 +275,22 @@ def would_block_prod_stats_cutover(path) -> bool:
275
275
  #
276
276
  # Bump ``_STATS_OPEN_FIXUPS_VERSION`` when a NEW open-time backfill is added, so
277
277
  # existing installs re-run the fixups once to pick it up.
278
- _STATS_OPEN_FIXUPS_VERSION = 1
278
+ #
279
+ # 1 -> 2 (public #5): the quota-projection schema gained
280
+ # ``quota_window_blocks.physical_group_key`` / ``physical_group_digest`` and the
281
+ # ``quota_projection_ledger_state`` row. An epoch-mismatched index resolves that
282
+ # by REBUILD, but a LEGACY index (``user_version <= LEGACY_STATS_HEAD``) takes
283
+ # the in-place cutover instead, and the cutover relies on this open-time schema
284
+ # apply — which a stamped marker skips outright. Without the bump such a DB is
285
+ # stamped at the new epoch while still missing the columns, and every subsequent
286
+ # open returns at the steady-state gate before any schema work could add them:
287
+ # a permanent `no such column: physical_group_key`.
288
+ #
289
+ # 2 -> 3 (public #5, I2 review): the same seam, one column later. The periodic
290
+ # verification adds `quota_projection_ledger_state.last_full_pass_at`, and a
291
+ # legacy index that already ran the fixups at version 2 would skip the schema
292
+ # apply that adds it.
293
+ _STATS_OPEN_FIXUPS_VERSION = 3
279
294
 
280
295
 
281
296
  def stats_open_fixups_current(conn: sqlite3.Connection) -> bool:
@@ -2286,7 +2286,13 @@ def _snapshot_data_version(sig) -> str:
2286
2286
  # account switch with zero new rows still flips the SSE change-signal. Empty
2287
2287
  # for every <=1-account install (byte-neutral — never appended).
2288
2288
  acct = getattr(sig, "accounts_digest", "")
2289
- return out if not acct else f"{out}.a{acct}"
2289
+ out = out if not acct else f"{out}.a{acct}"
2290
+ # public #5: a budgeted tick can change the Codex ingest backlog while every
2291
+ # other leg stays flat, and the envelope publishes that backlog. Folding it
2292
+ # in is what leaves the idle short-circuit so the source bundle is rebuilt
2293
+ # at all. Empty once the backlog has drained, so it is byte-neutral there.
2294
+ backlog = getattr(sig, "codex_ingest_backlog_sig", "")
2295
+ return out if not backlog else f"{out}.b{backlog}"
2290
2296
 
2291
2297
 
2292
2298
  def _tui_source_copy(value: object) -> object:
@@ -2618,10 +2624,18 @@ def _tui_build_source_bundle(
2618
2624
  accounts_digest=accounts_digest,
2619
2625
  )
2620
2626
  _acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
2627
+ # public #5: the hook's budgeted ingest can change what the Codex
2628
+ # envelope owes without moving `codex_physical_mutation_seq` — a tick
2629
+ # whose walk consumed only deduped or non-`token_count` bytes commits
2630
+ # no row. Without this leg `reuse_coherent_source_state` hands back the
2631
+ # prior Codex object and the `ingest_backlog` field never reaches the
2632
+ # wire. Empty (and so byte-neutral) once the backlog has drained.
2633
+ _backlog = getattr(signature, "codex_ingest_backlog_sig", "")
2634
+ _backlog_suffix = f":b{_backlog}" if _backlog else ""
2621
2635
  codex_version = (
2622
2636
  f"codex:{signature.max_codex_id}:"
2623
2637
  f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
2624
- f"{semantics.codex_identity}{_acct_suffix}"
2638
+ f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
2625
2639
  )
2626
2640
  claude_version = (
2627
2641
  f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
@@ -1328,9 +1328,15 @@ def _do_update_check() -> None:
1328
1328
  c._save_update_state(state)
1329
1329
 
1330
1330
 
1331
- def _spawn_detached(command: str) -> None:
1331
+ def _spawn_detached(command: str) -> bool:
1332
1332
  """Fire-and-forget a hidden self-subcommand as a detached worker.
1333
1333
 
1334
+ Returns whether the spawn was issued. The two update/telemetry callers
1335
+ ignore it — they have no alternative to fall back on — but the deferred
1336
+ Codex quota verification needs to distinguish "handed off" from "could not
1337
+ hand off", because the operation it is deferring is one it must NOT then
1338
+ run inline.
1339
+
1334
1340
  Detached `subprocess.Popen` with `start_new_session=True` so a parent
1335
1341
  exit (the user closes the shell) doesn't propagate SIGHUP to the child;
1336
1342
  stdin/stdout/stderr all `/dev/null` so the child can't pollute the
@@ -1349,9 +1355,10 @@ def _spawn_detached(command: str) -> None:
1349
1355
  start_new_session=True,
1350
1356
  close_fds=True,
1351
1357
  )
1358
+ return True
1352
1359
  except Exception:
1353
1360
  # Fire-and-forget: never let a spawn failure propagate.
1354
- pass
1361
+ return False
1355
1362
 
1356
1363
 
1357
1364
  def _spawn_background_update_check() -> None:
@@ -0,0 +1,168 @@
1
+ """Pure kernel for recovering backgrounded-MCP results (no I/O).
2
+
3
+ Claude Code moves an MCP call that exceeds 120s to a background task. The
4
+ completion is persisted ONLY as an attachment/queued_command record with
5
+ commandMode=="task-notification" carrying <task-id> — never as a type:"user"
6
+ line and never with a <tool-use-id>. The bridge back to the originating call is
7
+ the placeholder tool_result, which names the task twice in two independently
8
+ worded places.
9
+
10
+ Two notification shapes exist and only one of them already works:
11
+
12
+ A subagent / Monitor type:"user" line joins on <tool-use-id>
13
+ B backgrounded MCP attachment record carries <task-id> ONLY
14
+
15
+ Shape A is classified META at ingest and joined in ``_assemble_session``'s
16
+ finalize stage today. Shape B never appears as a ``type:"user"`` line, so
17
+ nothing rescues it — this module is the parser + selection half of that rescue.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+
25
+ # Gate: only a real harness placeholder may yield a task id. Ordinary prose that
26
+ # happens to contain "as task X" must never match.
27
+ _PLACEHOLDER_GATE = re.compile(r'\AMCP tool "[^"]+" is still running after ')
28
+ _AS_TASK = re.compile(r"moved to the background as task (\S+?)[\s.,;]")
29
+ _TASK_ID_QUOTED = re.compile(r'task_id "([^"]+)"')
30
+
31
+ _TASK_ID_TAG = re.compile(r"<task-id>([^<]*)</task-id>")
32
+ _STATUS_TAG = re.compile(r"<status>([^<]*)</status>")
33
+ _SUMMARY_TAG = re.compile(r"<summary>([^<]*)</summary>")
34
+
35
+ # Every field copied from the notification wrapper into the transcript store is
36
+ # bounded. Task ids are identities, so an oversized one is rejected rather than
37
+ # truncated; status/summary are presentation metadata and may be clipped.
38
+ MAX_TASK_ID_CHARS = 256
39
+ MAX_STATUS_CHARS = 64
40
+ MAX_SUMMARY_CHARS = 1024
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class BackgroundNotification:
45
+ task_id: str
46
+ status: str
47
+ summary: str
48
+ result_text: "str | None"
49
+
50
+
51
+ def parse_placeholder_task_id(text) -> "str | None":
52
+ """Task id from a backgrounded-MCP placeholder tool_result, else None.
53
+
54
+ BOTH spellings are read and must AGREE. Disagreement returns None rather
55
+ than picking one — a wrong id would attach a response to the wrong call,
56
+ which is worse than leaving the placeholder visible. Either spelling ALONE
57
+ is sufficient (the harness wording is not contractual), but the leading
58
+ ``MCP tool "…" is still running after`` gate must match, so ordinary prose
59
+ quoting a task id can never be mistaken for a placeholder.
60
+ """
61
+ if not text or not _PLACEHOLDER_GATE.match(text):
62
+ return None
63
+ a = _AS_TASK.search(text)
64
+ b = _TASK_ID_QUOTED.search(text)
65
+ ids = {m.group(1) for m in (a, b) if m is not None}
66
+ if len(ids) != 1:
67
+ return None
68
+ task_id = ids.pop()
69
+ return task_id if len(task_id) <= MAX_TASK_ID_CHARS else None
70
+
71
+
72
+ def _extract_result(body: str) -> "str | None":
73
+ """Span between the FIRST <result> and the wrapper's LAST </result>.
74
+
75
+ Delimiter contract: the result carries arbitrary MCP text that may itself
76
+ contain a literal ``</result>``, so a non-greedy match would truncate it.
77
+ ``rfind`` deliberately takes the last close tag BEFORE the closing
78
+ ``</task-notification>``. Content after that wrapper is foreign and cannot
79
+ extend the result.
80
+ """
81
+ start = body.find("<result>")
82
+ if start == -1:
83
+ return None
84
+ wrapper_end = body.find("</task-notification>", start + len("<result>"))
85
+ if wrapper_end == -1:
86
+ return None
87
+ end = body.rfind("</result>", start + len("<result>"), wrapper_end)
88
+ if end == -1 or end <= start:
89
+ return None
90
+ return body[start + len("<result>"):end].strip() or None
91
+
92
+
93
+ def parse_task_notification(body) -> "BackgroundNotification | None":
94
+ """Parse a <task-notification> body. None when it carries no task id.
95
+
96
+ <summary> deliberately carries a TRUNCATED task id (kravg1b9 for task
97
+ kravg1b9s) and is never used as an identity.
98
+ """
99
+ if not body:
100
+ return None
101
+ m = _TASK_ID_TAG.search(body)
102
+ if m is None or not m.group(1).strip():
103
+ return None
104
+ task_id = m.group(1).strip()
105
+ if len(task_id) > MAX_TASK_ID_CHARS:
106
+ return None
107
+ status = _STATUS_TAG.search(body)
108
+ summary = _SUMMARY_TAG.search(body)
109
+ return BackgroundNotification(
110
+ task_id=task_id,
111
+ status=(status.group(1).strip()[:MAX_STATUS_CHARS] if status else ""),
112
+ summary=(summary.group(1).strip()[:MAX_SUMMARY_CHARS] if summary else ""),
113
+ result_text=_extract_result(body),
114
+ )
115
+
116
+
117
+ def unambiguous_placeholder_tasks(placeholders):
118
+ """Return one task per tool id only when every claim agrees.
119
+
120
+ ``placeholders`` may be a mapping for legacy callers or an iterable of
121
+ ``(tool_use_id, task_id)`` claims. The iterable form preserves conflicting
122
+ claimants that a dictionary would silently collapse last-writer-wins.
123
+ Repeated identical claims are harmless; distinct claims fail closed.
124
+ """
125
+ claims = placeholders.items() if hasattr(placeholders, "items") else placeholders
126
+ by_tool = {}
127
+ for tool_use_id, task_id in claims:
128
+ by_tool.setdefault(tool_use_id, set()).add(task_id)
129
+ return {
130
+ tool_use_id: next(iter(task_ids))
131
+ for tool_use_id, task_ids in by_tool.items()
132
+ if len(task_ids) == 1
133
+ }
134
+
135
+
136
+ def select_background_joins(placeholders, notifications):
137
+ """``{tool_use_id: BackgroundNotification}`` for UNAMBIGUOUS matches only.
138
+
139
+ ``placeholders`` is a mapping or an iterable of claimant pairs. Background
140
+ task ids carry no session-uniqueness guarantee comparable to Anthropic tool
141
+ ids, and a resumed session is assembled across every source file, so this
142
+ FAILS CLOSED: one tool id claiming conflicting tasks, one task id claimed by
143
+ more than one tool id, or more than one completed notification joins
144
+ NOTHING. A visible placeholder is a recoverable disappointment; a response
145
+ attached to the wrong call is a correctness failure.
146
+
147
+ This is the ONE selection rule — the full-payload resolver calls it too, so
148
+ a card can never display one capped response and load a different full one.
149
+ """
150
+ by_task: "dict[str, list]" = {}
151
+ for tuid, task_id in unambiguous_placeholder_tasks(placeholders).items():
152
+ by_task.setdefault(task_id, []).append(tuid)
153
+
154
+ usable: "dict[str, list]" = {}
155
+ for n in notifications:
156
+ if n.status != "completed" or not n.result_text:
157
+ continue
158
+ usable.setdefault(n.task_id, []).append(n)
159
+
160
+ out = {}
161
+ for task_id, tuids in by_task.items():
162
+ if len(tuids) != 1:
163
+ continue # ambiguous placeholder side
164
+ cands = usable.get(task_id) or []
165
+ if len(cands) != 1:
166
+ continue # absent or ambiguous notification side
167
+ out[tuids[0]] = cands[0]
168
+ return out