cctally 1.109.0 → 1.111.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 (47) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_cache.py +62 -13
  4. package/bin/_cctally_core.py +8 -1
  5. package/bin/_cctally_dashboard.py +422 -195
  6. package/bin/_cctally_dashboard_conversation.py +231 -40
  7. package/bin/_cctally_dashboard_envelope.py +48 -32
  8. package/bin/_cctally_dashboard_sources.py +503 -109
  9. package/bin/_cctally_db.py +44 -0
  10. package/bin/_cctally_diagnosis_sources.py +181 -15
  11. package/bin/_cctally_doctor.py +15 -3
  12. package/bin/_cctally_forecast.py +25 -12
  13. package/bin/_cctally_journal.py +59 -32
  14. package/bin/_cctally_milestone_history.py +65 -9
  15. package/bin/_cctally_parser.py +5 -0
  16. package/bin/_cctally_record.py +50 -12
  17. package/bin/_cctally_rederive.py +899 -30
  18. package/bin/_cctally_source_analytics.py +567 -133
  19. package/bin/_cctally_transcript.py +27 -2
  20. package/bin/_cctally_tui.py +39 -6
  21. package/bin/_cctally_update.py +420 -14
  22. package/bin/_lib_cache_report.py +7 -10
  23. package/bin/_lib_codex_conversation_query.py +286 -63
  24. package/bin/_lib_codex_metadata.py +207 -0
  25. package/bin/_lib_conversation_query.py +254 -37
  26. package/bin/_lib_doctor.py +16 -2
  27. package/bin/_lib_pricing.py +80 -276
  28. package/bin/_lib_rederive.py +903 -2
  29. package/bin/_lib_view_models.py +5 -0
  30. package/bin/cctally +18 -5
  31. package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
  32. package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
  33. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
  34. package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
  35. package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
  36. package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
  37. package/dashboard/static/assets/index-COhxe3vU.css +1 -0
  38. package/dashboard/static/assets/index-DdU62sov.js +13 -0
  39. package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
  40. package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
  41. package/dashboard/static/dashboard.html +3 -3
  42. package/package.json +2 -1
  43. package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
  44. package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
  45. package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
  46. package/dashboard/static/assets/index-DLVwACUM.js +0 -13
  47. package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +0 -9
@@ -116,6 +116,18 @@ def _cmd_transcript_export(args) -> int:
116
116
  return 0
117
117
 
118
118
 
119
+ #: The M5 refusal, verbatim. Exit 3 is the code `docs/commands/transcript.md`
120
+ #: already uses for an attribution cache that cannot be established.
121
+ _ANON_UNAVAILABLE_MSG = (
122
+ "transcript: anonymized export is unavailable: {count} Codex project "
123
+ "path(s) could not be read; run cctally cache-sync --source codex --rebuild"
124
+ )
125
+ _ANON_AMBIGUOUS_MSG = (
126
+ "transcript: anonymized export is unavailable: {count} Codex project "
127
+ "path(s) have ambiguous account provenance; use --raw or omit --account"
128
+ )
129
+
130
+
119
131
  def _cmd_transcript_export_qualified(
120
132
  c, args, session_id, scope, raw, output, speed_arg) -> int:
121
133
  """Qualified (``v1.``) export via the neutral dispatch layer (§4.1). Anonymized
@@ -157,10 +169,23 @@ def _cmd_transcript_export_qualified(
157
169
  if not raw:
158
170
  cq = c._load_sibling("_lib_conversation_query")
159
171
  anon = c._load_sibling("_lib_conversation_anon")
160
- plan = cq.build_anon_plan_for_sources(
172
+ result = cq.build_anon_plan_for_sources(
161
173
  conn, home_dir=os.path.expanduser("~"),
162
174
  sources={cref.source})
163
- md = anon.scrub_text(md, plan)
175
+ # #850 M5. A Codex project path the store cannot read cannot become
176
+ # a scrub token, and such a path can appear in ANY transcript, so
177
+ # the anonymized export refuses while any of them is undecodable
178
+ # rather than emitting bytes that only look scrubbed. `--raw` and
179
+ # `search` escape, and a Claude-only plan never reaches this.
180
+ if result.undecodable_cwd_rows:
181
+ eprint(_ANON_UNAVAILABLE_MSG.format(
182
+ count=result.undecodable_cwd_rows))
183
+ return 3
184
+ if result.ambiguous_cwd_rows:
185
+ eprint(_ANON_AMBIGUOUS_MSG.format(
186
+ count=result.ambiguous_cwd_rows))
187
+ return 3
188
+ md = anon.scrub_text(md, result.plan)
164
189
  finally:
165
190
  conn.close()
166
191
 
@@ -1676,7 +1676,12 @@ def _tui_build_current_week(
1676
1676
  Returns None when no current-week usage snapshot exists.
1677
1677
  """
1678
1678
  # #769 S11 (#824): ONE read, held-inclusive, split per axis below.
