cctally 1.110.0 → 1.112.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +5 -6
  3. package/bin/_cctally_cache.py +14 -7
  4. package/bin/_cctally_core.py +12 -1
  5. package/bin/_cctally_dashboard.py +60 -154
  6. package/bin/_cctally_dashboard_envelope.py +7 -6
  7. package/bin/_cctally_dashboard_sources.py +90 -55
  8. package/bin/_cctally_doctor.py +73 -27
  9. package/bin/_cctally_forecast.py +2 -1
  10. package/bin/_cctally_journal.py +147 -6
  11. package/bin/_cctally_milestones.py +29 -3
  12. package/bin/_cctally_quota.py +10 -1
  13. package/bin/_cctally_record.py +136 -4
  14. package/bin/_cctally_rederive.py +311 -8
  15. package/bin/_cctally_source_analytics.py +20 -8
  16. package/bin/_cctally_tui.py +546 -45
  17. package/bin/_cctally_update.py +420 -14
  18. package/bin/_lib_cache_report.py +7 -10
  19. package/bin/_lib_codex_conversation_query.py +40 -4
  20. package/bin/_lib_codex_metadata.py +57 -11
  21. package/bin/_lib_cost_provenance.py +303 -0
  22. package/bin/_lib_dashboard_sources.py +263 -28
  23. package/bin/_lib_doctor.py +2 -2
  24. package/bin/_lib_ingest_frontier.py +137 -38
  25. package/bin/_lib_pricing.py +24 -263
  26. package/bin/_lib_rederive.py +704 -36
  27. package/bin/_lib_snapshot_cache.py +332 -9
  28. package/bin/cctally +18 -5
  29. package/dashboard/static/assets/{ConversationsView-BB9vUiL6.js → ConversationsView-CEZ-BUyi.js} +1 -1
  30. package/dashboard/static/assets/{DoctorModal-DFY2iPDf.js → DoctorModal-CE1oH_Zy.js} +1 -1
  31. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +1 -0
  32. package/dashboard/static/assets/{ProjectsDrillPanel-DAv39uSo.js → ProjectsDrillPanel-B3DZoCeD.js} +1 -1
  33. package/dashboard/static/assets/{SourceDetailModal-DSAWo2Fp.js → SourceDetailModal-CHp9EgLP.js} +1 -1
  34. package/dashboard/static/assets/{UpdateModal-R_9dOVnh.js → UpdateModal-5iKST6jo.js} +1 -1
  35. package/dashboard/static/assets/{index-DdU62sov.js → index-Cm0VTxJC.js} +2 -2
  36. package/dashboard/static/assets/index-Dt4tqf43.css +1 -0
  37. package/dashboard/static/assets/{outlineNavigation-BOCLcRa6.js → outlineNavigation-DkXGL8J6.js} +1 -1
  38. package/dashboard/static/dashboard.html +2 -2
  39. package/package.json +2 -1
  40. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +0 -1
  41. package/dashboard/static/assets/index-COhxe3vU.css +0 -1
@@ -75,7 +75,12 @@ worker / polling thread:
75
75
  (two-thread pump → callbacks + log lines),
76
76
  ``_do_update_install`` (acquire lock → run steps → release →
77
77
  rotate log; dry-run path skips lock + subprocesses),
