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.
@@ -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),
@@ -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 difference is a malformed row. Rows whose required text is
1912
- blank, or whose capture or reset instant SQLite cannot read as a time, are
1913
- excluded in SQL exactly as the loop below excludes them; a row that survives
1914
- those predicates but still fails a Python parse can, on this path, hide an
1915
- older valid capture of the same window that the full load would have
1916
- reported. That trade is confined to a store already carrying corrupt quota
1917
- rows, and it is the only behavioural difference between the two paths.
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
- if sql_bounded and max_rows is not None:
2083
- if active_at is not None:
2084
- sql += (
2085
- " ORDER BY (unixepoch(resets_at_utc) > unixepoch(?)) DESC, "
2086
- "unixepoch(captured_at_utc) DESC, unixepoch(resets_at_utc) DESC, "
2087
- "source_path DESC, line_offset DESC"
2088
- )
2089
- params.append(_utc_iso(active_at))
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 unixepoch(captured_at_utc) DESC, "
2093
- "unixepoch(resets_at_utc) DESC, source_path DESC, line_offset DESC"
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
@@ -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
 
@@ -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). The timeout
780
- is 3.0s — comfortably above the server's _DASHBOARD_SYNC_LOCK_TIMEOUT_
781
- SECONDS (2.0s) bounded lock-wait — so the client stays connected until
782
- the 204 lands and never leaves a broken-pipe log line in the dashboard's
783
- terminal. In the common case (lock free) it returns in single-digit ms.
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
- return _hook_tick_oauth_refresh_locked(
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
- return _authoritative_record_usage(
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(args)
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() -> "_candidates.ReductionDecision | None":
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":