cctally 1.117.0 → 1.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +7 -3
  3. package/bin/_cctally_cache.py +2783 -175
  4. package/bin/_cctally_completion.py +106 -33
  5. package/bin/_cctally_core.py +69 -4
  6. package/bin/_cctally_dashboard.py +227 -23
  7. package/bin/_cctally_dashboard_conversation.py +5 -1
  8. package/bin/_cctally_dashboard_envelope.py +60 -19
  9. package/bin/_cctally_dashboard_sources.py +23 -1
  10. package/bin/_cctally_db.py +193 -153
  11. package/bin/_cctally_diagnosis_sources.py +63 -7
  12. package/bin/_cctally_doctor.py +865 -93
  13. package/bin/_cctally_five_hour.py +124 -54
  14. package/bin/_cctally_forecast.py +301 -7
  15. package/bin/_cctally_journal.py +186 -18
  16. package/bin/_cctally_milestone_history.py +1193 -108
  17. package/bin/_cctally_oauth_samples.py +286 -15
  18. package/bin/_cctally_parser.py +2 -2
  19. package/bin/_cctally_quota.py +104 -12
  20. package/bin/_cctally_record.py +153 -29
  21. package/bin/_cctally_rederive.py +608 -68
  22. package/bin/_cctally_statusline.py +108 -81
  23. package/bin/_cctally_store.py +179 -38
  24. package/bin/_cctally_tui.py +1210 -170
  25. package/bin/_cctally_update.py +32 -13
  26. package/bin/_lib_codex_conversation_query.py +52 -14
  27. package/bin/_lib_codex_drain_admission.py +224 -6
  28. package/bin/_lib_codex_metadata.py +39 -0
  29. package/bin/_lib_conversation_anon.py +15 -8
  30. package/bin/_lib_conversation_dispatch.py +7 -0
  31. package/bin/_lib_conversation_query.py +289 -70
  32. package/bin/_lib_dashboard_sources.py +131 -9
  33. package/bin/_lib_detector_access_registry.py +497 -26
  34. package/bin/_lib_doctor.py +85 -20
  35. package/bin/_lib_doctor_scan_index.py +321 -0
  36. package/bin/_lib_ingest_frontier.py +20 -1
  37. package/bin/_lib_oauth_samples.py +23 -7
  38. package/bin/_lib_pricing.py +146 -16
  39. package/bin/_lib_quota.py +19 -0
  40. package/bin/_lib_quota_model.py +29 -11
  41. package/bin/_lib_rederive.py +11 -6
  42. package/bin/_lib_retained_records.py +516 -0
  43. package/bin/_lib_snapshot_cache.py +18 -0
  44. package/bin/_lib_stale_proof.py +272 -43
  45. package/bin/_lib_stats_content_revision.py +831 -0
  46. package/bin/_lib_tick_stats.py +10 -5
  47. package/bin/_lib_write_io.py +12 -5
  48. package/bin/cctally +8 -1
  49. package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
  50. package/dashboard/static/assets/{DoctorModal-BqmXbnhn.js → DoctorModal-D4QSq6uz.js} +1 -1
  51. package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
  52. package/dashboard/static/assets/{ProjectsDrillPanel-B-HZkDnT.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
  53. package/dashboard/static/assets/{SourceDetailModal-QhJkHB7i.js → SourceDetailModal-NyyEfXmc.js} +1 -1
  54. package/dashboard/static/assets/{UpdateModal-CkXEIQcK.js → UpdateModal-CFLq9pT1.js} +1 -1
  55. package/dashboard/static/assets/{index-HKS1s56o.js → index-BKTWH9JE.js} +2 -2
  56. package/dashboard/static/assets/{outlineNavigation-BNGaNPCp.js → outlineNavigation-BnGUwCS3.js} +3 -3
  57. package/dashboard/static/dashboard.html +1 -1
  58. package/package.json +4 -1
  59. package/dashboard/static/assets/ConversationsView-Dcfn8T6f.js +0 -72
  60. package/dashboard/static/assets/ModalRoot-Ck3WJK2V.js +0 -1
@@ -2450,6 +2450,108 @@ def _dashboard_sync_loop(
2450
2450
  sleep(min(0.1, max(0.0, deadline - now)))
2451
2451
 
2452
2452
 
2453
+ def _frozen_deferral_settle_loop(
2454
+ *,
2455
+ stop,
2456
+ interval: float,
2457
+ ref,
2458
+ sync_lock,
2459
+ run_locked,
2460
+ mark_rebuilding,
2461
+ is_deferred,
2462
+ monotonic=time.monotonic,
2463
+ wait=None,
2464
+ ) -> None:
2465
+ """Retry a frozen dashboard's no-ingest build until recovery permits it (#932).
2466
+
2467
+ `--no-sync` starts no sync thread, so a frozen dashboard whose first build
2468
+ DEFERRED (an older-epoch index, or a corruption heal running detached) would
2469
+ otherwise hold that data-less deferral frame for its whole life. This loop
2470
+ retries the dashboard's existing locked publisher with ``skip_sync=True``
2471
+ (the same no-ingest build a manual ``POST /api/sync`` runs: no ingest, no
2472
+ OAuth refresh, no derivations) only while the held frame is an EXPLICIT
2473
+ stats deferral (``is_deferred``, the `SyncFailureAttribution.deferred`
2474
+ predicate), and exits for good on any other outcome — a populated or empty
2475
+ success, a crash, an exhausted corruption retry.
2476
+
2477
+ Cadence: one ``interval`` before the first attempt, then
2478
+ ``_next_deadline(t0, interval, work)``, the #313 cooldown that bounds duty
2479
+ to 50 %. The ``sync_lock`` acquire is NON-blocking; contention skips that
2480
+ cadence. Every attempt does all of its work under the lock and rechecks
2481
+ ``stop`` right after acquiring it, which is what makes
2482
+ `_retire_frozen_deferral_settle`'s lock fence exact. ``wait(seconds)``
2483
+ returns True when stopped (``stop.wait`` in production).
2484
+ """
2485
+ if wait is None:
2486
+ wait = stop.wait
2487
+ deadline = monotonic() + interval
2488
+ while True:
2489
+ if wait(max(0.0, deadline - monotonic())) or stop.is_set():
2490
+ return
2491
+ if not is_deferred(ref.get()):
2492
+ return # settled elsewhere (a manual sync): no extra build
2493
+ if not sync_lock.acquire(blocking=False):
2494
+ deadline = monotonic() + interval
2495
+ continue
2496
+ try:
2497
+ if stop.is_set() or not is_deferred(ref.get()):
2498
+ return
2499
+ t0 = monotonic()
2500
+ try:
2501
+ with _rebuilding_claim(mark_rebuilding):
2502
+ run_locked(skip_sync=True)
2503
+ except Exception: # noqa: BLE001 — logged; the loop is done
2504
+ _log_sync_iteration_failure()
2505
+ return
2506
+ work = monotonic() - t0
2507
+ still_deferred = is_deferred(ref.get())
2508
+ finally:
2509
+ sync_lock.release()
2510
+ if not still_deferred:
2511
+ return
2512
+ deadline = _next_deadline(t0, interval, work)
2513
+
2514
+
2515
+ def _make_frozen_deferral_settle_thread(
2516
+ *, stop, interval, ref, hub, sync_lock, run_sync_now_locked,
2517
+ ) -> threading.Thread:
2518
+ """The daemon thread running `_frozen_deferral_settle_loop` (#932)."""
2519
+ collaborators = _make_sync_loop_collaborators(ref=ref, hub=hub)
2520
+ tui = _cctally()._cctally_tui
2521
+ return threading.Thread(
2522
+ target=lambda: _frozen_deferral_settle_loop(
2523
+ stop=stop,
2524
+ interval=float(interval),
2525
+ ref=ref,
2526
+ sync_lock=sync_lock,
2527
+ run_locked=run_sync_now_locked,
2528
+ mark_rebuilding=collaborators["mark_rebuilding"],
2529
+ is_deferred=tui._tui_snapshot_is_stats_deferral,
2530
+ ),
2531
+ daemon=True,
2532
+ name="dashboard-frozen-deferral-settle",
2533
+ )
2534
+
2535
+
2536
+ def _retire_frozen_deferral_settle(*, stop, sync_lock, thread) -> None:
2537
+ """Retire the settle thread before the resources its attempts use (#932).
2538
+
2539
+ A lock fence, not a timed join: set ``stop``, then acquire and release
2540
+ ``sync_lock`` (blocking, no timeout), which waits out any attempt already
2541
+ in flight — its build, verifier submission, ``set_final`` and publish all
2542
+ run under the lock — and no attempt can start after it, because each
2543
+ rechecks ``stop`` right after acquiring. The join that follows only
2544
+ collects a thread that is waiting on ``stop`` or returning. The lock is
2545
+ released before the caller retires the memory verifiers, whose unsafe
2546
+ completion takes the same lock. Intentionally unbounded: it drains
2547
+ whichever holder has the lock.
2548
+ """
2549
+ stop.set()
2550
+ sync_lock.acquire()
2551
+ sync_lock.release()
2552
+ thread.join()
2553
+
2554
+
2453
2555
  def _dashboard_startup_schema_migration(*, run_derivations: bool = True) -> None:
2454
2556
  """Advance both stores to head once, at startup, in EVERY mode (#780).
2455
2557
 
@@ -7135,6 +7237,25 @@ def _projects_week_usage_rows(
7135
7237
  return rows
7136
7238
 
7137
7239
 
7240
+ def _projects_content_revision_leg(conn: "sqlite3.Connection") -> str:
7241
+ """The Claude content revision memo leg read on ``conn`` (#1050) for a
7242
+ call without a dispatch leg (tests, the drill-down fallback).
7243
+
7244
+ A ``current`` store's leg is its token. Any other state names itself, so a
7245
+ store with no revision tracking (a bare fixture database) memoizes exactly
7246
+ as it did before #1050 instead of missing on every call; the tick never
7247
+ takes this path, and its own dispatch leg on such a store is a fresh nonce.
7248
+ """
7249
+ import _lib_stats_content_revision as k
7250
+ try:
7251
+ revision = k.read_claude_content_revision(conn)
7252
+ except k.StatsContentRevisionUnreadable:
7253
+ return "?unreadable"
7254
+ if revision.state == k.STATE_CURRENT:
7255
+ return revision.token
7256
+ return f"?{revision.state}"
7257
+
7258
+
7138
7259
  def _build_projects_envelope(
7139
7260
  conn: "sqlite3.Connection",
7140
7261
  *,
@@ -7142,6 +7263,7 @@ def _build_projects_envelope(
7142
7263
  current_week: "Any | None" = None,
7143
7264
  weeks_back: int = 12,
7144
7265
  use_projects_env_cache: bool = False,
7266
+ content_revision_leg: "str | None" = None,
7145
7267
  ) -> dict:
7146
7268
  """Build the ``projects.{current_week, trend}`` envelope block.
7147
7269
 
@@ -7172,6 +7294,12 @@ def _build_projects_envelope(
7172
7294
  ``(max(session_entries.id), max_wus_id, max(mutation_seq), cw_week_start,
7173
7295
  weeks_back)`` — the ``mutation_seq`` leg (#270 §7d) busts the memo on an
7174
7296
  id-stable in-place finalization.
7297
+
7298
+ ``content_revision_leg`` (#1050 spec 4.5 item 7) is the dashboard tick's
7299
+ dispatch leg of the stats.db Claude content revision, read before this
7300
+ lookup; the memo key carries it, so an in-place ``weekly_percent``
7301
+ correction (which moves no ``MAX(id)``) rebuilds the envelope. ``None``
7302
+ reads it here, on ``conn``.
7175
7303
  """
7176
7304
  c = _cctally()
7177
7305
 
@@ -7184,6 +7312,7 @@ def _build_projects_envelope(
7184
7312
  # SELECT uses below).
7185
7313
  cur = conn.execute("SELECT COALESCE(MAX(id), 0) FROM session_entries")
7186
7314
  max_id = cur.fetchone()[0]
7315
+ stats_readable = True
7187
7316
  try:
7188
7317
  cur = conn.execute(
7189
7318
  "SELECT COALESCE(MAX(id), 0) FROM weekly_usage_snapshots"
@@ -7191,6 +7320,15 @@ def _build_projects_envelope(
7191
7320
  max_wus_id = cur.fetchone()[0]
7192
7321
  except sqlite3.OperationalError:
7193
7322
  max_wus_id = 0
7323
+ stats_readable = False
7324
+ # #1050 spec 4.5 item 7: the attributed percentages are functions of the
7325
+ # stored `weekly_percent`, which a correction rewrites in place. The leg is
7326
+ # the dispatch's; a caller without one reads it here, before the lookup.
7327
+ # A connection with no stats tables publishes no stats-derived value, so
7328
+ # it keys on no leg; an untrusted or unreadable identity is a fresh nonce.
7329
+ if content_revision_leg is None:
7330
+ content_revision_leg = (
7331
+ _projects_content_revision_leg(conn) if stats_readable else "")
7194
7332
  # #270 (§7d, Codex-2b): this OUTER whole-envelope memo returns BEFORE the
7195
7333
  # inner per-(project, week) reconcile cache, so an id-stable in-place
7196
7334
  # finalization — which leaves MAX(id)/MAX(wus id) flat — would serve the
@@ -7214,7 +7352,8 @@ def _build_projects_envelope(
7214
7352
  cw_key: "dt.datetime | None" = None
7215
7353
  if current_week is not None:
7216
7354
  cw_key = getattr(current_week, "week_start_at", None)
7217
- memo_key = (max_id, max_wus_id, entry_mutation_seq, cw_key, weeks_back)
7355
+ memo_key = (max_id, max_wus_id, entry_mutation_seq, cw_key, weeks_back,
7356
+ content_revision_leg)
7218
7357
  cached = _PROJECTS_ENV_MEMO.get("value")
7219
7358
  if cached is not None and _PROJECTS_ENV_MEMO.get("key") == memo_key:
7220
7359
  return cached
@@ -11544,12 +11683,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11544
11683
  assembly runs inside one transaction per DB. 400 malformed key/source;
11545
11684
  404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
11546
11685
 
11547
- ``?account=<account_key|unattributed>`` (#416, Codex only) focuses the
11548
- cycle on one account: its own crossings, its own spend. ABSENT is the
11549
- shipped merged response and is byte-identical — the client sends the
11550
- qualifier only under account focus, exactly as `/api/source/` does. The
11686
+ ``?account=<account_key|unattributed>`` (#416 Codex; #836 Claude)
11687
+ focuses the cycle on one account: its own crossings, its own spend.
11688
+ ABSENT is the shipped merged response and is byte-identical — the
11689
+ client sends the qualifier only under account focus (Codex) or for the
11690
+ displayed account (Claude), exactly as `/api/source/` does. The
11551
11691
  vendor-wide ``*`` sentinel is rejected here (unlike `/api/source/`,
11552
11692
  where it labels an alert row): it names no cycle to render.
11693
+
11694
+ Claude (#836 spec 5.6) accepts the qualifier only while Claude is
11695
+ decorated (more than one REAL account), checked in the same read
11696
+ transaction as the assembly: 400 on an undecorated store, 404
11697
+ ``unknown_key`` for an unknown or non-Claude account or a key outside
11698
+ that account's scoped index, and never a retry against the merged
11699
+ index.
11553
11700
  """
11554
11701
  import re as _re
11555
11702
  import urllib.parse as _urlparse
@@ -11576,11 +11723,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11576
11723
  ):
11577
11724
  self._send_milestones_json(400, {"error": "invalid account"})
11578
11725
  return
11579
- if source != "codex":
11580
- # Claude weeks are not account-partitioned on this route yet;
11581
- # accepting the qualifier silently would be a privacy lie.
11582
- self._send_milestones_json(400, {"error": "invalid account"})
11583
- return
11584
11726
  account_key = raw_account[0]
11585
11727
  c = sys.modules["cctally"]
11586
11728
  try:
@@ -11592,12 +11734,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11592
11734
  self._send_milestones_json(400, {"error": "invalid cycle key"})
11593
11735
  return
11594
11736
  conn = open_db()
11737
+ rejected = False
11738
+ detail = None
11595
11739
  try:
11596
11740
  conn.execute("BEGIN")
11597
- detail = c.build_claude_week_detail(conn, key)
11741
+ if account_key is None:
11742
+ detail = c.build_claude_week_detail(conn, key)
11743
+ else:
11744
+ # #836 spec 5.6. Only a DECORATED store partitions
11745
+ # Claude history by account. On an undecorated one the
11746
+ # qualifier is refused, never ignored: answering it with
11747
+ # the merged week would claim an account scope the
11748
+ # payload does not have, which is the privacy lie the
11749
+ # earlier blanket rejection guarded against. Checked
11750
+ # inside this transaction so the gate and the assembly
11751
+ # read one state.
11752
+ import _cctally_account
11753
+ if not _cctally_account.provider_is_decorated(
11754
+ conn, "claude"):
11755
+ rejected = True
11756
+ elif account_key == "unattributed" or conn.execute(
11757
+ "SELECT 1 FROM accounts WHERE account_key = ? "
11758
+ "AND provider = 'claude'",
11759
+ (account_key,)).fetchone() is not None:
11760
+ detail = c.build_claude_week_detail(
11761
+ conn, key, account_key=account_key,
11762
+ now_utc=_command_as_of())
11598
11763
  conn.commit()
11599
11764
  finally:
11600
11765
  conn.close()
11766
+ if rejected:
11767
+ self._send_milestones_json(400, {"error": "invalid account"})
11768
+ return
11601
11769
  if detail is None:
11602
11770
  self._send_milestones_json(404, {
11603
11771
  "error": "unknown week", "code": "unknown_key",
@@ -12197,7 +12365,6 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
12197
12365
  c = _cctally()
12198
12366
  tui = c._cctally_tui
12199
12367
  now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
12200
- hydrating = not getattr(args, "no_sync", False)
12201
12368
  errors = [f"stats-open: {exc}"]
12202
12369
  envelope_precompute = None
12203
12370
  try:
@@ -12220,19 +12387,16 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
12220
12387
  # a fresh success. There is no earlier success to preserve either.
12221
12388
  "last_sync_at": None,
12222
12389
  "last_sync_error": "; ".join(errors),
12223
- "sync_failures": (
12224
- tui.SyncFailureAttribution(
12225
- leg="stats-open",
12226
- database="stats",
12227
- corruption=tui._stats_open_failure_is_corruption(exc),
12228
- ),
12229
- ),
12390
+ "sync_failures": (tui._tui_deferral_attribution(exc),),
12230
12391
  "doctor_payload": doctor_payload,
12231
12392
  "envelope_precompute": envelope_precompute,
12232
- "hydrating": hydrating,
12393
+ # #932 §4.2: hydrating in BOTH modes. A frozen dashboard's settle loop
12394
+ # (`_frozen_deferral_settle_loop`) replaces this frame once recovery
12395
+ # permits a build, so it must read as loading, never as "restart the
12396
+ # dashboard".
12397
+ "hydrating": True,
12398
+ "source_bundle": tui._tui_hydrating_source_bundle(),
12233
12399
  }
12234
- if hydrating:
12235
- replacements["source_bundle"] = tui._tui_hydrating_source_bundle()
12236
12400
  return dataclasses.replace(tui._tui_empty_snapshot(now_utc), **replacements)
12237
12401
 
12238
12402
 
@@ -12322,8 +12486,11 @@ def _dashboard_initial_snapshot_once(
12322
12486
  )
12323
12487
  if conn is not None:
12324
12488
  try:
12489
+ reading_errors: list[Exception] = []
12325
12490
  try:
12326
- cw = tui._tui_build_current_week(conn, now_utc, skip_sync=True)
12491
+ cw = tui._tui_build_current_week(
12492
+ conn, now_utc, skip_sync=True,
12493
+ reading_errors=reading_errors)
12327
12494
  except Exception as exc: # noqa: BLE001 — never block the bind
12328
12495
  tui._tui_capture_sync_failure(
12329
12496
  conn,
@@ -12334,6 +12501,18 @@ def _dashboard_initial_snapshot_once(
12334
12501
  exc=exc,
12335
12502
  stats_heal_attempted=stats_heal_attempted,
12336
12503
  )
12504
+ # #1018 twin review F1: a failed confirming-reading lookup keeps
12505
+ # the card and is recorded like its other reads.
12506
+ for exc in reading_errors:
12507
+ tui._tui_capture_sync_failure(
12508
+ conn,
12509
+ errors,
12510
+ sync_failures,
12511
+ leg="current-week-reading",
12512
+ database="stats_or_cache",
12513
+ exc=exc,
12514
+ stats_heal_attempted=stats_heal_attempted,
12515
+ )
12337
12516
  try:
12338
12517
  fc_view = tui._tui_build_forecast_view(
12339
12518
  conn, now_utc, skip_sync=True
@@ -12781,6 +12960,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12781
12960
  update_check_stop = threading.Event()
12782
12961
  update_check_thread = _DashboardUpdateCheckThread(
12783
12962
  update_check_stop, hub=hub, snapshot_ref=ref, runtime_bind=args.host,
12963
+ # #932 §4.2: serialize its republish with every snapshot publisher.
12964
+ sync_lock=sync_lock,
12784
12965
  )
12785
12966
  update_check_thread.start()
12786
12967
 
@@ -12880,6 +13061,23 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12880
13061
  file=sys.stderr,
12881
13062
  )
12882
13063
 
13064
+ # #932: a frozen dashboard whose first build DEFERRED (an older-epoch
13065
+ # index or a detached heal) has no sync thread to replace that frame, so a
13066
+ # one-shot settle loop retries the no-ingest build until recovery permits
13067
+ # it. Started after the server objects exist and immediately before the
13068
+ # shutdown-managed region below.
13069
+ frozen_settle_stop = threading.Event()
13070
+ frozen_settle_thread = None
13071
+ if args.no_sync and _cctally()._cctally_tui._tui_snapshot_is_stats_deferral(
13072
+ initial):
13073
+ frozen_settle_thread = _make_frozen_deferral_settle_thread(
13074
+ stop=frozen_settle_stop,
13075
+ interval=float(args.sync_interval),
13076
+ ref=ref, hub=hub, sync_lock=sync_lock,
13077
+ run_sync_now_locked=_run_sync_now_locked,
13078
+ )
13079
+ frozen_settle_thread.start()
13080
+
12883
13081
  # Block the main thread until SIGINT/SIGTERM. The wait is driven by a
12884
13082
  # self-pipe wakeup fd (signal.set_wakeup_fd) rather than a
12885
13083
  # threading.Event, so a single signal unblocks it unconditionally — the
@@ -12900,6 +13098,12 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12900
13098
  _lib_write_io.clear_local_instance()
12901
13099
  if sync_thread is not None:
12902
13100
  sync_thread.stop()
13101
+ # #932: fence the settle thread through `sync_lock` BEFORE the memory
13102
+ # verifiers, their completion locks, the server and the hub retire.
13103
+ if frozen_settle_thread is not None:
13104
+ _retire_frozen_deferral_settle(
13105
+ stop=frozen_settle_stop, sync_lock=sync_lock,
13106
+ thread=frozen_settle_thread)
12903
13107
  conversation_sync_stop.set()
12904
13108
  if conversation_sync_thread is not None:
12905
13109
  conversation_sync_thread.join(timeout=2)
@@ -639,7 +639,11 @@ def _codex_observation_hold_only(stats, *, executor_owned=False):
639
639
  return False
640
640
  if getattr(stats, "barrier_retry", False) and not executor_owned:
641
641
  return False
642
- skipped = getattr(stats, "container_skipped", {})
642
+ # #1052: every skip the pass saw, its unit phase's included, when the
643
+ # stats object splits them; otherwise its single census dict.
644
+ every_skip = getattr(stats, "all_container_skipped", None)
645
+ skipped = (every_skip() if callable(every_skip)
646
+ else getattr(stats, "container_skipped", {}))
643
647
  return (bool(getattr(stats, "container_pending", 0) or any(skipped.values()))
644
648
  and set(skipped) <= {
645
649
  "decoder-unavailable", "decode-error", "read-error",
@@ -1274,6 +1274,25 @@ def _sync_failure_envelope(
1274
1274
  "action": "cctally db repair --db stats --yes",
1275
1275
  }
1276
1276
 
1277
+ # #932 §4.1: an epoch deferral whose admission FAILED has no rebuild
1278
+ # running (no marker, no worker), so "rebuilding" would be false. It is the
1279
+ # existing server-sync error until a later admission succeeds.
1280
+ def field(item, key, default=None):
1281
+ return (item.get(key, default) if isinstance(item, dict)
1282
+ else getattr(item, key, default))
1283
+
1284
+ for item in attributions or ():
1285
+ if (field(item, "database") == "stats"
1286
+ and not bool(field(item, "corruption", False))
1287
+ and bool(field(item, "deferred", False))
1288
+ and field(item, "deferral_outcome") == "failed"):
1289
+ return {
1290
+ "kind": "server_sync",
1291
+ "label": "⚠ server sync error",
1292
+ "detail": "The server could not complete its background sync.",
1293
+ "action": None,
1294
+ }
1295
+
1277
1296
  if attributed("stats"):
1278
1297
  return {
1279
1298
  "kind": "maintenance_active",
@@ -1676,6 +1695,29 @@ def _header_rate_and_delta(current_rate, current_week_start, trend):
1676
1695
  return current_rate, None
1677
1696
 
1678
1697
 
1698
+ def current_week_freshness(captured, *, now_utc: "dt.datetime",
1699
+ oauth_usage_cfg: "dict | None" = None
1700
+ ) -> "dict | None":
1701
+ """`current_week.freshness` (``{label, captured_at, age_seconds}``) of a
1702
+ confirming-reading instant, or None when there is none. The one
1703
+ derivation the envelope and the source payload's idle refresh share
1704
+ (#1018 twin spec 5.4/5.5)."""
1705
+ if not isinstance(captured, dt.datetime):
1706
+ return None
1707
+ if captured.tzinfo is None:
1708
+ captured = captured.replace(tzinfo=dt.timezone.utc)
1709
+ age_s = max(0.0, (now_utc - captured).total_seconds())
1710
+ _fresh_cfg = (
1711
+ oauth_usage_cfg if oauth_usage_cfg is not None
1712
+ else sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS
1713
+ )
1714
+ return {
1715
+ "label": sys.modules["cctally"]._freshness_label(age_s, _fresh_cfg),
1716
+ "captured_at": _iso_z(captured),
1717
+ "age_seconds": int(age_s),
1718
+ }
1719
+
1720
+
1679
1721
  def snapshot_to_envelope(snap: "DataSnapshot", *,
1680
1722
  now_utc: "dt.datetime",
1681
1723
  monotonic_now: "float | None" = None,
@@ -1850,25 +1892,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1850
1892
  elif tp == 90:
1851
1893
  budget_90 = dpd
1852
1894
 
1853
- # Freshness envelope for current_week — derived from
1854
- # cw.latest_snapshot_at (a datetime, not an ISO string). None when
1855
- # cw is absent or has no snapshot timestamp; the React client renders
1856
- # a placeholder in that case. Refs spec §3.4.
1857
- cw_freshness: "dict | None" = None
1858
- if cw is not None and cw.latest_snapshot_at is not None:
1859
- captured = cw.latest_snapshot_at
1860
- if captured.tzinfo is None:
1861
- captured = captured.replace(tzinfo=dt.timezone.utc)
1862
- age_s = max(0.0, (now_utc - captured).total_seconds())
1863
- _fresh_cfg = (
1864
- oauth_usage_cfg if oauth_usage_cfg is not None
1865
- else sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS
1866
- )
1867
- cw_freshness = {
1868
- "label": sys.modules["cctally"]._freshness_label(age_s, _fresh_cfg),
1869
- "captured_at": _iso_z(captured),
1870
- "age_seconds": int(age_s),
1871
- }
1895
+ # Freshness envelope for current_week (spec §3.4) — derived from
1896
+ # cw.latest_weekly_reading_at, the newest genuine reading confirming the
1897
+ # displayed value (#1018 twin spec 5.4). None when cw is absent or has no
1898
+ # resolvable confirmation; the React client renders a placeholder then.
1899
+ # `last_snapshot_age_sec` below keeps the stored row's capture.
1900
+ cw_freshness = current_week_freshness(
1901
+ None if cw is None else getattr(cw, "latest_weekly_reading_at", None),
1902
+ now_utc=now_utc, oauth_usage_cfg=oauth_usage_cfg)
1872
1903
 
1873
1904
  week_lbl: "str | None" = None
1874
1905
  reset_at_utc: "dt.datetime | None" = None
@@ -2557,6 +2588,16 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2557
2588
  # PREVIEW badge when set.
2558
2589
  **sys.modules["_cctally_dashboard"]._channel_env_fragment(),
2559
2590
  }
2591
+ # #836 (spec 5.1): the displayed account, decorated builds ONLY. Below the
2592
+ # R8 threshold both keys are omitted (never null), so an undecorated
2593
+ # `current_week` keeps its bytes. Appended after the literal so the key
2594
+ # order of every existing field is unchanged. This identity — never
2595
+ # `five_hour_block.account_key`, absent with no live block — is what the
2596
+ # client labels and scopes its history navigation by.
2597
+ if (cw is not None and getattr(cw, "account_decorated", False)
2598
+ and isinstance(envelope.get("current_week"), dict)):
2599
+ envelope["current_week"]["account_key"] = cw.account_key
2600
+ envelope["current_week"]["account_label"] = cw.account_label
2560
2601
  envelope.update(_source_bundle_to_envelope(getattr(snap, "source_bundle", None)))
2561
2602
  _overlay_claude_source_session_titles(envelope, snap, transcripts_visible)
2562
2603
  _overlay_codex_source_session_labels(envelope, snap, transcripts_visible)
@@ -1490,6 +1490,7 @@ def resolve_dashboard_source_semantics(
1490
1490
  config: Mapping[str, object] | None,
1491
1491
  *,
1492
1492
  display_tz_name: str | None,
1493
+ speed: str | None = None,
1493
1494
  ) -> DashboardSourceSemantics:
1494
1495
  """Resolve dashboard semantics through the shipped CLI kernels.
1495
1496
 
@@ -1497,12 +1498,17 @@ def resolve_dashboard_source_semantics(
1497
1498
  it preserves the CLI's all-$CODEX_HOME fast-service-tier behavior. The
1498
1499
  weekly index comes from the same ``get_week_start_name``/``WEEKDAY_MAP``
1499
1500
  pair used by the report and budget command surfaces.
1501
+
1502
+ ``speed`` (#1050 spec 4.7) is a tier that resolver already returned this
1503
+ tick — the dashboard captures it beside its dispatch read and keys the idle
1504
+ gate on it — so the build uses that one value; ``None`` resolves here.
1500
1505
  """
1501
1506
  c = sys.modules["cctally"]
1502
1507
  raw_config = dict(config or {})
1503
1508
  week_start_name = get_week_start_name(raw_config)
1504
1509
  week_start_idx = c.WEEKDAY_MAP[week_start_name]
1505
- speed = c._resolve_codex_speed("auto")
1510
+ if speed is None:
1511
+ speed = c._resolve_codex_speed("auto")
1506
1512
  budget_config = c._get_budget_config(raw_config)
1507
1513
  quota_alerts = c._get_quota_alerts_config(raw_config)
1508
1514
  raw_cache_report = raw_config.get("cache_report")
@@ -5929,6 +5935,20 @@ def reset_codex_account_scope_cache(*, keep_counters: bool = False) -> None:
5929
5935
  _CODEX_DERIVED_COHERENCE.clear()
5930
5936
 
5931
5937
 
5938
+ def _codex_cycle_index_cost_inputs(context: "DashboardReadContext") -> dict:
5939
+ """The cost inputs every Codex cycle stamp names (#1050 spec 4.7): this
5940
+ read's cache handle and resolved speed, and — when the build computed it
5941
+ for its own signature — the `codex_stats_digest` that admits the index's
5942
+ process memo."""
5943
+ stats_identity = context.stats_identity
5944
+ return {
5945
+ "cache_conn": context.cache_conn,
5946
+ "speed": context.speed,
5947
+ "stats_digest": (
5948
+ stats_identity[0] if stats_identity is not None else None),
5949
+ }
5950
+
5951
+
5932
5952
  def _codex_account_scopes_wire(
5933
5953
  context: DashboardReadContext,
5934
5954
  *,
@@ -6060,6 +6080,7 @@ def _codex_account_scopes_wire(
6060
6080
  sys.modules["cctally"].build_codex_cycle_index(
6061
6081
  context.stats_conn, identity=cycle,
6062
6082
  now_utc=context.now_utc, account_key=key,
6083
+ **_codex_cycle_index_cost_inputs(context),
6063
6084
  )
6064
6085
  )
6065
6086
  except sqlite3.Error:
@@ -8704,6 +8725,7 @@ def _build_codex_source_state(
8704
8725
  cycle_index = tuple(
8705
8726
  sys.modules["cctally"].build_codex_cycle_index(
8706
8727
  context.stats_conn, identity=cycle, now_utc=context.now_utc,
8728
+ **_codex_cycle_index_cost_inputs(context),
8707
8729
  )
8708
8730
  )
8709
8731
  except sqlite3.Error: