cctally 1.99.0 → 1.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -191,11 +191,10 @@ What stays in bin/cctally:
191
191
  ``ns["_build_current_week_share_panel_data"]``,
192
192
  ``ns["_build_daily_share_panel_data"]``,
193
193
  ``ns["_build_monthly_share_panel_data"]``,
194
- ``ns["_build_blocks_share_panel_data"]``, ``ns["STATIC_DIR"]``,
195
- ``ns["_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS"]``, plus
194
+ ``ns["_build_blocks_share_panel_data"]``, ``ns["STATIC_DIR"]``, plus
196
195
  ``monkeypatch.setitem`` mutations on
197
- ``_dashboard_build_weekly_periods``, ``_dashboard_build_blocks_panel``,
198
- and ``_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS``). Forces the **eager
196
+ ``_dashboard_build_weekly_periods`` and
197
+ ``_dashboard_build_blocks_panel``). Forces the **eager
199
198
  re-export** carve-out per spec §4.8 (same precedent as Phase E
200
199
  #19/#20 + Phase F #21):
201
200
 
@@ -266,6 +265,7 @@ import copy
266
265
  import contextlib
267
266
  import dataclasses
268
267
  import datetime as dt
268
+ import gzip
269
269
  import hmac
270
270
  import io
271
271
  import json
@@ -285,6 +285,7 @@ import urllib.error
285
285
  import urllib.parse
286
286
  import urllib.request
287
287
  import webbrowser as _wb
288
+ import zlib
288
289
  from dataclasses import dataclass, field, replace
289
290
  from collections.abc import Mapping
290
291
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -419,7 +420,8 @@ from _lib_dashboard_settings_contract import (
419
420
  from _cctally_config import save_config, _load_config_unlocked
420
421
  from _cctally_db import _render_migration_error_banner
421
422
  from _cctally_cache import (
422
- get_entries, iter_entries, iter_entries_with_id, open_cache_db,
423
+ get_entries, iter_entries, iter_entries_with_id,
424
+ open_cache_db as _raw_open_cache_db,
423
425
  open_conversations_db, sync_cache, sync_claude_conversations,
424
426
  sync_codex_conversations,
425
427
  _prune_orphaned_cache_entries,
@@ -439,6 +441,65 @@ from _lib_snapshot_cache import (
439
441
  _max_id as _snapshot_max_id,
440
442
  _reset_sig as _snapshot_reset_sig,
441
443
  )
444
+ import _lib_tick_stats
445
+
446
+
447
+ # === F22a: count the silent Group A cache-open failures (#583 S1 §1.6) =====
448
+ # `_group_a_daily_buckets`, `_group_a_weekly_buckets` and
449
+ # `_group_a_monthly_buckets` each wrap their `open_cache_db()` in
450
+ # `try: … except Exception: return None`, where `None` means "fall back to the
451
+ # wide from-scratch fetch". Output stays byte-identical, so no golden moves and
452
+ # nothing surfaces — the failure is invisible even to `doctor`.
453
+ #
454
+ # The count is taken from OUTSIDE those three functions, for two reasons. From
455
+ # outside them a `None` return cannot distinguish a disabled cache, an open
456
+ # failure and a later fallback, so this wrapper is the only place the open
457
+ # failure is observable as itself. And session S5 owns those three helpers, so
458
+ # counting here leaves all three byte-for-byte unchanged and leaves no
459
+ # overlapping hunk to merge.
460
+
461
+ _GROUP_A_CACHE_OPENERS = (
462
+ ("_group_a_daily_buckets", "daily"),
463
+ ("_group_a_weekly_buckets", "weekly"),
464
+ ("_group_a_monthly_buckets", "monthly"),
465
+ )
466
+
467
+
468
+ def _group_a_cache_failure_kind(code):
469
+ """Which Group A bucket builder owns this code object, or None.
470
+
471
+ Matched by ``__code__`` IDENTITY, never by ``co_name``: a name match can be
472
+ satisfied by an unrelated function of the same name, and a diagnostic that
473
+ credits the wrong counter is worse than one that credits none. Resolved
474
+ against the live module globals rather than a memo so it cannot go stale.
475
+ """
476
+ for name, kind in _GROUP_A_CACHE_OPENERS:
477
+ if getattr(globals().get(name), "__code__", None) is code:
478
+ return kind
479
+ return None
480
+
481
+
482
+ def open_cache_db(*args, **kwargs):
483
+ """``_cctally_cache.open_cache_db``, plus the Group A failure count.
484
+
485
+ Behaviour-preserving. On an exception it identifies the caller, increments
486
+ the matching fixed counter, and re-raises the ORIGINAL exception unchanged.
487
+
488
+ It fails open in both directions: on no caller match, or on any failure of
489
+ the frame introspection itself, it increments nothing and still re-raises.
490
+ A diagnostic must never replace the error it was observing. Introspection
491
+ runs only on the already-exceptional path, so the steady-state cost is nil.
492
+ """
493
+ try:
494
+ return _raw_open_cache_db(*args, **kwargs)
495
+ except Exception:
496
+ try:
497
+ kind = _group_a_cache_failure_kind(sys._getframe(1).f_code)
498
+ if kind is not None:
499
+ _lib_tick_stats.note_cache_open_failure(kind)
500
+ except Exception: # noqa: BLE001 — never mask the original failure
501
+ pass
502
+ raise
442
503
 
443
504
 
444
505
  # === #279 S5: consumer-only dashboard siblings ============================
@@ -1477,33 +1538,222 @@ def _next_deadline(t0: float, interval: float, work: float) -> float:
1477
1538
  return (t0 + work) + max(interval, work)
1478
1539
 
1479
1540
 
1541
+ def _conversation_next_deadline(
1542
+ t0: float, interval: float, work: float
1543
+ ) -> float:
1544
+ """Monotonic deadline for the next conversation sync pass (#583 S4 / F5).
1545
+
1546
+ Same algebra as `_next_deadline`, and deliberately a SEPARATE function. The
1547
+ two loops' bounds are independent regressions: sharing one helper would let
1548
+ a later change to the main loop's scheduling silently remove this thread's
1549
+ duty bound, which is the defect F5 exists to fix. A test asserts the two
1550
+ currently agree, so a divergence has to be a deliberate act.
1551
+
1552
+ work >= interval -> period = 2*work -> duty capped at 50% of one core,
1553
+ scale-independently. work < interval -> period = work + interval, which is
1554
+ the fixed-sleep cadence this replaced, so a small install sees no
1555
+ behavioural change.
1556
+ """
1557
+ return (t0 + work) + max(interval, work)
1558
+
1559
+
1560
+ def _log_sync_iteration_failure() -> None:
1561
+ """Route an escaped sync-iteration exception through the log chokepoint.
1562
+
1563
+ The traceback is the operator signal; the loop deliberately continues, so
1564
+ without this the failure would be entirely silent.
1565
+ """
1566
+ import traceback
1567
+ try:
1568
+ _lib_log.get_logger("dashboard").error(
1569
+ "sync iteration failed:\n%s", traceback.format_exc(),
1570
+ )
1571
+ except Exception: # noqa: BLE001 — logging must never kill the drainer
1572
+ pass
1573
+
1574
+
1575
+ def _make_dashboard_run_iteration(
1576
+ *, sync_lock, run_sync_now, run_sync_now_locked, skip_sync,
1577
+ monotonic=time.monotonic, heal_interval_seconds=60.0,
1578
+ ):
1579
+ """Return the dashboard sync thread's whole-iteration callable.
1580
+
1581
+ Module-level for the same reason ``_make_run_sync_now_locked`` is: the
1582
+ body is the only place the #583 S2 §4 contract "one OAuth refresh
1583
+ immediately before one rebuild, holding ``sync_lock`` across both" exists,
1584
+ and as a closure inside ``cmd_dashboard`` no test could reach it.
1585
+
1586
+ ``run_iteration`` performs one WHOLE iteration — the rebuild plus the
1587
+ orphan self-heal maintenance — so the loop's measured duration drives the
1588
+ cooldown deadline (#313 P2 / F10), not just the rebuild.
1589
+
1590
+ The refresh leg runs only for a batch carrying the refresh bit, and never
1591
+ under ``skip_sync``: ``--no-sync`` freezes the data and performs no network
1592
+ calls. Preserve 9's rule applies here rather than in the handler, so the
1593
+ periodic path cannot fire a redundant rebuild between the two steps, and
1594
+ many queued ``refresh=1`` requests collapse to one OAuth call because
1595
+ ``capture_batch`` ORs their intents.
1596
+
1597
+ ``_refresh_usage_inproc`` and ``_dashboard_self_heal_orphans`` are called
1598
+ by bare name on purpose, so a test patching either on this module (or on
1599
+ the ``cctally`` namespace the shim delegates to) reaches this body.
1600
+ """
1601
+ last_heal = [monotonic()]
1602
+
1603
+ def run_iteration(batch=None) -> dict:
1604
+ warnings: list = []
1605
+ if batch is not None and batch[1] and not skip_sync:
1606
+ with sync_lock:
1607
+ result = _refresh_usage_inproc()
1608
+ if result.status != "ok":
1609
+ warnings.append({"code": result.status})
1610
+ run_sync_now_locked(skip_sync=skip_sync)
1611
+ else:
1612
+ run_sync_now(skip_sync=skip_sync)
1613
+ # Self-heal removed-worktree orphans on a ~60s cadence (far rarer than
1614
+ # the sync tick — a deleted worktree is not urgent). Non-blocking on
1615
+ # the flock, so a contended tick just retries next cadence; gated off
1616
+ # under --no-sync.
1617
+ if (not skip_sync
1618
+ and monotonic() - last_heal[0] >= heal_interval_seconds):
1619
+ last_heal[0] = monotonic()
1620
+ _dashboard_self_heal_orphans(skip_sync=skip_sync)
1621
+ # A queued request has no HTTP response, so a deferred refresh's
1622
+ # warnings ride the settlement frame instead.
1623
+ return {"warnings": warnings}
1624
+
1625
+ return run_iteration
1626
+
1627
+
1628
+ def _make_sync_loop_collaborators(*, ref, hub) -> dict:
1629
+ """Bind a `_SnapshotRef` to an `SSEHub` for `_dashboard_sync_loop`.
1630
+
1631
+ Returns the loop's collaborator keyword arguments, which are the three
1632
+ publication points of #583 S2 spec §6.2 that the loop owns: point 2
1633
+ (``rebuilding=true`` with ``started_id`` advanced, immediately before work
1634
+ begins), point 4 (``rebuilding=false`` plus the settled fields), and the
1635
+ batchless equivalent of both. The reference re-stamps its held snapshot on
1636
+ every mutation, so ``ref.get()`` already carries the new counters.
1637
+
1638
+ Extracted to module level so a test can drive the loop through the SAME
1639
+ wiring the dashboard's sync thread uses. A test that rebuilt these three
1640
+ closures itself would assert only that its own copy publishes.
1641
+ """
1642
+ def capture_batch():
1643
+ batch = ref.capture_batch()
1644
+ hub.publish(ref.get())
1645
+ return batch
1646
+
1647
+ def settle(batch_id, status, warnings=()) -> None:
1648
+ ref.settle(batch_id, status, warnings)
1649
+ hub.publish(ref.get())
1650
+
1651
+ def mark_rebuilding(value) -> None:
1652
+ # Publish only on a real transition. A requested tick has already
1653
+ # published through capture_batch/settle, which set the same flag.
1654
+ if ref.mark_rebuilding(value):
1655
+ hub.publish(ref.get())
1656
+
1657
+ return {
1658
+ "pending_request": ref.pending_request,
1659
+ "capture_batch": capture_batch,
1660
+ "settle": settle,
1661
+ "mark_rebuilding": mark_rebuilding,
1662
+ }
1663
+
1664
+
1480
1665
  def _dashboard_sync_loop(
1481
1666
  *,
1482
1667
  stop,
1483
1668
  interval: float,
1484
1669
  run_iteration,
1485
- take_sync_request,
1670
+ take_sync_request=None,
1486
1671
  monotonic=time.monotonic,
1487
1672
  sleep=time.sleep,
1673
+ pending_request=None,
1674
+ capture_batch=None,
1675
+ settle=None,
1676
+ mark_rebuilding=None,
1488
1677
  ) -> None:
1489
- """Run the dashboard's automatic sync loop with a work-proportional cooldown.
1678
+ """Periodic rebuild loop with a #313-preserving request floor (#583 S2).
1490
1679
 
1491
1680
  ``run_iteration`` performs one whole automatic iteration (rebuild plus any
1492
1681
  orphan self-heal / retention maintenance the thread does), so its measured
1493
- duration — not just the rebuild — drives the deadline (F10). The manual
1494
- ``POST /api/sync`` refresh is a separate synchronous path under ``sync_lock``
1495
- and is unaffected; ``take_sync_request`` is the TUI force-refresh flag that
1496
- breaks the cooldown early.
1682
+ duration — not just the rebuild — drives the deadline (F10).
1683
+
1684
+ Automatic cadence is unchanged: ``_next_deadline(t0, interval, work)``. A
1685
+ queued request may start a batch earlier, but never before ``t0 + 2*work``,
1686
+ which is exactly the ``period >= 2*work`` that caps CPU duty at 50% of one
1687
+ core, scale-independently. When ``work >= interval`` the automatic deadline
1688
+ already equals ``t0 + 2*work``, so the floor coincides with it and a request
1689
+ changes nothing; the floor binds only when ``work < interval``, where it
1690
+ turns a 5.5 s wait into a 1.0 s one for a 0.5 s rebuild.
1691
+
1692
+ The floor is never later than the deadline, so a pending request is always
1693
+ serviced at or before the automatic tick it would otherwise wait for.
1694
+
1695
+ ``pending_request`` PEEKS and never consumes: a poll firing before the floor
1696
+ is met must not discard the request (spec §5.2). ``capture_batch`` claims
1697
+ every outstanding request atomically at the moment work starts, and
1698
+ ``settle`` records the batch's terminal state. ``take_sync_request`` is the
1699
+ legacy test-and-clear flag and is left injectable for callers that still
1700
+ drive the loop that way.
1701
+
1702
+ ``mark_rebuilding`` publishes the in-flight flag for EVERY iteration,
1703
+ requested or automatic, and clears it on every exit path. It is separate
1704
+ from ``capture_batch``/``settle`` on purpose: those two also move the
1705
+ settlement counters, which must not advance for a batch that never existed.
1497
1706
  """
1498
1707
  while not stop.is_set():
1708
+ batch = None
1709
+ if (pending_request is not None and capture_batch is not None
1710
+ and pending_request()):
1711
+ batch = capture_batch()
1712
+ if mark_rebuilding is not None:
1713
+ # BEFORE t0: the publish is a non-blocking queue put, and keeping
1714
+ # it outside the measured span leaves the #313 bound's algebra
1715
+ # exactly as it is. An automatic tick reaches this with no batch
1716
+ # captured, which is the whole point — `rebuilding` describes the
1717
+ # iteration, not the request that may or may not have started it.
1718
+ mark_rebuilding(True)
1499
1719
  t0 = monotonic()
1500
- run_iteration()
1501
- work = monotonic() - t0
1720
+ status, warnings = "ok", ()
1721
+ try:
1722
+ result = (run_iteration(batch=batch) if batch is not None
1723
+ else run_iteration())
1724
+ if isinstance(result, dict):
1725
+ warnings = tuple(result.get("warnings") or ())
1726
+ except Exception: # noqa: BLE001 — see below
1727
+ # An escaped exception must not kill the only drainer: an accepted
1728
+ # 202 would then never reach a terminal state, and the client would
1729
+ # hold `queued…` forever waiting for a settlement no surviving
1730
+ # thread can publish.
1731
+ status = "failed"
1732
+ _log_sync_iteration_failure()
1733
+ finally:
1734
+ # Failure time is charged to the cooldown exactly like success
1735
+ # time, so a crash loop cannot busy-spin.
1736
+ work = monotonic() - t0
1737
+ if batch is not None and settle is not None:
1738
+ settle(batch[0], status, warnings)
1739
+ if mark_rebuilding is not None:
1740
+ # Every exit path, including an escaped exception: a flag left
1741
+ # set would pin the client's chip at `syncing…` for the life of
1742
+ # the process. `settle` has already cleared it on a requested
1743
+ # tick, so this publishes nothing extra there.
1744
+ mark_rebuilding(False)
1745
+
1502
1746
  deadline = _next_deadline(t0, interval, work)
1503
- while not stop.is_set() and monotonic() < deadline:
1504
- if take_sync_request():
1747
+ floor = t0 + 2.0 * work
1748
+ while not stop.is_set():
1749
+ now = monotonic()
1750
+ ready = (pending_request is not None and capture_batch is not None
1751
+ and now >= floor and pending_request())
1752
+ if ready or now >= deadline:
1505
1753
  break
1506
- sleep(min(0.1, max(0.0, deadline - monotonic())))
1754
+ if take_sync_request is not None and take_sync_request():
1755
+ break # legacy test-and-clear path, unchanged
1756
+ sleep(min(0.1, max(0.0, deadline - now)))
1507
1757
 
1508
1758
 
1509
1759
  def _dashboard_maybe_prune_retention() -> None:
@@ -1532,6 +1782,147 @@ def _dashboard_maybe_prune_retention() -> None:
1532
1782
  pass
1533
1783
 
1534
1784
 
1785
+ def _conversation_sync_pass() -> str:
1786
+ """One WHOLE transcript-ingest pass (#583 S4 / F5).
1787
+
1788
+ Store open, both provider syncs, the retention prune and the close. The
1789
+ loop measures this callable's duration as `work`, so anything left outside
1790
+ it would be work outside the duty denominator — exactly the F5 defect.
1791
+
1792
+ Returns a status from `_lib_tick_stats.CONVERSATION_STATUSES`. The prune is
1793
+ attempted whenever the store OPENED, including after a failing sync: the
1794
+ documented contract is a throttled prune driven by this thread, not a prune
1795
+ conditional on a successful ingest.
1796
+
1797
+ The returned status describes the OPEN and the two SYNCS, and nothing else.
1798
+ `_dashboard_maybe_prune_retention`'s outcome is discarded here and that
1799
+ function ends in `except Exception: pass`, so a pass that ingested cleanly
1800
+ and then failed only in the prune reports `ok`. Widening the status to
1801
+ cover the prune would mean widening `CONVERSATION_STATUSES`, which is a
1802
+ closed set by design.
1803
+
1804
+ When the primary open FAILS the pass must not prune, and this is a
1805
+ constraint rather than a detail. `_dashboard_maybe_prune_retention` opens
1806
+ its own conversations connection and reaches the retention due/throttle
1807
+ check only after that open, so an unconditional prune would attempt a
1808
+ second open of the same unopenable store on every pass, swallow the
1809
+ failure, and repeat. The duty bound would cap CPU share while doing nothing
1810
+ about the duplicated migration, recovery and I/O pressure. The invariant is
1811
+ therefore NO SECOND OPEN ATTEMPT AFTER A FAILED OPEN — stated that narrowly
1812
+ because a successful pass opens the store twice by design, once here and
1813
+ once inside the prune.
1814
+ """
1815
+ try:
1816
+ conn = open_conversations_db()
1817
+ except (OSError, sqlite3.DatabaseError) as exc:
1818
+ eprint(f"[conversations] background sync unavailable: {exc}")
1819
+ return "store_unavailable"
1820
+ status = "ok"
1821
+ try:
1822
+ sync_claude_conversations(conn)
1823
+ sync_codex_conversations(conn)
1824
+ except (OSError, sqlite3.DatabaseError) as exc:
1825
+ eprint(f"[conversations] background sync unavailable: {exc}")
1826
+ status = "store_unavailable"
1827
+ except Exception as exc: # noqa: BLE001
1828
+ # Transcript parsing/normalization is deliberately outside the core
1829
+ # freshness loop. Keep this worker alive so a later clean tick can
1830
+ # self-heal instead of permanently stopping after one malformed
1831
+ # provider record.
1832
+ eprint(
1833
+ "[conversations] background sync failed: "
1834
+ f"{type(exc).__name__}: {exc}"
1835
+ )
1836
+ status = "error"
1837
+ finally:
1838
+ try:
1839
+ conn.close()
1840
+ except Exception: # noqa: BLE001
1841
+ pass
1842
+ _dashboard_maybe_prune_retention()
1843
+ return status
1844
+
1845
+
1846
+ def _conversation_sync_loop(
1847
+ *,
1848
+ stop,
1849
+ interval: float,
1850
+ run_iteration,
1851
+ monotonic=time.monotonic,
1852
+ thread_time_ns=None,
1853
+ wait=None,
1854
+ record=None,
1855
+ ) -> None:
1856
+ """Transcript ingest loop, bounded like the main one (#583 S4 / F5).
1857
+
1858
+ Extracted from a closure inside `cmd_dashboard` so the duty property can be
1859
+ driven by a virtual clock rather than merely asserted. `run_iteration`
1860
+ performs one WHOLE pass, so its measured duration is what drives the
1861
+ deadline.
1862
+
1863
+ The previous fixed `wait(interval)` prevented literal 100% duty for finite
1864
+ work but provided no scale-independent ceiling below it: a 30 s pass ran
1865
+ 30-on/5-off, about 86% duty, and nothing bounded that as the store grew.
1866
+ """
1867
+ if thread_time_ns is None:
1868
+ thread_time_ns = time.thread_time_ns
1869
+ if wait is None:
1870
+ wait = stop.wait
1871
+ seq = 0
1872
+ while not stop.is_set():
1873
+ t0 = monotonic()
1874
+ cpu0 = thread_time_ns()
1875
+ try:
1876
+ status = run_iteration() or "ok"
1877
+ except Exception: # noqa: BLE001 — the worker must outlive one bad pass
1878
+ _log_sync_iteration_failure()
1879
+ status = "error"
1880
+ work = max(0.0, monotonic() - t0)
1881
+ cpu_ns = max(0, thread_time_ns() - cpu0)
1882
+ seq += 1
1883
+ if record is not None:
1884
+ record(
1885
+ seq=seq,
1886
+ started_ns=int(t0 * 1e9),
1887
+ ended_ns=int((t0 + work) * 1e9),
1888
+ duration_ns=int(work * 1e9),
1889
+ cpu_ns=cpu_ns,
1890
+ # No period is passed: `period_ns` is the FORWARD interval, so
1891
+ # the recorder stamps it onto the PREVIOUS record when this
1892
+ # pass's start closes it. Pairing a pass's CPU with the
1893
+ # interval that preceded it would shift the denominator by one
1894
+ # pass and publish a share with no upper bound.
1895
+ status=status,
1896
+ )
1897
+ deadline = _conversation_next_deadline(t0, interval, work)
1898
+ remaining = deadline - monotonic()
1899
+ if remaining > 0:
1900
+ wait(remaining)
1901
+
1902
+
1903
+ def _make_conversation_sync_thread(*, stop, sync_interval, no_sync):
1904
+ """Build the bounded conversation-sync thread, or None under --no-sync.
1905
+
1906
+ #320: transcript/search ingestion runs on its own thread and SQLite file, so
1907
+ a multi-GB first rebuild or a contended conversations.db cannot delay
1908
+ `_run_sync_now`, its `last_sync_at` stamp, or core SSE publication. #583 S4
1909
+ gives that thread the main loop's 50%-duty bound and publishes each pass
1910
+ into the second `_lib_tick_stats` ring.
1911
+ """
1912
+ if no_sync:
1913
+ return None
1914
+ return threading.Thread(
1915
+ target=lambda: _conversation_sync_loop(
1916
+ stop=stop,
1917
+ interval=max(5.0, float(sync_interval)),
1918
+ run_iteration=_conversation_sync_pass,
1919
+ record=_lib_tick_stats.record_conversation_pass,
1920
+ ),
1921
+ daemon=True,
1922
+ name="dashboard-conversations-sync",
1923
+ )
1924
+
1925
+
1535
1926
  def _make_run_sync_now(*args, **kwargs):
1536
1927
  return sys.modules["cctally"]._make_run_sync_now(*args, **kwargs)
1537
1928
 
@@ -1897,10 +2288,9 @@ def __getattr__(name): # pylint: disable=invalid-name
1897
2288
  # are pure constants / read-only objects whose identity is stable across
1898
2289
  # the process lifetime; binding them once at load time keeps bare-name
1899
2290
  # reads in moved bodies working without per-call attribute lookups.
1900
- # Path constants and tunables that tests monkeypatch (STATIC_DIR,
1901
- # _DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS) are eager-re-exported FROM the
1902
- # sibling at bin/cctally so monkeypatches propagate; this block carries
1903
- # things that are NEVER patched at runtime.
2291
+ # Path constants and tunables that tests monkeypatch (STATIC_DIR) are
2292
+ # eager-re-exported FROM the sibling at bin/cctally so monkeypatches
2293
+ # propagate; this block carries things that are NEVER patched at runtime.
1904
2294
  BLOCK_DURATION = sys.modules["cctally"].BLOCK_DURATION
1905
2295
 
1906
2296
 
@@ -1958,25 +2348,177 @@ def _resolve_dashboard_bind_for_runtime(stored: str) -> str:
1958
2348
  # Pre-extract location: bin/cctally L16265.
1959
2349
 
1960
2350
  class _SnapshotRef:
1961
- """Thread-safe holder for the current DataSnapshot."""
2351
+ """Thread-safe holder for the current DataSnapshot and the sync queue.
2352
+
2353
+ #583 S2. This object is the SINGLE authority for queue and activity
2354
+ state. Builders must never construct activity counters themselves: A2
2355
+ and the final publish both replace the snapshot wholesale, so a request
2356
+ accepted mid-build would otherwise have its counter overwritten by the
2357
+ older snapshot the builder had already assembled. Every mutator below
2358
+ re-stamps the held snapshot, and ``set()`` merges the authoritative
2359
+ activity in and RETURNS the merged object so a publish site can send
2360
+ exactly what the reference now holds.
2361
+
2362
+ The legacy ``_sync_requested`` flag and ``take_sync_request()`` are the
2363
+ TUI's mechanism (bin/_cctally_tui.py:5581 and :5832) and keep their
2364
+ exact test-and-clear semantics. The dashboard uses the counters.
2365
+ """
1962
2366
 
1963
2367
  def __init__(self, initial: DataSnapshot) -> None:
1964
2368
  import threading
2369
+ import uuid
1965
2370
  self._lock = threading.Lock()
1966
- self._snap = initial
1967
2371
  self._sync_requested = False
2372
+ # Fixed 16 chars: the envelope's byte count must stay deterministic
2373
+ # for bench/baselines/envelope-oracle.json to remain comparable.
2374
+ self.server_epoch = uuid.uuid4().hex[:16]
2375
+ self._requested_id = 0
2376
+ self._requested_refresh = False
2377
+ self._started_id = 0
2378
+ self._settled_id = 0
2379
+ self._settled_status = None
2380
+ self._settled_warnings = ()
2381
+ # OWNER-SCOPED, not a single process-wide boolean. Two independent
2382
+ # rebuilders write this state — the periodic sync loop and any HTTP
2383
+ # handler thread that wins the non-blocking `sync_lock` acquire — and a
2384
+ # boolean gives neither of them a way to know who set it. A clear must
2385
+ # therefore remove only the clearing thread's own claim; `rebuilding`
2386
+ # is then true exactly while at least one rebuilder holds one.
2387
+ #
2388
+ # Owner scoping made a LEAKED CLAIM strictly worse than the boolean it
2389
+ # replaced, so do not record the opposite. Under the boolean a leaked
2390
+ # `True` was cleared by whichever rebuilder next reached `set_final`, so
2391
+ # it self-healed on the following rebuild. Every clear site here discards
2392
+ # `threading.get_ident()`, so a claim left behind by a thread that has
2393
+ # exited can be discarded by NO other thread, and `rebuilding` would stay
2394
+ # true — pinning every client's chip at `syncing…` for the life of the
2395
+ # process.
2396
+ #
2397
+ # No leak is reachable today, but the argument splits by CALLER, not by
2398
+ # add site. `mark_rebuilding` below is reached from both routes — the
2399
+ # sync loop through `_make_sync_loop_collaborators` and an HTTP handler
2400
+ # thread through `DashboardHTTPHandler.mark_rebuilding` — so reading one
2401
+ # add site answers for neither. Four callers add a claim.
2402
+ #
2403
+ # Two of the four are bracketed: `_handle_post_sync` and
2404
+ # `_handle_post_settings` each mark inside a `try` whose `finally`
2405
+ # clears, so nothing between the two can leak the claim.
2406
+ #
2407
+ # The other two are the sync loop's `capture_batch()` and its
2408
+ # `mark_rebuilding(True)`, and they are NOT bracketed. Both run before
2409
+ # `t0` and therefore before the `try:` whose `finally` clears them; the
2410
+ # loop's own comment at `mark_rebuilding(True)` gives the #313
2411
+ # duty-algebra reason for that one's placement. `_dashboard_sync_loop`'s
2412
+ # `while` has no outer handler, so a raise in that gap would kill the
2413
+ # drainer and leak the claim together. The gap is safe because nothing
2414
+ # in it raises: the `_restamp_locked()` each add performs is a
2415
+ # `dataclasses.replace` over `DataSnapshot`, a plain dataclass with no
2416
+ # `__post_init__`, no `init=False` field and no `InitVar`;
2417
+ # `SSEHub.publish` holds its own lock and swallows
2418
+ # `queue.Full`/`queue.Empty`; `ref.get()` is a lock-and-return; and what
2419
+ # remains is a clock read and two local assignments.
2420
+ #
2421
+ # A new `add` must therefore satisfy one of the two: a same-thread
2422
+ # `finally` that clears it, or a proven non-raising path to one. There
2423
+ # is no self-healing path behind either.
2424
+ self._rebuilding_owners: set[int] = set()
2425
+ self._snap = self._stamped_locked(initial)
2426
+
2427
+ def _activity_locked(self) -> dict:
2428
+ return {
2429
+ "server_epoch": self.server_epoch,
2430
+ "rebuilding": bool(self._rebuilding_owners),
2431
+ "requested_id": self._requested_id,
2432
+ "started_id": self._started_id,
2433
+ "settled_id": self._settled_id,
2434
+ "settled_status": self._settled_status,
2435
+ "settled_warnings": self._settled_warnings,
2436
+ }
2437
+
2438
+ def _stamped_locked(self, snap: DataSnapshot) -> DataSnapshot:
2439
+ import dataclasses
2440
+ return dataclasses.replace(snap, sync_activity=self._activity_locked())
2441
+
2442
+ def _restamp_locked(self) -> None:
2443
+ self._snap = self._stamped_locked(self._snap)
2444
+
2445
+ def activity(self) -> dict:
2446
+ with self._lock:
2447
+ return self._activity_locked()
1968
2448
 
1969
2449
  def get(self) -> DataSnapshot:
1970
2450
  with self._lock:
1971
2451
  return self._snap
1972
2452
 
1973
- def set(self, snap: DataSnapshot) -> None:
2453
+ def set(self, snap: DataSnapshot) -> DataSnapshot:
2454
+ """Store ``snap`` with the authoritative activity merged in.
2455
+
2456
+ Returns the merged object. Publish sites must send the RETURN value,
2457
+ not their local build result, or a request accepted mid-build has its
2458
+ counter erased by the older snapshot the builder assembled.
2459
+ """
2460
+ with self._lock:
2461
+ self._snap = self._stamped_locked(snap)
2462
+ return self._snap
2463
+
2464
+ def replace_fields(self, **changes) -> DataSnapshot:
2465
+ """Atomically apply narrow field changes to the latest snapshot.
2466
+
2467
+ Out-of-band publishers compute small derived fields independently of
2468
+ the main snapshot builder. They must not read a whole snapshot, do
2469
+ that work, then call ``set()``: a sync can publish in between and the
2470
+ stale whole-object write would erase its newer data. This method keeps
2471
+ the read/replace/write sequence under the reference lock and re-stamps
2472
+ the authoritative activity state before returning the publishable
2473
+ object.
2474
+ """
2475
+ import dataclasses
2476
+ with self._lock:
2477
+ self._snap = self._stamped_locked(
2478
+ dataclasses.replace(self._snap, **changes)
2479
+ )
2480
+ return self._snap
2481
+
2482
+ def set_final(self, snap: DataSnapshot) -> DataSnapshot:
2483
+ """Store ``snap`` as the iteration's TERMINAL state: the merge of
2484
+ ``set()`` plus dropping THIS thread's claim on ``_rebuilding_owners``,
2485
+ under one lock acquisition.
2486
+
2487
+ Without this a rebuild costs three published frames — the flag going
2488
+ up, the build's own final publish, and the flag coming back down — and
2489
+ the third exists only because the loop clears the flag after the
2490
+ rebuild has already published. Each extra frame is one shared
2491
+ ``snapshot_to_envelope`` plus one byte-ready JSON frame per variant,
2492
+ and one whole-store replacement in each connected browser, which is
2493
+ the opposite of what a dashboard-performance session is for.
2494
+
2495
+ Storing and clearing through two calls would not help: the frame
2496
+ published between them would carry ``rebuilding: true`` and the third
2497
+ frame would come back. The clear has to be part of the same store.
2498
+
2499
+ The clear drops THIS thread's claim only. Clearing outright would end
2500
+ one rebuilder's build by declaring every rebuilder idle: a handler
2501
+ thread that finished first would publish ``rebuilding: false`` over a
2502
+ periodic rebuild that had already marked itself and was still blocked on
2503
+ ``sync_lock``, and nothing would correct that until the next tick.
2504
+ """
1974
2505
  with self._lock:
1975
- self._snap = snap
2506
+ self._rebuilding_owners.discard(threading.get_ident())
2507
+ self._snap = self._stamped_locked(snap)
2508
+ return self._snap
2509
+
2510
+ def request_sync(self, refresh: bool = False) -> int:
2511
+ """Enqueue a coalescing sync request; returns its identifier.
1976
2512
 
1977
- def request_sync(self) -> None:
2513
+ ``refresh`` defaults False so the TUI's argument-less call site is
2514
+ unchanged.
2515
+ """
1978
2516
  with self._lock:
1979
2517
  self._sync_requested = True
2518
+ self._requested_id += 1
2519
+ self._requested_refresh = self._requested_refresh or bool(refresh)
2520
+ self._restamp_locked()
2521
+ return self._requested_id
1980
2522
 
1981
2523
  def take_sync_request(self) -> bool:
1982
2524
  # Atomic test-and-clear — threading.Event's is_set()/clear() pair
@@ -1985,6 +2527,261 @@ class _SnapshotRef:
1985
2527
  taken, self._sync_requested = self._sync_requested, False
1986
2528
  return taken
1987
2529
 
2530
+ def pending_request(self) -> bool:
2531
+ """Peek, never consume. A poll firing before the service floor must
2532
+ not clear the request (#583 S2 spec 5.2)."""
2533
+ with self._lock:
2534
+ return self._requested_id > self._started_id
2535
+
2536
+ def capture_batch(self) -> tuple:
2537
+ """Atomically claim every outstanding request as one batch."""
2538
+ with self._lock:
2539
+ self._started_id = self._requested_id
2540
+ refresh = self._requested_refresh
2541
+ self._requested_refresh = False
2542
+ self._rebuilding_owners.add(threading.get_ident())
2543
+ self._restamp_locked()
2544
+ return (self._started_id, refresh)
2545
+
2546
+ def mark_rebuilding(self, value: bool) -> bool:
2547
+ """Set the in-flight flag alone; return True iff it CHANGED.
2548
+
2549
+ Deliberately narrow. ``rebuilding`` must be true for the duration of
2550
+ every sync iteration, but ``capture_batch``/``settle`` run only when a
2551
+ request is pending, so an automatic tick — the normal case on a
2552
+ dashboard nobody is clicking — left the flag permanently false.
2553
+ Widening those two to batchless ticks is the wrong fix: ``settle``
2554
+ would advance ``settled_id``/``settled_status``/``settled_warnings``
2555
+ for a batch that never existed, and spec §6.2 says those three
2556
+ describe the most recently SETTLED batch.
2557
+
2558
+ The changed/unchanged return lets the loop's collaborator publish
2559
+ exactly once per transition: on a requested tick ``capture_batch`` has
2560
+ already set the flag and published, so this reports no change and adds
2561
+ no duplicate frame.
2562
+
2563
+ A mark adds or drops the CALLING thread's claim, and the transition is
2564
+ the emptiness of the owner set changing. Publish-on-transition is
2565
+ preserved exactly, because a transition is now empty-to-nonempty or
2566
+ nonempty-to-empty. The set is not a counter, so a claim is idempotent
2567
+ per thread and one thread cannot hold two nested claims.
2568
+ """
2569
+ with self._lock:
2570
+ ident = threading.get_ident()
2571
+ before = bool(self._rebuilding_owners)
2572
+ if value:
2573
+ self._rebuilding_owners.add(ident)
2574
+ else:
2575
+ self._rebuilding_owners.discard(ident)
2576
+ after = bool(self._rebuilding_owners)
2577
+ if before == after:
2578
+ return False
2579
+ self._restamp_locked()
2580
+ return True
2581
+
2582
+ def settle(self, batch_id: int, status: str, warnings=()) -> None:
2583
+ """Record a batch's terminal state.
2584
+
2585
+ ``settled_status`` and ``settled_warnings`` describe the most recently
2586
+ settled batch and are retained across subsequent automatic frames:
2587
+ clearing them on the next ordinary tick would tell a client its request
2588
+ settled while destroying the warnings explaining how, and the queued
2589
+ contract removed the HTTP response that used to carry them.
2590
+ """
2591
+ with self._lock:
2592
+ self._settled_id = max(self._settled_id, int(batch_id))
2593
+ self._settled_status = status
2594
+ self._settled_warnings = tuple(warnings)
2595
+ # This thread's claim only — `capture_batch` added it, and another
2596
+ # rebuilder's concurrent claim is not this batch's to end.
2597
+ self._rebuilding_owners.discard(threading.get_ident())
2598
+ self._restamp_locked()
2599
+
2600
+
2601
+ class _SSEDelivery:
2602
+ """One publication, projected and encoded at most once per variant.
2603
+
2604
+ #583 S3 §5. ``_serve_api_events`` used to call ``snapshot_to_envelope`` plus
2605
+ ``encode_dashboard_json`` inside its per-connection loop, so N connected
2606
+ clients projected and encoded the same data N times per tick — about
2607
+ 3.4 MB each on a production-scale store.
2608
+
2609
+ The clock is pinned HERE, once, so every client served from this delivery
2610
+ agrees on the age fields instead of differing by the fan-out latency.
2611
+ ``SSEHub.subscribe`` deliberately builds a FRESH delivery for its seed
2612
+ rather than handing out a stored one, because a client connecting between
2613
+ ticks would otherwise render an age frozen at the previous publication.
2614
+
2615
+ The cache lock is PER DELIVERY. The hub's own lock must never be held
2616
+ across a multi-megabyte projection.
2617
+
2618
+ Sharing is only sound when the projection is a function of the snapshot
2619
+ plus the variant key. ``_serve_api_events`` therefore refuses to share a
2620
+ snapshot that carries no ``envelope_precompute``: ``snapshot_to_envelope``
2621
+ then reads configuration inline and runs the real doctor gather per call,
2622
+ neither of which is keyed. It also captures ``_channel_env_fragment``'s
2623
+ preview-channel process state, which is process-global rather than
2624
+ connection-specific — stated so the "function of its key" claim is
2625
+ complete.
2626
+ """
2627
+
2628
+ __slots__ = ("snapshot", "pinned_now_utc", "pinned_monotonic",
2629
+ "_cache", "_lock")
2630
+
2631
+ def __init__(self, snapshot, pinned_now_utc, pinned_monotonic) -> None:
2632
+ self.snapshot = snapshot
2633
+ self.pinned_now_utc = pinned_now_utc
2634
+ self.pinned_monotonic = pinned_monotonic
2635
+ self._cache: dict = {}
2636
+ self._lock = threading.Lock()
2637
+
2638
+ def encoded(self, variant_key, project_fn) -> bytes:
2639
+ """Return complete SSE frame bytes for ``variant_key``, building once.
2640
+
2641
+ Double-checked: the fast path is a lock-free dict read, and the slow
2642
+ path re-checks under the lock so two threads racing on the same missing
2643
+ variant produce one projection, JSON encoding and frame assembly. The
2644
+ cached value is byte-ready so fan-out never repeats UTF-8 encoding for
2645
+ each connection. A MISS for any valid variant computes that variant —
2646
+ normalizing an INVALID privacy input to False is the CALLER's job, done
2647
+ before the key is built. Those are different situations and conflating
2648
+ them either leaks or breaks the gate.
2649
+ """
2650
+ hit = self._cache.get(variant_key)
2651
+ if hit is not None:
2652
+ return hit
2653
+ with self._lock:
2654
+ hit = self._cache.get(variant_key)
2655
+ if hit is not None:
2656
+ return hit
2657
+ built = project_fn(variant_key)
2658
+ self._cache[variant_key] = built
2659
+ return built
2660
+
2661
+
2662
+ # #583 S3 §5. A distinct slot for "no oauth_usage configuration at all", so it
2663
+ # cannot collide with an EMPTY configuration. Both used to canonicalize to `()`.
2664
+ _OAUTH_CFG_ABSENT = ("\x00cctally:oauth-usage-absent",)
2665
+
2666
+
2667
+ def _canonical_oauth_key(cfg):
2668
+ """A hashable canonical form of the oauth_usage config block.
2669
+
2670
+ #583 S3 §5. The delivery cache is keyed by a tuple, and the resolved
2671
+ ``oauth_usage`` config is a dict, which is unhashable. Sorted items give a
2672
+ stable key for two connections that resolved the same configuration, which
2673
+ is the normal case — every connection reads the same file.
2674
+
2675
+ An ABSENT configuration and an EMPTY one are different configurations and
2676
+ get different keys. Collapsing them is unreachable today, because
2677
+ ``_get_oauth_usage_config`` is defaults-filled and never returns an empty
2678
+ mapping — which is precisely why it would go unnoticed if a later change
2679
+ made it reachable, inside a key whose entire job is keeping two
2680
+ configurations apart. Callers pass a mapping or ``None``.
2681
+ """
2682
+ if cfg is None:
2683
+ return _OAUTH_CFG_ABSENT
2684
+ return tuple(sorted((str(k), repr(v)) for k, v in cfg.items()))
2685
+
2686
+
2687
+ # #583 S3 §6. The SSE keep-alive interval, as a module constant so a test can
2688
+ # drive the keep-alive path without waiting fifteen seconds. It matters that
2689
+ # the path is testable: under compression a raw `wfile.write` of the keep-alive
2690
+ # comment corrupts everything after it, and the failure is silent for one
2691
+ # interval and then permanent for that connection.
2692
+ _SSE_KEEPALIVE_SECONDS = 15
2693
+
2694
+
2695
+ def _accepts_gzip(header_value: "str | None") -> bool:
2696
+ """Whether this client accepts gzip, parsed on token boundaries.
2697
+
2698
+ #583 S3 §6. A substring test for "gzip" compresses for a client sending
2699
+ ``gzip;q=0``, which is an explicit refusal, and for an unrelated token such
2700
+ as ``notgzip`` or ``x-gzip``. A malformed quality value falls back to
2701
+ identity rather than raising, because this runs on the publish path and
2702
+ must never take a connection down.
2703
+
2704
+ ``*`` is honoured as a wildcard, but an explicit ``gzip`` entry wins over
2705
+ it in either direction: ``gzip;q=0, *`` is a refusal even though the
2706
+ wildcard would otherwise accept.
2707
+ """
2708
+ if not header_value:
2709
+ return False
2710
+ wildcard = None
2711
+ for part in header_value.split(","):
2712
+ token, _, params = part.strip().partition(";")
2713
+ token = token.strip().lower()
2714
+ if token not in ("gzip", "*"):
2715
+ continue
2716
+ q = 1.0
2717
+ malformed = False
2718
+ for param in params.split(";"):
2719
+ name, _, value = param.strip().partition("=")
2720
+ if name.strip().lower() != "q":
2721
+ continue
2722
+ try:
2723
+ q = float(value.strip())
2724
+ except ValueError:
2725
+ malformed = True
2726
+ break
2727
+ if malformed:
2728
+ return False
2729
+ if token == "gzip":
2730
+ return q > 0.0
2731
+ if wildcard is None:
2732
+ wildcard = q
2733
+ return bool(wildcard is not None and wildcard > 0.0)
2734
+
2735
+
2736
+ def _delivery_is_shareable(snapshot) -> bool:
2737
+ """Whether one projection of ``snapshot`` may be shared across clients.
2738
+
2739
+ #583 S3 §5. Sharing is sound only when the projection is a function of the
2740
+ snapshot plus the stated variant key. The gate keys on ONE field,
2741
+ ``envelope_precompute``: without it ``snapshot_to_envelope`` reads
2742
+ ``config.json`` inline (``bin/_cctally_dashboard_envelope.py:1194``), so two
2743
+ calls with the same key can differ and the result is not cacheable. Those
2744
+ snapshots are fixtures, the initial empty snapshot and positionally-
2745
+ constructed ones — never a live tick.
2746
+
2747
+ The doctor block is NOT part of this gate, and saying it was would misstate
2748
+ what the predicate reads. It is guarded separately by ``doctor_payload``
2749
+ (``:1627``), which is set independently of ``envelope_precompute`` because
2750
+ each catches its own failure, so a snapshot can carry the precompute and
2751
+ still run the real gather. That is sound to share anyway: the gather is
2752
+ process-global rather than connection-specific, and both of its inputs —
2753
+ ``now_utc`` and ``runtime_bind`` — are already fixed by the delivery's pin
2754
+ and by the variant key.
2755
+ """
2756
+ return getattr(snapshot, "envelope_precompute", None) is not None
2757
+
2758
+
2759
+ def _drain_to_newest(q, first):
2760
+ """Return the newest delivery queued on ``q``, discarding older ones.
2761
+
2762
+ #583 S3 §5. ``SSEHub`` uses a four-slot queue and ``publish`` discards only
2763
+ ONE oldest entry when full, so a client that falls behind holds a backlog
2764
+ of up to four deliveries. Each delivery pins its clock at publication, so
2765
+ replaying that backlog would render ages several publish periods stale — a
2766
+ regression against the present behaviour, where each frame is projected at
2767
+ consumption time and its ages are therefore current.
2768
+
2769
+ The fix is on the CONSUMER side deliberately: ``SSEHub.publish`` is
2770
+ governed by Preserve 4 and the A2 publication tests depend on its
2771
+ behaviour, so it is not modified. Draining here is the latest-wins
2772
+ behaviour the hub's own docstring already describes.
2773
+
2774
+ ``first`` is the item the caller already took off the queue with its own
2775
+ blocking ``get``, so the ``queue.Empty`` keep-alive path stays where it is.
2776
+ """
2777
+ import queue as _queue
2778
+ newest = first
2779
+ while True:
2780
+ try:
2781
+ newest = q.get_nowait()
2782
+ except _queue.Empty:
2783
+ return newest
2784
+
1988
2785
 
1989
2786
  class SSEHub:
1990
2787
  """Thread-safe fan-out hub for SSE clients.
@@ -1993,6 +2790,12 @@ class SSEHub:
1993
2790
  client queues so a slow browser cannot back-pressure the sync thread.
1994
2791
  Consumers call `subscribe()` to obtain a `queue.Queue`, then read
1995
2792
  with a timeout; call `unsubscribe()` on disconnect or at teardown.
2793
+
2794
+ #583 S3 §5: what the queues carry is a `_SSEDelivery` wrapping the
2795
+ published snapshot, not the snapshot itself, so one tick projects and
2796
+ encodes once per variant instead of once per connected client. The
2797
+ queueing behaviour below — size, latest-wins discard, lock discipline — is
2798
+ unchanged and is governed by Preserve 4.
1996
2799
  """
1997
2800
 
1998
2801
  def __init__(self, maxsize: int = 4) -> None:
@@ -2012,12 +2815,32 @@ class SSEHub:
2012
2815
  self._queues.append(q)
2013
2816
  if self._last is not None:
2014
2817
  # Seed the new subscriber so it renders immediately.
2818
+ # #583 S3 §5: a FRESH delivery over the same snapshot, with the
2819
+ # clock sampled NOW. Handing out `self._last` would render this
2820
+ # client's ages frozen at the previous publication, which for a
2821
+ # tab opened late in a publish period is visibly wrong.
2822
+ seed = _SSEDelivery(
2823
+ snapshot=self._last.snapshot,
2824
+ pinned_now_utc=dt.datetime.now(dt.timezone.utc),
2825
+ pinned_monotonic=time.monotonic(),
2826
+ )
2015
2827
  try:
2016
- q.put_nowait(self._last)
2828
+ q.put_nowait(seed)
2017
2829
  except _queue.Full:
2018
2830
  pass
2019
2831
  return q
2020
2832
 
2833
+ def latest(self):
2834
+ """The most recently published delivery, or None before the first.
2835
+
2836
+ #583 S3 §7: `/api/data` serves the most recently PUBLISHED state. The
2837
+ snapshot reference is mutated by four operations that publish
2838
+ separately, so reading the reference could report a `hydrating` flag no
2839
+ client was ever sent (#600).
2840
+ """
2841
+ with self._lock:
2842
+ return self._last
2843
+
2021
2844
  def unsubscribe(self, q) -> None:
2022
2845
  with self._lock:
2023
2846
  try:
@@ -2027,8 +2850,16 @@ class SSEHub:
2027
2850
 
2028
2851
  def publish(self, snapshot) -> None:
2029
2852
  import queue as _queue
2853
+ # #583 S3 §5: wrap ONCE, outside the hub lock, so every queue and
2854
+ # `_last` share one projection cache for this tick. Built before the
2855
+ # lock because construction must not run under it.
2856
+ delivery = _SSEDelivery(
2857
+ snapshot=snapshot,
2858
+ pinned_now_utc=dt.datetime.now(dt.timezone.utc),
2859
+ pinned_monotonic=time.monotonic(),
2860
+ )
2030
2861
  with self._lock:
2031
- self._last = snapshot
2862
+ self._last = delivery
2032
2863
  # Latest-wins coalescing (#278 §2.6): every published snapshot is a
2033
2864
  # COMPLETE state replacement, so a client only ever needs the
2034
2865
  # newest. On a full queue drop the STALE queued frame and enqueue
@@ -2045,14 +2876,14 @@ class SSEHub:
2045
2876
  # re-put cannot lose to it.
2046
2877
  for q in self._queues:
2047
2878
  try:
2048
- q.put_nowait(snapshot)
2879
+ q.put_nowait(delivery)
2049
2880
  except _queue.Full:
2050
2881
  try:
2051
2882
  q.get_nowait() # discard the oldest, stale frame
2052
2883
  except _queue.Empty:
2053
2884
  pass
2054
2885
  try:
2055
- q.put_nowait(snapshot)
2886
+ q.put_nowait(delivery)
2056
2887
  except _queue.Full:
2057
2888
  # Defensive: a consumer racing between our get and put
2058
2889
  # could only have removed items, so this is unreachable
@@ -5301,14 +6132,31 @@ def _channel_env_fragment() -> dict:
5301
6132
  return {}
5302
6133
 
5303
6134
 
5304
- # Bounded wait for /api/sync's lock acquisition. The periodic background
5305
- # sync thread holds sync_lock during sync_cache + snapshot build (often
5306
- # 100-1500ms under active CC sessions); a non-blocking try_acquire would
5307
- # 503 the user's click whenever it lands inside that window, silently
5308
- # dropping their refresh-usage intent. 2s is generous enough to span a
5309
- # normal periodic tick yet short enough to surface a stuck rebuild as
5310
- # 503 instead of hanging the request indefinitely.
5311
- _DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS = 2.0
6135
+
6136
+ # Upper bound on the ONE blocking `sync_lock` acquire left in the tree: a
6137
+ # manual `POST /api/sync` under `--no-sync`. Every other path either acquires
6138
+ # non-blocking or enqueues. #583 S2 removed the old bounded acquire along with
6139
+ # its 503, which left this one bare and a bare acquire pins an HTTP handler
6140
+ # thread forever when a rebuild wedges (a `cache.db` read that never returns, a
6141
+ # hung builder), with no diagnostic at all.
6142
+ #
6143
+ # 30s rather than the old 2s because the holder in this mode is a whole
6144
+ # synchronous rebuild (measured 1.9-6.3 s), not a periodic tick, so the bound
6145
+ # has to be a wedge detector rather than a contention timeout. On expiry the
6146
+ # endpoint answers 200 with a `sync_busy` warning: that stays inside its
6147
+ # declared status vocabulary, does NOT reintroduce 503, and does not strand the
6148
+ # client the way a 202 would in a mode where nothing drains the queue.
6149
+ #
6150
+ # Recorded, not resolved: 30 s also outlives any plausible browser fetch
6151
+ # timeout, so on a real wedge the client aborts first and this handler writes
6152
+ # its 200 to a socket nobody is reading — broken-pipe noise in the dashboard's
6153
+ # terminal. That interaction is exactly why the earlier 2.0 s bound was chosen
6154
+ # against a 3.0 s client timeout
6155
+ # (docs/superpowers/specs/2026-06-13-refresh-usage-dashboard-nudge-design.md,
6156
+ # the "Timeout chosen above the server's lock-wait" bullet). Deciding between
6157
+ # the two needs the client-side timeout in view as well, which is out of scope
6158
+ # here; a later session should pick one with both numbers in front of it.
6159
+ _DASHBOARD_NO_SYNC_LOCK_TIMEOUT_SECONDS = 30.0
5312
6160
 
5313
6161
 
5314
6162
  # === DashboardHTTPHandler (the /api/* + static surface) ===================
@@ -5671,6 +6519,8 @@ _POST_ROUTES = (
5671
6519
  ("exact", "/api/share/presets/rename", "_handle_share_presets_rename_post",
5672
6520
  None, False),
5673
6521
  ("exact", "/api/share/history", "_handle_share_history_post", None, False),
6522
+ ("exact", "/api/debug/backend/trace", "_handle_post_debug_backend_trace",
6523
+ None, False),
5674
6524
  )
5675
6525
 
5676
6526
  _DELETE_ROUTES = (
@@ -5950,15 +6800,54 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5950
6800
  )
5951
6801
  self.end_headers()
5952
6802
 
6803
+ @classmethod
6804
+ def publish_activity(cls) -> None:
6805
+ """Republish the held snapshot so a new counter reaches clients now.
6806
+
6807
+ #583 S2 spec 6.2 publication point 1: acknowledge an accepted request
6808
+ without waiting for a rebuild. The reference re-stamps its held
6809
+ snapshot on every mutation, so ``get()`` already carries the new
6810
+ ``requested_id``.
6811
+ """
6812
+ ref = getattr(cls, "snapshot_ref", None)
6813
+ hub = getattr(cls, "hub", None)
6814
+ if ref is None or hub is None:
6815
+ return
6816
+ hub.publish(ref.get())
6817
+
6818
+ @classmethod
6819
+ def mark_rebuilding(cls, value: bool) -> None:
6820
+ """Publish the in-flight flag for a HANDLER-driven rebuild.
6821
+
6822
+ #583 S2 §6.3. `rebuilding` describes the rebuild, not the thread that
6823
+ started it, and an UNCONTENDED manual refresh rebuilds synchronously
6824
+ right here — `202 queued` is only the contended branch. Without this
6825
+ pair a user clicking the sync chip ran a multi-second rebuild during
6826
+ which every other connected tab published `rebuilding: false` and could
6827
+ not tell a busy dashboard from a wedged one.
6828
+
6829
+ Publishes only on a real transition, exactly like the sync loop's
6830
+ collaborator, so the rebuild's own terminal publish (`set_final`) leaves
6831
+ the trailing mark with nothing to say.
6832
+ """
6833
+ ref = getattr(cls, "snapshot_ref", None)
6834
+ hub = getattr(cls, "hub", None)
6835
+ if ref is None or hub is None:
6836
+ return
6837
+ if ref.mark_rebuilding(value):
6838
+ hub.publish(ref.get())
6839
+
5953
6840
  def _handle_post_sync(self) -> None:
5954
6841
  """Trigger refresh-usage + snapshot rebuild on user demand.
5955
6842
 
5956
6843
  Flow:
5957
6844
  1. Origin/Host CSRF check.
5958
- 2. acquire(timeout=_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS) -> 503
5959
- only on truly degenerate contention beyond the timeout.
5960
- 3. With lock held: under --no-sync skip refresh; otherwise call
5961
- _refresh_usage_inproc(); always call run_sync_now_locked.
6845
+ 2. Non-blocking acquire. A machine nudge, or a held lock, enqueues
6846
+ on ``_SnapshotRef`` and answers 202 with the request identifier
6847
+ and this process's server epoch.
6848
+ 3. Lock free, human click: exactly the pre-#583 path — under
6849
+ --no-sync skip refresh; otherwise call ``_refresh_usage_inproc()``;
6850
+ always call run_sync_now_locked.
5962
6851
  4. Return 204 on clean success, 200 + JSON warnings on
5963
6852
  non-ok refresh status, 500 on unexpected exception.
5964
6853
 
@@ -5967,17 +6856,36 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5967
6856
  run_sync_now_locked assumes the caller holds sync_lock - that's the
5968
6857
  whole point of the Task 0 lock split.
5969
6858
 
5970
- Bounded wait (vs. earlier non-blocking try_acquire): the periodic
5971
- background thread holds the lock for hundreds of ms each tick, and
5972
- a non-blocking acquire would 503 any click that landed inside that
5973
- window silently dropping the user's force-refresh intent (the
5974
- periodic thread doesn't run refresh-usage). Waiting up to ~2s lets
5975
- the click span a normal periodic tick while still 503-ing on
5976
- truly stuck contention.
6859
+ #583 S2. The bounded acquire and its 503 are gone. A click landing
6860
+ inside the periodic thread's lock-hold used to wait up to ~2s and then
6861
+ 503 on stuck contention, which silently dropped the user's intent; it
6862
+ now queues, is serviced by the sync loop under the duty floor, and its
6863
+ settlement arrives on the published frame. This endpoint's status
6864
+ vocabulary is 403 / 202 / 200 / 204 / 500. The two other 503 sites
6865
+ (quota_projection_incomplete) are untouched.
6866
+
6867
+ A MACHINE NUDGE always queues, even when the lock is free. This is
6868
+ load-bearing: cmd_record_usage fires at Claude Code's status-line
6869
+ cadence, so a nudge taking the synchronous path would rebuild at that
6870
+ frequency and reopen the #313 peg, bypassing the loop's floor. The
6871
+ nudge is identified by an explicit ``queue=1``; an older refresh-usage
6872
+ binary posting without it takes the synchronous path, which is today's
6873
+ behaviour.
5977
6874
 
5978
6875
  --no-sync mode: refresh skipped (frozen mode preserves "no network
5979
6876
  calls"), rebuild still runs with skip_sync=True (the wired
5980
- staticmethod closes over args.no_sync, so the no-arg call DTRT).
6877
+ staticmethod closes over args.no_sync, so the no-arg call DTRT). A
6878
+ manual request there acquires sync_lock BLOCKING rather than
6879
+ non-blocking, because nothing would drain a queue in that mode —
6880
+ bounded by ``_DASHBOARD_NO_SYNC_LOCK_TIMEOUT_SECONDS``, past which it
6881
+ answers 200 with a ``sync_busy`` warning rather than pinning the
6882
+ handler thread on a wedged rebuild. A
6883
+ machine nudge is REFUSED there with 204 and enqueues nothing: the
6884
+ documented contract freezes data to the startup snapshot, and a queued
6885
+ nudge serviced by a skip_sync rebuild would read newly persisted rows
6886
+ and unfreeze it. A manual refresh=1 states the skip with a
6887
+ ``refresh_skipped_no_sync`` warning instead of performing no refresh
6888
+ silently.
5981
6889
 
5982
6890
  Refresh failures DO NOT cause 500 - they surface as warnings in the
5983
6891
  200 envelope and the rebuild still runs so the snapshot stays
@@ -5985,28 +6893,87 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5985
6893
  """
5986
6894
  if not self._check_origin_csrf():
5987
6895
  return
5988
- sync_lock = type(self).sync_lock
5989
- if not sync_lock.acquire(
5990
- timeout=sys.modules["cctally"]._DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS):
5991
- self.send_error(503, "sync in progress")
6896
+ cls = type(self)
6897
+ sync_lock = cls.sync_lock
6898
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
6899
+ do_refresh = query.get("refresh", ["1"])[0] != "0"
6900
+ is_machine_nudge = query.get("queue", ["0"])[0] == "1"
6901
+
6902
+ if is_machine_nudge and cls.no_sync:
6903
+ self.send_response(204)
6904
+ self.end_headers()
6905
+ return
6906
+
6907
+ # Under --no-sync a manual request WAITS for the lock rather than
6908
+ # queueing. Nothing drains a queue in that mode (`sync_thread` is None),
6909
+ # and the lock is not always free there: POST /api/settings calls
6910
+ # `run_sync_now()`, which takes it blocking precisely so a config change
6911
+ # propagates in a mode whose periodic thread never runs. A click landing
6912
+ # inside that hold used to answer 202 for a batch nobody could capture,
6913
+ # leaving `requested_id > started_id` true forever. The holder in that
6914
+ # mode is always another short synchronous rebuild, so the wait is
6915
+ # bounded. The machine-nudge refusal above is unaffected.
6916
+ if cls.no_sync and not is_machine_nudge:
6917
+ if not sync_lock.acquire(
6918
+ timeout=_DASHBOARD_NO_SYNC_LOCK_TIMEOUT_SECONDS):
6919
+ # A wedged rebuild, not ordinary contention: the holder in this
6920
+ # mode is one short synchronous rebuild. Say so instead of
6921
+ # pinning this thread for the life of the process.
6922
+ self._respond_json(200, {
6923
+ "status": "ok",
6924
+ "warnings": [{"code": "sync_busy"}],
6925
+ })
6926
+ return
6927
+ acquired = True
6928
+ else:
6929
+ acquired = not is_machine_nudge and sync_lock.acquire(blocking=False)
6930
+ if not acquired:
6931
+ # `cls.no_sync` is UNREACHABLE-false here today, so the guard never
6932
+ # subtracts anything: a manual request under --no-sync took the
6933
+ # bounded blocking acquire above and either holds the lock or has
6934
+ # already answered, and a machine nudge under --no-sync answered 204
6935
+ # before either branch. The guard is kept because it states the
6936
+ # invariant the queue depends on — nothing drains a queue under
6937
+ # --no-sync, so a queued batch there must never carry an OAuth
6938
+ # intent — and a future queueing path in that mode would silently
6939
+ # violate it if this were dropped as dead code.
6940
+ request_id = cls.snapshot_ref.request_sync(
6941
+ refresh=do_refresh and not cls.no_sync
6942
+ )
6943
+ cls.publish_activity() # acknowledge before responding
6944
+ self._respond_json(202, {
6945
+ "status": "queued",
6946
+ "request_id": request_id,
6947
+ "server_epoch": cls.snapshot_ref.server_epoch,
6948
+ })
5992
6949
  return
5993
6950
  try:
5994
- do_refresh = (
5995
- urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
5996
- .get("refresh", ["1"])[0] != "0"
5997
- )
6951
+ # The locked section is a REBUILD, and this is the path an
6952
+ # uncontended manual refresh actually takes, so it reports itself
6953
+ # like every other rebuild does.
6954
+ cls.mark_rebuilding(True)
5998
6955
  warnings: list = []
5999
- if do_refresh and not type(self).no_sync:
6000
- result = _refresh_usage_inproc()
6001
- if result.status != "ok":
6002
- warnings.append({"code": result.status})
6956
+ if do_refresh:
6957
+ if cls.no_sync:
6958
+ warnings.append({"code": "refresh_skipped_no_sync"})
6959
+ else:
6960
+ result = _refresh_usage_inproc()
6961
+ if result.status != "ok":
6962
+ warnings.append({"code": result.status})
6003
6963
  try:
6004
- type(self).run_sync_now_locked()
6964
+ cls.run_sync_now_locked()
6005
6965
  except Exception as exc:
6006
6966
  self.log_error("/api/sync rebuild failed: %r", exc)
6007
6967
  self.send_error(500, "sync failed")
6008
6968
  return
6009
6969
  finally:
6970
+ # Drops THIS thread's claim only, so the ordering against the lock
6971
+ # release is not what makes it safe: a concurrent rebuilder's claim
6972
+ # is a different set member and this call cannot touch it, whichever
6973
+ # side of the release it runs on. On the success path the rebuild's
6974
+ # terminal publish has already dropped this thread's claim and this
6975
+ # adds no frame; the exception path is what needs it.
6976
+ cls.mark_rebuilding(False)
6010
6977
  sync_lock.release()
6011
6978
 
6012
6979
  if warnings:
@@ -6177,12 +7144,44 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6177
7144
  conn.close()
6178
7145
  except Exception: # noqa: BLE001 -- a diagnostic must not expose raw errors.
6179
7146
  cache_state = {"status": "unavailable"}
7147
+ perf = self._perf_gate()
7148
+ tick_state = _lib_tick_stats.snapshot()
7149
+ requested, applied = perf.pending_state()
6180
7150
  body = {
6181
7151
  "schemaVersion": 1,
6182
7152
  "version": _debug_tool_version(),
6183
7153
  "generated_at": (last or {}).get("generated_at"),
6184
7154
  "dataset": dataset,
6185
7155
  "phases": (last or {}).get("phases"),
7156
+ # #583 S1 §3.1 asked for the stored tree's instant beside
7157
+ # `phases`, so a GET after `--trace off` cannot present an old tree
7158
+ # as current — disabling tracing does not clear the stored tree.
7159
+ # It was already there: the top-level `generated_at` above IS the
7160
+ # tree's instant, read from the same slot. A `phases_generated_at`
7161
+ # key was added and measured byte-identical to it on a live
7162
+ # endpoint, so it is not repeated here.
7163
+ "tick": {
7164
+ "dispatch_counts": dict(tick_state.dispatch_counts),
7165
+ "cache_open_failures": dict(tick_state.cache_open_failures),
7166
+ "tick_seq": tick_state.tick_seq,
7167
+ "records": [r.as_wire() for r in tick_state.records],
7168
+ "standalone": (
7169
+ tick_state.standalone.as_wire()
7170
+ if tick_state.standalone is not None else None
7171
+ ),
7172
+ # #583 S4: the SECOND work loop's ring, published under the
7173
+ # same object and behind the same loopback gate. An empty list
7174
+ # is a reachable steady state (`--no-sync` never starts the
7175
+ # thread), so the key is always present.
7176
+ "conversation_sync": [
7177
+ r.as_wire() for r in tick_state.conversation_records
7178
+ ],
7179
+ },
7180
+ "tracing": {
7181
+ "requested": requested,
7182
+ "applied": applied,
7183
+ "applies_at": perf.applies_at(),
7184
+ },
6186
7185
  "cache_state": cache_state,
6187
7186
  "sources": sources,
6188
7187
  # Additive, and named rather than folded into `cache_state`: a
@@ -6195,6 +7194,60 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6195
7194
  body["note"] = "tracing_disabled"
6196
7195
  self._respond_json(200, body)
6197
7196
 
7197
+ def _handle_post_debug_backend_trace(self) -> None:
7198
+ """POST ``/api/debug/backend/trace`` — arm the deep phase trace (§3.2).
7199
+
7200
+ Body ``{"enabled": true|false}``; any other shape is 400.
7201
+
7202
+ Gated in three layers, in this order. ``_require_api_auth`` runs first,
7203
+ automatically for any ``/api/*`` path in ``do_POST``, and enforces the
7204
+ bearer whenever the dashboard minted a token. ``_require_debug_backend_
7205
+ allowed`` then applies the loopback TCP peer plus the IP-literal
7206
+ ``Host``. ``_check_origin_csrf`` applies Origin/Host parity last.
7207
+
7208
+ Retaining the CSRF layer matters even though the peer is already known
7209
+ to be loopback: the loopback and anti-rebinding checks do not stop a
7210
+ malicious page aiming a simple form POST straight at
7211
+ ``http://127.0.0.1:8789``. It is also why a command-line client must
7212
+ send an ``Origin`` matching the ``Host`` it calls — `_check_origin_csrf`
7213
+ rejects a request with none. That is not a weakening, because a
7214
+ non-browser client can set arbitrary headers regardless; the check
7215
+ exists to stop a page making the BROWSER issue the request.
7216
+
7217
+ The flip itself happens at the rebuild boundary in `_lib_perf.
7218
+ apply_pending`, not here, which is why the response reports `applied`
7219
+ as it is now and `applies_at` names when the request takes effect.
7220
+ """
7221
+ if not self._require_debug_backend_allowed():
7222
+ return
7223
+ if not self._check_origin_csrf():
7224
+ return
7225
+ try:
7226
+ length = int(self.headers.get("Content-Length", "0") or "0")
7227
+ except ValueError:
7228
+ length = 0
7229
+ if length <= 0 or length > 4096:
7230
+ self._respond_json(400, {"error": "body required (<=4 KB)"})
7231
+ return
7232
+ try:
7233
+ body = json.loads(self.rfile.read(length).decode("utf-8"))
7234
+ except (ValueError, UnicodeDecodeError):
7235
+ self._respond_json(400, {"error": "malformed JSON body"})
7236
+ return
7237
+ if (not isinstance(body, dict) or set(body) != {"enabled"}
7238
+ or not isinstance(body.get("enabled"), bool)):
7239
+ self._respond_json(
7240
+ 400, {"error": 'body must be {"enabled": true|false}'})
7241
+ return
7242
+ perf = self._perf_gate()
7243
+ perf.request_enabled(body["enabled"])
7244
+ requested, applied = perf.pending_state()
7245
+ self._respond_json(200, {
7246
+ "requested": requested,
7247
+ "applied": applied,
7248
+ "applies_at": perf.applies_at(),
7249
+ })
7250
+
6198
7251
  def _handle_post_settings(self) -> None:
6199
7252
  """Persist a settings update and trigger an immediate SSE broadcast.
6200
7253
 
@@ -6933,10 +7986,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6933
7986
  # under --no-sync). run_sync_now is the same path POST /api/sync
6934
7987
  # uses; under skip_sync=True it still rebuilds + publishes via
6935
7988
  # hub.publish, which is what each SSE listener pulls.
7989
+ #
7990
+ # #583 S2 §6.3: this rebuild is the same multi-second locked rebuild
7991
+ # POST /api/sync runs, so it reports itself the same way. The mark is
7992
+ # outside the acquire because `run_sync_now` takes `sync_lock` itself;
7993
+ # a wait for a rebuild already in flight is honestly in-flight too.
6936
7994
  try:
7995
+ type(self).mark_rebuilding(True)
6937
7996
  type(self).run_sync_now()
6938
7997
  except Exception as exc:
6939
7998
  eprint(f"warning: settings broadcast failed: {exc!r}")
7999
+ finally:
8000
+ type(self).mark_rebuilding(False)
6940
8001
 
6941
8002
  self._respond_json(200, out)
6942
8003
 
@@ -7206,8 +8267,41 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7206
8267
  self.wfile.write(body)
7207
8268
 
7208
8269
  def _serve_api_data(self) -> None:
8270
+ # #583 S3 §6/§7. TWO phases. Preparation may answer a JSON 500 because
8271
+ # nothing has been sent yet. Commit may NOT: once `send_response` has
8272
+ # run, a second `_respond_json` writes another HTTP response onto an
8273
+ # already-committed stream. The old handler wrapped both in one `try`
8274
+ # and did exactly that on any partial write.
8275
+ # #600 / §7: the LAST PUBLISHED state, not the reference. The dashboard
8276
+ # mutates the reference through four operations — `set`,
8277
+ # `capture_batch`, `settle`, `mark_rebuilding` — and publication is a
8278
+ # separate call in every case, so reading the reference can report a
8279
+ # `hydrating` flag no client was ever sent. There is no atomic boundary
8280
+ # to read instead, and creating one would mean editing `_SnapshotRef`
8281
+ # and the sync loop, so this endpoint is DEFINED as serving the most
8282
+ # recently published state. Deliberately no silent fallback to the
8283
+ # reference: that fallback is the disagreement this fixes.
8284
+ #
8285
+ # #583 S3 §6: `hub.latest()` gets its own guard, and the 503 is answered
8286
+ # OUTSIDE the preparation `try` below. Writing the 503 inside that `try`
8287
+ # meant a failure part-way through it was caught by the same `except`
8288
+ # that answers a JSON 500, appending a SECOND HTTP response onto a
8289
+ # stream this handler had already committed — the very defect the
8290
+ # prepare/commit split exists to remove, on the one path it did not
8291
+ # cover. The duplicated 500 arm below is the price of that separation.
7209
8292
  try:
7210
- snap = self.snapshot_ref.get()
8293
+ delivery = self.hub.latest()
8294
+ except Exception as exc: # noqa: BLE001
8295
+ self.log_error("api/data failed before commit: %r", exc)
8296
+ self._respond_json(500, {"error": "internal error"})
8297
+ return
8298
+ if delivery is None:
8299
+ self._respond_json(503, {"error": "no snapshot published yet"})
8300
+ return
8301
+
8302
+ try:
8303
+ # ---- preparation ---------------------------------------------
8304
+ snap = delivery.snapshot
7211
8305
  # Resolve oauth_usage cfg out here so snapshot_to_envelope stays
7212
8306
  # pure (no per-request FS read on the dashboard hot path).
7213
8307
  # Tolerate user config typos -- fall back to defaults rather than
@@ -7243,18 +8337,34 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7243
8337
  # finding) — one predicate, two consumers, desync impossible.
7244
8338
  env["transcriptsEnabled"] = visible
7245
8339
  body = encode_dashboard_json_bytes(env, ensure_ascii=False)
8340
+ gzip_on = _accepts_gzip(self.headers.get("Accept-Encoding"))
8341
+ if gzip_on:
8342
+ body = gzip.compress(body, 6)
8343
+ except Exception as exc: # noqa: BLE001
8344
+ # #279 S5 F6.1 (spec §8): a snapshot/envelope/dumps failure used to
8345
+ # escape to _QuietThreadingHTTPServer.handle_error (stdlib traceback +
8346
+ # dropped socket, no 500). Mirror _handle_get_doctor: log + JSON 500.
8347
+ self.log_error("api/data failed before commit: %r", exc)
8348
+ self._respond_json(500, {"error": "internal error"})
8349
+ return
8350
+
8351
+ # ---- commit ------------------------------------------------------
8352
+ try:
7246
8353
  self.send_response(200)
7247
8354
  self.send_header("Content-Type", "application/json; charset=utf-8")
8355
+ if gzip_on:
8356
+ self.send_header("Content-Encoding", "gzip")
8357
+ self.send_header("Vary", "Accept-Encoding")
7248
8358
  self.send_header("Content-Length", str(len(body)))
7249
8359
  self.send_header("Cache-Control", "no-cache")
7250
8360
  self.end_headers()
7251
8361
  self.wfile.write(body)
7252
8362
  except Exception as exc: # noqa: BLE001
7253
- # #279 S5 F6.1 (spec §8): a snapshot/envelope/dumps failure used to
7254
- # escape to _QuietThreadingHTTPServer.handle_error (stdlib traceback +
7255
- # dropped socket, no 500). Mirror _handle_get_doctor: log + JSON 500.
7256
- self.log_error("api/data failed: %r", exc)
7257
- self._respond_json(500, {"error": "internal error"})
8363
+ # Headers are committed, so no status code is available. Log and
8364
+ # close; NEVER `_respond_json` here that appends a second HTTP
8365
+ # response onto a stream the client is already reading as one.
8366
+ self.log_error("api/data failed after commit: %r", exc)
8367
+ self.close_connection = True
7258
8368
 
7259
8369
  def _handle_get_doctor(self) -> None:
7260
8370
  """`GET /api/doctor` — full kernel-serialized doctor report (spec §5.6).
@@ -7271,6 +8381,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7271
8381
  dashboard's default bind; no CSRF gating here (mirrors
7272
8382
  `/api/data`, `/api/session/:id`, `/api/block/:start_at`).
7273
8383
  """
8384
+ # Preparation and commit are separate. Before headers, a failure can
8385
+ # still become a JSON 500. After headers, another response would
8386
+ # corrupt the stream, so the only valid recovery is log + close.
7274
8387
  try:
7275
8388
  _ld = sys.modules["cctally"]._load_sibling("_lib_doctor")
7276
8389
  state = doctor_gather_state(
@@ -7280,6 +8393,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7280
8393
  body = encode_dashboard_json_bytes(
7281
8394
  _ld.serialize_json(report), ensure_ascii=False,
7282
8395
  )
8396
+ except Exception as exc: # noqa: BLE001
8397
+ self.log_error("/api/doctor failed before commit: %r", exc)
8398
+ self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
8399
+ return
8400
+
8401
+ try:
7283
8402
  self.send_response(200)
7284
8403
  self.send_header("Content-Type", "application/json; charset=utf-8")
7285
8404
  self.send_header("Content-Length", str(len(body)))
@@ -7287,8 +8406,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7287
8406
  self.end_headers()
7288
8407
  self.wfile.write(body)
7289
8408
  except Exception as exc: # noqa: BLE001
7290
- self.log_error("/api/doctor failed: %r", exc)
7291
- self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
8409
+ self.log_error("/api/doctor failed after commit: %r", exc)
8410
+ self.close_connection = True
7292
8411
 
7293
8412
  def _handle_get_session_detail(self, path: str) -> None:
7294
8413
  """Return TuiSessionDetail JSON for the given session id (spec §3.2).
@@ -7621,43 +8740,46 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7621
8740
  None,
7622
8741
  )
7623
8742
  if target is None:
8743
+ status = 404
7624
8744
  body = encode_dashboard_json_bytes({"error": "block not found"})
7625
- self.send_response(404)
7626
- self.send_header("Content-Type", "application/json; charset=utf-8")
7627
- self.send_header("Content-Length", str(len(body)))
7628
- self.end_headers()
7629
- self.wfile.write(body)
7630
- return
7631
- block_entries = [
7632
- e for e in entries_in_window
7633
- if target.start_time <= e.timestamp < target.end_time
7634
- ]
7635
- # Resolve display tz once per request so the block detail's
7636
- # `label` matches the snapshot envelope's blocks panel.
7637
- # Shared resolver -- same warn-once semantics as
7638
- # `_compute_display_block` and `_tui_build_snapshot`. F3:
7639
- # honor the dashboard's `--tz` override (set as a class attr
7640
- # by cmd_dashboard) so the block-detail label speaks the
7641
- # same zone the rest of the envelope speaks.
7642
- _detail_tz = _resolve_display_tz_obj(
7643
- _apply_display_tz_override(
7644
- load_config(), type(self).display_tz_pref_override
8745
+ else:
8746
+ block_entries = [
8747
+ e for e in entries_in_window
8748
+ if target.start_time <= e.timestamp < target.end_time
8749
+ ]
8750
+ # Resolve display tz once per request so the block detail's
8751
+ # `label` matches the snapshot envelope's blocks panel.
8752
+ # Shared resolver -- same warn-once semantics as
8753
+ # `_compute_display_block` and `_tui_build_snapshot`. F3:
8754
+ # honor the dashboard's `--tz` override (set as a class attr
8755
+ # by cmd_dashboard) so the block-detail label speaks the
8756
+ # same zone the rest of the envelope speaks.
8757
+ _detail_tz = _resolve_display_tz_obj(
8758
+ _apply_display_tz_override(
8759
+ load_config(), type(self).display_tz_pref_override
8760
+ )
7645
8761
  )
7646
- )
7647
- detail = _build_block_detail(
7648
- target, block_entries, display_tz=_detail_tz,
7649
- )
8762
+ detail = _build_block_detail(
8763
+ target, block_entries, display_tz=_detail_tz,
8764
+ )
8765
+ status = 200
8766
+ body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
7650
8767
  except Exception as exc:
7651
- self.log_error("/api/block failed: %r", exc)
8768
+ self.log_error("/api/block failed before commit: %r", exc)
7652
8769
  self.send_error(500, "block detail failed")
7653
8770
  return
7654
- body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
7655
- self.send_response(200)
7656
- self.send_header("Content-Type", "application/json; charset=utf-8")
7657
- self.send_header("Content-Length", str(len(body)))
7658
- self.send_header("Cache-Control", "no-cache")
7659
- self.end_headers()
7660
- self.wfile.write(body)
8771
+
8772
+ try:
8773
+ self.send_response(status)
8774
+ self.send_header("Content-Type", "application/json; charset=utf-8")
8775
+ self.send_header("Content-Length", str(len(body)))
8776
+ if status == 200:
8777
+ self.send_header("Cache-Control", "no-cache")
8778
+ self.end_headers()
8779
+ self.wfile.write(body)
8780
+ except Exception as exc: # noqa: BLE001
8781
+ self.log_error("/api/block failed after commit: %r", exc)
8782
+ self.close_connection = True
7661
8783
 
7662
8784
  def _send_milestones_json(self, status: int, body: dict) -> None:
7663
8785
  payload = encode_dashboard_json_bytes(body, ensure_ascii=False)
@@ -7786,7 +8908,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7786
8908
  # reset and never resolves as current.
7787
8909
  identity = resolve_codex_cycle_detail_identity(
7788
8910
  cache_conn, source_root_keys=roots, now_utc=now_utc,
7789
- account_key=account_key,
8911
+ account_key=account_key, stats_conn=stats_conn,
7790
8912
  )
7791
8913
  result = c.build_codex_cycle_detail(
7792
8914
  stats_conn, cache_conn, identity=identity, key=key,
@@ -7827,14 +8949,46 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7827
8949
 
7828
8950
  def _serve_api_events(self) -> None:
7829
8951
  import queue as _queue
8952
+ gzip_on = _accepts_gzip(self.headers.get("Accept-Encoding"))
7830
8953
  self.send_response(200)
7831
8954
  self.send_header("Content-Type", "text/event-stream; charset=utf-8")
7832
8955
  self.send_header("Cache-Control", "no-cache")
7833
8956
  self.send_header("Connection", "keep-alive")
7834
8957
  # Nginx/proxies: disable buffering so events flow immediately.
8958
+ # #583 S3 §6: this STAYS under compression. It addresses an
8959
+ # intermediary proxy, not our own buffering, and is orthogonal.
7835
8960
  self.send_header("X-Accel-Buffering", "no")
8961
+ self.send_header("Vary", "Accept-Encoding")
8962
+ if gzip_on:
8963
+ self.send_header("Content-Encoding", "gzip")
7836
8964
  self.end_headers()
7837
8965
 
8966
+ # #583 S3 §6. ONE stateful compressor per connection. EVERY byte after
8967
+ # the headers goes through it — updates AND the keep-alive comment. A
8968
+ # raw write of even two bytes corrupts the whole remainder of the
8969
+ # stream, and the failure is silent until the next frame.
8970
+ #
8971
+ # One compressor per CONNECTION rather than one per tick, deliberately:
8972
+ # sharing compressed bytes across clients requires each frame to be an
8973
+ # independent gzip member, and cross-browser support for incrementally
8974
+ # decoding a concatenated multi-member stream under `Content-Encoding`
8975
+ # is not established. The saving would be proportional to connected
8976
+ # clients minus one — exactly zero at one open tab. Filed as a residual.
8977
+ _comp = (zlib.compressobj(6, zlib.DEFLATED, 16 + zlib.MAX_WBITS)
8978
+ if gzip_on else None)
8979
+
8980
+ def _emit(raw: bytes) -> None:
8981
+ if _comp is None:
8982
+ self.wfile.write(raw)
8983
+ else:
8984
+ # Z_SYNC_FLUSH, not a bare compress(): zlib buffers a small
8985
+ # frame entirely, so without the flush the client receives
8986
+ # nothing at all until some later write happens to spill it.
8987
+ chunk = _comp.compress(raw) + _comp.flush(zlib.Z_SYNC_FLUSH)
8988
+ if chunk:
8989
+ self.wfile.write(chunk)
8990
+ self.wfile.flush()
8991
+
7838
8992
  # Resolve oauth_usage cfg once per SSE connection so the per-tick
7839
8993
  # envelope build stays free of FS reads. A config edit during the
7840
8994
  # connection's lifetime won't take effect until the client
@@ -7854,37 +9008,74 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7854
9008
  # ~15s after bootstrap). Mirrors `/api/data`'s per-request injection.
7855
9009
  transcripts_enabled = self._transcripts_visible_to_request()
7856
9010
 
9011
+ # #583 S3 §5. Normalize the privacy input BEFORE it becomes a cache
9012
+ # key: an unresolvable value is the RESTRICTIVE one. An ordinary cache
9013
+ # MISS for the `true` variant still computes `true` — that is a
9014
+ # different situation, and conflating the two either leaks transcript
9015
+ # content or breaks the gate.
9016
+ visible = bool(transcripts_enabled)
9017
+ # Only `visible` and the oauth config can differ between two
9018
+ # connections in this process today. The two process constants stay in
9019
+ # the key so a later change making either per-connection cannot
9020
+ # silently serve one client another client's payload.
9021
+ variant = (
9022
+ visible,
9023
+ _canonical_oauth_key(cfg_oauth),
9024
+ type(self).display_tz_pref_override,
9025
+ type(self).cctally_host,
9026
+ )
9027
+
7857
9028
  q = self.hub.subscribe()
7858
9029
  try:
7859
9030
  while True:
7860
9031
  try:
7861
- snap = q.get(timeout=15)
9032
+ delivery = q.get(timeout=_SSE_KEEPALIVE_SECONDS)
7862
9033
  except _queue.Empty:
7863
9034
  # Keep-alive. Comment lines are ignored by EventSource
7864
- # but stop idle-proxy timeouts.
7865
- self.wfile.write(b": keep-alive\n\n")
7866
- self.wfile.flush()
9035
+ # but stop idle-proxy timeouts. #583 S3 §6: through
9036
+ # `_emit`, never a raw `wfile.write` — under compression a
9037
+ # raw write here corrupts every byte after it.
9038
+ _emit(b": keep-alive\n\n")
7867
9039
  continue
7868
- env = snapshot_to_envelope(
7869
- snap,
7870
- now_utc=dt.datetime.now(dt.timezone.utc),
7871
- monotonic_now=time.monotonic(),
7872
- oauth_usage_cfg=cfg_oauth,
7873
- display_tz_pref_override=type(self).display_tz_pref_override,
7874
- runtime_bind=type(self).cctally_host,
7875
- # #264 S3: gate the in-envelope session `title` on the same
7876
- # connection-scoped predicate that drives transcriptsEnabled.
7877
- transcripts_visible=transcripts_enabled,
7878
- )
7879
- env["transcriptsEnabled"] = transcripts_enabled
7880
- msg = (
7881
- "event: update\n"
7882
- + "data: "
7883
- + encode_dashboard_json(env, ensure_ascii=False)
7884
- + "\n\n"
7885
- )
7886
- self.wfile.write(msg.encode("utf-8"))
7887
- self.wfile.flush()
9040
+ # #583 S3 §5: skip to the newest queued delivery. The queue
9041
+ # holds four and `publish` discards only one oldest, so a
9042
+ # lagging client would otherwise replay a backlog whose clocks
9043
+ # were pinned several publish periods ago. The blocking `get`
9044
+ # above keeps its own `queue.Empty` keep-alive path.
9045
+ delivery = _drain_to_newest(q, delivery)
9046
+
9047
+ def _project(_key, _d=delivery, _v=visible):
9048
+ env = snapshot_to_envelope(
9049
+ _d.snapshot,
9050
+ now_utc=_d.pinned_now_utc,
9051
+ monotonic_now=_d.pinned_monotonic,
9052
+ oauth_usage_cfg=cfg_oauth,
9053
+ display_tz_pref_override=type(self).display_tz_pref_override,
9054
+ runtime_bind=type(self).cctally_host,
9055
+ # #264 S3: gate the in-envelope session `title` on the
9056
+ # same connection-scoped predicate that drives
9057
+ # transcriptsEnabled.
9058
+ transcripts_visible=_v,
9059
+ )
9060
+ # Part of the CACHED variant, not a post-projection
9061
+ # mutation: the payload is shared across every client
9062
+ # holding this delivery, so mutating it here would apply
9063
+ # to all of them.
9064
+ env["transcriptsEnabled"] = _v
9065
+ payload = encode_dashboard_json_bytes(
9066
+ env, ensure_ascii=False,
9067
+ )
9068
+ return b"event: update\ndata: " + payload + b"\n\n"
9069
+
9070
+ if _delivery_is_shareable(delivery.snapshot):
9071
+ frame = delivery.encoded(variant, _project)
9072
+ else:
9073
+ # #583 S3 §5: this snapshot's projection is NOT a function
9074
+ # of the snapshot plus the key — `snapshot_to_envelope`
9075
+ # reads configuration inline and runs the real doctor
9076
+ # gather per call — so it must not be cached and shared.
9077
+ frame = _project(variant)
9078
+ _emit(frame)
7888
9079
  except (BrokenPipeError, ConnectionResetError,
7889
9080
  ConnectionAbortedError, socket.timeout):
7890
9081
  # #279 S1 F3: a stalled send past the handler timeout raises
@@ -7898,7 +9089,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7898
9089
  # traceback. Headers are already committed — no 500 is possible; the
7899
9090
  # win is routing the operator signal through the _lib_log chokepoint
7900
9091
  # (self.log_error) + a deliberate clean close via the finally below.
9092
+ #
9093
+ # #583 S3 §6: under compression this path must add NOTHING to the
9094
+ # stream. Do not append plaintext, another gzip member, or a
9095
+ # trailer to a truncated stream; do not call `Z_FINISH` here,
9096
+ # because the stateful compressor may have advanced even though its
9097
+ # output was not fully written; and do not skip the frame and carry
9098
+ # on, for the same reason. The browser's own EventSource reconnect
9099
+ # opens a fresh response with a fresh compressor, which is the
9100
+ # correct recovery.
7901
9101
  self.log_error("api/events stream failed: %r", exc)
9102
+ self.close_connection = True
7902
9103
  finally:
7903
9104
  self.hub.unsubscribe(q)
7904
9105
 
@@ -8269,7 +9470,10 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
8269
9470
  "fingerprint": "sha1:" + ("0" * 40),
8270
9471
  }
8271
9472
  replacements = {
8272
- "last_sync_at": _time.monotonic(),
9473
+ # #583 S2 §6.1: this frame is degraded by construction — it carries a
9474
+ # `stats-open` error and no successful build ran, so it must not stamp
9475
+ # a fresh success. There is no earlier success to preserve either.
9476
+ "last_sync_at": None,
8273
9477
  "last_sync_error": "; ".join(errors),
8274
9478
  "sync_failures": (
8275
9479
  tui.SyncFailureAttribution(
@@ -8412,7 +9616,10 @@ def _dashboard_initial_snapshot_once(
8412
9616
  current_week=cw,
8413
9617
  forecast=fc,
8414
9618
  forecast_view=fc_view,
8415
- last_sync_at=_time.monotonic(),
9619
+ # #583 S2 §6.1: a build that failed does not stamp a success. This is
9620
+ # the FIRST build, so a failure retains None rather than becoming
9621
+ # freshly successful — there is no earlier success to preserve.
9622
+ last_sync_at=(None if errors else _time.monotonic()),
8416
9623
  last_sync_error=("; ".join(errors) if errors else None),
8417
9624
  sync_failures=tuple(sync_failures),
8418
9625
  doctor_payload=doctor_payload,
@@ -8640,16 +9847,23 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8640
9847
 
8641
9848
  ref = _SnapshotRef(initial)
8642
9849
  hub = SSEHub()
8643
- hub.publish(initial) # seed for early subscribers
9850
+ # #583 S2: seed for early subscribers, published from the reference so the
9851
+ # very first frame already carries the process's `server_epoch`. A seed
9852
+ # published without it would make the client discard its (empty) outstanding
9853
+ # set on the next frame's epoch change — harmless, but it would also mean
9854
+ # the first frame a client ever sees disagrees with every later one.
9855
+ hub.publish(ref.get())
8644
9856
 
8645
9857
  # sync_lock serializes sync-work between the periodic sync thread and
8646
9858
  # the POST /api/sync handler. Held only around _tui_build_snapshot +
8647
9859
  # ref.set + hub.publish — NOT around the handler's response path.
8648
- # The handler uses acquire(timeout=…) so a click that lands inside
8649
- # the periodic thread's lock-hold waits briefly rather than 503-ing
8650
- # and silently dropping the user's force-refresh intent; only stuck
8651
- # contention beyond the timeout produces 503. The lock inside
8652
- # _run_sync_now is what actually prevents overlap.
9860
+ # The handler acquires it NON-BLOCKING (#583 S2): a click that lands
9861
+ # inside the periodic thread's lock-hold is queued on _SnapshotRef and
9862
+ # answered 202, and the sync loop services it under the duty floor. The
9863
+ # bounded acquire and its 503 are gone. The one exception is --no-sync,
9864
+ # where nothing would drain that queue, so a manual request there waits on
9865
+ # a blocking acquire instead. The lock inside _run_sync_now is what
9866
+ # actually prevents overlap.
8653
9867
  sync_lock = threading.Lock()
8654
9868
 
8655
9869
  # Build the two variants up front. The locked variant is exposed on the
@@ -8706,32 +9920,27 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8706
9920
 
8707
9921
  class _DashboardSyncThread(_c_for_subclass._TuiSyncThread):
8708
9922
  def _run(self) -> None:
8709
- last_heal = [_time.monotonic()]
8710
-
8711
- def run_iteration() -> None:
8712
- _run_sync_now(skip_sync=self._skip_sync)
8713
- # Self-heal removed-worktree orphans on a ~60s cadence (far
8714
- # rarer than the sync tick — a deleted worktree is not urgent).
8715
- # Non-blocking on the flock, so a contended tick just retries
8716
- # next cadence; gated off under --no-sync. Runs INSIDE the
8717
- # measured iteration so its cost counts toward the cooldown
8718
- # deadline (#313 P2 / F10).
8719
- if (not self._skip_sync
8720
- and _time.monotonic() - last_heal[0] >= 60.0):
8721
- last_heal[0] = _time.monotonic()
8722
- _dashboard_self_heal_orphans(skip_sync=self._skip_sync)
9923
+ run_iteration = _make_dashboard_run_iteration(
9924
+ sync_lock=sync_lock,
9925
+ run_sync_now=_run_sync_now,
9926
+ run_sync_now_locked=_run_sync_now_locked,
9927
+ skip_sync=self._skip_sync,
9928
+ monotonic=_time.monotonic,
9929
+ )
8723
9930
 
8724
9931
  # Work-proportional cooldown (F10): sleep to t0 + max(interval, work)
8725
- # so a slow rebuild cannot peg a full core. The manual POST /api/sync
8726
- # refresh runs synchronously under sync_lock, independent of this
8727
- # cooldown, so a user force-refresh is always immediate.
9932
+ # so a slow rebuild cannot peg a full core. #583 S2 adds the
9933
+ # request-driven start, floored at t0 + 2*work so a queued refresh
9934
+ # cannot drive the duty above the same #313 bound. The dashboard no
9935
+ # longer passes the legacy test-and-clear flag: under a floor a poll
9936
+ # firing early would clear the request and discard it.
8728
9937
  _dashboard_sync_loop(
8729
9938
  stop=self._stop,
8730
9939
  interval=self._interval,
8731
9940
  run_iteration=run_iteration,
8732
- take_sync_request=self._ref.take_sync_request,
8733
9941
  monotonic=_time.monotonic,
8734
9942
  sleep=_time.sleep,
9943
+ **_make_sync_loop_collaborators(ref=ref, hub=hub),
8735
9944
  )
8736
9945
 
8737
9946
  sync_thread = (
@@ -8743,43 +9952,14 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8743
9952
  if sync_thread is not None:
8744
9953
  sync_thread.start()
8745
9954
 
8746
- # #320: transcript/search ingestion runs on its own thread and SQLite file.
8747
- # A multi-GB first rebuild or a contended conversations.db therefore cannot
8748
- # delay `_run_sync_now`, its `last_sync_at` stamp, or core SSE publication.
9955
+ # The loop, the pass and this thread's construction are module-level (see
9956
+ # `_conversation_sync_loop`): the extraction is what lets the duty bound be
9957
+ # proven on a virtual clock instead of asserted.
8749
9958
  conversation_sync_stop = threading.Event()
8750
-
8751
- def _conversation_sync_loop() -> None:
8752
- interval = max(5.0, float(args.sync_interval))
8753
- while not conversation_sync_stop.is_set():
8754
- conn = None
8755
- try:
8756
- conn = open_conversations_db()
8757
- sync_claude_conversations(conn)
8758
- sync_codex_conversations(conn)
8759
- _dashboard_maybe_prune_retention()
8760
- except (OSError, sqlite3.DatabaseError) as exc:
8761
- eprint(f"[conversations] background sync unavailable: {exc}")
8762
- except Exception as exc: # noqa: BLE001
8763
- # Transcript parsing/normalization is deliberately outside the
8764
- # core freshness loop. Keep this worker alive so a later clean
8765
- # tick can self-heal instead of permanently stopping after one
8766
- # malformed provider record.
8767
- eprint(
8768
- "[conversations] background sync failed: "
8769
- f"{type(exc).__name__}: {exc}"
8770
- )
8771
- finally:
8772
- if conn is not None:
8773
- conn.close()
8774
- conversation_sync_stop.wait(interval)
8775
-
8776
- conversation_sync_thread = (
8777
- None if args.no_sync
8778
- else threading.Thread(
8779
- target=_conversation_sync_loop,
8780
- daemon=True,
8781
- name="dashboard-conversations-sync",
8782
- )
9959
+ conversation_sync_thread = _make_conversation_sync_thread(
9960
+ stop=conversation_sync_stop,
9961
+ sync_interval=args.sync_interval,
9962
+ no_sync=args.no_sync,
8783
9963
  )
8784
9964
  if conversation_sync_thread is not None:
8785
9965
  conversation_sync_thread.start()
@@ -8790,7 +9970,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8790
9970
  # relying on the data-sync thread's lifecycle.
8791
9971
  update_check_stop = threading.Event()
8792
9972
  update_check_thread = _DashboardUpdateCheckThread(
8793
- update_check_stop, hub=hub, snapshot_ref=ref,
9973
+ update_check_stop, hub=hub, snapshot_ref=ref, runtime_bind=args.host,
8794
9974
  )
8795
9975
  update_check_thread.start()
8796
9976