78
- ``_resolve_execvp_target`` (npm shim re-entry path, spec §5.7).
78
+ ``_resolve_execvp_target`` (spec §5.7; npm execs Python directly
79
+ since #868) and ``_execvp_target_problem`` (target validity).
80
+ - Install evidence (#868): ``install-success.json`` via
81
+ ``_write_install_success_record`` / ``_read_install_success_record``,
82
+ ``_update_lock_is_held``, ``_install_root_for_entrypoint``,
83
+ ``_read_changelog_version_at``, ``_install_layout``.
79
84
  - Dashboard surface: ``UpdateWorker`` (single-slot orchestrator,
80
85
  spec §5.6; idempotent-release contract per §5.6.1),
81
86
  ``_DashboardUpdateCheckThread`` (poll cadence ≠ network-call
@@ -184,12 +189,14 @@ import argparse
184
189
  import datetime as dt
185
190
  import fcntl
186
191
  import json
192
+ import math
187
193
  import os
188
194
  import pathlib
189
195
  import queue
190
196
  import re
191
197
  import secrets
192
198
  import shlex
199
+ import shutil
193
200
  import subprocess
194
201
  import sys
195
202
  import threading
@@ -677,6 +684,101 @@ def _release_update_lock(fd: int) -> None:
677
684
  pass
678
685
 
679
686
 
687
+ _INSTALL_SUCCESS_RECORD_SCHEMA = 1
688
+
689
+
690
+ @dataclass(frozen=True)
691
+ class InstallSuccessRecord:
692
+ """One install event from ``install-success.json`` (#868)."""
693
+
694
+ version: str
695
+ at_utc: str
696
+ token: str
697
+
698
+
699
+ def _write_install_success_record(version: str) -> None:
700
+ """Atomically replace the install-only success record (#868).
701
+
702
+ ``_do_update_check`` loads ``update-state.json`` before its network fetch
703
+ and saves that object afterward, so an install that stamps success during
704
+ the fetch loses its fields there. Only the install paths write this file,
705
+ so no update check can erase an install event. Every write mints a fresh
706
+ random token: a running dashboard compares tokens, never times, so a clock
707
+ step can neither hide a new install nor revive an old one.
708
+ """
709
+ path = _cctally_core.INSTALL_SUCCESS_RECORD_PATH
710
+ path.parent.mkdir(parents=True, exist_ok=True)
711
+ payload = (json.dumps({
712
+ "_schema": _INSTALL_SUCCESS_RECORD_SCHEMA,
713
+ "version": version,
714
+ "at_utc": _now_utc().isoformat(),
715
+ "token": secrets.token_hex(16),
716
+ }, sort_keys=True) + "\n").encode("utf-8")
717
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
718
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
719
+ try:
720
+ try:
721
+ view = memoryview(payload)
722
+ while view:
723
+ view = view[os.write(fd, view):]
724
+ os.fsync(fd)
725
+ finally:
726
+ os.close(fd)
727
+ os.replace(str(tmp), str(path))
728
+ except BaseException:
729
+ try:
730
+ os.unlink(str(tmp))
731
+ except OSError:
732
+ pass
733
+ raise
734
+
735
+
736
+ def _read_install_success_record() -> "InstallSuccessRecord | None":
737
+ """The current install-success record, or None when absent or malformed."""
738
+ try:
739
+ raw = json.loads(
740
+ _cctally_core.INSTALL_SUCCESS_RECORD_PATH.read_text(encoding="utf-8"))
741
+ except (OSError, ValueError):
742
+ return None
743
+ if not isinstance(raw, dict) or raw.get("_schema") != _INSTALL_SUCCESS_RECORD_SCHEMA:
744
+ return None
745
+ fields = (raw.get("version"), raw.get("at_utc"), raw.get("token"))
746
+ if not all(isinstance(v, str) and v for v in fields):
747
+ return None
748
+ try:
749
+ _release_parse_semver(fields[0])
750
+ except ValueError:
751
+ return None
752
+ return InstallSuccessRecord(*fields)
753
+
754
+
755
+ def _update_lock_is_held() -> bool:
756
+ """True unless ``update.lock`` is provably free; never creates the file.
757
+
758
+ A shared non-blocking probe conflicts with the updater's exclusive lock. A
759
+ lock file left behind by a finished or crashed updater carries no lock, so
760
+ it reads as free. The probe holds its shared lock for an instant, so an
761
+ updater whose own non-blocking attempt lands in that instant takes its
762
+ contention path. Any other error proves nothing about the updater and
763
+ reads as held.
764
+ """
765
+ try:
766
+ fd = os.open(str(_cctally_core.UPDATE_LOCK_PATH), os.O_RDONLY)
767
+ except FileNotFoundError:
768
+ return False
769
+ except OSError:
770
+ return True
771
+ try:
772
+ try:
773
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
774
+ except OSError:
775
+ return True
776
+ fcntl.flock(fd, fcntl.LOCK_UN)
777
+ return False
778
+ finally:
779
+ os.close(fd)
780
+
781
+
680
782
  def _rotate_update_log_if_needed() -> None:
681
783
  """Rotate ``update.log`` → ``update.log.1`` when the live log
682
784
  crosses :data:`UPDATE_LOG_ROTATE_BYTES` (1 MB, spec §1.5).
@@ -905,6 +1007,9 @@ def _stamp_install_success_to_state(
905
1007
  installed version. Skipped on brew for the reason above.
906
1008
  4. ``state.latest_version`` — last resort, also covers the npm
907
1009
  path when CHANGELOG is unreadable.
1010
+
1011
+ The same version is also written to ``install-success.json`` (#868),
1012
+ which a running dashboard reads to decide whether to restart itself.
908
1013
  """
909
1014
  c = _cctally()
910
1015
  state = c._load_update_state() or {"_schema": 1}
@@ -923,6 +1028,15 @@ def _stamp_install_success_to_state(
923
1028
  state["current_version"] = cur
924
1029
  state["last_install_success_at_utc"] = _now_utc().isoformat()
925
1030
  c._save_update_state(state)
1031
+ try:
1032
+ c._write_install_success_record(cur)
1033
+ except OSError as exc:
1034
+ # The install itself succeeded; only a running dashboard's
1035
+ # automatic restart loses its evidence.
1036
+ eprint(f"cctally: could not write "
1037
+ f"{_cctally_core.INSTALL_SUCCESS_RECORD_PATH.name} ({exc}); "
1038
+ "a running dashboard will not restart itself for this "
1039
+ "install")
926
1040
 
927
1041
 
928
1042
  def _self_heal_current_version() -> None:
@@ -2106,7 +2220,7 @@ def _do_update_install(
2106
2220
  c._rotate_update_log_if_needed()
2107
2221
 
2108
2222
 
2109
- # === Dashboard execvp re-entry (spec §5.7) ===
2223
+ # === Dashboard execvp re-entry (spec §5.7; #868) ===
2110
2224
  # ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT are captured at dashboard
2111
2225
  # server boot in cmd_dashboard (in bin/cctally, written via
2112
2226
  # ``global ORIGINAL_SYS_ARGV, ORIGINAL_ENTRYPOINT`` so the running
@@ -2117,26 +2231,81 @@ def _do_update_install(
2117
2231
  #
2118
2232
  # _resolve_execvp_target uses them to return (entrypoint, exec_argv)
2119
2233
  # for os.execvp:
2120
- # - npm: entrypoint = <prefix>/bin/cctally → Node shim, which
2121
- # re-resolves CCTALLY_PYTHON before re-spawning Python (so a
2122
- # custom interpreter setting survives the restart).
2234
+ # - npm: the interpreter the Node shim would use (CCTALLY_PYTHON, else
2235
+ # python3 on PATH) on the install's bin/cctally. Re-entering the shim
2236
+ # would add a Node process per restart, because the shim starts Python
2237
+ # with spawnSync and waits for it (#868).
2123
2238
  # - brew: entrypoint = <brew>/bin/cctally → symlink into the
2124
2239
  # post-upgrade Python script with its rewritten shebang.
2125
2240
  # - Fallback when shutil.which("cctally") returned None: use
2126
- # sys.argv[0] directly. Loses the npm shim layer; we accept the
2127
- # degraded edge case rather than guess.
2241
+ # sys.argv[0] directly.
2242
+ # The install watcher (#868) and UpdateWorker both refuse a target that
2243
+ # _execvp_target_problem rejects.
2128
2244
 
2129
2245
 
2130
- def _resolve_execvp_target() -> tuple[str, list[str]]:
2131
- """Return (entrypoint, exec_argv) per spec §5.7.
2246
+ def _install_root_for_entrypoint(entrypoint: "str | None") -> "pathlib.Path | None":
2247
+ """Install root behind a user-facing entrypoint, resolved afresh (#868).
2132
2248
 
2133
- Re-enters the npm shim by execvp'ing the PATH-resolved ``cctally``
2134
- (Node shim for npm, brew symlink for brew). Falls back to
2135
- ``sys.argv[0]`` only when ``shutil.which`` returned ``None`` at
2136
- dashboard boot (rare absolute-path invocation).
2249
+ npm: ``<prefix>/bin/cctally`` → ``…/node_modules/cctally/bin/cctally-npm-shim.js``
2250
+ → ``…/node_modules/cctally``. Homebrew: ``<prefix>/bin/cctally`` →
2251
+ ``Cellar/cctally/<v>/libexec/bin/cctally`` → ``…/libexec``, where the
2252
+ formula installs ``CHANGELOG.md``. The process's own ``CHANGELOG_PATH`` is
2253
+ never used for this: it stays inside the install the process booted from.
2254
+ """
2255
+ if not entrypoint:
2256
+ return None
2257
+ try:
2258
+ real = pathlib.Path(os.path.realpath(entrypoint))
2259
+ except (OSError, ValueError):
2260
+ return None
2261
+ if real.parent.name != "bin":
2262
+ return None
2263
+ return real.parent.parent
2264
+
2265
+
2266
+ def _read_changelog_version_at(root) -> "str | None":
2267
+ """Latest stamped version in ``<root>/CHANGELOG.md``, or None."""
2268
+ try:
2269
+ text = (pathlib.Path(root) / "CHANGELOG.md").read_text(encoding="utf-8")
2270
+ except (OSError, UnicodeDecodeError):
2271
+ return None
2272
+ m = _cctally().RELEASE_HEADER_RE.search(text)
2273
+ return m.group(1) if m else None
2274
+
2275
+
2276
+ def _install_layout(root) -> str:
2277
+ """``"npm"``, ``"brew"`` or ``"other"`` for an install root."""
2278
+ root = pathlib.Path(root)
2279
+ if ((root / "bin" / "cctally-npm-shim.js").is_file()
2280
+ and (root / "package.json").is_file()):
2281
+ return "npm"
2282
+ parts = root.parts
2283
+ if (root.name == "libexec" and len(parts) >= 4
2284
+ and parts[-4] == "Cellar" and parts[-3] == "cctally"):
2285
+ return "brew"
2286
+ return "other"
2287
+
2288
+
2289
+ def _resolve_execvp_target() -> tuple[str, list[str]]:
2290
+ """Return (entrypoint, exec_argv) for the in-place restart.
2291
+
2292
+ npm installs exec the interpreter the shim would use (``CCTALLY_PYTHON``,
2293
+ else ``python3`` on ``PATH``) on the install's ``bin/cctally`` directly, so
2294
+ the serving Python process keeps its PID and the original shim stays its
2295
+ only Node parent (#868). Homebrew re-enters the captured entrypoint, which
2296
+ resolves into the newly linked keg. Falls back to ``sys.argv[0]`` only when
2297
+ ``shutil.which`` returned ``None`` at dashboard boot.
2137
2298
  """
2138
2299
  c = _cctally()
2139
2300
  if c.ORIGINAL_ENTRYPOINT is not None:
2301
+ root = _install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT)
2302
+ if root is not None and _install_layout(root) == "npm":
2303
+ python = os.environ.get("CCTALLY_PYTHON") or "python3"
2304
+ if os.path.sep not in python:
2305
+ python = shutil.which(python) or python
2306
+ return (python,
2307
+ [python, str(root / "bin" / "cctally"),
2308
+ *c.ORIGINAL_SYS_ARGV[1:]])
2140
2309
  return (
2141
2310
  c.ORIGINAL_ENTRYPOINT,
2142
2311
  [c.ORIGINAL_ENTRYPOINT, *c.ORIGINAL_SYS_ARGV[1:]],
@@ -2144,6 +2313,34 @@ def _resolve_execvp_target() -> tuple[str, list[str]]:
2144
2313
  return (c.ORIGINAL_SYS_ARGV[0], list(c.ORIGINAL_SYS_ARGV))
2145
2314
 
2146
2315
 
2316
+ def _execvp_target_problem(entrypoint: str, argv: "list[str]") -> "str | None":
2317
+ """Why ``os.execvp(entrypoint, argv)`` cannot start cctally, or None.
2318
+
2319
+ The executable must resolve to an executable file. When ``argv[1]`` is an
2320
+ install's ``bin/cctally`` (the npm target), that script must exist and be
2321
+ readable, the same file the npm shim checks before it starts Python.
2322
+ """
2323
+ path = entrypoint if os.path.sep in entrypoint else shutil.which(entrypoint)
2324
+ if not path or not os.path.isfile(path) or not os.access(path, os.X_OK):
2325
+ return f"restart target {entrypoint!r} is not an executable file"
2326
+ if (len(argv) >= 2 and os.path.isabs(argv[1])
2327
+ and pathlib.Path(argv[1]).parts[-2:] == ("bin", "cctally")):
2328
+ if not (os.path.isfile(argv[1]) and os.access(argv[1], os.R_OK)):
2329
+ return f"restart script {argv[1]!r} is missing or unreadable"
2330
+ return None
2331
+
2332
+
2333
+ class AutomaticRestartInProgress(Exception):
2334
+ """``UpdateWorker.start`` refused: the install watcher holds the restart
2335
+ claim (#868, maintainer decision 6)."""
2336
+
2337
+ def __init__(self, version: str) -> None:
2338
+ self.version = version
2339
+ super().__init__(
2340
+ f"The dashboard is restarting to load v{version}. "
2341
+ "Try again in a moment.")
2342
+
2343
+
2147
2344
  class UpdateWorker:
2148
2345
  """Single-slot dashboard-side update orchestrator (spec §5.6).
2149
2346
 
@@ -2175,15 +2372,23 @@ class UpdateWorker:
2175
2372
  # consumer ever subscribes, ``start()`` reaps stale entries on
2176
2373
  # the next run.
2177
2374
  self._streams: dict[str, "queue.Queue"] = {}
2375
+ # The install watcher's restart claim (#868): the candidate version
2376
+ # while held, else None. Guarded by ``_lock`` together with
2377
+ # ``_current_id``, so a claim and an admitted run exclude each other.
2378
+ self._auto_restart_version: "str | None" = None
2178
2379
 
2179
2380
  def start(self, version: "str | None") -> tuple[bool, str]:
2180
2381
  """Begin a run. Returns (accepted, run_id).
2181
2382
 
2182
2383
  ``accepted=False`` when another run is in progress; the
2183
2384
  returned ``run_id`` is the in-progress one (so the caller can
2184
- surface it as ``run_id_in_progress`` to the client).
2385
+ surface it as ``run_id_in_progress`` to the client). Raises
2386
+ :class:`AutomaticRestartInProgress` while the install watcher holds
2387
+ the restart claim (#868).
2185
2388
  """
2186
2389
  with self._lock:
2390
+ if self._auto_restart_version is not None:
2391
+ raise AutomaticRestartInProgress(self._auto_restart_version)
2187
2392
  if self._current_id is not None:
2188
2393
  return (False, self._current_id)
2189
2394
  # Reap any stale entries from prior no-consumer runs. Safe
@@ -2206,6 +2411,26 @@ class UpdateWorker:
2206
2411
  with self._lock:
2207
2412
  return {"current_run_id": self._current_id}
2208
2413
 
2414
+ def claim_automatic_restart(self, version: str) -> bool:
2415
+ """Claim the in-place restart for the install watcher (#868).
2416
+
2417
+ Fails while an update run is admitted, because that run owns its own
2418
+ stamp, flush, lock release and exec. The claim is not an update run:
2419
+ it has no run id, stream or event, so ``status()`` still reports no
2420
+ run and a client that clicks Update meanwhile gets
2421
+ :class:`AutomaticRestartInProgress` from ``start``.
2422
+ """
2423
+ with self._lock:
2424
+ if (self._current_id is not None
2425
+ or self._auto_restart_version is not None):
2426
+ return False
2427
+ self._auto_restart_version = version
2428
+ return True
2429
+
2430
+ def release_automatic_restart(self) -> None:
2431
+ with self._lock:
2432
+ self._auto_restart_version = None
2433
+
2209
2434
  def _emit(self, run_id: str, event: dict) -> None:
2210
2435
  q = self._streams.get(run_id)
2211
2436
  if q is not None:
@@ -2310,6 +2535,9 @@ class UpdateWorker:
2310
2535
  _log_update_event(log_fd, "INSTALL_SUCCESS")
2311
2536
  c._stamp_install_success_to_state(resolved_version, method)
2312
2537
  entrypoint, exec_argv = c._resolve_execvp_target()
2538
+ problem = c._execvp_target_problem(entrypoint, exec_argv)
2539
+ if problem is not None:
2540
+ raise UpdateError(problem)
2313
2541
  self._emit(run_id, {"type": "execvp", "argv": exec_argv})
2314
2542
  try:
2315
2543
  log_fd.close()
@@ -2347,6 +2575,184 @@ class UpdateWorker:
2347
2575
  # start() sweeps stale entries on the next run.
2348
2576
 
2349
2577
 
2578
+ # === Install watch (#868) =================================================
2579
+ # A running dashboard restarts itself in place when a proven install puts a
2580
+ # strictly newer cctally under it. Spec:
2581
+ # docs/superpowers/specs/2026-09-23-868-dashboard-restart-on-install-design.md
2582
+
2583
+ _INSTALL_WATCH_INTERVAL_S = 60.0
2584
+ _INSTALL_RESTART_RETRY_DELAYS_S = (60.0, 300.0, 900.0)
2585
+ _INSTALL_RESTART_FLUSH_S = 0.5
2586
+
2587
+
2588
+ @dataclass(frozen=True)
2589
+ class InstallBootState:
2590
+ """What the dashboard process booted from. Later polls compare against it,
2591
+ never against wall-clock time, so a restarted process cannot qualify on the
2592
+ install it booted from."""
2593
+
2594
+ version: "str | None"
2595
+ root: "pathlib.Path | None"
2596
+ record_token: "str | None"
2597
+
2598
+
2599
+ def capture_install_boot_state() -> InstallBootState:
2600
+ c = _cctally()
2601
+ latest = c._release_read_latest_release_version()
2602
+ record = _read_install_success_record()
2603
+ return InstallBootState(
2604
+ version=latest[0] if latest else None,
2605
+ root=_install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT),
2606
+ record_token=record.token if record else None,
2607
+ )
2608
+
2609
+
2610
+ @dataclass(frozen=True)
2611
+ class RestartCandidate:
2612
+ """A proven newer install. ``identity`` keys the retry budget: the version
2613
+ with the record token (updater) or the resolved install root (Homebrew)."""
2614
+
2615
+ version: str
2616
+ evidence: str
2617
+ identity: tuple
2618
+
2619
+
2620
+ class InstallWatch:
2621
+ """Restart the dashboard in place after a proven newer install (#868).
2622
+
2623
+ Proven means cctally's updater wrote a new install-success record, or
2624
+ Homebrew switched the entrypoint into a newer keg. A manual npm install
2625
+ rewrites files in place with no completion signal, so it never qualifies.
2626
+
2627
+ The restart meets the Update-button safety bar and no more: nothing is
2628
+ stopped before the exec, and an in-flight write is interrupted as in a
2629
+ crash. The claim on ``UpdateWorker`` keeps an admitted update run as the
2630
+ sole owner of its own restart.
2631
+ """
2632
+
2633
+ def __init__(self, boot, *, worker_fn, exec_fn=os.execvp,
2634
+ sleep_fn=time.sleep, monotonic_fn=time.monotonic,
2635
+ log_fn=None, interval_s=_INSTALL_WATCH_INTERVAL_S,
2636
+ retry_delays=_INSTALL_RESTART_RETRY_DELAYS_S,
2637
+ flush_s=_INSTALL_RESTART_FLUSH_S):
2638
+ self._boot = boot
2639
+ self._worker_fn = worker_fn
2640
+ self._exec = exec_fn
2641
+ self._sleep = sleep_fn
2642
+ self._monotonic = monotonic_fn
2643
+ self._log = log_fn or eprint
2644
+ self.interval_s = interval_s
2645
+ self._retry_delays = tuple(retry_delays)
2646
+ self._flush_s = flush_s
2647
+ # identity -> (failed attempts, monotonic time of the next attempt)
2648
+ self._attempts: dict = {}
2649
+ self._stop = threading.Event()
2650
+ self._thread: "threading.Thread | None" = None
2651
+
2652
+ def _say(self, msg: str) -> None:
2653
+ # A lost log line, e.g. stderr on a closed pipe, must not count as a
2654
+ # failed restart attempt or end the watch thread.
2655
+ try:
2656
+ self._log(msg)
2657
+ except Exception: # noqa: BLE001
2658
+ pass
2659
+
2660
+ def find_candidate(self) -> "RestartCandidate | None":
2661
+ boot = self._boot
2662
+ if boot.version is None:
2663
+ return None
2664
+ root = _install_root_for_entrypoint(_cctally().ORIGINAL_ENTRYPOINT)
2665
+ if root is None:
2666
+ return None
2667
+ record = _read_install_success_record()
2668
+ if (record is not None and record.token != boot.record_token
2669
+ and _semver_gt(record.version, boot.version)
2670
+ and not _update_lock_is_held()
2671
+ and _read_changelog_version_at(root) == record.version):
2672
+ return RestartCandidate(record.version, "updater",
2673
+ (record.version, "updater", record.token))
2674
+ # Homebrew links the new keg only after installing it completely, so
2675
+ # the switch itself is the completion event.
2676
+ if root != boot.root and _install_layout(root) == "brew":
2677
+ version = _read_changelog_version_at(root)
2678
+ if version and _semver_gt(version, boot.version):
2679
+ return RestartCandidate(version, "brew",
2680
+ (version, "brew", str(root)))
2681
+ return None
2682
+
2683
+ def poll_once(self) -> str:
2684
+ """One check. Returns ``"idle"``, ``"backoff"``, ``"exhausted"``,
2685
+ ``"deferred"``, ``"stopped"``, ``"failed"`` or ``"exec"``."""
2686
+ candidate = self.find_candidate()
2687
+ if candidate is None:
2688
+ return "idle"
2689
+ now = self._monotonic()
2690
+ prior = self._attempts.get(candidate.identity)
2691
+ if prior is not None:
2692
+ failures, next_at = prior
2693
+ if failures > len(self._retry_delays):
2694
+ return "exhausted"
2695
+ if now < next_at:
2696
+ return "backoff"
2697
+ worker = self._worker_fn()
2698
+ if worker is None:
2699
+ return "deferred"
2700
+ c = _cctally()
2701
+ claimed = False
2702
+ try:
2703
+ entrypoint, argv = c._resolve_execvp_target()
2704
+ problem = c._execvp_target_problem(entrypoint, argv)
2705
+ if problem is None:
2706
+ again = self.find_candidate()
2707
+ if again is None or again.identity != candidate.identity:
2708
+ problem = "the install changed before the restart"
2709
+ if problem is not None:
2710
+ raise UpdateError(problem)
2711
+ if not worker.claim_automatic_restart(candidate.version):
2712
+ return "deferred"
2713
+ claimed = True
2714
+ self._say(f"[update] restarting dashboard: v{self._boot.version} "
2715
+ f"-> v{candidate.version} ({candidate.evidence} install)")
2716
+ # Lets a POST /api/update already refused under the claim finish
2717
+ # writing its 503 before the process image is replaced.
2718
+ self._sleep(self._flush_s)
2719
+ if self._stop.is_set():
2720
+ worker.release_automatic_restart()
2721
+ return "stopped"
2722
+ self._exec(entrypoint, argv)
2723
+ except Exception as exc: # noqa: BLE001 — a failed attempt keeps serving
2724
+ if claimed:
2725
+ worker.release_automatic_restart()
2726
+ failures = (prior[0] if prior else 0) + 1
2727
+ delay = (self._retry_delays[failures - 1]
2728
+ if failures <= len(self._retry_delays) else math.inf)
2729
+ self._attempts[candidate.identity] = (failures, now + delay)
2730
+ self._say(f"[update] automatic restart to v{candidate.version} "
2731
+ f"failed: {exc}")
2732
+ return "failed"
2733
+ return "exec"
2734
+
2735
+ def run(self) -> None:
2736
+ while not self._stop.wait(self.interval_s):
2737
+ try:
2738
+ self.poll_once()
2739
+ except Exception as exc: # noqa: BLE001 — the watch must not die
2740
+ self._say(f"[update] install watch poll failed: {exc!r}")
2741
+
2742
+ def start(self) -> "threading.Thread":
2743
+ thread = threading.Thread(target=self.run, name="cctally-install-watch",
2744
+ daemon=True)
2745
+ thread.start()
2746
+ self._thread = thread
2747
+ return thread
2748
+
2749
+ def stop(self, timeout: float = 2.0) -> None:
2750
+ self._stop.set()
2751
+ thread = self._thread
2752
+ if thread is not None and thread is not threading.current_thread():
2753
+ thread.join(timeout)
2754
+
2755
+
2350
2756
  class _DashboardUpdateCheckThread(threading.Thread):
2351
2757
  """Dedicated update-check polling thread (spec §3.5).
2352
2758
 
@@ -107,13 +107,10 @@ def _import_pricing_kernel():
107
107
 
108
108
 
109
109
  # ``DEFAULT_TIERED_THRESHOLD`` above is Anthropic's per-call >200K-tokens tier,
110
- # READ FROM ``_lib_pricing`` rather than duplicated here (#714). It used to be a
111
- # literal `200_000` with nothing keeping it equal to
112
- # ``_lib_pricing.TIERED_THRESHOLD``, which made it a pricing value a reload
113
- # could not replace. It is refreshed by ``PRICING_EXPORT_BINDINGS`` on every
114
- # swap, and ``_compute_entry_cache_dollars`` resolves it at CALL time — a
115
- # default argument would bind it once at function-definition time, so the
116
- # refresh would reach the name and never the calls.
110
+ # READ FROM ``_lib_pricing.TIERED_THRESHOLD`` rather than duplicated here, so
111
+ # the two cannot drift. ``_compute_entry_cache_dollars`` resolves it at CALL
112
+ # time instead of binding it as a default argument, so this module global is
113
+ # the one place the value is taken from.
117
114
  # Callers may still override via the ``tiered_threshold`` kwarg.
118
115
 
119
116
 
@@ -384,9 +381,9 @@ def _compute_entry_cache_dollars(
384
381
  warning for unknown models elsewhere.
385
382
 
386
383
  ``tiered_threshold=None`` resolves ``DEFAULT_TIERED_THRESHOLD`` HERE rather
387
- than in the signature (#714). A default argument is evaluated once, at
388
- function-definition time, so an in-process pricing reload would refresh the
389
- module global and leave every call pricing from the superseded revision.
384
+ than in the signature. A default argument is evaluated once, at
385
+ function-definition time, so a change to the module global would never
386
+ reach the calls.
390
387
  """
391
388
  if tiered_threshold is None:
392
389
  tiered_threshold = DEFAULT_TIERED_THRESHOLD
@@ -3314,11 +3314,8 @@ def _stored_filter_sql(
3314
3314
  clauses.append(f"{alias}.project_key = ?")
3315
3315
  params.append(project_key)
3316
3316
  if undecodable_keys:
3317
- ordered = sorted(undecodable_keys)
3318
- placeholders = ",".join("?" for _ in ordered)
3319
3317
  clauses.append(
3320
- f"{alias}.conversation_key NOT IN ({placeholders})")
3321
- params.extend(ordered)
3318
+ f"cctally_codex_undecodable({alias}.conversation_key) = 0")
3322
3319
  if model is not None:
3323
3320
  # models_json is the writer's canonical JSON array. Searching for the
3324
3321
  # complete JSON string literal is exact and does not require JSON1.
@@ -3347,10 +3344,49 @@ def _page_costs(
3347
3344
  return totals
3348
3345
 
3349
3346
 
3347
+ @contextlib.contextmanager
3348
+ def _stored_undecodable_membership(
3349
+ conn: sqlite3.Connection, undecodable_keys: "frozenset[str]",
3350
+ ):
3351
+ """Install the stored page's request-bounded membership predicate.
3352
+
3353
+ Only one membership bit crosses the SQL boundary; the raw unscoped read's
3354
+ attribution values never do. Key cardinality consumes no host parameters,
3355
+ registration executes no SQL, and cleanup prevents a later request from
3356
+ seeing this request's frozen set.
3357
+ """
3358
+ if not undecodable_keys:
3359
+ yield
3360
+ return
3361
+ frozen = frozenset(undecodable_keys)
3362
+ conn.create_function(
3363
+ "cctally_codex_undecodable", 1,
3364
+ lambda conversation_key: int(conversation_key in frozen),
3365
+ deterministic=True,
3366
+ )
3367
+ try:
3368
+ yield
3369
+ finally:
3370
+ conn.create_function("cctally_codex_undecodable", 1, None)
3371
+
3372
+
3350
3373
  def _stored_browse_page(
3351
3374
  conn: sqlite3.Connection, *, effective_speed: str,
3352
3375
  project_key: str | None, model: str | None, limit: int,
3353
3376
  cursor: str | None, undecodable_keys: "frozenset[str]" = frozenset(),
3377
+ ):
3378
+ membership = undecodable_keys if project_key is not None else frozenset()
3379
+ with _stored_undecodable_membership(conn, membership):
3380
+ return _stored_browse_page_queries(
3381
+ conn, effective_speed=effective_speed, project_key=project_key,
3382
+ model=model, limit=limit, cursor=cursor,
3383
+ undecodable_keys=undecodable_keys)
3384
+
3385
+
3386
+ def _stored_browse_page_queries(
3387
+ conn: sqlite3.Connection, *, effective_speed: str,
3388
+ project_key: str | None, model: str | None, limit: int,
3389
+ cursor: str | None, undecodable_keys: "frozenset[str]" = frozenset(),
3354
3390
  ):
3355
3391
  where_sql, filter_params = _stored_filter_sql(
3356
3392
  "r", project_key, model, undecodable_keys)