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.
- package/CHANGELOG.md +52 -0
- package/README.md +6 -6
- package/bin/_cctally_cache.py +62 -13
- package/bin/_cctally_core.py +8 -1
- package/bin/_cctally_dashboard.py +422 -195
- package/bin/_cctally_dashboard_conversation.py +231 -40
- package/bin/_cctally_dashboard_envelope.py +48 -32
- package/bin/_cctally_dashboard_sources.py +503 -109
- package/bin/_cctally_db.py +44 -0
- package/bin/_cctally_diagnosis_sources.py +181 -15
- package/bin/_cctally_doctor.py +15 -3
- package/bin/_cctally_forecast.py +25 -12
- package/bin/_cctally_journal.py +59 -32
- package/bin/_cctally_milestone_history.py +65 -9
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_record.py +50 -12
- package/bin/_cctally_rederive.py +899 -30
- package/bin/_cctally_source_analytics.py +567 -133
- package/bin/_cctally_transcript.py +27 -2
- package/bin/_cctally_tui.py +39 -6
- package/bin/_cctally_update.py +420 -14
- package/bin/_lib_cache_report.py +7 -10
- package/bin/_lib_codex_conversation_query.py +286 -63
- package/bin/_lib_codex_metadata.py +207 -0
- package/bin/_lib_conversation_query.py +254 -37
- package/bin/_lib_doctor.py +16 -2
- package/bin/_lib_pricing.py +80 -276
- package/bin/_lib_rederive.py +903 -2
- package/bin/_lib_view_models.py +5 -0
- package/bin/cctally +18 -5
- package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
- package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
- package/dashboard/static/assets/index-COhxe3vU.css +1 -0
- package/dashboard/static/assets/index-DdU62sov.js +13 -0
- package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
- package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +2 -1
- package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
- package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
- package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
- package/dashboard/static/assets/index-DLVwACUM.js +0 -13
- package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +0 -9
|
@@ -278,6 +278,7 @@ import secrets
|
|
|
278
278
|
import shutil
|
|
279
279
|
import signal as _signal
|
|
280
280
|
import socket
|
|
281
|
+
import select
|
|
281
282
|
import sqlite3
|
|
282
283
|
import sys
|
|
283
284
|
import threading
|
|
@@ -297,7 +298,7 @@ from _lib_retained_size import retained_size_bytes
|
|
|
297
298
|
|
|
298
299
|
|
|
299
300
|
class _QuietThreadingHTTPServer(ThreadingHTTPServer):
|
|
300
|
-
"""
|
|
301
|
+
"""Bounded HTTP threads with quiet client-disconnect handling.
|
|
301
302
|
|
|
302
303
|
A backgrounded/closed/reloaded dashboard tab hangs up mid-response, so the
|
|
303
304
|
per-request thread's socket write raises one of the "peer went away"
|
|
@@ -313,6 +314,111 @@ class _QuietThreadingHTTPServer(ThreadingHTTPServer):
|
|
|
313
314
|
"""
|
|
314
315
|
|
|
315
316
|
daemon_threads = True
|
|
317
|
+
request_queue_size = 256
|
|
318
|
+
max_request_threads = 96
|
|
319
|
+
_reject_workers_count = 4
|
|
320
|
+
_reject_queue_size = 256
|
|
321
|
+
_busy_body = b'{"error":"server busy"}'
|
|
322
|
+
_busy_response = (
|
|
323
|
+
b"HTTP/1.1 503 Service Unavailable\r\n"
|
|
324
|
+
b"Content-Type: application/json\r\n"
|
|
325
|
+
b"Cache-Control: no-store\r\n"
|
|
326
|
+
b"Connection: close\r\n"
|
|
327
|
+
+ f"Content-Length: {len(_busy_body)}\r\n\r\n".encode("ascii")
|
|
328
|
+
+ _busy_body
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def __init__(self, *args, **kwargs):
|
|
332
|
+
self._request_slots = threading.BoundedSemaphore(
|
|
333
|
+
self.max_request_threads)
|
|
334
|
+
self._reject_queue: queue.Queue[socket.socket] = queue.Queue(
|
|
335
|
+
maxsize=self._reject_queue_size)
|
|
336
|
+
self._reject_stop = threading.Event()
|
|
337
|
+
self._reject_start_lock = threading.Lock()
|
|
338
|
+
self._reject_workers: list[threading.Thread] = []
|
|
339
|
+
super().__init__(*args, **kwargs)
|
|
340
|
+
|
|
341
|
+
def _reject_request(self, request: socket.socket) -> None:
|
|
342
|
+
try:
|
|
343
|
+
# Read only the bounded request headers so a close with unread
|
|
344
|
+
# input does not reset a client before it sees the 503. Slow peers
|
|
345
|
+
# are confined to a fixed reject worker, never the accept loop.
|
|
346
|
+
request.settimeout(0.25)
|
|
347
|
+
received = bytearray()
|
|
348
|
+
while b"\r\n\r\n" not in received and len(received) < 8192:
|
|
349
|
+
chunk = request.recv(min(8192 - len(received), 4096))
|
|
350
|
+
if not chunk:
|
|
351
|
+
break
|
|
352
|
+
received.extend(chunk)
|
|
353
|
+
request.sendall(self._busy_response)
|
|
354
|
+
except OSError:
|
|
355
|
+
pass # A peer that disconnected cannot receive the overload reply.
|
|
356
|
+
finally:
|
|
357
|
+
self.shutdown_request(request)
|
|
358
|
+
|
|
359
|
+
def _reject_loop(self) -> None:
|
|
360
|
+
while not self._reject_stop.is_set():
|
|
361
|
+
try:
|
|
362
|
+
request = self._reject_queue.get(timeout=0.1)
|
|
363
|
+
except queue.Empty:
|
|
364
|
+
continue
|
|
365
|
+
try:
|
|
366
|
+
self._reject_request(request)
|
|
367
|
+
finally:
|
|
368
|
+
self._reject_queue.task_done()
|
|
369
|
+
|
|
370
|
+
def _queue_rejection(self, request: socket.socket) -> None:
|
|
371
|
+
with self._reject_start_lock:
|
|
372
|
+
if not self._reject_workers:
|
|
373
|
+
for index in range(self._reject_workers_count):
|
|
374
|
+
worker = threading.Thread(
|
|
375
|
+
target=self._reject_loop,
|
|
376
|
+
name=f"cctally-http-reject-{index}", daemon=True,
|
|
377
|
+
)
|
|
378
|
+
worker.start()
|
|
379
|
+
self._reject_workers.append(worker)
|
|
380
|
+
try:
|
|
381
|
+
self._reject_queue.put_nowait(request)
|
|
382
|
+
except queue.Full:
|
|
383
|
+
# This last-resort reply is nonblocking. It keeps the accept loop
|
|
384
|
+
# responsive even if the bounded reject queue also saturates.
|
|
385
|
+
try:
|
|
386
|
+
request.setblocking(False)
|
|
387
|
+
request.send(self._busy_response)
|
|
388
|
+
except OSError:
|
|
389
|
+
pass
|
|
390
|
+
finally:
|
|
391
|
+
self.shutdown_request(request)
|
|
392
|
+
|
|
393
|
+
def process_request(self, request, client_address):
|
|
394
|
+
if not self._request_slots.acquire(blocking=False):
|
|
395
|
+
self._queue_rejection(request)
|
|
396
|
+
return
|
|
397
|
+
try:
|
|
398
|
+
super().process_request(request, client_address)
|
|
399
|
+
except BaseException:
|
|
400
|
+
self._request_slots.release()
|
|
401
|
+
self.shutdown_request(request)
|
|
402
|
+
raise
|
|
403
|
+
|
|
404
|
+
def process_request_thread(self, request, client_address):
|
|
405
|
+
try:
|
|
406
|
+
super().process_request_thread(request, client_address)
|
|
407
|
+
finally:
|
|
408
|
+
self._request_slots.release()
|
|
409
|
+
|
|
410
|
+
def server_close(self):
|
|
411
|
+
self._reject_stop.set()
|
|
412
|
+
while True:
|
|
413
|
+
try:
|
|
414
|
+
request = self._reject_queue.get_nowait()
|
|
415
|
+
except queue.Empty:
|
|
416
|
+
break
|
|
417
|
+
self.shutdown_request(request)
|
|
418
|
+
self._reject_queue.task_done()
|
|
419
|
+
for worker in self._reject_workers:
|
|
420
|
+
worker.join(timeout=0.5)
|
|
421
|
+
super().server_close()
|
|
316
422
|
|
|
317
423
|
def handle_error(self, request, client_address):
|
|
318
424
|
exc = sys.exc_info()[1]
|
|
@@ -332,6 +438,30 @@ class _DiagnosisFlight:
|
|
|
332
438
|
result: Any = None
|
|
333
439
|
error: BaseException | None = None
|
|
334
440
|
callers: int = 0
|
|
441
|
+
deadline: float = 0.0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class _DiagnosisOverloaded(Exception):
|
|
445
|
+
"""The bounded diagnosis admission has no room for another caller."""
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class _DiagnosisTimedOut(Exception):
|
|
449
|
+
"""A diagnosis did not finish within its flight's deadline."""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class _DiagnosisClientGone(Exception):
|
|
453
|
+
"""The requesting peer disconnected while its diagnosis was pending."""
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _diagnosis_peer_gone(connection: socket.socket) -> bool:
|
|
457
|
+
"""Check a waiting GET socket without consuming any request bytes."""
|
|
458
|
+
try:
|
|
459
|
+
if not select.select([connection], [], [], 0)[0]:
|
|
460
|
+
return False
|
|
461
|
+
return not connection.recv(
|
|
462
|
+
1, socket.MSG_PEEK | getattr(socket, "MSG_DONTWAIT", 0))
|
|
463
|
+
except (OSError, ValueError):
|
|
464
|
+
return True
|
|
335
465
|
|
|
336
466
|
|
|
337
467
|
def _diagnosis_flight_key(
|
|
@@ -360,48 +490,155 @@ class _DiagnosisAdmission:
|
|
|
360
490
|
never retained, so this adds no stale report cache or periodic work.
|
|
361
491
|
"""
|
|
362
492
|
|
|
363
|
-
def __init__(self
|
|
493
|
+
def __init__(self, *, max_callers: int = 32, max_per_key: int = 16,
|
|
494
|
+
max_keys: int = 8, deadline_seconds: float = 15.0) -> None:
|
|
495
|
+
if min(max_callers, max_per_key, max_keys) < 1 or deadline_seconds <= 0:
|
|
496
|
+
raise ValueError("diagnosis admission limits must be positive")
|
|
364
497
|
self._changed = threading.Condition()
|
|
365
498
|
self._admission = threading.BoundedSemaphore(1)
|
|
366
499
|
self._flights: dict[tuple[Any, bool, bool], _DiagnosisFlight] = {}
|
|
500
|
+
self._callers = 0
|
|
501
|
+
self._max_callers = max_callers
|
|
502
|
+
self._max_per_key = max_per_key
|
|
503
|
+
self._max_keys = max_keys
|
|
504
|
+
self._deadline_seconds = deadline_seconds
|
|
367
505
|
|
|
368
|
-
def
|
|
506
|
+
def _finish(self, key, flight, *, result=None, error=None) -> None:
|
|
507
|
+
with self._changed:
|
|
508
|
+
if flight.done.is_set():
|
|
509
|
+
return
|
|
510
|
+
flight.result = result
|
|
511
|
+
flight.error = error
|
|
512
|
+
flight.done.set()
|
|
513
|
+
if self._flights.get(key) is flight:
|
|
514
|
+
del self._flights[key]
|
|
515
|
+
self._changed.notify_all()
|
|
516
|
+
|
|
517
|
+
def _produce(self, key, flight, build, bounded_build) -> None:
|
|
518
|
+
remaining = max(0.0, flight.deadline - time.monotonic())
|
|
519
|
+
acquired = self._admission.acquire(timeout=remaining)
|
|
520
|
+
if not acquired:
|
|
521
|
+
self._finish(key, flight, error=_DiagnosisTimedOut())
|
|
522
|
+
return
|
|
523
|
+
try:
|
|
524
|
+
# A queued flight may have expired while a prior producer held the
|
|
525
|
+
# single process-wide slot. It must never start a late build.
|
|
526
|
+
if flight.done.is_set() or time.monotonic() >= flight.deadline:
|
|
527
|
+
self._finish(key, flight, error=_DiagnosisTimedOut())
|
|
528
|
+
return
|
|
529
|
+
try:
|
|
530
|
+
remaining = max(0.0, flight.deadline - time.monotonic())
|
|
531
|
+
result = (
|
|
532
|
+
bounded_build(remaining)
|
|
533
|
+
if bounded_build is not None else build()
|
|
534
|
+
)
|
|
535
|
+
except TimeoutError:
|
|
536
|
+
self._finish(key, flight, error=_DiagnosisTimedOut())
|
|
537
|
+
except BaseException as exc:
|
|
538
|
+
self._finish(
|
|
539
|
+
key, flight,
|
|
540
|
+
error=(_DiagnosisTimedOut()
|
|
541
|
+
if time.monotonic() >= flight.deadline else exc),
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
if time.monotonic() >= flight.deadline:
|
|
545
|
+
self._finish(key, flight, error=_DiagnosisTimedOut())
|
|
546
|
+
else:
|
|
547
|
+
self._finish(key, flight, result=result)
|
|
548
|
+
finally:
|
|
549
|
+
# Production passes a killable process build, so reaching the
|
|
550
|
+
# deadline retires that process tree before this slot is released.
|
|
551
|
+
# Direct in-process builds remain available only to focused unit
|
|
552
|
+
# callers whose producer is known to terminate.
|
|
553
|
+
self._admission.release()
|
|
554
|
+
|
|
555
|
+
def run(self, key: tuple[Any, bool, bool], build, *, peer_gone=None,
|
|
556
|
+
bounded_build=None):
|
|
369
557
|
with self._changed:
|
|
370
558
|
flight = self._flights.get(key)
|
|
371
559
|
owner = flight is None
|
|
560
|
+
if self._callers >= self._max_callers:
|
|
561
|
+
raise _DiagnosisOverloaded()
|
|
372
562
|
if owner:
|
|
373
|
-
|
|
563
|
+
if len(self._flights) >= self._max_keys:
|
|
564
|
+
raise _DiagnosisOverloaded()
|
|
565
|
+
flight = _DiagnosisFlight(
|
|
566
|
+
deadline=time.monotonic() + self._deadline_seconds,
|
|
567
|
+
)
|
|
374
568
|
self._flights[key] = flight
|
|
569
|
+
elif flight.callers >= self._max_per_key:
|
|
570
|
+
raise _DiagnosisOverloaded()
|
|
375
571
|
flight.callers += 1
|
|
572
|
+
self._callers += 1
|
|
376
573
|
self._changed.notify_all()
|
|
377
|
-
|
|
378
|
-
if not owner:
|
|
379
|
-
flight.done.wait()
|
|
380
|
-
if flight.error is not None:
|
|
381
|
-
raise flight.error
|
|
382
|
-
return flight.result
|
|
383
|
-
|
|
384
574
|
try:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
575
|
+
if owner:
|
|
576
|
+
try:
|
|
577
|
+
threading.Thread(
|
|
578
|
+
target=self._produce,
|
|
579
|
+
args=(key, flight, build, bounded_build),
|
|
580
|
+
name="cctally-diagnosis", daemon=True,
|
|
581
|
+
).start()
|
|
582
|
+
except BaseException as exc:
|
|
583
|
+
self._finish(key, flight, error=exc)
|
|
584
|
+
while not flight.done.is_set():
|
|
585
|
+
if peer_gone is not None and peer_gone():
|
|
586
|
+
raise _DiagnosisClientGone()
|
|
587
|
+
remaining = flight.deadline - time.monotonic()
|
|
588
|
+
if remaining <= 0:
|
|
589
|
+
break
|
|
590
|
+
flight.done.wait(min(0.1, remaining))
|
|
591
|
+
if not flight.done.is_set():
|
|
592
|
+
self._finish(key, flight, error=_DiagnosisTimedOut())
|
|
391
593
|
with self._changed:
|
|
392
|
-
flight.error
|
|
393
|
-
|
|
594
|
+
if flight.error is not None:
|
|
595
|
+
raise flight.error
|
|
596
|
+
return flight.result
|
|
394
597
|
finally:
|
|
395
598
|
with self._changed:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
flight.done.set()
|
|
599
|
+
flight.callers -= 1
|
|
600
|
+
self._callers -= 1
|
|
399
601
|
self._changed.notify_all()
|
|
400
602
|
|
|
401
603
|
|
|
402
604
|
_DIAGNOSIS_ADMISSION = _DiagnosisAdmission()
|
|
403
605
|
|
|
404
606
|
|
|
607
|
+
class _StaticValidatorCache:
|
|
608
|
+
"""Bounded validators for bytes already served by this process."""
|
|
609
|
+
|
|
610
|
+
def __init__(self, max_entries: int = 128) -> None:
|
|
611
|
+
self._lock = threading.Lock()
|
|
612
|
+
self._entries: OrderedDict[tuple[pathlib.Path, bool],
|
|
613
|
+
tuple[tuple[int, ...], str]] = OrderedDict()
|
|
614
|
+
self._max_entries = max_entries
|
|
615
|
+
|
|
616
|
+
@staticmethod
|
|
617
|
+
def fingerprint(stat_result: os.stat_result) -> tuple[int, ...]:
|
|
618
|
+
# ctime catches a rewrite that restores size and mtime. Device/inode
|
|
619
|
+
# distinguishes replacement of a mutable-name shell during a build.
|
|
620
|
+
return (stat_result.st_dev, stat_result.st_ino, stat_result.st_size,
|
|
621
|
+
stat_result.st_mtime_ns, stat_result.st_ctime_ns)
|
|
622
|
+
|
|
623
|
+
def get(self, key, fingerprint) -> str | None:
|
|
624
|
+
with self._lock:
|
|
625
|
+
entry = self._entries.get(key)
|
|
626
|
+
if entry is None or entry[0] != fingerprint:
|
|
627
|
+
return None
|
|
628
|
+
self._entries.move_to_end(key)
|
|
629
|
+
return entry[1]
|
|
630
|
+
|
|
631
|
+
def put(self, key, fingerprint, etag: str) -> None:
|
|
632
|
+
with self._lock:
|
|
633
|
+
self._entries[key] = (fingerprint, etag)
|
|
634
|
+
self._entries.move_to_end(key)
|
|
635
|
+
if len(self._entries) > self._max_entries:
|
|
636
|
+
self._entries.popitem(last=False)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
_STATIC_VALIDATORS = _StaticValidatorCache()
|
|
640
|
+
|
|
641
|
+
|
|
405
642
|
def _cctally():
|
|
406
643
|
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
407
644
|
return sys.modules["cctally"]
|
|
@@ -475,10 +712,6 @@ from _lib_aggregators import (
|
|
|
475
712
|
_finalize_bucket,
|
|
476
713
|
)
|
|
477
714
|
from _lib_fmt import stable_sum
|
|
478
|
-
# The MODULE, not just the names: #714's `pricing_snapshot_context` has to be
|
|
479
|
-
# reached through the live module object so a test that swaps the snapshot is
|
|
480
|
-
# seen by the request, build and sync entries below.
|
|
481
|
-
import _lib_pricing
|
|
482
715
|
from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
|
|
483
716
|
_short_model_name, claude_usage_dict)
|
|
484
717
|
from _lib_five_hour import _canonical_5h_window_key, _round_to_ten_minutes
|
|
@@ -669,6 +902,7 @@ from _cctally_dashboard_envelope import (
|
|
|
669
902
|
_envelope_rows_projected,
|
|
670
903
|
_envelope_rows_project_budget,
|
|
671
904
|
_ENVELOPE_AXIS_MAPPERS,
|
|
905
|
+
_alert_account_resolver,
|
|
672
906
|
_build_alerts_envelope_array,
|
|
673
907
|
_build_meter_rate_change_array,
|
|
674
908
|
_model_breakdowns_to_models,
|
|
@@ -2034,13 +2268,6 @@ def _make_dashboard_run_iteration(
|
|
|
2034
2268
|
|
|
2035
2269
|
def run_iteration(batch=None) -> dict:
|
|
2036
2270
|
warnings: list = []
|
|
2037
|
-
# #714: the whole snapshot build runs on ONE pricing revision. A swap
|
|
2038
|
-
# landing part-way through would publish a snapshot whose panels were
|
|
2039
|
-
# priced from two different tables.
|
|
2040
|
-
with _lib_pricing.pricing_snapshot_context():
|
|
2041
|
-
return _run_iteration_body(batch, warnings)
|
|
2042
|
-
|
|
2043
|
-
def _run_iteration_body(batch, warnings: list) -> dict:
|
|
2044
2271
|
if batch is not None and batch[1] and not skip_sync:
|
|
2045
2272
|
with sync_lock:
|
|
2046
2273
|
result = _refresh_usage_inproc()
|
|
@@ -2318,103 +2545,38 @@ def _wake_schema_writer() -> None:
|
|
|
2318
2545
|
_SCHEMA_WAKE_THREAD = None
|
|
2319
2546
|
|
|
2320
2547
|
|
|
2321
|
-
|
|
2322
|
-
#: cadence, because a wall-clock one would re-poll or stall across a system
|
|
2323
|
-
#: clock change.
|
|
2324
|
-
_PRICING_WATCH_INTERVAL_S = 60.0
|
|
2548
|
+
_INSTALL_WATCH = None
|
|
2325
2549
|
|
|
2326
2550
|
|
|
2327
|
-
|
|
2328
|
-
"""
|
|
2551
|
+
def _start_install_watch(boot):
|
|
2552
|
+
"""Own the install watch on its own lifecycle thread, in every mode (#868).
|
|
2329
2553
|
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
and picks the new tables up, and only a long-lived process holds the old
|
|
2333
|
-
ones.
|
|
2334
|
-
|
|
2335
|
-
STAT FIRST, PARSE SECOND. The poll compares an inode/size/mtime signature
|
|
2336
|
-
and parses only when it changed, so the steady state is one `stat` a
|
|
2337
|
-
minute rather than an `ast.parse` of a 1,400-line file.
|
|
2338
|
-
|
|
2339
|
-
NEVER RAISES OUT OF `poll_once`. The file it reads is one an installer may
|
|
2340
|
-
be part-way through writing, so a half-written file is the ordinary case
|
|
2341
|
-
rather than an exceptional one; a raising poll would end the thread and
|
|
2342
|
-
leave the process permanently stale, which is the very condition this
|
|
2343
|
-
exists to clear.
|
|
2554
|
+
Not started from a git checkout, where file changes are development work,
|
|
2555
|
+
not installs.
|
|
2344
2556
|
"""
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
self.interval_s = (_PRICING_WATCH_INTERVAL_S if interval_s is None
|
|
2351
|
-
else interval_s)
|
|
2352
|
-
self._signature = None
|
|
2353
|
-
self._stop = threading.Event()
|
|
2354
|
-
|
|
2355
|
-
def _current_signature(self):
|
|
2356
|
-
try:
|
|
2357
|
-
st = self.source_path.stat()
|
|
2358
|
-
except OSError:
|
|
2359
|
-
return None
|
|
2360
|
-
return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
|
|
2361
|
-
|
|
2362
|
-
def poll_once(self) -> bool:
|
|
2363
|
-
"""One check. Returns True iff a new snapshot was adopted."""
|
|
2364
|
-
try:
|
|
2365
|
-
signature = self._current_signature()
|
|
2366
|
-
if signature is None or signature == self._signature:
|
|
2367
|
-
return False
|
|
2368
|
-
self._signature = signature
|
|
2369
|
-
pricing = sys.modules["_lib_pricing"]
|
|
2370
|
-
candidate = pricing.read_pricing_snapshot_from_source(
|
|
2371
|
-
self.source_path)
|
|
2372
|
-
if not pricing.adopt_pricing_candidate(candidate):
|
|
2373
|
-
return False
|
|
2374
|
-
eprint("[pricing] adopted pricing revision "
|
|
2375
|
-
f"{candidate.snapshot_date} without restarting")
|
|
2376
|
-
return True
|
|
2377
|
-
except Exception: # noqa: BLE001
|
|
2378
|
-
# Including `PricingCandidateRejected`: a rejected candidate is a
|
|
2379
|
-
# normal outcome, and the signature is already recorded so the
|
|
2380
|
-
# same bad file is not re-parsed every minute.
|
|
2381
|
-
return False
|
|
2382
|
-
|
|
2383
|
-
def run(self) -> None:
|
|
2384
|
-
while not self._stop.wait(self.interval_s):
|
|
2385
|
-
self.poll_once()
|
|
2386
|
-
|
|
2387
|
-
def stop(self) -> None:
|
|
2388
|
-
self._stop.set()
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
_PRICING_WATCH: "_PricingWatch | None" = None
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
def _start_pricing_watch() -> "_PricingWatch | None":
|
|
2395
|
-
"""Own the pricing detector on a lifecycle thread that runs in BOTH modes.
|
|
2396
|
-
|
|
2397
|
-
Deliberately not `_DashboardSyncThread` and not the conversation sync
|
|
2398
|
-
thread: `cmd_dashboard` disables both under `--no-sync`, so a detector in
|
|
2399
|
-
either would silently have no owner exactly where a long-lived process is
|
|
2400
|
-
most likely to run.
|
|
2401
|
-
"""
|
|
2402
|
-
global _PRICING_WATCH
|
|
2403
|
-
if _PRICING_WATCH is not None:
|
|
2404
|
-
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
|
|
2405
2562
|
try:
|
|
2406
|
-
watch =
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
target=watch.run, name="cctally-pricing-watch", daemon=True)
|
|
2410
|
-
thread.start()
|
|
2563
|
+
watch = InstallWatch(
|
|
2564
|
+
boot, worker_fn=lambda: getattr(_cctally(), "_UPDATE_WORKER", None))
|
|
2565
|
+
watch.start()
|
|
2411
2566
|
except Exception as exc: # noqa: BLE001
|
|
2412
|
-
eprint(f"[
|
|
2567
|
+
eprint(f"[update] install watch not started ({exc})")
|
|
2413
2568
|
return None
|
|
2414
|
-
|
|
2569
|
+
_INSTALL_WATCH = watch
|
|
2415
2570
|
return watch
|
|
2416
2571
|
|
|
2417
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
|
+
|
|
2418
2580
|
def _dashboard_maybe_prune_retention() -> None:
|
|
2419
2581
|
"""#313 P3 (F7): throttled transcript retention prune driven from the
|
|
2420
2582
|
independent conversation sync thread. Opens a dedicated transcript
|
|
@@ -2574,18 +2736,7 @@ def _conversation_sync_pass() -> str:
|
|
|
2574
2736
|
therefore NO SECOND OPEN ATTEMPT AFTER A FAILED OPEN — stated that narrowly
|
|
2575
2737
|
because a successful pass opens the store twice by design, once here and
|
|
2576
2738
|
once inside the prune.
|
|
2577
|
-
|
|
2578
|
-
#714: the whole pass runs on ONE pricing revision. This pass writes
|
|
2579
|
-
materialized cost, so a swap landing between the Claude leg and the Codex
|
|
2580
|
-
leg would derive one store's rollup from two revisions — the ordered-write
|
|
2581
|
-
guard would not even see it, because both halves are the same process.
|
|
2582
2739
|
"""
|
|
2583
|
-
with _lib_pricing.pricing_snapshot_context():
|
|
2584
|
-
return _conversation_sync_pass_body()
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
def _conversation_sync_pass_body() -> str:
|
|
2588
|
-
"""The body of `_conversation_sync_pass`, run under its pricing context."""
|
|
2589
2740
|
try:
|
|
2590
2741
|
conn = open_conversations_db()
|
|
2591
2742
|
except (OSError, sqlite3.DatabaseError) as exc:
|
|
@@ -3137,6 +3288,14 @@ def _DashboardUpdateCheckThread(*args, **kwargs):
|
|
|
3137
3288
|
return sys.modules["cctally"]._DashboardUpdateCheckThread(*args, **kwargs)
|
|
3138
3289
|
|
|
3139
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
|
+
|
|
3140
3299
|
# Module-level __getattr__ — lazy-resolves a handful of cctally globals at
|
|
3141
3300
|
# attribute-access time (used for the ``except cctally.SomeError`` sites
|
|
3142
3301
|
# that need real class identity, and for non-callable constants that the
|
|
@@ -4807,6 +4966,7 @@ def _blocks_view_with_retained_facts(conn, view):
|
|
|
4807
4966
|
models=_model_breakdowns_to_models(
|
|
4808
4967
|
facts["model_breakdowns"], facts["cost_usd"],
|
|
4809
4968
|
),
|
|
4969
|
+
facts_source="retained",
|
|
4810
4970
|
)
|
|
4811
4971
|
return dataclasses.replace(
|
|
4812
4972
|
view,
|
|
@@ -8371,19 +8531,6 @@ _GET_ROUTES = (
|
|
|
8371
8531
|
("scope", "endpoint.conversation_detail"), True),
|
|
8372
8532
|
)
|
|
8373
8533
|
|
|
8374
|
-
#: Handlers that block for the life of a client connection rather than for one
|
|
8375
|
-
#: response. `_dispatch` must NOT pin a pricing revision around these: the pin
|
|
8376
|
-
#: would last as long as the stream, so a viewer who left a tab open would keep
|
|
8377
|
-
#: pricing from whichever revision was live when the tab connected — the exact
|
|
8378
|
-
#: staleness #714 removes. Both are streams that carry no cost of their own;
|
|
8379
|
-
#: the envelopes they forward are priced by the build that produced them, which
|
|
8380
|
-
#: has its own context.
|
|
8381
|
-
_STREAMING_ROUTE_HANDLERS = frozenset({
|
|
8382
|
-
"_serve_api_events",
|
|
8383
|
-
"_handle_get_update_stream",
|
|
8384
|
-
"_handle_get_conversation_events",
|
|
8385
|
-
})
|
|
8386
|
-
|
|
8387
8534
|
_POST_ROUTES = (
|
|
8388
8535
|
("exact", "/api/auth", "_handle_post_auth", None, False),
|
|
8389
8536
|
("exact", "/api/sync", "_handle_post_sync", None, False),
|
|
@@ -8519,13 +8666,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8519
8666
|
eleven path-taking handlers. (NOTE: returning a bool, not ``fn()`` —
|
|
8520
8667
|
the void handlers all return None, so a None-return contract could not
|
|
8521
8668
|
tell "handled" from "unmatched".)
|
|
8522
|
-
|
|
8523
|
-
#714: every matched route runs inside a pricing-snapshot context, so a
|
|
8524
|
-
response is never assembled from two pricing revisions when an
|
|
8525
|
-
installer replaces `_lib_pricing.py` mid-request. The two long-lived
|
|
8526
|
-
streaming routes are excluded — a context entered there would pin one
|
|
8527
|
-
revision for the whole life of the stream, which is minutes to hours
|
|
8528
|
-
and the opposite of what the reload exists to achieve.
|
|
8529
8669
|
"""
|
|
8530
8670
|
path = self.path.split("?", 1)[0]
|
|
8531
8671
|
for kind, pattern, name, perf, wants_path in table:
|
|
@@ -8539,19 +8679,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8539
8679
|
continue
|
|
8540
8680
|
fn = getattr(self, name)
|
|
8541
8681
|
args = (path,) if wants_path else ()
|
|
8542
|
-
if
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
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]):
|
|
8548
8689
|
fn(*args)
|
|
8549
|
-
elif perf[0] == "scope":
|
|
8550
|
-
with self._perf_scope(perf[1]):
|
|
8551
|
-
fn(*args)
|
|
8552
|
-
else: # "phase"
|
|
8553
|
-
with self._perf_gate().phase(perf[1]):
|
|
8554
|
-
fn(*args)
|
|
8555
8690
|
return True
|
|
8556
8691
|
return False
|
|
8557
8692
|
|
|
@@ -10103,7 +10238,31 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10103
10238
|
primary_model="claude-sonnet-4-6",
|
|
10104
10239
|
)
|
|
10105
10240
|
status = _dispatch_alert_notification(payload, mode="test")
|
|
10106
|
-
|
|
10241
|
+
# Dispatch and alerts.log keep the builder's internal snake_case key.
|
|
10242
|
+
# The response is an AlertEntry-shaped toast preview, so its public
|
|
10243
|
+
# account fields must follow the same R8 gate as real envelope rows.
|
|
10244
|
+
# Work on a copy: removing the key from `payload` would also change
|
|
10245
|
+
# what the native notifier and test log receive.
|
|
10246
|
+
response_alert = {k: v for k, v in payload.items() if k != "account_key"}
|
|
10247
|
+
provider = (
|
|
10248
|
+
"codex" if axis == "codex_budget" or (
|
|
10249
|
+
axis == "projected" and metric == "codex_budget_usd"
|
|
10250
|
+
) else "claude"
|
|
10251
|
+
)
|
|
10252
|
+
if _cctally_core.DB_PATH.exists():
|
|
10253
|
+
try:
|
|
10254
|
+
conn = _stats_ro_guarded()
|
|
10255
|
+
try:
|
|
10256
|
+
response_alert.update(_alert_account_resolver(conn)(
|
|
10257
|
+
provider, payload.get("account_key"),
|
|
10258
|
+
))
|
|
10259
|
+
finally:
|
|
10260
|
+
conn.close()
|
|
10261
|
+
except (OSError, sqlite3.Error):
|
|
10262
|
+
# A rehearsal still works without a readable account registry;
|
|
10263
|
+
# no writable DB open or synthetic milestone is needed.
|
|
10264
|
+
pass
|
|
10265
|
+
self._respond_json(200, {"alert": response_alert, "dispatch": status})
|
|
10107
10266
|
|
|
10108
10267
|
|
|
10109
10268
|
# ---- share endpoints (spec §5.1) — thin delegators to the F1 sibling ----
|
|
@@ -10198,15 +10357,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10198
10357
|
return False
|
|
10199
10358
|
|
|
10200
10359
|
def _serve_static_file(self, path: pathlib.Path, ctype: str) -> None:
|
|
10201
|
-
try:
|
|
10202
|
-
body = path.read_bytes()
|
|
10203
|
-
except FileNotFoundError:
|
|
10204
|
-
self.send_error(404, "not found")
|
|
10205
|
-
return
|
|
10206
|
-
except IsADirectoryError:
|
|
10207
|
-
self.send_error(404, "not found")
|
|
10208
|
-
return
|
|
10209
|
-
|
|
10210
10360
|
compressible = (
|
|
10211
10361
|
ctype.startswith("text/")
|
|
10212
10362
|
or ctype.startswith("application/javascript")
|
|
@@ -10215,15 +10365,54 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10215
10365
|
gzip_on = compressible and _accepts_gzip(
|
|
10216
10366
|
self.headers.get("Accept-Encoding")
|
|
10217
10367
|
)
|
|
10218
|
-
encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
|
|
10219
|
-
etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
|
|
10220
10368
|
cache_control = (
|
|
10221
10369
|
"public, max-age=31536000, immutable"
|
|
10222
10370
|
if self._is_hashed_static_asset(path)
|
|
10223
10371
|
else "no-cache"
|
|
10224
10372
|
)
|
|
10373
|
+
validator = self.headers.get("If-None-Match")
|
|
10374
|
+
key = (path, gzip_on)
|
|
10375
|
+
try:
|
|
10376
|
+
before = _STATIC_VALIDATORS.fingerprint(path.stat())
|
|
10377
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
10378
|
+
self.send_error(404, "not found")
|
|
10379
|
+
return
|
|
10380
|
+
|
|
10381
|
+
# Only a validator derived from bytes previously served for this exact
|
|
10382
|
+
# encoding and unchanged file identity can answer before I/O. In
|
|
10383
|
+
# particular, a mutable dashboard.html replacement invalidates this.
|
|
10384
|
+
if validator:
|
|
10385
|
+
warm_etag = _STATIC_VALIDATORS.get(key, before)
|
|
10386
|
+
if warm_etag and self._etag_matches(validator, warm_etag):
|
|
10387
|
+
self.send_response(304)
|
|
10388
|
+
self.send_header("ETag", warm_etag)
|
|
10389
|
+
self.send_header("Cache-Control", cache_control)
|
|
10390
|
+
if compressible:
|
|
10391
|
+
self.send_header("Vary", "Accept-Encoding")
|
|
10392
|
+
if gzip_on:
|
|
10393
|
+
self.send_header("Content-Encoding", "gzip")
|
|
10394
|
+
self.end_headers()
|
|
10395
|
+
return
|
|
10225
10396
|
|
|
10226
|
-
|
|
10397
|
+
try:
|
|
10398
|
+
body = path.read_bytes()
|
|
10399
|
+
except FileNotFoundError:
|
|
10400
|
+
self.send_error(404, "not found")
|
|
10401
|
+
return
|
|
10402
|
+
except IsADirectoryError:
|
|
10403
|
+
self.send_error(404, "not found")
|
|
10404
|
+
return
|
|
10405
|
+
|
|
10406
|
+
encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
|
|
10407
|
+
etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
|
|
10408
|
+
try:
|
|
10409
|
+
after = _STATIC_VALIDATORS.fingerprint(path.stat())
|
|
10410
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
10411
|
+
after = None
|
|
10412
|
+
if after == before:
|
|
10413
|
+
_STATIC_VALIDATORS.put(key, before, etag)
|
|
10414
|
+
|
|
10415
|
+
if after == before and self._etag_matches(validator, etag):
|
|
10227
10416
|
self.send_response(304)
|
|
10228
10417
|
self.send_header("ETag", etag)
|
|
10229
10418
|
self.send_header("Cache-Control", cache_control)
|
|
@@ -10588,12 +10777,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10588
10777
|
scope, transcripts_visible, reveal,
|
|
10589
10778
|
)
|
|
10590
10779
|
|
|
10591
|
-
def
|
|
10592
|
-
report = sources.build_diagnosis(
|
|
10593
|
-
scope,
|
|
10594
|
-
measured_at=now_utc,
|
|
10595
|
-
transcripts_visible=transcripts_visible,
|
|
10596
|
-
)
|
|
10780
|
+
def _project_diagnosis(report):
|
|
10597
10781
|
scopes = {
|
|
10598
10782
|
result.source: diagnosis._scope_for(
|
|
10599
10783
|
sources, scope, result.source,
|
|
@@ -10612,10 +10796,43 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10612
10796
|
)
|
|
10613
10797
|
return status, body
|
|
10614
10798
|
|
|
10799
|
+
def _prepare_diagnosis():
|
|
10800
|
+
return _project_diagnosis(sources.build_diagnosis(
|
|
10801
|
+
scope,
|
|
10802
|
+
measured_at=now_utc,
|
|
10803
|
+
transcripts_visible=transcripts_visible,
|
|
10804
|
+
))
|
|
10805
|
+
|
|
10806
|
+
def _prepare_bounded_diagnosis(remaining):
|
|
10807
|
+
return _project_diagnosis(
|
|
10808
|
+
sources.build_diagnosis_bounded(
|
|
10809
|
+
scope,
|
|
10810
|
+
measured_at=now_utc,
|
|
10811
|
+
transcripts_visible=transcripts_visible,
|
|
10812
|
+
timeout_seconds=remaining,
|
|
10813
|
+
)
|
|
10814
|
+
)
|
|
10815
|
+
|
|
10615
10816
|
status, body = _DIAGNOSIS_ADMISSION.run(
|
|
10616
10817
|
flight_key,
|
|
10617
10818
|
_prepare_diagnosis,
|
|
10819
|
+
peer_gone=lambda: _diagnosis_peer_gone(self.connection),
|
|
10820
|
+
bounded_build=_prepare_bounded_diagnosis,
|
|
10618
10821
|
)
|
|
10822
|
+
except _DiagnosisClientGone:
|
|
10823
|
+
return
|
|
10824
|
+
except _DiagnosisOverloaded:
|
|
10825
|
+
self._send_diagnosis_json(503, {
|
|
10826
|
+
"error": "diagnosis busy; retry shortly",
|
|
10827
|
+
"code": "diagnosis_overloaded",
|
|
10828
|
+
})
|
|
10829
|
+
return
|
|
10830
|
+
except _DiagnosisTimedOut:
|
|
10831
|
+
self._send_diagnosis_json(503, {
|
|
10832
|
+
"error": "diagnosis timed out; retry shortly",
|
|
10833
|
+
"code": "diagnosis_timeout",
|
|
10834
|
+
})
|
|
10835
|
+
return
|
|
10619
10836
|
except _DiagnosisSelectorError as exc:
|
|
10620
10837
|
self._send_diagnosis_json(
|
|
10621
10838
|
400, {"error": exc.message, "code": exc.code})
|
|
@@ -11410,7 +11627,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
11410
11627
|
as an explicit user pin; the worker resolves the selected channel
|
|
11411
11628
|
afresh. CSRF-gated. Returns
|
|
11412
11629
|
202 + ``{"run_id": ...}`` on accept; 409 + ``{"run_id_in_progress": ...}``
|
|
11413
|
-
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.
|
|
11414
11633
|
"""
|
|
11415
11634
|
if not self._check_origin_csrf():
|
|
11416
11635
|
return
|
|
@@ -11432,7 +11651,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
11432
11651
|
# The dashboard has no explicit-version input. Older clients sent the
|
|
11433
11652
|
# cached beta target here; trusting it as a pin recreates #342 whenever
|
|
11434
11653
|
# the registry advances while the modal is open.
|
|
11435
|
-
|
|
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
|
|
11436
11659
|
if accepted:
|
|
11437
11660
|
self._respond_json(202, {"run_id": run_id})
|
|
11438
11661
|
else:
|
|
@@ -12018,16 +12241,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12018
12241
|
_cctally_core.enable_quota_projection_reconciliation()
|
|
12019
12242
|
|
|
12020
12243
|
# Spec §5.7: capture the un-mutated argv + PATH-resolved entrypoint
|
|
12021
|
-
# at boot
|
|
12022
|
-
#
|
|
12023
|
-
#
|
|
12024
|
-
#
|
|
12025
|
-
# them via the module-proxy so
|
|
12026
|
-
#
|
|
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.
|
|
12027
12251
|
_c_boot = _cctally()
|
|
12028
12252
|
_c_boot.ORIGINAL_SYS_ARGV = list(sys.argv)
|
|
12029
12253
|
_c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
|
|
12030
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()
|
|
12031
12258
|
|
|
12032
12259
|
# Load config for the bind-host + expose-transcripts resolution below. (#217
|
|
12033
12260
|
# S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
|
|
@@ -12112,11 +12339,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12112
12339
|
_cctally_cache_module.set_conversation_derivations_suppressed(
|
|
12113
12340
|
bool(args.no_sync))
|
|
12114
12341
|
_dashboard_startup_schema_migration(run_derivations=not bool(args.no_sync))
|
|
12115
|
-
# #
|
|
12116
|
-
#
|
|
12117
|
-
|
|
12118
|
-
# the one mode a long-lived read-only dashboard runs in.
|
|
12119
|
-
_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)
|
|
12120
12345
|
_heal = _dashboard_self_heal_orphans(skip_sync=bool(args.no_sync))
|
|
12121
12346
|
if _heal is not None and _heal.pruned_files:
|
|
12122
12347
|
print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
|
|
@@ -12382,6 +12607,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12382
12607
|
try:
|
|
12383
12608
|
_dashboard_wait_for_signal((_signal.SIGINT, _signal.SIGTERM))
|
|
12384
12609
|
finally:
|
|
12610
|
+
# First, so a shutdown can never be followed by an automatic restart.
|
|
12611
|
+
_stop_install_watch()
|
|
12385
12612
|
if sync_thread is not None:
|
|
12386
12613
|
sync_thread.stop()
|
|
12387
12614
|
conversation_sync_stop.set()
|