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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.112.0] - 2026-09-29
9
+
10
+ ### Added
11
+ - Claude cost reports price `claude-sonnet-5-5` at its published input, output and cache rates instead of showing $0 for new usage.
12
+
13
+ ### Changed
14
+ - `cctally dashboard` uses less CPU while idle on a large history.
15
+
16
+ ### Fixed
17
+ - The Conversation Viewer keeps project-filtered browse working with any number of unreadable Codex project paths, avoids repeated metadata schema probes, and preserves SQLite alias ordering.
18
+ - The TUI and dashboard clear a Codex metadata retry as soon as its transient partial generation is rejected, so the first healthy refresh recovers immediately instead of waiting through a stale retry delay.
19
+ - `cctally db rederive` corrects costs recorded while a model had no rate card, including provably unpriced $0 five-hour blocks. A Codex budget alert that fallback pricing overstated is retired and can fire again when real spend crosses.
20
+ - After `cctally db rederive --yes`, a still-crossed Claude budget, projected or project-budget alert no longer breaks every later stats ingest until the period ends, and fired five-hour alerts stay in Recent Alerts.
21
+ - On phones, the dashboard's `Current Usage` dialog shows its full title and hides the onboarding hint while the dialog is open.
22
+ - An idle `cctally dashboard` no longer rebuilds its Codex view on every refresh after a Codex weekly reset with no newer Codex activity, which cost steady CPU while nothing changed.
23
+ - `cctally db rederive` no longer reprices the unchanged five-hour milestones of a closed block whose cost is frozen when a rate card changes: each keeps its recorded cumulative cost; its marginal changes only to follow the prior milestone.
24
+
25
+ ## [1.111.0] - 2026-09-24
26
+
27
+ ### Fixed
28
+ - After `cctally update` or a Homebrew upgrade installs a newer version, a running `dashboard` restarts itself so new prices and code apply. Restart it yourself once after installing this release, and after any manual `npm install -g`.
29
+ - A `dashboard` restart after an update no longer leaves an extra Node process running on npm installs.
30
+
8
31
  ## [1.110.0] - 2026-09-22
9
32
 
10
33
  ### Added
package/README.md CHANGED
@@ -30,15 +30,14 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
30
30
  </p>
31
31
 
32
32
  <!-- cctally:latest-stable:begin -->
33
- **Latest stable: v1.109.0** (2026-09-15)
33
+ **Latest stable: v1.111.0** (2026-09-24)
34
34
 
35
- Highlights from the `v1.104.0` to `v1.109.0` stable upgrade:
35
+ Highlights from the `v1.109.0` to `v1.111.0` stable upgrade:
36
36
 
37
- - The dashboard picks up a new pricing revision without a restart. It re-reads the deployed pricing file every 60 seconds, adopts a complete newer revision, and keeps serving; no connected client is dropped.
38
- - The conversation filter popover says `Projects appear once indexing finishes.` in place of `No projects.` while the transcript index is being rebuilt. Model counts stay live on the Claude tab.
39
- - The dashboard's Projects panel gives every billing cycle of a credited week its own percentage. Both cycles shared one calendar date, and only the later reading survived, so the second cycle could never carry one.
37
+ - After `cctally update` or a Homebrew upgrade installs a newer version, a running `dashboard` restarts itself so new prices and code apply. Restart it yourself once after installing this release, and after any manual `npm install -g`.
38
+ - A `dashboard` restart after an update no longer leaves an extra Node process running on npm installs.
40
39
 
41
- [See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.109.0)
40
+ [See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.111.0)
42
41
  <!-- cctally:latest-stable:end -->
43
42
 
44
43
  ## Quick start
@@ -9478,6 +9478,7 @@ def _sum_codex_cost_for_range(
9478
9478
  speed: str = "auto",
9479
9479
  skip_sync: bool = False,
9480
9480
  account_key: "str | None" = None,
9481
+ pricing_provenance: "dict | None" = None,
9481
9482
  ) -> float:
9482
9483
  """Sum USD Codex cost of all `codex_session_entries` in ``[start, end)``.
9483
9484
 
@@ -9511,17 +9512,23 @@ def _sum_codex_cost_for_range(
9511
9512
  """
9512
9513
  c = _cctally()
9513
9514
  eff_speed = c._resolve_codex_speed(speed)
