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
package/bin/_cctally_parser.py
CHANGED
|
@@ -57,6 +57,22 @@ class CLIHelpFormatter(
|
|
|
57
57
|
kwargs.setdefault("max_help_position", 30)
|
|
58
58
|
super().__init__(prog, **kwargs) # type: ignore[arg-type]
|
|
59
59
|
|
|
60
|
+
def _get_help_string(self, action: argparse.Action) -> "str | None":
|
|
61
|
+
"""Never state a default for a required argument.
|
|
62
|
+
|
|
63
|
+
CPython 3.13 stopped appending ``(default: ...)`` to a required
|
|
64
|
+
argument's help; 3.11 and 3.12 still append it. Without this the same
|
|
65
|
+
parser prints two different help texts depending on the interpreter,
|
|
66
|
+
so `cctally account attribute --help` renders a meaningless
|
|
67
|
+
``(default: None)`` under `--since` on the two older versions and the
|
|
68
|
+
goldened help diverges on every lane that is not 3.13. Pinning the
|
|
69
|
+
3.13 rule for every supported version is what makes `--help` a
|
|
70
|
+
property of this program rather than of the interpreter running it.
|
|
71
|
+
"""
|
|
72
|
+
if action.required:
|
|
73
|
+
return action.help
|
|
74
|
+
return super()._get_help_string(action)
|
|
75
|
+
|
|
60
76
|
|
|
61
77
|
_PHYSICAL_PROVIDER_SOURCES = ("claude", "codex")
|
|
62
78
|
|
|
@@ -3197,7 +3213,7 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
|
|
|
3197
3213
|
)
|
|
3198
3214
|
db_checkpoint.add_argument(
|
|
3199
3215
|
"--db",
|
|
3200
|
-
choices=("cache", "stats"),
|
|
3216
|
+
choices=("cache", "conversations", "stats"),
|
|
3201
3217
|
default="cache",
|
|
3202
3218
|
help="Which DB to checkpoint (default: cache)",
|
|
3203
3219
|
)
|
|
@@ -3227,6 +3243,74 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
|
|
|
3227
3243
|
)
|
|
3228
3244
|
db_vacuum.set_defaults(func=c.cmd_db_vacuum)
|
|
3229
3245
|
|
|
3246
|
+
|
|
3247
|
+
def _build_dashboard_perf_parser(subparsers, name, *, help_text, xref=None):
|
|
3248
|
+
"""Build the `dashboard-perf` parser (#583 S1 §3.3).
|
|
3249
|
+
|
|
3250
|
+
Follows the `_build_doctor_parser` shape: `c = _cctally()` is resolved
|
|
3251
|
+
INSIDE the function, and the parser finishes with `set_defaults`.
|
|
3252
|
+
"""
|
|
3253
|
+
c = _cctally()
|
|
3254
|
+
p = subparsers.add_parser(
|
|
3255
|
+
name,
|
|
3256
|
+
help=help_text,
|
|
3257
|
+
formatter_class=CLIHelpFormatter,
|
|
3258
|
+
description=textwrap.dedent(
|
|
3259
|
+
"""\
|
|
3260
|
+
Read a running dashboard's tick cost, and arm its phase trace.
|
|
3261
|
+
|
|
3262
|
+
Reports the publish period SEPARATELY for the Codex-active and
|
|
3263
|
+
Codex-idle regimes (median plus the observed range, with an
|
|
3264
|
+
explicit "no samples yet" for a regime that has none), the
|
|
3265
|
+
tick-cost breakdown with the mutually exclusive ingest and builder
|
|
3266
|
+
halves named, the dispatch mix, any silent Group A cache-open
|
|
3267
|
+
failures, and the trace state.
|
|
3268
|
+
|
|
3269
|
+
--trace on/off arms or disarms the deep phase trace on the running
|
|
3270
|
+
process. The flip takes effect at the dashboard's next
|
|
3271
|
+
authoritative build, which is why the report states the requested
|
|
3272
|
+
and the applied state separately.
|
|
3273
|
+
|
|
3274
|
+
Connects only to an IP-literal loopback host and never to a LAN
|
|
3275
|
+
address. A hostname is refused, `localhost` included, which is
|
|
3276
|
+
stricter than the endpoint itself: the server treats `localhost`
|
|
3277
|
+
as a loopback name and would serve it. The client refuses a name
|
|
3278
|
+
because it cannot verify that the resolver's answer is this
|
|
3279
|
+
machine.
|
|
3280
|
+
|
|
3281
|
+
Exit codes: 0 on a decoded HTTP 200, 2 on argument validation
|
|
3282
|
+
including a non-loopback target or an out-of-range port, 3 on a
|
|
3283
|
+
connection, HTTP, authentication, timeout or malformed-response
|
|
3284
|
+
failure — including "no dashboard is running".
|
|
3285
|
+
|
|
3286
|
+
See docs/commands/dashboard-perf.md.
|
|
3287
|
+
"""
|
|
3288
|
+
),
|
|
3289
|
+
)
|
|
3290
|
+
p.add_argument(
|
|
3291
|
+
"--host", default="127.0.0.1",
|
|
3292
|
+
help="Loopback IP literal of the running dashboard (default 127.0.0.1)",
|
|
3293
|
+
)
|
|
3294
|
+
p.add_argument(
|
|
3295
|
+
"--port", type=int, default=None,
|
|
3296
|
+
help="Dashboard port (default: the same default `cctally dashboard` uses)",
|
|
3297
|
+
)
|
|
3298
|
+
p.add_argument(
|
|
3299
|
+
"--token", default=None,
|
|
3300
|
+
help="Bearer token, when the dashboard minted one for LAN access",
|
|
3301
|
+
)
|
|
3302
|
+
p.add_argument(
|
|
3303
|
+
"--trace", choices=("on", "off"), default=None,
|
|
3304
|
+
help="Arm or disarm the deep phase trace on the running process",
|
|
3305
|
+
)
|
|
3306
|
+
p.add_argument(
|
|
3307
|
+
"--json", action="store_true",
|
|
3308
|
+
help="Emit machine-readable JSON to stdout (schemaVersion: 1)",
|
|
3309
|
+
)
|
|
3310
|
+
p.set_defaults(func=c.cmd_dashboard_perf)
|
|
3311
|
+
return p
|
|
3312
|
+
|
|
3313
|
+
|
|
3230
3314
|
def _build_doctor_parser(subparsers, name, *, help_text, xref=None):
|
|
3231
3315
|
"""Build the `doctor` parser (registered via _REGISTRATION; #279 S6 W3).
|
|
3232
3316
|
|
|
@@ -3660,6 +3744,7 @@ _REGISTRATION = (
|
|
|
3660
3744
|
_Reg('setup', _build_setup_parser, "Install provider hooks/handlers + symlinks", None, None),
|
|
3661
3745
|
_Reg('db', _build_db_parser, "Migration / DB management (status, skip, unskip)", None, None),
|
|
3662
3746
|
_Reg('doctor', _build_doctor_parser, "Diagnose data freshness and install state", None, None),
|
|
3747
|
+
_Reg('dashboard-perf', _build_dashboard_perf_parser, "Read a running dashboard's tick cost; arm its phase trace", None, None),
|
|
3663
3748
|
_Reg('pricing-check', _build_pricing_check_parser, "Detect stale or missing embedded model pricing", None, None),
|
|
3664
3749
|
_Reg('hook-tick', _build_hook_tick_parser, argparse.SUPPRESS, None, None),
|
|
3665
3750
|
_Reg('__preview', _build_preview_parser, argparse.SUPPRESS, None, lambda c: getattr(c, "cmd_preview", None) is not None),
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -253,14 +253,26 @@ PROJECTION_READ_SITE_ACTIONS: "dict[str, str]" = {
|
|
|
253
253
|
#: an unnamed caller reduces `gate_at_caller` to an assertion nothing tests, and
|
|
254
254
|
#: that is how the first version of this map came to claim a gating caller that
|
|
255
255
|
#: neither called the kernel nor called the gate.
|
|
256
|
+
#:
|
|
257
|
+
#: `_cctally_dashboard_sources.py::_codex_quota_reuse_identity` joined the list
|
|
258
|
+
#: with #583 S5. It derives `codex_stats_digest` when its caller cannot supply
|
|
259
|
+
#: one, which is the share path and the per-request cycle-detail route — neither
|
|
260
|
+
#: of which computes a build signature, so neither had already gated. It gates
|
|
261
|
+
#: and, uniquely among the named callers, returns `None` on
|
|
262
|
+
#: `QuotaProjectionIncomplete` rather than propagating: it produces a cache KEY,
|
|
263
|
+
#: and the fail-safe for an identity that cannot be established is a cold read.
|
|
264
|
+
#: The retry signal is not lost, because its callers reach a `gate`-classified
|
|
265
|
+
#: projection read within a few statements.
|
|
256
266
|
PROJECTION_GATE_CALLERS: "dict[str, tuple[str, ...]]" = {
|
|
257
267
|
"_lib_dashboard_sources.py::<module>::quota_projection_state": (
|
|
258
268
|
"_cctally_tui.py::_tui_build_source_bundle",
|
|
259
269
|
"_cctally_tui.py::_tui_compute_dispatch_signature",
|
|
270
|
+
"_cctally_dashboard_sources.py::_codex_quota_reuse_identity",
|
|
260
271
|
),
|
|
261
272
|
"_lib_dashboard_sources.py::<module>::quota_window_blocks": (
|
|
262
273
|
"_cctally_tui.py::_tui_build_source_bundle",
|
|
263
274
|
"_cctally_tui.py::_tui_compute_dispatch_signature",
|
|
275
|
+
"_cctally_dashboard_sources.py::_codex_quota_reuse_identity",
|
|
264
276
|
),
|
|
265
277
|
}
|
|
266
278
|
|
|
@@ -1908,13 +1920,14 @@ def load_codex_quota_observations(
|
|
|
1908
1920
|
because SQL compares seconds while ``physical_order_key`` compares full
|
|
1909
1921
|
datetimes and then breaks ties on the reset anchor and physical position.
|
|
1910
1922
|
|
|
1911
|
-
The residual
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1923
|
+
The residual differences are malformed or non-canonical instants. A row
|
|
1924
|
+
that survives SQLite's predicates but still fails Python parsing can hide
|
|
1925
|
+
an older valid capture of the same window. In the reverse direction,
|
|
1926
|
+
``datetime.fromisoformat`` accepts basic-format ISO instants such as
|
|
1927
|
+
``20260814T120000Z`` that SQLite's ``unixepoch`` rejects, so the SQL path
|
|
1928
|
+
drops that row and may let an older capture win. Neither shape is emitted
|
|
1929
|
+
by ``_utc_iso``; both are confined to a store carrying hand-written or
|
|
1930
|
+
corrupt quota rows.
|
|
1918
1931
|
"""
|
|
1919
1932
|
for name, value in (
|
|
1920
1933
|
("captured_at_or_after", captured_at_or_after), ("active_at", active_at),
|
|
@@ -2079,23 +2092,29 @@ def load_codex_quota_observations(
|
|
|
2079
2092
|
else:
|
|
2080
2093
|
sql += " AND unixepoch(captured_at_utc) >= unixepoch(?)"
|
|
2081
2094
|
params.append(_utc_iso(captured_at_or_after))
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2095
|
+
# ``latest_per_identity`` replaces the base query wholesale below, so
|
|
2096
|
+
# assembling its ORDER BY here would be dead work.
|
|
2097
|
+
if not latest_per_identity:
|
|
2098
|
+
if sql_bounded and max_rows is not None:
|
|
2099
|
+
if active_at is not None:
|
|
2100
|
+
sql += (
|
|
2101
|
+
" ORDER BY (unixepoch(resets_at_utc) > unixepoch(?)) DESC, "
|
|
2102
|
+
"unixepoch(captured_at_utc) DESC, unixepoch(resets_at_utc) DESC, "
|
|
2103
|
+
"source_path DESC, line_offset DESC"
|
|
2104
|
+
)
|
|
2105
|
+
params.append(_utc_iso(active_at))
|
|
2106
|
+
else:
|
|
2107
|
+
sql += (
|
|
2108
|
+
" ORDER BY unixepoch(captured_at_utc) DESC, "
|
|
2109
|
+
"unixepoch(resets_at_utc) DESC, source_path DESC, line_offset DESC"
|
|
2110
|
+
)
|
|
2111
|
+
sql += " LIMIT ?"
|
|
2112
|
+
params.append(max_rows)
|
|
2090
2113
|
else:
|
|
2091
2114
|
sql += (
|
|
2092
|
-
" ORDER BY
|
|
2093
|
-
"
|
|
2115
|
+
" ORDER BY source_root_key, captured_at_utc, resets_at_utc, "
|
|
2116
|
+
"source_path, line_offset"
|
|
2094
2117
|
)
|
|
2095
|
-
sql += " LIMIT ?"
|
|
2096
|
-
params.append(max_rows)
|
|
2097
|
-
else:
|
|
2098
|
-
sql += " ORDER BY source_root_key, captured_at_utc, resets_at_utc, source_path, line_offset"
|
|
2099
2118
|
# ONE SHARD PER GROUP (public #5), not one disjunction over all of them.
|
|
2100
2119
|
# Measured on a 211K-row / 608-group store: an OR over the five-member
|
|
2101
2120
|
# equality gives up and SCANs the table (2 groups 45.7ms, 3 groups
|
package/bin/_cctally_record.py
CHANGED
|
@@ -4172,6 +4172,8 @@ def cmd_record_usage(
|
|
|
4172
4172
|
args: argparse.Namespace, *,
|
|
4173
4173
|
ingest_mode: str = "authoritative",
|
|
4174
4174
|
writer: str = "record-usage",
|
|
4175
|
+
nudge_dashboard: bool = True,
|
|
4176
|
+
nudge_sink=None,
|
|
4175
4177
|
) -> int:
|
|
4176
4178
|
"""Record usage from the Claude Code status line rate_limits — DB journal
|
|
4177
4179
|
redesign reroute (Appendix A).
|
|
@@ -4187,7 +4189,23 @@ def cmd_record_usage(
|
|
|
4187
4189
|
observes its own write synchronously; "opportunistic" (hook-tick OAuth /
|
|
4188
4190
|
dedup ticks) skips a busy ingest lock and lets the winner consume the line.
|
|
4189
4191
|
``writer`` is the obs line ``src``. Returns 0 on a recorded/deduped tick, 2
|
|
4190
|
-
on an implausible weekly resets_at.
|
|
4192
|
+
on an implausible weekly resets_at.
|
|
4193
|
+
|
|
4194
|
+
``nudge_dashboard`` (#583 S2 §5.3) enqueues a rebuild on a locally running
|
|
4195
|
+
dashboard when — and only when — the ingest emitted events. Pass False
|
|
4196
|
+
from `_refresh_usage_inproc`, which runs inside the dashboard's own
|
|
4197
|
+
``sync_lock`` while servicing a ``refresh=1`` and whose caller
|
|
4198
|
+
``cmd_refresh_usage`` already nudges once itself.
|
|
4199
|
+
|
|
4200
|
+
``nudge_sink`` is for callers that hold a cross-process lock across this
|
|
4201
|
+
call. When supplied it is invoked INSTEAD of the nudge, and that caller
|
|
4202
|
+
fires the real nudge once its critical section has ended. The nudge is a
|
|
4203
|
+
loopback POST with a multi-second timeout, so a stalled nudge inside such a
|
|
4204
|
+
section stalls every other process contending on the same lock. There are
|
|
4205
|
+
two such locks and both supply a sink: ``_selected_state_lock`` (the OAuth
|
|
4206
|
+
refresh and the authoritative statusline publication) and the statusline
|
|
4207
|
+
persist flock (``_statusline_reduce_and_publish``, reached from the forked
|
|
4208
|
+
persist child and from the ``sync_for_test`` foreground path)."""
|
|
4191
4209
|
|
|
4192
4210
|
# ULP-noise sanitization is applied at the cmd_record_usage ingress
|
|
4193
4211
|
# boundary so every downstream consumer (HWM files, DB rows,
|
|
@@ -4310,7 +4328,24 @@ def cmd_record_usage(
|
|
|
4310
4328
|
account=obs_account))
|
|
4311
4329
|
# authoritative observes its own write synchronously; opportunistic skips a
|
|
4312
4330
|
# busy ingest lock and lets the current holder consume the appended line.
|
|
4313
|
-
_jr.run_stats_ingest(mode=ingest_mode)
|
|
4331
|
+
result = _jr.run_stats_ingest(mode=ingest_mode)
|
|
4332
|
+
# #583 S2 §5.3. Nudge only on a MATERIAL change. This runs at Claude
|
|
4333
|
+
# Code's status-line cadence, so an unconditional nudge would queue a
|
|
4334
|
+
# rebuild for work that changed nothing displayed. `consumed` is the wrong
|
|
4335
|
+
# signal — unchanged observations advance ingestion without changing
|
|
4336
|
+
# anything the dashboard shows — and `alerts` is the wrong signal, because
|
|
4337
|
+
# it covers only a subset of material events: a new 5-hour window changes
|
|
4338
|
+
# the dashboard without necessarily firing an alert. The nudge happens
|
|
4339
|
+
# after `run_stats_ingest` returns, which is already after post-commit
|
|
4340
|
+
# alert dispatch, so the alerts ordering is untouched.
|
|
4341
|
+
if (nudge_dashboard
|
|
4342
|
+
and getattr(result, "ran", False)
|
|
4343
|
+
and getattr(result, "error", None) is None
|
|
4344
|
+
and int(getattr(result, "events_emitted", 0)) > 0):
|
|
4345
|
+
# A caller holding the selected-state lock supplies a sink and fires
|
|
4346
|
+
# the real nudge after releasing it — a network call has no business
|
|
4347
|
+
# inside that critical section.
|
|
4348
|
+
(nudge_sink or _cctally()._nudge_dashboard_repaint)()
|
|
4314
4349
|
return 0
|
|
4315
4350
|
|
|
4316
4351
|
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -716,6 +716,11 @@ def _refresh_usage_inproc_locked(
|
|
|
716
716
|
*({"fiveHour"} if five_pct is not None else set()),
|
|
717
717
|
},
|
|
718
718
|
lock_held=True,
|
|
719
|
+
# #583 S2 §5.3: the dashboard calls this INSIDE its own `sync_lock`
|
|
720
|
+
# while servicing a `refresh=1`, so an inner nudge would enqueue a
|
|
721
|
+
# second rebuild for work already underway. `cmd_refresh_usage`
|
|
722
|
+
# already nudges once on its own after this returns.
|
|
723
|
+
nudge_dashboard=False,
|
|
719
724
|
)
|
|
720
725
|
if authoritative.status != "ok":
|
|
721
726
|
return _RefreshUsageResult(
|
|
@@ -761,6 +766,25 @@ def _refresh_usage_inproc_locked(
|
|
|
761
766
|
return _RefreshUsageResult(status="ok", payload=payload, warnings=warnings)
|
|
762
767
|
|
|
763
768
|
|
|
769
|
+
def _resolve_dashboard_api_token() -> "str | None":
|
|
770
|
+
"""Return the bearer token a running dashboard would require, if known.
|
|
771
|
+
|
|
772
|
+
#583 S2 §4. `_require_api_auth` admits an unauthenticated `/api/*` request
|
|
773
|
+
only when `cctally_api_token` is None, and the dashboard mints that token
|
|
774
|
+
with `secrets.token_urlsafe(32)` at startup for a non-loopback bind. It is
|
|
775
|
+
printed once ("dashboard: LAN access token: …") and placed in the startup
|
|
776
|
+
URL's fragment; it is never written to `config.json` and never persisted.
|
|
777
|
+
So there is NO configured token for a separate `refresh-usage` process to
|
|
778
|
+
read, and this environment variable is the only mechanism available today:
|
|
779
|
+
export the printed token and the nudge authenticates. Without it a
|
|
780
|
+
LAN-bound, token-configured dashboard answers 401 and the helper's
|
|
781
|
+
swallow-everything contract discards the nudge, which is a pre-existing
|
|
782
|
+
defect rather than a regression.
|
|
783
|
+
"""
|
|
784
|
+
token = (os.environ.get("CCTALLY_DASHBOARD_API_TOKEN") or "").strip()
|
|
785
|
+
return token or None
|
|
786
|
+
|
|
787
|
+
|
|
764
788
|
def _nudge_dashboard_repaint(port: int = 8789, timeout_seconds: float = 3.0) -> None:
|
|
765
789
|
"""Best-effort: tell a locally-running dashboard to rebuild+broadcast NOW.
|
|
766
790
|
|
|
@@ -776,20 +800,28 @@ def _nudge_dashboard_repaint(port: int = 8789, timeout_seconds: float = 3.0) ->
|
|
|
776
800
|
|
|
777
801
|
CSRF: the dashboard's _check_origin_csrf requires Origin/Host authority
|
|
778
802
|
parity. urllib auto-sets Host from the URL; we set Origin to the
|
|
779
|
-
byte-identical authority so the POST is accepted (not 403).
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
the
|
|
783
|
-
|
|
803
|
+
byte-identical authority so the POST is accepted (not 403).
|
|
804
|
+
|
|
805
|
+
#583 S2 §4. The URL carries `queue=1`, which marks this as a MACHINE
|
|
806
|
+
NUDGE: the handler enqueues it even when `sync_lock` is free, because this
|
|
807
|
+
fires at Claude Code's status-line cadence and a synchronous rebuild at
|
|
808
|
+
that frequency would reopen the #313 peg. The response is therefore a
|
|
809
|
+
202 that arrives as soon as the request is queued, not a 204 that waits
|
|
810
|
+
for a rebuild — the 3.0s timeout is now generous rather than tuned to a
|
|
811
|
+
server-side lock-wait. A bearer header is sent when a token is
|
|
812
|
+
resolvable; see `_resolve_dashboard_api_token`.
|
|
784
813
|
"""
|
|
785
814
|
# Whole body (incl. Request construction) inside the try so the
|
|
786
815
|
# "swallows EVERY error" contract holds structurally, not just because
|
|
787
816
|
# Request() happens to be total for the hardcoded loopback URL.
|
|
788
817
|
try:
|
|
789
|
-
url = f"http://127.0.0.1:{port}/api/sync?refresh=0"
|
|
818
|
+
url = f"http://127.0.0.1:{port}/api/sync?refresh=0&queue=1"
|
|
819
|
+
headers = {"Origin": f"http://127.0.0.1:{port}"}
|
|
820
|
+
token = _cctally()._resolve_dashboard_api_token()
|
|
821
|
+
if token:
|
|
822
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
790
823
|
req = urllib.request.Request(
|
|
791
|
-
url, data=b"", method="POST",
|
|
792
|
-
headers={"Origin": f"http://127.0.0.1:{port}"},
|
|
824
|
+
url, data=b"", method="POST", headers=headers,
|
|
793
825
|
)
|
|
794
826
|
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
|
|
795
827
|
resp.read()
|
|
@@ -922,16 +954,28 @@ def _hook_tick_oauth_refresh(
|
|
|
922
954
|
# its suppression conditions. A concurrent publisher can otherwise make
|
|
923
955
|
# this tick issue an unnecessary OAuth request between the initial gate
|
|
924
956
|
# and the request itself.
|
|
957
|
+
# #583 S2: the nudge is deferred out of the critical section. This frame
|
|
958
|
+
# owns the lock across the OAuth fetch AND the authoritative record, so
|
|
959
|
+
# `_authoritative_record_usage` cannot release it — the deferral has to
|
|
960
|
+
# reach the frame that acquired it. The nudge is a loopback POST with a
|
|
961
|
+
# multi-second timeout and this lock is an `fcntl.flock` every cctally
|
|
962
|
+
# process contends on, so one unresponsive listener would otherwise stall
|
|
963
|
+
# every other process's selected-state writes at status-line cadence.
|
|
964
|
+
deferred = []
|
|
925
965
|
try:
|
|
926
966
|
with c._selected_state_lock():
|
|
927
|
-
|
|
967
|
+
out = _hook_tick_oauth_refresh_locked(
|
|
928
968
|
c,
|
|
929
969
|
token=token,
|
|
930
970
|
timeout_seconds=timeout_seconds,
|
|
931
971
|
throttle_seconds=throttle_seconds,
|
|
972
|
+
nudge_sink=lambda: deferred.append(1),
|
|
932
973
|
)
|
|
933
974
|
except OSError:
|
|
934
975
|
return "err(record-usage=exc)", None
|
|
976
|
+
if deferred:
|
|
977
|
+
c._nudge_dashboard_repaint()
|
|
978
|
+
return out
|
|
935
979
|
|
|
936
980
|
|
|
937
981
|
def _hook_tick_oauth_refresh_locked(
|
|
@@ -940,8 +984,13 @@ def _hook_tick_oauth_refresh_locked(
|
|
|
940
984
|
token: str,
|
|
941
985
|
timeout_seconds: float,
|
|
942
986
|
throttle_seconds: float,
|
|
987
|
+
nudge_sink=None,
|
|
943
988
|
) -> tuple[str, dict | None]:
|
|
944
|
-
"""Automatic OAuth path with the selected-state lock already held.
|
|
989
|
+
"""Automatic OAuth path with the selected-state lock already held.
|
|
990
|
+
|
|
991
|
+
``nudge_sink`` records that a dashboard nudge is warranted; the caller
|
|
992
|
+
fires it after releasing the lock (#583 S2).
|
|
993
|
+
"""
|
|
945
994
|
# Backfill gate: an inflight/invalid tombstone bypasses only selected-age
|
|
946
995
|
# suppression so a later authoritative result can repair it. The normal
|
|
947
996
|
# throttle and 429 deadline continue to bound OAuth traffic.
|
|
@@ -997,6 +1046,7 @@ def _hook_tick_oauth_refresh_locked(
|
|
|
997
1046
|
record_args,
|
|
998
1047
|
{"sevenDay", *({"fiveHour"} if five_pct is not None else set())},
|
|
999
1048
|
lock_held=True,
|
|
1049
|
+
nudge_sink=nudge_sink,
|
|
1000
1050
|
)
|
|
1001
1051
|
if authoritative.status != "ok":
|
|
1002
1052
|
reason = authoritative.reason or ""
|
|
@@ -988,21 +988,42 @@ def _authoritative_record_usage(
|
|
|
988
988
|
observed_axes,
|
|
989
989
|
*,
|
|
990
990
|
lock_held: bool = False,
|
|
991
|
+
nudge_dashboard: bool = True,
|
|
992
|
+
nudge_sink=None,
|
|
991
993
|
) -> _AuthoritativeRecordResult:
|
|
992
994
|
"""Record OAuth authority under write-ahead tombstones and reconcile it.
|
|
993
995
|
|
|
994
996
|
``lock_held`` is for OAuth refresh callers that already hold the selected
|
|
995
997
|
lock across their fetch, authoritative publication, and matching backoff
|
|
996
998
|
transition. All other callers acquire the same blocking lock here.
|
|
999
|
+
|
|
1000
|
+
``nudge_dashboard`` (#583 S2 §5.3) is passed straight through to
|
|
1001
|
+
``cmd_record_usage``. It defaults on, so the statusline / hook path keeps
|
|
1002
|
+
nudging; ``_refresh_usage_inproc`` passes False.
|
|
1003
|
+
|
|
1004
|
+
``nudge_sink`` defers the nudge out of the selected-state critical section.
|
|
1005
|
+
The nudge is a loopback POST with a multi-second timeout, and this lock is
|
|
1006
|
+
an ``fcntl.flock`` every cctally process contends on, so firing it here
|
|
1007
|
+
would let one unresponsive listener stall every other process's
|
|
1008
|
+
selected-state writes. This frame owns the lock only on the
|
|
1009
|
+
``lock_held=False`` path, so that is the only path that can fire the
|
|
1010
|
+
deferred nudge itself; a caller passing ``lock_held=True`` owns the lock
|
|
1011
|
+
and must supply its own sink, or the nudge stays inside its section.
|
|
997
1012
|
"""
|
|
998
1013
|
if not lock_held:
|
|
1014
|
+
deferred = []
|
|
999
1015
|
try:
|
|
1000
1016
|
with _selected_state_lock():
|
|
1001
|
-
|
|
1002
|
-
args, observed_axes, lock_held=True
|
|
1017
|
+
result = _authoritative_record_usage(
|
|
1018
|
+
args, observed_axes, lock_held=True,
|
|
1019
|
+
nudge_dashboard=nudge_dashboard,
|
|
1020
|
+
nudge_sink=(nudge_sink or (lambda: deferred.append(1))),
|
|
1003
1021
|
)
|
|
1004
1022
|
except OSError as exc:
|
|
1005
1023
|
return _AuthoritativeRecordResult("record_failed", str(exc))
|
|
1024
|
+
if deferred:
|
|
1025
|
+
_cctally()._nudge_dashboard_repaint()
|
|
1026
|
+
return result
|
|
1006
1027
|
|
|
1007
1028
|
now_epoch = int(time.time())
|
|
1008
1029
|
try:
|
|
@@ -1011,7 +1032,8 @@ def _authoritative_record_usage(
|
|
|
1011
1032
|
return _AuthoritativeRecordResult("record_failed", str(exc))
|
|
1012
1033
|
|
|
1013
1034
|
try:
|
|
1014
|
-
rc = _cctally().cmd_record_usage(
|
|
1035
|
+
rc = _cctally().cmd_record_usage(
|
|
1036
|
+
args, nudge_dashboard=nudge_dashboard, nudge_sink=nudge_sink)
|
|
1015
1037
|
except _cctally().StatsRebuildDeferred as exc:
|
|
1016
1038
|
return _AuthoritativeRecordResult("record_failed", str(exc))
|
|
1017
1039
|
except Exception as exc:
|
|
@@ -1109,7 +1131,18 @@ def _projection_changed(before: "_candidates.DbProjection", after: "_candidates.
|
|
|
1109
1131
|
return before.five_hour != after.five_hour or before.seven_day != after.seven_day
|
|
1110
1132
|
|
|
1111
1133
|
|
|
1112
|
-
def _statusline_reduce_and_publish(
|
|
1134
|
+
def _statusline_reduce_and_publish(
|
|
1135
|
+
*, nudge_sink=None) -> "_candidates.ReductionDecision | None":
|
|
1136
|
+
"""Reduce the candidate spool and publish the winner.
|
|
1137
|
+
|
|
1138
|
+
``nudge_sink`` (#583 S2) is threaded to ``cmd_record_usage``. EVERY caller
|
|
1139
|
+
of this function runs it inside the statusline persist flock — the forked
|
|
1140
|
+
child takes it blocking, and the ``sync_for_test`` path runs under the
|
|
1141
|
+
parent's hold — and the dashboard nudge is a loopback POST with a
|
|
1142
|
+
multi-second timeout. Firing it in here would let one unresponsive listener
|
|
1143
|
+
stall every statusline render on this machine, at status-line cadence. The
|
|
1144
|
+
caller that acquired the lock fires the real nudge once it has released.
|
|
1145
|
+
"""
|
|
1113
1146
|
now_epoch = int(time.time())
|
|
1114
1147
|
candidates = _load_candidate_spool(now_epoch=now_epoch)
|
|
1115
1148
|
if not candidates:
|
|
@@ -1170,7 +1203,7 @@ def _statusline_reduce_and_publish() -> "_candidates.ReductionDecision | None":
|
|
|
1170
1203
|
# "record_failed".
|
|
1171
1204
|
try:
|
|
1172
1205
|
record_rc = _cctally().cmd_record_usage(
|
|
1173
|
-
args, ingest_mode="opportunistic"
|
|
1206
|
+
args, ingest_mode="opportunistic", nudge_sink=nudge_sink
|
|
1174
1207
|
)
|
|
1175
1208
|
except _cctally().StatsRebuildDeferred:
|
|
1176
1209
|
return decision
|
|
@@ -1255,9 +1288,14 @@ def _fork_persist(parent_lock_fd: int) -> None:
|
|
|
1255
1288
|
fcntl.flock(child_fd, fcntl.LOCK_EX)
|
|
1256
1289
|
except OSError:
|
|
1257
1290
|
child_fd = -1
|
|
1291
|
+
# #583 S2: the dashboard nudge fires AFTER this lock is released. This
|
|
1292
|
+
# is the path a real statusline render takes, and the nudge is a
|
|
1293
|
+
# loopback POST with a multi-second timeout.
|
|
1294
|
+
deferred = []
|
|
1258
1295
|
try:
|
|
1259
1296
|
if child_fd >= 0:
|
|
1260
|
-
_statusline_reduce_and_publish(
|
|
1297
|
+
_statusline_reduce_and_publish(
|
|
1298
|
+
nudge_sink=lambda: deferred.append(1))
|
|
1261
1299
|
finally:
|
|
1262
1300
|
if child_fd >= 0:
|
|
1263
1301
|
try:
|
|
@@ -1268,6 +1306,8 @@ def _fork_persist(parent_lock_fd: int) -> None:
|
|
|
1268
1306
|
os.close(child_fd)
|
|
1269
1307
|
except OSError:
|
|
1270
1308
|
pass
|
|
1309
|
+
if deferred:
|
|
1310
|
+
_cctally()._nudge_dashboard_repaint()
|
|
1271
1311
|
except BaseException:
|
|
1272
1312
|
pass
|
|
1273
1313
|
finally:
|
|
@@ -1288,16 +1328,22 @@ def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
|
1288
1328
|
lock_fd = c._try_acquire_persist_lock()
|
|
1289
1329
|
if lock_fd is None:
|
|
1290
1330
|
return
|
|
1331
|
+
# #583 S2: same deferral as the forked child. This frame owns the lock, so
|
|
1332
|
+
# it is the frame that must fire the nudge once it has released it.
|
|
1333
|
+
deferred = []
|
|
1291
1334
|
try:
|
|
1292
1335
|
preliminary = _preliminary_decision()
|
|
1293
1336
|
if preliminary is None or preliminary.action == "NOOP":
|
|
1294
1337
|
return
|
|
1295
1338
|
if sync_for_test:
|
|
1296
|
-
_statusline_reduce_and_publish(
|
|
1339
|
+
_statusline_reduce_and_publish(
|
|
1340
|
+
nudge_sink=lambda: deferred.append(1))
|
|
1297
1341
|
return
|
|
1298
1342
|
_fork_persist(lock_fd)
|
|
1299
1343
|
finally:
|
|
1300
1344
|
c._release_persist_lock(lock_fd)
|
|
1345
|
+
if deferred:
|
|
1346
|
+
c._nudge_dashboard_repaint()
|
|
1301
1347
|
|
|
1302
1348
|
|
|
1303
1349
|
def _try_acquire_statusline_oauth_lock() -> "int | None":
|