1679
- fetched = _fetch_current_week_snapshots(conn, now_utc, include_held=True)
1679
+ # Retain the owner of the exact non-held weekly sample this card displays,
1680
+ # including when one real account coexists with unattributed history. R8
1681
+ # controls decoration, not the physical population used for computation.
1682
+ # Cost and forecast retain their existing merged semantics.
1683
+ fetched = _fetch_current_week_snapshots(
1684
+ conn, now_utc, include_held=True, include_account=True)
1680
1685
  if fetched is None:
1681
1686
  return None
1682
1687
  week_start_at, week_end_at, observations = fetched
@@ -1690,9 +1695,10 @@ def _tui_build_current_week(
1690
1695
  if not observations:
1691
1696
  return None
1692
1697
  # Held-inclusive sample shape: (captured_at_utc, weekly_percent,
1693
- # five_hour_percent, weekly_observation_held). That helper does not surface
1694
- # five_hour_resets_at, so do a targeted lookup below for the freshest
1695
- # non-NULL reset timestamp on the current week.
1698
+ # five_hour_percent, weekly_observation_held[, account_key]). The optional
1699
+ # account key identifies the displayed weekly sample on a decorated store.
1700
+ # The helper does not surface five_hour_resets_at, so do a targeted lookup
1701
+ # below for the freshest non-NULL reset timestamp on the current week.
1696
1702
  #
1697
1703
  # #769 S11 (#824). This card publishes BOTH axes, and the dashboard
1698
1704
  # envelope reads `used_pct` and `latest_snapshot_at` straight off it for
@@ -1707,7 +1713,11 @@ def _tui_build_current_week(
1707
1713
  return None
1708
1714
  latest = weekly_samples[-1]
1709
1715
  used_pct = float(latest[1])
1710
- five_hr_raw = observations[-1][2]
1716
+ weekly_account = latest[4]
1717
+ # Five-hour readings may be newer held observations, but must remain on
1718
+ # the account that supplied the displayed weekly reading.
1719
+ five_hour_samples = [s for s in observations if s[4] == weekly_account]
1720
+ five_hr_raw = five_hour_samples[-1][2]
1711
1721
  five_hr_pct = float(five_hr_raw) if five_hr_raw is not None else None
1712
1722
  # #556 S1 §3.3: one walk yields both halves. The range is whatever
1713
1723
  # `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
@@ -1753,8 +1763,9 @@ def _tui_build_current_week(
1753
1763
  f"SELECT five_hour_resets_at FROM weekly_usage_snapshots "
1754
1764
  f"WHERE week_start_at IN ({placeholders}) "
1755
1765
  f" AND five_hour_resets_at IS NOT NULL "
1766
+ f" AND account_key = ? "
1756
1767
  f"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
1757
- tuple(matching_ws_texts),
1768
+ tuple(matching_ws_texts) + (weekly_account,),
1758
1769
  ).fetchone()
1759
1770
  if reset_row is not None:
1760
1771
  try:
@@ -1797,6 +1808,7 @@ def _tui_build_current_week(
1797
1808
  freshness_age=freshness_age,
1798
1809
  five_hour_block=_select_current_block_for_envelope(
1799
1810
  conn, current_used_pct=used_pct, now_utc=now_utc,
1811
+ account_key=weekly_account,
1800
1812
  ),
1801
1813
  total_tokens=total_tokens,
1802
1814
  )
@@ -3553,6 +3565,20 @@ def _tui_retain_refused_partial(
3553
3565
  _PARTIAL_RETRY_STATE, provider=provider,
3554
3566
  )
3555
3567
  return False
3568
+ # #846 §4.6. THE CARRIER DECIDES RETENTION, and it decides before the
3569
+ # warnings are consulted. This leg derived the retry cause from
3570
+ # `prior.warnings` alone, and a transient qualified-read failure publishes
3571
+ # the warning code `codex_metadata_incomplete`, which the retry kernel
3572
+ # normalizes to the retainable cause `metadata_incomplete` — so the second
3573
+ # tick retained the transient generation for up to `PARTIAL_RETRY_INTERVAL`
3574
+ # while the chip promised a retry that never came. No reuse or retention
3575
+ # gate may retain a retryable carrier.
3576
+ health = getattr(prior, "metadata_health", None)
3577
+ if isinstance(health, Mapping) and bool(health.get("retryable")):
3578
+ _PARTIAL_RETRY_STATE = clear_partial_retry(
3579
+ _PARTIAL_RETRY_STATE, provider=provider,
3580
+ )
3581
+ return False
3556
3582
  if (
3557
3583
  prior.freshness != "fresh"
3558
3584
  or prior.data is None
@@ -4337,6 +4363,13 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
4337
4363
  # one by reading the path the first fix did not reach.
4338
4364
  health = state.metadata_health
4339
4365
  if health is not None and bool(health.get("retryable")):
4366
+ # #846 §4.6: refuse AND clear any armed key, so the tick after the
4367
+ # fault clears rebuilds rather than finding a key still inside its
4368
+ # deadline.
4369
+ global _PARTIAL_RETRY_STATE
4370
+ _PARTIAL_RETRY_STATE = clear_partial_retry(
4371
+ _PARTIAL_RETRY_STATE, provider=source,
4372
+ )
4340
4373
  return False
4341
4374
  return True
4342
4375
 
@@ -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