9515
+ entries = c.get_codex_entries(
9516
+ start, end, skip_sync=skip_sync, account_key=account_key,
9517
+ )
9518
+ if pricing_provenance is not None:
9519
+ import _lib_cost_provenance
9520
+ total, evidence = _lib_cost_provenance.codex_cost_with_provenance(
9521
+ entries, end=end, speed=eff_speed,
9522
+ )
9523
+ pricing_provenance.update(evidence)
9524
+ return total
9514
9525
  total = 0.0
9515
- for entry in c.get_codex_entries(start, end, skip_sync=skip_sync,
9516
- account_key=account_key):
9526
+ for entry in entries:
9517
9527
  if entry.timestamp >= end:
9518
9528
  continue
9519
9529
  total += c._calculate_codex_entry_cost(
9520
- entry.model,
9521
- entry.input_tokens,
9522
- entry.cached_input_tokens,
9523
- entry.output_tokens,
9524
- entry.reasoning_output_tokens,
9530
+ entry.model, entry.input_tokens, entry.cached_input_tokens,
9531
+ entry.output_tokens, entry.reasoning_output_tokens,
9525
9532
  speed=eff_speed,
9526
9533
  )
9527
9534
  return total
@@ -80,6 +80,7 @@ def _init_paths_from_env() -> None:
80
80
  global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
81
81
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
82
82
  global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
83
+ global INSTALL_SUCCESS_RECORD_PATH
83
84
  global CLAUDE_PROJECTS_DIR, CLAUDE_JSON_PATH
84
85
  global TELEMETRY_INSTALL_ID_PATH, TELEMETRY_LAST_BEAT_PATH
85
86
  global TELEMETRY_NOTICE_SHOWN_PATH, TELEMETRY_FIRST_SEEN_PATH
@@ -195,6 +196,8 @@ def _init_paths_from_env() -> None:
195
196
  UPDATE_LOG_PATH = APP_DIR / "update.log"
196
197
  UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
197
198
  UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
199
+ # Written only by the install paths (#868), never by an update check.
200
+ INSTALL_SUCCESS_RECORD_PATH = APP_DIR / "install-success.json"
198
201
 
199
202
  # Anonymous install-count telemetry markers (see spec 2026-07-07).
200
203
  # All four derive from APP_DIR and are re-bound here so a redirected
@@ -465,7 +468,12 @@ _init_paths_from_env()
465
468
  # a migration is unchanged: the registry is frozen at 13, and an epoch-current
466
469
  # open returns before any schema work, so a handler or an
467
470
  # `add_column_if_missing` would never run on an upgraded install.
468
- STATS_INDEX_EPOCH = 1015
471
+ # 1015 -> 1016 (#869): an open five-hour block records the pricing evidence
472
+ # used to compute its current cost. Closing a different block on a successor
473
+ # tick must carry that earlier evidence into the journal, even after this
474
+ # binary learns a new model card. A pre-existing open projection that was
475
+ # rebuilt without the marker remains unproven until a new tick recomputes it.
476
+ STATS_INDEX_EPOCH = 1016
469
477
  LEGACY_STATS_HEAD = 13
470
478
 
471
479
  #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
@@ -2503,6 +2511,7 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2503
2511
  total_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
2504
2512
  total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
2505
2513
  total_cost_usd REAL NOT NULL DEFAULT 0,
2514
+ pricing_provenance_json TEXT,
2506
2515
  is_closed INTEGER NOT NULL DEFAULT 0,
2507
2516
  created_at_utc TEXT NOT NULL,
2508
2517
  last_updated_at_utc TEXT NOT NULL,
@@ -2514,6 +2523,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2514
2523
  add_column_if_missing(
2515
2524
  conn, "five_hour_blocks", "account_key",
2516
2525
  "TEXT NOT NULL DEFAULT 'unattributed'")
