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.
- package/CHANGELOG.md +32 -0
- package/bin/_cctally_cache.py +827 -37
- package/bin/_cctally_config.py +125 -0
- package/bin/_cctally_core.py +86 -2
- package/bin/_cctally_dashboard_cache_report.py +31 -0
- package/bin/_cctally_dashboard_conversation.py +26 -7
- package/bin/_cctally_dashboard_sources.py +51 -0
- package/bin/_cctally_db.py +626 -0
- package/bin/_cctally_doctor.py +84 -1
- package/bin/_cctally_journal.py +2 -1
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_quota.py +1358 -112
- package/bin/_cctally_record.py +249 -9
- package/bin/_cctally_setup.py +14 -5
- package/bin/_cctally_store.py +16 -1
- package/bin/_cctally_tui.py +16 -2
- package/bin/_cctally_update.py +9 -2
- package/bin/_lib_background_mcp.py +168 -0
- package/bin/_lib_cache_report.py +19 -2
- package/bin/_lib_codex_conversation.py +8 -0
- package/bin/_lib_codex_conversation_query.py +8 -7
- package/bin/_lib_conversation.py +105 -5
- package/bin/_lib_conversation_dispatch.py +15 -4
- package/bin/_lib_conversation_query.py +294 -2
- package/bin/_lib_dashboard_sources.py +5 -1
- package/bin/_lib_doctor.py +202 -1
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_quota_alert_axes.py +188 -0
- package/bin/_lib_quota_ledger.py +274 -0
- package/bin/_lib_snapshot_cache.py +36 -0
- package/bin/cctally +6 -3
- package/dashboard/static/assets/index-BgoYXdus.js +92 -0
- package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
- package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
package/bin/_cctally_record.py
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -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
|
-
|
|
1481
|
-
|
|
1482
|
-
#
|
|
1483
|
-
|
|
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
|
-
|
|
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:
|
package/bin/_cctally_store.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -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
|
-
|
|
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}:"
|
package/bin/_cctally_update.py
CHANGED
|
@@ -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) ->
|
|
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
|
-
|
|
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
|