cctally 1.99.0 → 1.100.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.
- package/CHANGELOG.md +52 -0
- package/bin/_cctally_dashboard.py +1370 -190
- package/bin/_cctally_dashboard_envelope.py +98 -4
- package/bin/_cctally_dashboard_perf.py +433 -0
- package/bin/_cctally_dashboard_sources.py +450 -56
- package/bin/_cctally_db.py +30 -14
- package/bin/_cctally_doctor.py +1331 -1148
- package/bin/_cctally_parser.py +86 -1
- package/bin/_cctally_quota.py +40 -21
- package/bin/_cctally_record.py +37 -2
- package/bin/_cctally_refresh.py +60 -10
- package/bin/_cctally_statusline.py +53 -7
- package/bin/_cctally_tui.py +335 -30
- package/bin/_cctally_update.py +28 -22
- package/bin/_lib_dashboard_sources.py +26 -7
- package/bin/_lib_doctor.py +37 -0
- package/bin/_lib_jsonl.py +4 -2
- package/bin/_lib_perf.py +132 -3
- package/bin/_lib_source_analytics.py +2 -2
- package/bin/_lib_tick_stats.py +538 -0
- package/bin/cctally +19 -7
- package/dashboard/static/assets/index-B5YfQEtn.css +1 -0
- package/dashboard/static/assets/index-Bt59nMMO.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-Bcbm-DNP.js +0 -97
- package/dashboard/static/assets/index-hJP4wlIO.css +0 -1
|
@@ -906,6 +906,7 @@ def _sync_failure_envelope(
|
|
|
906
906
|
|
|
907
907
|
def attributed(
|
|
908
908
|
database: str, *, corruption: bool = False, leg: str | None = None,
|
|
909
|
+
sqlite_busy: bool = False,
|
|
909
910
|
) -> bool:
|
|
910
911
|
for item in attributions or ():
|
|
911
912
|
item_database = (
|
|
@@ -920,9 +921,15 @@ def _sync_failure_envelope(
|
|
|
920
921
|
item.get("leg") if isinstance(item, dict)
|
|
921
922
|
else getattr(item, "leg", None)
|
|
922
923
|
)
|
|
924
|
+
item_busy = (
|
|
925
|
+
item.get("sqlite_busy") if isinstance(item, dict)
|
|
926
|
+
else getattr(item, "sqlite_busy", False)
|
|
927
|
+
)
|
|
923
928
|
if item_database == database and (
|
|
924
929
|
not corruption or bool(item_corruption)
|
|
925
|
-
) and (leg is None or item_leg == leg)
|
|
930
|
+
) and (leg is None or item_leg == leg) and (
|
|
931
|
+
not sqlite_busy or bool(item_busy)
|
|
932
|
+
):
|
|
926
933
|
return True
|
|
927
934
|
return False
|
|
928
935
|
|
|
@@ -977,6 +984,34 @@ def _sync_failure_envelope(
|
|
|
977
984
|
"action": None,
|
|
978
985
|
}
|
|
979
986
|
|
|
987
|
+
# #583 S2 §7. A locked cache.db is named rather than left in the generic
|
|
988
|
+
# bucket. Typed attribution only — a blanket uncorrupted-cache branch
|
|
989
|
+
# would be too broad, because ANY exception raised while reading cache.db
|
|
990
|
+
# produces that attribution and every one would then be told to
|
|
991
|
+
# checkpoint. `database="stats_or_cache"` deliberately does NOT reach here:
|
|
992
|
+
# that value means ownership was not established, and a guess would
|
|
993
|
+
# produce a confidently wrong remedy.
|
|
994
|
+
# Corruption outranks busy, and the gate is required rather than implied by
|
|
995
|
+
# branch order: `attributed(...)` scans the WHOLE attribution list, so
|
|
996
|
+
# without it a tick carrying a corruption-shaped cache attribution AND a
|
|
997
|
+
# separate busy one rendered "cache database busy" and dropped the
|
|
998
|
+
# corruption message — the more urgent of the two, whose remedy is not
|
|
999
|
+
# interchangeable with a checkpoint. The gate names the TYPED corruption
|
|
1000
|
+
# attribution only: a busy attribution still outranks the legacy raw-text
|
|
1001
|
+
# corruption legs below, which is what Preserve 10 asks for.
|
|
1002
|
+
if attributed("cache", sqlite_busy=True) and not attributed(
|
|
1003
|
+
"cache", corruption=True
|
|
1004
|
+
):
|
|
1005
|
+
return {
|
|
1006
|
+
"kind": "cache_busy",
|
|
1007
|
+
"label": "⚠ cache database busy",
|
|
1008
|
+
"detail": (
|
|
1009
|
+
"The dashboard could not complete sync because cache.db "
|
|
1010
|
+
"stayed locked."
|
|
1011
|
+
),
|
|
1012
|
+
"action": "cctally db checkpoint",
|
|
1013
|
+
}
|
|
1014
|
+
|
|
980
1015
|
text = error.casefold()
|
|
981
1016
|
if (
|
|
982
1017
|
"stale maintenance marker" in text
|
|
@@ -1018,6 +1053,43 @@ def _sync_failure_envelope(
|
|
|
1018
1053
|
}
|
|
1019
1054
|
|
|
1020
1055
|
|
|
1056
|
+
def _sync_activity_envelope(activity: "dict | None") -> dict:
|
|
1057
|
+
"""Serialize `_SnapshotRef`'s queue/activity state (#583 S2 spec 6.2).
|
|
1058
|
+
|
|
1059
|
+
Always returns a complete object: a snapshot built before the reference
|
|
1060
|
+
stamped one carries ``None``, and an absent object must read as idle
|
|
1061
|
+
rather than as a missing key the client has to special-case.
|
|
1062
|
+
|
|
1063
|
+
``server_epoch`` is a fixed-length, per-process token with no data content.
|
|
1064
|
+
It is excluded from `bin/cctally-snapshot-measure`'s stable digest and
|
|
1065
|
+
sentinelized by `bin/cctally-dashboard-test`, the same treatment
|
|
1066
|
+
``data_version`` and ``doctor`` already get. Everything else here is real
|
|
1067
|
+
published content and stays in the digest.
|
|
1068
|
+
"""
|
|
1069
|
+
act = activity or {}
|
|
1070
|
+
return {
|
|
1071
|
+
"server_epoch": act.get("server_epoch") or "",
|
|
1072
|
+
"rebuilding": bool(act.get("rebuilding", False)),
|
|
1073
|
+
"requested_id": int(act.get("requested_id", 0)),
|
|
1074
|
+
"started_id": int(act.get("started_id", 0)),
|
|
1075
|
+
"settled_id": int(act.get("settled_id", 0)),
|
|
1076
|
+
"settled_status": act.get("settled_status"),
|
|
1077
|
+
# Tuples are not JSON. Convert here rather than relying on the encoder
|
|
1078
|
+
# rendering one as an array by luck. The isinstance filter is a
|
|
1079
|
+
# fail-open guard: `dict(w)` raises on a non-mapping, and this
|
|
1080
|
+
# serializer runs on every published frame, so one malformed warning
|
|
1081
|
+
# would take down the whole envelope rather than drop one entry. The
|
|
1082
|
+
# test is `Mapping`, not `dict`, because the thing that raises is a
|
|
1083
|
+
# NON-mapping; a mapping that is merely not a `dict` converts fine, and
|
|
1084
|
+
# rejecting it would silently drop a good warning — and this field is
|
|
1085
|
+
# the only place a queued request's deferred outcome is reported.
|
|
1086
|
+
"settled_warnings": [
|
|
1087
|
+
dict(w) for w in (act.get("settled_warnings") or ())
|
|
1088
|
+
if isinstance(w, Mapping)
|
|
1089
|
+
],
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
|
|
1021
1093
|
def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
1022
1094
|
now_utc: "dt.datetime",
|
|
1023
1095
|
monotonic_now: "float | None" = None,
|
|
@@ -1588,6 +1660,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1588
1660
|
# assembled); False on every complete/stable snapshot. ``getattr``
|
|
1589
1661
|
# default keeps positionally-constructed fixture snapshots serializing.
|
|
1590
1662
|
"hydrating": bool(getattr(snap, "hydrating", False)),
|
|
1663
|
+
# #583 S2: queue / activity state, owned by `_SnapshotRef`. Published
|
|
1664
|
+
# unconditionally so a client can always read it; an absent or empty
|
|
1665
|
+
# dict renders as the idle object. Monotonic identifiers rather than a
|
|
1666
|
+
# boolean, because `SSEHub.publish` is latest-wins and the frame that
|
|
1667
|
+
# would prove a particular request finished may never be delivered.
|
|
1668
|
+
"sync_activity": _sync_activity_envelope(
|
|
1669
|
+
getattr(snap, "sync_activity", None)
|
|
1670
|
+
),
|
|
1591
1671
|
"generated_at": _iso_z(snap.generated_at),
|
|
1592
1672
|
# #300: the all-inputs data-version string at build time (changes iff any
|
|
1593
1673
|
# DB leg the detail endpoints read changed; flat on an idle tick). The
|
|
@@ -1858,9 +1938,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1858
1938
|
|
|
1859
1939
|
|
|
1860
1940
|
def _claude_source_session_rows(envelope: dict) -> list:
|
|
1861
|
-
"""The SOURCE-scoped Claude session row lists
|
|
1862
|
-
|
|
1863
|
-
|
|
1941
|
+
"""The SOURCE-scoped Claude session row lists the overlay must cover.
|
|
1942
|
+
|
|
1943
|
+
Since source schema 10 (#583 S3 §4) there is exactly ONE such list, on the
|
|
1944
|
+
physical ``sources.claude`` entry. ``sources.all.data.providers.claude`` is
|
|
1945
|
+
published null, so the mirror candidate below is a no-op that the
|
|
1946
|
+
``isinstance(data, Mapping)`` guard drops; it is retained because
|
|
1947
|
+
``sourceRows.ts::collectSourceSessionRows`` still reads
|
|
1948
|
+
``providers?.claude ?? env.sources.claude.data``, and a tab still running a
|
|
1949
|
+
version 9 bundle after an in-place ``execvp`` update would otherwise get an
|
|
1950
|
+
ungated row list. Removing the null stub and this candidate together is
|
|
1951
|
+
filed as a residual.
|
|
1952
|
+
"""
|
|
1864
1953
|
sources = envelope.get("sources")
|
|
1865
1954
|
if not isinstance(sources, Mapping):
|
|
1866
1955
|
return []
|
|
@@ -1930,6 +2019,11 @@ def _codex_source_session_rows(envelope: dict) -> list:
|
|
|
1930
2019
|
carries one ``account_scopes[*].sessions`` child per account. The client
|
|
1931
2020
|
swaps that child into view when an account chip is focused, so the private
|
|
1932
2021
|
label overlay must cover it under the same per-request transcript gate.
|
|
2022
|
+
|
|
2023
|
+
Since source schema 10 (#583 S3 §4) those lists all live on the physical
|
|
2024
|
+
``sources.codex`` entry. ``sources.all.data.providers.codex`` is published
|
|
2025
|
+
null, so the mirror candidate below is a no-op the ``isinstance`` guard
|
|
2026
|
+
drops; it is retained for the same version 9 reason as the Claude twin.
|
|
1933
2027
|
"""
|
|
1934
2028
|
sources = envelope.get("sources")
|
|
1935
2029
|
if not isinstance(sources, Mapping):
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""`cctally dashboard-perf` — read a running dashboard's tick cost (#583 S1 §3.3).
|
|
2
|
+
|
|
3
|
+
Reads the loopback diagnostic `/api/debug/backend` and renders the three
|
|
4
|
+
things an operator on a slow install needs: the publish period stated
|
|
5
|
+
SEPARATELY for the Codex-active and Codex-idle regimes, the tick-cost
|
|
6
|
+
breakdown with the ingest and builder halves named, and the dispatch mix. It
|
|
7
|
+
also arms and disarms the deep phase trace on the running process, so one
|
|
8
|
+
command covers both halves of F38 and neither answer needs a restart.
|
|
9
|
+
|
|
10
|
+
The command reaches only an IP-literal loopback host, and it is DELIBERATELY
|
|
11
|
+
STRICTER than the server about that. `_lib_transcript_access.is_loopback`
|
|
12
|
+
treats `localhost` and `::1` as loopback names before it tries to parse an IP
|
|
13
|
+
literal, so the endpoint serves `Host: localhost:8789` with 200. This command
|
|
14
|
+
still refuses a name: a name is resolved by the operating system, the client
|
|
15
|
+
cannot verify that the resolver's answer is this machine, and an unambiguous
|
|
16
|
+
target is worth more than the convenience. A hostname that is not a loopback
|
|
17
|
+
name is refused by the server too.
|
|
18
|
+
|
|
19
|
+
It also sends an `Origin` header matching the `Host` it calls, because
|
|
20
|
+
`_check_origin_csrf` rejects a request that carries none. Retaining that check
|
|
21
|
+
is deliberate — the loopback and anti-rebinding gates do not stop a malicious
|
|
22
|
+
page aiming a form POST at `http://127.0.0.1:8789` — and a non-browser client
|
|
23
|
+
can set arbitrary headers regardless, so satisfying it costs nothing.
|
|
24
|
+
|
|
25
|
+
Exit codes follow `docs/cli-contract.md`: 0 for a decoded HTTP 200, 2 for
|
|
26
|
+
argument validation including a non-loopback target, and 3 for connection,
|
|
27
|
+
HTTP, authentication, timeout or malformed-response failures — "no dashboard
|
|
28
|
+
is running" among them.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import ipaddress
|
|
33
|
+
import json
|
|
34
|
+
import sys
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.request
|
|
37
|
+
|
|
38
|
+
from _lib_dashboard_json import encode_dashboard_json, encode_dashboard_json_bytes
|
|
39
|
+
|
|
40
|
+
_TIMEOUT_SECONDS = 5.0
|
|
41
|
+
_DIAGNOSTIC_PATH = "/api/debug/backend"
|
|
42
|
+
_TRACE_PATH = "/api/debug/backend/trace"
|
|
43
|
+
|
|
44
|
+
#: The two regimes the period is reported for. `not_observed` is discarded:
|
|
45
|
+
#: a tick no build's Codex decision reached says nothing about either cost
|
|
46
|
+
#: regime, and folding it into one of them would misreport that regime.
|
|
47
|
+
_REPORTED_REGIMES = ("active", "idle")
|
|
48
|
+
|
|
49
|
+
_REGIME_LABELS = {"active": "Codex-active", "idle": "Codex-idle"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cctally():
|
|
53
|
+
return sys.modules["cctally"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DashboardPerfError(Exception):
|
|
57
|
+
"""A staged failure: exit 3. Carries the message the user sees."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_loopback_target(host: str) -> str:
|
|
61
|
+
"""Return `host` when it is an IP-literal loopback address, else raise.
|
|
62
|
+
|
|
63
|
+
A hostname is rejected even when it resolves to loopback, because the
|
|
64
|
+
endpoint's own anti-rebinding gate requires an IP-literal `Host`.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
address = ipaddress.ip_address(host)
|
|
68
|
+
except ValueError:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"--host must be an IP literal such as 127.0.0.1 or ::1 (got "
|
|
71
|
+
f"{host!r}); a hostname is refused by the endpoint's "
|
|
72
|
+
f"anti-rebinding gate"
|
|
73
|
+
) from None
|
|
74
|
+
if not address.is_loopback:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"--host must be a loopback address (got {host!r}); "
|
|
77
|
+
f"dashboard-perf never contacts a LAN address"
|
|
78
|
+
)
|
|
79
|
+
return host
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _authority(host: str, port: int) -> str:
|
|
83
|
+
if ":" in host: # an IPv6 literal needs brackets
|
|
84
|
+
return f"[{host}]:{port}"
|
|
85
|
+
return f"{host}:{port}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _request(host, port, path, *, token, body=None):
|
|
89
|
+
"""One loopback HTTP round trip. Raises DashboardPerfError on any failure."""
|
|
90
|
+
authority = _authority(host, port)
|
|
91
|
+
url = f"http://{authority}{path}"
|
|
92
|
+
data = None
|
|
93
|
+
headers = {"Origin": f"http://{authority}", "Accept": "application/json"}
|
|
94
|
+
if body is not None:
|
|
95
|
+
data = encode_dashboard_json_bytes(body)
|
|
96
|
+
headers["Content-Type"] = "application/json"
|
|
97
|
+
if token:
|
|
98
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
99
|
+
request = urllib.request.Request(url, data=data, headers=headers,
|
|
100
|
+
method="POST" if data else "GET")
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as resp:
|
|
103
|
+
raw = resp.read()
|
|
104
|
+
except urllib.error.HTTPError as exc:
|
|
105
|
+
detail = {401: "authentication required — pass --token",
|
|
106
|
+
403: "refused by the loopback gate",
|
|
107
|
+
404: "this dashboard predates dashboard-perf"}.get(
|
|
108
|
+
exc.code, "unexpected response")
|
|
109
|
+
raise DashboardPerfError(
|
|
110
|
+
f"{url} answered HTTP {exc.code}: {detail}") from None
|
|
111
|
+
except urllib.error.URLError as exc:
|
|
112
|
+
raise DashboardPerfError(
|
|
113
|
+
f"cannot reach {url}: {exc.reason}. Is a dashboard running on "
|
|
114
|
+
f"port {port}?") from None
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
raise DashboardPerfError(f"cannot reach {url}: {exc}") from None
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(raw.decode("utf-8"))
|
|
119
|
+
except (ValueError, UnicodeDecodeError):
|
|
120
|
+
raise DashboardPerfError(
|
|
121
|
+
f"{url} returned a malformed response") from None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── the per-regime derivation (spec §3.3) ───────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def summarise_regime_periods(records) -> dict:
|
|
128
|
+
"""Partition the ring by `codex_regime` and describe each reported regime.
|
|
129
|
+
|
|
130
|
+
Discards `not_observed` and every record whose `period_ns` is null — the
|
|
131
|
+
first publish of a process has no predecessor, so it measures no period.
|
|
132
|
+
|
|
133
|
+
Reports the MEDIAN rather than the mean, because one startup or recovery
|
|
134
|
+
outlier otherwise dominates a 64-sample window; the observed range is
|
|
135
|
+
reported beside it so the outlier is still visible.
|
|
136
|
+
"""
|
|
137
|
+
# Imported HERE, not at module scope. `bin/cctally` re-exports this
|
|
138
|
+
# module eagerly, so a top-level import is paid by `statusline` on every
|
|
139
|
+
# Claude Code prompt and by `hook-tick` on every tool batch — measured at
|
|
140
|
+
# about 2.6 ms on `cctally --version` — for one `median` call in a command
|
|
141
|
+
# neither of them runs. The other importers in this tree
|
|
142
|
+
# (`_cctally_forecast.py`, `_lib_cache_report.py`) do the same.
|
|
143
|
+
import statistics
|
|
144
|
+
|
|
145
|
+
buckets = {regime: [] for regime in _REPORTED_REGIMES}
|
|
146
|
+
for record in records or ():
|
|
147
|
+
regime = record.get("codex_regime")
|
|
148
|
+
period = record.get("period_ns")
|
|
149
|
+
if regime in buckets and period is not None:
|
|
150
|
+
buckets[regime].append(int(period))
|
|
151
|
+
summary = {}
|
|
152
|
+
for regime, samples in buckets.items():
|
|
153
|
+
if not samples:
|
|
154
|
+
summary[regime] = {"samples": 0, "median_ns": None,
|
|
155
|
+
"min_ns": None, "max_ns": None}
|
|
156
|
+
continue
|
|
157
|
+
summary[regime] = {
|
|
158
|
+
"samples": len(samples),
|
|
159
|
+
"median_ns": int(statistics.median(samples)),
|
|
160
|
+
"min_ns": min(samples),
|
|
161
|
+
"max_ns": max(samples),
|
|
162
|
+
}
|
|
163
|
+
return summary
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def summarise_all_periods(records) -> dict:
|
|
167
|
+
"""The publish period over EVERY tick, whatever its Codex regime.
|
|
168
|
+
|
|
169
|
+
The two regime rows refine this; they do not gate it. A dispatch-`idle`
|
|
170
|
+
tick returns before `_tui_build_source_bundle`, so no build reaches the
|
|
171
|
+
Codex decision and the tick is stamped `not_observed`, which the regime
|
|
172
|
+
partition discards — correctly, because a tick that observed no decision
|
|
173
|
+
says nothing about either cost regime. On a mostly-idle install that left
|
|
174
|
+
the flagship figure reading `no samples yet` on both rows while every
|
|
175
|
+
record after the first carried a correct `period_ns`, so the operator
|
|
176
|
+
learned nothing about an install whose period was perfectly well measured.
|
|
177
|
+
"""
|
|
178
|
+
samples = [int(r["period_ns"]) for r in records or ()
|
|
179
|
+
if r.get("period_ns") is not None]
|
|
180
|
+
if not samples:
|
|
181
|
+
return {"samples": 0, "median_ns": None, "min_ns": None,
|
|
182
|
+
"max_ns": None}
|
|
183
|
+
import statistics
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"samples": len(samples),
|
|
187
|
+
"median_ns": int(statistics.median(samples)),
|
|
188
|
+
"min_ns": min(samples),
|
|
189
|
+
"max_ns": max(samples),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _seconds(ns) -> str:
|
|
194
|
+
return "—" if ns is None else f"{ns / 1_000_000_000:.2f}s"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _millis(ns) -> str:
|
|
198
|
+
return "—" if ns is None else f"{ns / 1_000_000:.0f}ms"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _mean_or_none(values):
|
|
202
|
+
return int(sum(values) / len(values)) if values else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _median(values):
|
|
206
|
+
import statistics
|
|
207
|
+
|
|
208
|
+
return int(statistics.median(values))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _render_conversation_sync(tick: dict) -> list:
|
|
212
|
+
"""The second work loop's cost, beside the first's (#583 S4 / F5).
|
|
213
|
+
|
|
214
|
+
The share is `sum(cpu_ns) / sum(period_ns)` over the passes that carry a
|
|
215
|
+
forward period — a same-process ratio, so it is a measurement rather than a
|
|
216
|
+
machine-speed assertion. `period_ns` is `start[i+1] - start[i]`, so the
|
|
217
|
+
NEWEST retained pass has none: its successor has not started yet. That pass
|
|
218
|
+
contributes to neither sum, which is what keeps numerator and denominator
|
|
219
|
+
spanning the same window. Over the passes that do pair, the periods
|
|
220
|
+
telescope to `start[last] - start[first]` and each pass's CPU falls inside
|
|
221
|
+
its own period, so the ratio is the loop's true duty over that span.
|
|
222
|
+
|
|
223
|
+
Rows are read defensively. A running dashboard older or newer than this
|
|
224
|
+
reader can publish a different field set, and a diagnostic must degrade
|
|
225
|
+
rather than take itself down. A period of zero or less is dropped together
|
|
226
|
+
with its CPU: it cannot be a real interval, and a negative one would
|
|
227
|
+
subtract from the denominator.
|
|
228
|
+
"""
|
|
229
|
+
rows = tick.get("conversation_sync") or []
|
|
230
|
+
lines = ["", "Conversation sync loop"]
|
|
231
|
+
if not rows:
|
|
232
|
+
# The same literal the regime rows use. A loop with no samples must
|
|
233
|
+
# never render as a zero, which cannot be told apart from a measured
|
|
234
|
+
# idle loop. Under `--no-sync` the thread never starts, so this is that
|
|
235
|
+
# mode's correct and permanent reading.
|
|
236
|
+
lines.append(f" {'passes':<14} no samples yet")
|
|
237
|
+
return lines
|
|
238
|
+
durations = [int(r.get("duration_ns") or 0) for r in rows]
|
|
239
|
+
cpus = [int(r.get("cpu_ns") or 0) for r in rows]
|
|
240
|
+
paired = []
|
|
241
|
+
for record in rows:
|
|
242
|
+
cpu = record.get("cpu_ns")
|
|
243
|
+
period = record.get("period_ns")
|
|
244
|
+
if cpu is None or period is None or int(period) <= 0:
|
|
245
|
+
continue
|
|
246
|
+
paired.append((int(cpu), int(period)))
|
|
247
|
+
lines.append(
|
|
248
|
+
f" {'wall':<14} mean {_millis(_mean_or_none(durations))} "
|
|
249
|
+
f"over {len(rows)} pass(es)")
|
|
250
|
+
lines.append(f" {'thread cpu':<14} mean {_millis(_mean_or_none(cpus))}")
|
|
251
|
+
if paired:
|
|
252
|
+
periods = [p for _, p in paired]
|
|
253
|
+
lines.append(
|
|
254
|
+
f" {'period':<14} median {_seconds(_median(periods))} "
|
|
255
|
+
f"(range {_seconds(min(periods))}–{_seconds(max(periods))})")
|
|
256
|
+
share = sum(c for c, _ in paired) / sum(periods)
|
|
257
|
+
lines.append(f" {'cpu share':<14} {share * 100:.1f}% of one core")
|
|
258
|
+
else:
|
|
259
|
+
lines.append(f" {'period':<14} no samples yet")
|
|
260
|
+
statuses: dict = {}
|
|
261
|
+
for record in rows:
|
|
262
|
+
raw = record.get("status")
|
|
263
|
+
# A row with no status is malformed, not an outcome named `None`.
|
|
264
|
+
key = raw if isinstance(raw, str) and raw else "malformed"
|
|
265
|
+
statuses[key] = statuses.get(key, 0) + 1
|
|
266
|
+
detail = " · ".join(f"{k} {v}" for k, v in sorted(statuses.items()))
|
|
267
|
+
lines.append(f" {'status':<14} {detail}")
|
|
268
|
+
return lines
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def render_dashboard_perf(payload: dict) -> str:
|
|
272
|
+
"""The human report. Pure — takes the decoded diagnostic, returns text."""
|
|
273
|
+
tick = payload.get("tick") or {}
|
|
274
|
+
tracing = payload.get("tracing") or {}
|
|
275
|
+
records = tick.get("records") or []
|
|
276
|
+
lines = ["cctally dashboard-perf", ""]
|
|
277
|
+
|
|
278
|
+
lines.append("Publish period")
|
|
279
|
+
overall = summarise_all_periods(records)
|
|
280
|
+
if overall["samples"] == 0:
|
|
281
|
+
lines.append(f" {'all ticks':<14} no samples yet (0 of "
|
|
282
|
+
f"{len(records)} retained ticks qualify)")
|
|
283
|
+
else:
|
|
284
|
+
lines.append(
|
|
285
|
+
f" {'all ticks':<14} median {_seconds(overall['median_ns'])} "
|
|
286
|
+
f"over {overall['samples']} sample(s), "
|
|
287
|
+
f"range {_seconds(overall['min_ns'])}–"
|
|
288
|
+
f"{_seconds(overall['max_ns'])}")
|
|
289
|
+
summary = summarise_regime_periods(records)
|
|
290
|
+
for regime in _REPORTED_REGIMES:
|
|
291
|
+
stats = summary[regime]
|
|
292
|
+
label = _REGIME_LABELS[regime]
|
|
293
|
+
if stats["samples"] == 0:
|
|
294
|
+
# Stated, never inferred. A zero or a dash cannot distinguish
|
|
295
|
+
# "measured and fast" from "not measured", and telling those two
|
|
296
|
+
# apart is the whole point of this surface.
|
|
297
|
+
lines.append(f" {label:<14} no samples yet (0 of {len(records)} "
|
|
298
|
+
f"retained ticks qualify)")
|
|
299
|
+
continue
|
|
300
|
+
lines.append(
|
|
301
|
+
f" {label:<14} median {_seconds(stats['median_ns'])} "
|
|
302
|
+
f"over {stats['samples']} sample(s), "
|
|
303
|
+
f"range {_seconds(stats['min_ns'])}–{_seconds(stats['max_ns'])}")
|
|
304
|
+
lines.append("")
|
|
305
|
+
|
|
306
|
+
lines.append("Tick cost (exclusive halves; the remainder is orchestration)")
|
|
307
|
+
if records:
|
|
308
|
+
ingest = [r["ingest_ns"] for r in records if r.get("ingest_ran")]
|
|
309
|
+
builder = [r.get("builder_ns", 0) for r in records]
|
|
310
|
+
duration = [r.get("duration_ns", 0) for r in records]
|
|
311
|
+
lines.append(
|
|
312
|
+
f" ingest mean {_millis(_mean_or_none(ingest))} "
|
|
313
|
+
f"over {len(ingest)} tick(s) that ingested")
|
|
314
|
+
lines.append(
|
|
315
|
+
f" builder mean {_millis(_mean_or_none(builder))} "
|
|
316
|
+
f"over {len(builder)} tick(s)")
|
|
317
|
+
lines.append(
|
|
318
|
+
f" whole tick mean {_millis(_mean_or_none(duration))}")
|
|
319
|
+
# #583 S5 §2.4: the cache.db read pin, measured at its own BEGIN and
|
|
320
|
+
# ROLLBACK boundaries. Reported separately from `builder` because it
|
|
321
|
+
# is a SUBSET of builder time rather than a third exclusive half, and
|
|
322
|
+
# `.get(..., 0)` keeps an older record without the field readable.
|
|
323
|
+
pin = [r.get("cache_pin_ns", 0) or 0 for r in records]
|
|
324
|
+
lines.append(
|
|
325
|
+
f" cache pin mean {_millis(_mean_or_none(pin))} "
|
|
326
|
+
f"held inside builder")
|
|
327
|
+
newest = records[-1]
|
|
328
|
+
lines.append(
|
|
329
|
+
f" newest tick seq {newest.get('seq')} "
|
|
330
|
+
f"{newest.get('dispatch')}/{newest.get('codex_regime')}"
|
|
331
|
+
f"{'/cold' if newest.get('cold') else '/warm'} "
|
|
332
|
+
f"ingest {_millis(newest.get('ingest_ns'))} "
|
|
333
|
+
f"builder {_millis(newest.get('builder_ns'))} "
|
|
334
|
+
f"pin {_millis(newest.get('cache_pin_ns', 0) or 0)} "
|
|
335
|
+
f"total {_millis(newest.get('duration_ns'))}")
|
|
336
|
+
else:
|
|
337
|
+
lines.append(" no ticks recorded yet")
|
|
338
|
+
standalone = tick.get("standalone")
|
|
339
|
+
if standalone:
|
|
340
|
+
lines.append(
|
|
341
|
+
f" standalone builder {_millis(standalone.get('builder_ns'))} "
|
|
342
|
+
f"total {_millis(standalone.get('duration_ns'))} "
|
|
343
|
+
f"(the last build made outside a refresh tick)")
|
|
344
|
+
lines.extend(_render_conversation_sync(tick))
|
|
345
|
+
lines.append("")
|
|
346
|
+
|
|
347
|
+
counts = tick.get("dispatch_counts") or {}
|
|
348
|
+
lines.append(
|
|
349
|
+
f"Dispatch mix full {counts.get('full', 0)} · "
|
|
350
|
+
f"idle {counts.get('idle', 0)} · degraded {counts.get('degraded', 0)} "
|
|
351
|
+
f"(of {tick.get('tick_seq', 0)} completed ticks)")
|
|
352
|
+
|
|
353
|
+
failures = tick.get("cache_open_failures") or {}
|
|
354
|
+
if any(failures.values()):
|
|
355
|
+
detail = " · ".join(f"{k} {v}" for k, v in sorted(failures.items()))
|
|
356
|
+
lines.append(f"Group A cache-open failures {detail}")
|
|
357
|
+
lines.append(" These are silent: each one falls back to the wide "
|
|
358
|
+
"from-scratch fetch with byte-identical output.")
|
|
359
|
+
else:
|
|
360
|
+
lines.append("Group A cache-open failures none")
|
|
361
|
+
|
|
362
|
+
applied = tracing.get("applied")
|
|
363
|
+
requested = tracing.get("requested")
|
|
364
|
+
applies_at = tracing.get("applies_at", "none")
|
|
365
|
+
state = "on" if applied else "off"
|
|
366
|
+
lines.append(f"Phase trace applied {state} · requested "
|
|
367
|
+
f"{'on' if requested else 'off'} · applies_at {applies_at}")
|
|
368
|
+
if payload.get("phases") is not None:
|
|
369
|
+
# `generated_at` IS the stored tree's instant, from the same slot the
|
|
370
|
+
# tree comes from — which is what makes a stale tree readable as stale
|
|
371
|
+
# rather than as the last tick.
|
|
372
|
+
lines.append(f" a stored phase tree is available, generated at "
|
|
373
|
+
f"{payload.get('generated_at')}")
|
|
374
|
+
else:
|
|
375
|
+
lines.append(" no phase tree stored — arm one with "
|
|
376
|
+
"`cctally dashboard-perf --trace on`")
|
|
377
|
+
return "\n".join(lines) + "\n"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ── the command ─────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def cmd_dashboard_perf(args) -> int:
|
|
384
|
+
c = _cctally()
|
|
385
|
+
as_json = bool(getattr(args, "json", False))
|
|
386
|
+
try:
|
|
387
|
+
host = resolve_loopback_target(getattr(args, "host", None)
|
|
388
|
+
or "127.0.0.1")
|
|
389
|
+
except ValueError as exc:
|
|
390
|
+
print(f"dashboard-perf: {exc}", file=sys.stderr)
|
|
391
|
+
return 2
|
|
392
|
+
port = c._resolve_dashboard_port(getattr(args, "port", None))
|
|
393
|
+
if not isinstance(port, int) or not 1 <= port <= 65535:
|
|
394
|
+
# Argument validation, so exit 2. Left unchecked, a nonsensical port
|
|
395
|
+
# failed at connect time and reported itself as a transport failure,
|
|
396
|
+
# which `docs/cli-contract.md` reserves for a real one.
|
|
397
|
+
print(f"dashboard-perf: --port must be between 1 and 65535 (got "
|
|
398
|
+
f"{getattr(args, 'port', None)!r})", file=sys.stderr)
|
|
399
|
+
return 2
|
|
400
|
+
|
|
401
|
+
token = getattr(args, "token", None)
|
|
402
|
+
trace = getattr(args, "trace", None)
|
|
403
|
+
try:
|
|
404
|
+
trace_result = None
|
|
405
|
+
if trace is not None:
|
|
406
|
+
trace_result = _request(
|
|
407
|
+
host, port, _TRACE_PATH, token=token,
|
|
408
|
+
body={"enabled": trace == "on"},
|
|
409
|
+
)
|
|
410
|
+
payload = _request(host, port, _DIAGNOSTIC_PATH, token=token)
|
|
411
|
+
except DashboardPerfError as exc:
|
|
412
|
+
if as_json:
|
|
413
|
+
print(encode_dashboard_json(c.stamp_schema_version(
|
|
414
|
+
{"status": "error", "error": str(exc), "diagnostic": None})))
|
|
415
|
+
else:
|
|
416
|
+
print(f"dashboard-perf: {exc}", file=sys.stderr)
|
|
417
|
+
return 3
|
|
418
|
+
|
|
419
|
+
if as_json:
|
|
420
|
+
# `diagnostic` passes the server payload through VERBATIM and stays
|
|
421
|
+
# explicitly opaque, consistent with what `bin/_lib_perf.py` promises
|
|
422
|
+
# about phase names. The stamped wrapper is the stable part.
|
|
423
|
+
print(encode_dashboard_json(c.stamp_schema_version(
|
|
424
|
+
{"status": "ok", "diagnostic": payload})))
|
|
425
|
+
return 0
|
|
426
|
+
|
|
427
|
+
if trace_result is not None:
|
|
428
|
+
print(f"dashboard-perf: trace {trace} requested "
|
|
429
|
+
f"(requested={trace_result.get('requested')}, "
|
|
430
|
+
f"applied={trace_result.get('applied')}, "
|
|
431
|
+
f"applies_at={trace_result.get('applies_at')})")
|
|
432
|
+
sys.stdout.write(render_dashboard_perf(payload))
|
|
433
|
+
return 0
|