2526
+ add_column_if_missing(
2527
+ conn, "five_hour_blocks", "pricing_provenance_json", "TEXT")
2517
2528
  conn.execute(
2518
2529
  """
2519
2530
  CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
@@ -712,10 +712,6 @@ from _lib_aggregators import (
712
712
  _finalize_bucket,
713
713
  )
714
714
  from _lib_fmt import stable_sum
715
- # The MODULE, not just the names: #714's `pricing_snapshot_context` has to be
716
- # reached through the live module object so a test that swaps the snapshot is
717
- # seen by the request, build and sync entries below.
718
- import _lib_pricing
719
715
  from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
720
716
  _short_model_name, claude_usage_dict)
721
717
  from _lib_five_hour import _canonical_5h_window_key, _round_to_ten_minutes
@@ -2272,13 +2268,6 @@ def _make_dashboard_run_iteration(
2272
2268
 
2273
2269
  def run_iteration(batch=None) -> dict:
2274
2270
  warnings: list = []
2275
- # #714: the whole snapshot build runs on ONE pricing revision. A swap
2276
- # landing part-way through would publish a snapshot whose panels were
2277
- # priced from two different tables.
2278
- with _lib_pricing.pricing_snapshot_context():
2279
- return _run_iteration_body(batch, warnings)
2280
-
2281
- def _run_iteration_body(batch, warnings: list) -> dict:
2282
2271
  if batch is not None and batch[1] and not skip_sync:
2283
2272
  with sync_lock:
2284
2273
  result = _refresh_usage_inproc()
@@ -2556,103 +2545,38 @@ def _wake_schema_writer() -> None:
2556
2545
  _SCHEMA_WAKE_THREAD = None
2557
2546
 
2558
2547
 
2559
- #: How often the pricing watch stats the deployed pricing file. A monotonic
2560
- #: cadence, because a wall-clock one would re-poll or stall across a system
2561
- #: clock change.
2562
- _PRICING_WATCH_INTERVAL_S = 60.0
2563
-
2564
-
2565
- class _PricingWatch:
2566
- """Detect a replaced `_lib_pricing.py` and adopt it in process (#714).
2548
+ _INSTALL_WATCH = None
2567
2549
 
2568
- An installer overwrites the deployed file under a running dashboard.
2569
- Nothing about that reaches this process: every CLI invocation re-imports
2570
- and picks the new tables up, and only a long-lived process holds the old
2571
- ones.
2572
2550
 
2573
- STAT FIRST, PARSE SECOND. The poll compares an inode/size/mtime signature
2574
- and parses only when it changed, so the steady state is one `stat` a
2575
- minute rather than an `ast.parse` of a 1,400-line file.
2551
+ def _start_install_watch(boot):
2552
+ """Own the install watch on its own lifecycle thread, in every mode (#868).
2576
2553
 
2577
- NEVER RAISES OUT OF `poll_once`. The file it reads is one an installer may
2578
- be part-way through writing, so a half-written file is the ordinary case
2579
- rather than an exceptional one; a raising poll would end the thread and
2580
- leave the process permanently stale, which is the very condition this
2581
- exists to clear.
2554
+ Not started from a git checkout, where file changes are development work,
2555
+ not installs.
2582
2556
  """
2583
-
2584
- def __init__(self, source_path=None, interval_s=None):
2585
- self.source_path = pathlib.Path(
2586
- source_path if source_path is not None
2587
- else sys.modules["_lib_pricing"].__file__)
2588
- self.interval_s = (_PRICING_WATCH_INTERVAL_S if interval_s is None
2589
- else interval_s)
2590
- self._signature = None
2591
- self._stop = threading.Event()
2592
-
2593
- def _current_signature(self):
2594
- try:
2595
- st = self.source_path.stat()
2596
- except OSError:
2597
- return None
2598
- return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
2599
-
2600
- def poll_once(self) -> bool:
2601
- """One check. Returns True iff a new snapshot was adopted."""
2602
- try:
2603
- signature = self._current_signature()
2604
- if signature is None or signature == self._signature:
2605
- return False
2606
- self._signature = signature
2607
- pricing = sys.modules["_lib_pricing"]
2608
- candidate = pricing.read_pricing_snapshot_from_source(
2609
- self.source_path)
2610
- if not pricing.adopt_pricing_candidate(candidate):
2611
- return False
2612
- eprint("[pricing] adopted pricing revision "
2613
- f"{candidate.snapshot_date} without restarting")
2614
- return True
2615
- except Exception: # noqa: BLE001
2616
- # Including `PricingCandidateRejected`: a rejected candidate is a
2617
- # normal outcome, and the signature is already recorded so the
2618
- # same bad file is not re-parsed every minute.
2619
- return False
2620
-
2621
- def run(self) -> None:
2622
- while not self._stop.wait(self.interval_s):
2623
- self.poll_once()
2624
-
2625
- def stop(self) -> None:
2626
- self._stop.set()
2627
-
2628
-
2629
- _PRICING_WATCH: "_PricingWatch | None" = None
2630
-
2631
-
2632
- def _start_pricing_watch() -> "_PricingWatch | None":
2633
- """Own the pricing detector on a lifecycle thread that runs in BOTH modes.
2634
-
2635
- Deliberately not `_DashboardSyncThread` and not the conversation sync
2636
- thread: `cmd_dashboard` disables both under `--no-sync`, so a detector in
2637
- either would silently have no owner exactly where a long-lived process is
2638
- most likely to run.
2639
- """
2640
- global _PRICING_WATCH
2641
- if _PRICING_WATCH is not None:
2642
- return _PRICING_WATCH
2557
+ global _INSTALL_WATCH
2558
+ if _cctally_core._is_dev_checkout():
2559
+ return None
2560
+ if _INSTALL_WATCH is not None:
2561
+ return _INSTALL_WATCH
2643
2562
  try:
2644
- watch = _PricingWatch()
2645
- watch.poll_once() # establish the baseline signature; adopts nothing
2646
- thread = threading.Thread(
2647
- target=watch.run, name="cctally-pricing-watch", daemon=True)
2648
- thread.start()
2563
+ watch = InstallWatch(
2564
+ boot, worker_fn=lambda: getattr(_cctally(), "_UPDATE_WORKER", None))
2565
+ watch.start()
2649
2566
  except Exception as exc: # noqa: BLE001
2650
- eprint(f"[pricing] watch not started ({exc})")
2567
+ eprint(f"[update] install watch not started ({exc})")
2651
2568
  return None
2652
- _PRICING_WATCH = watch
2569
+ _INSTALL_WATCH = watch
2653
2570
  return watch
2654
2571
 
2655
2572
 
2573
+ def _stop_install_watch() -> None:
2574
+ global _INSTALL_WATCH
2575
+ watch, _INSTALL_WATCH = _INSTALL_WATCH, None
2576
+ if watch is not None:
2577
+ watch.stop()
2578
+
2579
+
2656
2580
  def _dashboard_maybe_prune_retention() -> None:
2657
2581
  """#313 P3 (F7): throttled transcript retention prune driven from the
2658
2582
  independent conversation sync thread. Opens a dedicated transcript
@@ -2812,18 +2736,7 @@ def _conversation_sync_pass() -> str:
2812
2736
  therefore NO SECOND OPEN ATTEMPT AFTER A FAILED OPEN — stated that narrowly
2813
2737
  because a successful pass opens the store twice by design, once here and
2814
2738
  once inside the prune.
2815
-
2816
- #714: the whole pass runs on ONE pricing revision. This pass writes
2817
- materialized cost, so a swap landing between the Claude leg and the Codex
2818
- leg would derive one store's rollup from two revisions — the ordered-write
2819
- guard would not even see it, because both halves are the same process.
2820
2739
  """
2821
- with _lib_pricing.pricing_snapshot_context():
2822
- return _conversation_sync_pass_body()
2823
-
2824
-
2825
- def _conversation_sync_pass_body() -> str:
2826
- """The body of `_conversation_sync_pass`, run under its pricing context."""
2827
2740
  try:
2828
2741
  conn = open_conversations_db()
2829
2742
  except (OSError, sqlite3.DatabaseError) as exc:
@@ -3375,6 +3288,14 @@ def _DashboardUpdateCheckThread(*args, **kwargs):
3375
3288
  return sys.modules["cctally"]._DashboardUpdateCheckThread(*args, **kwargs)
3376
3289
 
3377
3290
 
3291
+ def capture_install_boot_state(*args, **kwargs):
3292
+ return sys.modules["cctally"].capture_install_boot_state(*args, **kwargs)
3293
+
3294
+
3295
+ def InstallWatch(*args, **kwargs):
3296
+ return sys.modules["cctally"].InstallWatch(*args, **kwargs)
3297
+
3298
+
3378
3299
  # Module-level __getattr__ — lazy-resolves a handful of cctally globals at
3379
3300
  # attribute-access time (used for the ``except cctally.SomeError`` sites
3380
3301
  # that need real class identity, and for non-callable constants that the
@@ -8610,19 +8531,6 @@ _GET_ROUTES = (
8610
8531
  ("scope", "endpoint.conversation_detail"), True),
8611
8532
  )
8612
8533
 
8613
- #: Handlers that block for the life of a client connection rather than for one
8614
- #: response. `_dispatch` must NOT pin a pricing revision around these: the pin
8615
- #: would last as long as the stream, so a viewer who left a tab open would keep
8616
- #: pricing from whichever revision was live when the tab connected — the exact
8617
- #: staleness #714 removes. Both are streams that carry no cost of their own;
8618
- #: the envelopes they forward are priced by the build that produced them, which
8619
- #: has its own context.
8620
- _STREAMING_ROUTE_HANDLERS = frozenset({
8621
- "_serve_api_events",
8622
- "_handle_get_update_stream",
8623
- "_handle_get_conversation_events",
8624
- })
8625
-
8626
8534
  _POST_ROUTES = (
8627
8535
  ("exact", "/api/auth", "_handle_post_auth", None, False),
8628
8536
  ("exact", "/api/sync", "_handle_post_sync", None, False),
@@ -8758,13 +8666,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8758
8666
  eleven path-taking handlers. (NOTE: returning a bool, not ``fn()`` —
8759
8667
  the void handlers all return None, so a None-return contract could not
8760
8668
  tell "handled" from "unmatched".)
8761
-
8762
- #714: every matched route runs inside a pricing-snapshot context, so a
8763
- response is never assembled from two pricing revisions when an
8764
- installer replaces `_lib_pricing.py` mid-request. The two long-lived
8765
- streaming routes are excluded — a context entered there would pin one
8766
- revision for the whole life of the stream, which is minutes to hours
8767
- and the opposite of what the reload exists to achieve.
8768
8669
  """
8769
8670
  path = self.path.split("?", 1)[0]
8770
8671
  for kind, pattern, name, perf, wants_path in table:
@@ -8778,19 +8679,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8778
8679
  continue
8779
8680
  fn = getattr(self, name)
8780
8681
  args = (path,) if wants_path else ()
8781
- if name in _STREAMING_ROUTE_HANDLERS:
8782
- pinned = contextlib.nullcontext()
8783
- else:
8784
- pinned = _lib_pricing.pricing_snapshot_context()
8785
- with pinned:
8786
- if perf is None:
8682
+ if perf is None:
8683
+ fn(*args)
8684
+ elif perf[0] == "scope":
8685
+ with self._perf_scope(perf[1]):
8686
+ fn(*args)
8687
+ else: # "phase"
8688
+ with self._perf_gate().phase(perf[1]):
8787
8689
  fn(*args)
8788
- elif perf[0] == "scope":
8789
- with self._perf_scope(perf[1]):
8790
- fn(*args)
8791
- else: # "phase"
8792
- with self._perf_gate().phase(perf[1]):
8793
- fn(*args)
8794
8690
  return True
8795
8691
  return False
8796
8692
 
@@ -11731,7 +11627,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11731
11627
  as an explicit user pin; the worker resolves the selected channel
11732
11628
  afresh. CSRF-gated. Returns
11733
11629
  202 + ``{"run_id": ...}`` on accept; 409 + ``{"run_id_in_progress": ...}``
11734
- when another run is already in progress.
11630
+ when another run is already in progress; 503 + ``{"error": ...}`` while
11631
+ the install watcher holds the automatic-restart claim (#868), which
11632
+ the unchanged client shows as a failed start.
11735
11633
  """
11736
11634
  if not self._check_origin_csrf():
11737
11635
  return
@@ -11753,7 +11651,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11753
11651
  # The dashboard has no explicit-version input. Older clients sent the
11754
11652
  # cached beta target here; trusting it as a pin recreates #342 whenever
11755
11653
  # the registry advances while the modal is open.
11756
- accepted, run_id = worker.start(None)
11654
+ try:
11655
+ accepted, run_id = worker.start(None)
11656
+ except sys.modules["cctally"].AutomaticRestartInProgress as exc:
11657
+ self._respond_json(503, {"error": str(exc)})
11658
+ return
11757
11659
  if accepted:
11758
11660
  self._respond_json(202, {"run_id": run_id})
11759
11661
  else:
@@ -12339,16 +12241,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12339
12241
  _cctally_core.enable_quota_projection_reconciliation()
12340
12242
 
12341
12243
  # Spec §5.7: capture the un-mutated argv + PATH-resolved entrypoint
12342
- # at boot so the in-place ``execvp`` after a successful update
12343
- # re-enters the user-facing wrapper (npm Node shim → CCTALLY_PYTHON
12344
- # honoured; brew symlink → post-upgrade Python script). Module-level
12345
- # globals live in cctally (declared at L23205 pre-extract); we write
12346
- # them via the module-proxy so ``UpdateWorker`` (in _cctally_update)
12347
- # reads the right values via ``cctally.X`` at call time.
12244
+ # at boot. ``_resolve_execvp_target`` derives the in-place restart
12245
+ # from them: an npm install execs Python directly on its own
12246
+ # ``bin/cctally`` (no added Node process, #868), and a brew install
12247
+ # re-enters the symlink into the newly linked keg. Module-level
12248
+ # globals live in cctally; we write them via the module-proxy so
12249
+ # ``UpdateWorker`` and the install watch (in _cctally_update) read the
12250
+ # right values via ``cctally.X`` at call time.
12348
12251
  _c_boot = _cctally()
12349
12252
  _c_boot.ORIGINAL_SYS_ARGV = list(sys.argv)
12350
12253
  _c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
12351
12254
  _c_boot._UPDATE_WORKER = UpdateWorker()
12255
+ # #868: captured before any thread starts; the install watch compares every
12256
+ # later poll against what this process booted from.
12257
+ _install_boot = capture_install_boot_state()
12352
12258
 
12353
12259
  # Load config for the bind-host + expose-transcripts resolution below. (#217
12354
12260
  # S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
@@ -12433,11 +12339,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12433
12339
  _cctally_cache_module.set_conversation_derivations_suppressed(
12434
12340
  bool(args.no_sync))
12435
12341
  _dashboard_startup_schema_migration(run_derivations=not bool(args.no_sync))
12436
- # #714: the pricing-file watch, in EVERY mode for the same reason. Both
12437
- # `_DashboardSyncThread` and `_make_conversation_sync_thread` are disabled
12438
- # under `--no-sync`, so a detector placed in either would have no owner in
12439
- # the one mode a long-lived read-only dashboard runs in.
12440
- _start_pricing_watch()
12342
+ # #868: in EVERY mode, because `--no-sync` disables both sync threads and a
12343
+ # long-lived read-only dashboard is still left behind by an install.
12344
+ _start_install_watch(_install_boot)
12441
12345
  _heal = _dashboard_self_heal_orphans(skip_sync=bool(args.no_sync))
12442
12346
  if _heal is not None and _heal.pruned_files:
12443
12347
  print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
@@ -12703,6 +12607,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12703
12607
  try:
12704
12608
  _dashboard_wait_for_signal((_signal.SIGINT, _signal.SIGTERM))
12705
12609
  finally:
12610
+ # First, so a shutdown can never be followed by an automatic restart.
12611
+ _stop_install_watch()
12706
12612
  if sync_thread is not None:
12707
12613
  sync_thread.stop()
12708
12614
  conversation_sync_stop.set()
@@ -56,8 +56,8 @@ from _cctally_core import (
56
56
  )
57
57
  from _lib_dashboard_sources import (
58
58
  SOURCE_SCHEMA_VERSION,
59
- canonical_alerted_at as _canonical_alerted_at,
60
- canonical_alerted_at_sql as _canonical_alerted_at_sql,
59
+ alerted_at_order_key as _alerted_at_order_key,
60
+ alerted_at_order_key_sql as _alerted_at_order_key_sql,
61
61
  dashboard_resource_key as _dashboard_resource_key,
62
62
  )
63
63
  from _lib_display_tz import _compute_display_block, format_display_dt
@@ -475,9 +475,10 @@ def _alert_account_resolver(conn: sqlite3.Connection):
475
475
  # and a row excluded there is unrecoverable. Two spellings of one instant
476
476
  # compare equal here; a nonzero offset — which the ``all-combined`` fixture
477
477
  # stores deliberately — orders by its true instant rather than by its
478
- # local-time text.
479
- _CANON_ALERTED_AT = _canonical_alerted_at_sql()
480
- _CANON_ALERTED_AT_M = _canonical_alerted_at_sql("m.alerted_at")
478
+ # local-time text. #869 F14: the key carries the full sub-second instant, so a
479
+ # Codex crossing fired later in the same second as a Claude alert sorts newer.
480
+ _CANON_ALERTED_AT = _alerted_at_order_key_sql()
481
+ _CANON_ALERTED_AT_M = _alerted_at_order_key_sql("m.alerted_at")
481
482
 
482
483
 
483
484
  def _envelope_rows_weekly(
@@ -1169,7 +1170,7 @@ def _build_alerts_envelope_array(
1169
1170
  # `_combined_alert_rows` does — otherwise the operator sees an empty
1170
1171
  # panel and a message that identifies nothing.
1171
1172
  try:
1172
- return _canonical_alerted_at(alert["alerted_at"])
1173
+ return _alerted_at_order_key(alert["alerted_at"])
1173
1174
  except (KeyError, ValueError) as exc:
1174
1175
  identity = alert.get("key") or alert.get("id")
1175
1176
  raise ValueError(