cctally 1.95.4 → 1.96.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.
@@ -284,6 +284,32 @@ def _share_period_label(
284
284
  )
285
285
 
286
286
 
287
+ def _share_resolve_period_start(
288
+ period_start: dt.datetime,
289
+ *,
290
+ since_explicit: bool,
291
+ displayed_dates: "list[dt.date]",
292
+ display_tz: str,
293
+ ) -> dt.datetime:
294
+ """Keep a requested start, otherwise start at the first displayed day.
295
+
296
+ ``_parse_cli_date_range`` uses 2020-01-01 as the query sentinel when
297
+ ``--since`` is absent. That remains the correct read bound, but it is not
298
+ a period the user selected. Share artifacts therefore replace only that
299
+ defaulted presentation bound with the first civil day they actually
300
+ render. Empty artifacts retain the query bound because there is no
301
+ content-derived boundary to state (#527).
302
+ """
303
+ if since_explicit or not displayed_dates:
304
+ return period_start
305
+ first = min(displayed_dates)
306
+ zone = _resolve_tz(
307
+ display_tz,
308
+ fallback=period_start.tzinfo or dt.timezone.utc,
309
+ )
310
+ return dt.datetime(first.year, first.month, first.day, tzinfo=zone)
311
+
312
+
287
313
  def _share_display_zone(tz: "ZoneInfo | None"):
288
314
  """The concrete zone `_share_display_tz_label` names, as a tzinfo.
289
315
 
@@ -484,6 +510,7 @@ def _build_daily_snapshot(
484
510
  period_end: dt.datetime,
485
511
  display_tz: str,
486
512
  version: str,
513
+ since_explicit: bool,
487
514
  ) -> "ShareSnapshot":
488
515
  """Build a ShareSnapshot for `cctally daily`.
489
516
 
@@ -504,11 +531,12 @@ def _build_daily_snapshot(
504
531
  - `top_model` is the first entry of `model_breakdowns` (sorted by cost
505
532
  desc per upstream ccusage parity); empty → "—".
506
533
 
507
- `period_start` / `period_end` / `display_tz` are passed by the
508
- caller (they reflect the CLI's `--since` / `--until` window which
509
- may extend past the data window). `theme` and `reveal_projects`
510
- flow into the subtitle directly so the builder owns the canonical
511
- subtitle shape no post-build re-stamp at the gate site.
534
+ `period_start` / `period_end` / `display_tz` are passed by the caller.
535
+ An explicit range may extend past the data window; when `--since` was
536
+ omitted, `since_explicit=False` replaces only the share artifact's
537
+ sentinel start with the first displayed day (#527). `theme` and
538
+ `reveal_projects` flow into the subtitle directly so the builder owns the
539
+ canonical subtitle shape — no post-build re-stamp at the gate site.
512
540
  """
513
541
  _lib_share = _share_load_lib()
514
542
  columns = (
@@ -528,13 +556,16 @@ def _build_daily_snapshot(
528
556
 
529
557
  snap_rows: list = []
530
558
  chart_pts: list = []
559
+ displayed_period_dates: list[dt.date] = []
531
560
  for i, r in enumerate(rows):
532
561
  # `BucketUsage.bucket` is typed `str` (YYYY-MM-DD); guard against
533
562
  # empty / unparseable but skip the dead `dt.date` branch.
534
563
  bucket = getattr(r, "bucket", None)
535
564
  if isinstance(bucket, str) and bucket:
536
565
  try:
537
- date_str = dt.date.fromisoformat(bucket).isoformat()
566
+ parsed_date = dt.date.fromisoformat(bucket)
567
+ date_str = parsed_date.isoformat()
568
+ displayed_period_dates.append(parsed_date)
538
569
  except ValueError:
539
570
  date_str = bucket
540
571
  else:
@@ -564,6 +595,12 @@ def _build_daily_snapshot(
564
595
  _lib_share.Totalled(label="Days", value=str(len(chart_pts))),
565
596
  _lib_share.Totalled(label="Avg / day", value=f"${avg_cost:,.2f}"),
566
597
  )
598
+ period_start = _share_resolve_period_start(
599
+ period_start,
600
+ since_explicit=since_explicit,
601
+ displayed_dates=displayed_period_dates,
602
+ display_tz=display_tz,
603
+ )
567
604
  if rows:
568
605
  title = (
569
606
  f"Daily usage — {period_start.date().isoformat()} → "
@@ -595,6 +632,7 @@ def _build_monthly_snapshot(
595
632
  period_end: dt.datetime,
596
633
  display_tz: str,
597
634
  version: str,
635
+ since_explicit: bool,
598
636
  ) -> "ShareSnapshot":
599
637
  """Build a ShareSnapshot for `cctally monthly`.
600
638
 
@@ -613,11 +651,12 @@ def _build_monthly_snapshot(
613
651
  - `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
614
652
  months, matching the plan's intent.
615
653
 
616
- `period_start` / `period_end` / `display_tz` are passed by the
617
- caller (the CLI's `--since` / `--until` window may extend past
618
- the data window). `theme` / `reveal_projects` flow into the
619
- subtitle directly so the builder owns the canonical subtitle
620
- shape no post-build re-stamp at the gate site.
654
+ `period_start` / `period_end` / `display_tz` are passed by the caller.
655
+ An explicit range may extend past the data window; when `--since` was
656
+ omitted, `since_explicit=False` replaces only the share artifact's
657
+ sentinel start with the first displayed month (#527). `theme` /
658
+ `reveal_projects` flow into the subtitle directly so the builder owns the
659
+ canonical subtitle shape — no post-build re-stamp at the gate site.
621
660
  """
622
661
  # Caller MUST pass rows in chronological order so the BarChart bars
623
662
  # line up left-to-right with time. view.aggregated is newest-first.
@@ -632,12 +671,20 @@ def _build_monthly_snapshot(
632
671
  )
633
672
  snap_rows: list = []
634
673
  chart_pts: list = []
674
+ displayed_period_dates: list[dt.date] = []
635
675
  prev_cost: float | None = None
636
676
  for i, r in enumerate(rows):
637
677
  # `BucketUsage.bucket` is typed `str` ("YYYY-MM"); guard against
638
678
  # empty / unparseable but skip the dead `dt.date` branch.
639
679
  bucket = getattr(r, "bucket", None)
640
680
  month_str = bucket if isinstance(bucket, str) and bucket else "—"
681
+ if isinstance(bucket, str):
682
+ try:
683
+ displayed_period_dates.append(
684
+ dt.date.fromisoformat(f"{bucket}-01")
685
+ )
686
+ except ValueError:
687
+ pass
641
688
  cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
642
689
  total_tokens = int(getattr(r, "total_tokens", 0) or 0)
643
690
  if prev_cost is not None and prev_cost > 0:
@@ -668,6 +715,12 @@ def _build_monthly_snapshot(
668
715
  _lib_share.Totalled(label="Months", value=str(len(chart_pts))),
669
716
  _lib_share.Totalled(label="Avg / month", value=f"${avg_cost:,.2f}"),
670
717
  )
718
+ period_start = _share_resolve_period_start(
719
+ period_start,
720
+ since_explicit=since_explicit,
721
+ displayed_dates=displayed_period_dates,
722
+ display_tz=display_tz,
723
+ )
671
724
  if rows:
672
725
  title = (
673
726
  f"Monthly usage — {period_start.strftime('%Y-%m')} → "
@@ -703,6 +756,7 @@ def _build_weekly_snapshot(
703
756
  display_tz: str,
704
757
  version: str,
705
758
  breakdown_model: bool,
759
+ since_explicit: bool,
706
760
  ) -> "ShareSnapshot":
707
761
  """Build a ShareSnapshot for `cctally weekly`.
708
762
 
@@ -780,6 +834,7 @@ def _build_weekly_snapshot(
780
834
 
781
835
  snap_rows: list = []
782
836
  chart_pts: list = []
837
+ displayed_period_dates: list[dt.date] = []
783
838
  stacks: dict[str, list] = {}
784
839
  for i, r in enumerate(rows):
785
840
  # `BucketUsage.bucket` is typed `str` ("YYYY-MM-DD"); guard against
@@ -787,7 +842,9 @@ def _build_weekly_snapshot(
787
842
  bucket = getattr(r, "bucket", None)
788
843
  if isinstance(bucket, str) and bucket:
789
844
  try:
790
- week_label = dt.date.fromisoformat(bucket).isoformat()
845
+ parsed_date = dt.date.fromisoformat(bucket)
846
+ week_label = parsed_date.isoformat()
847
+ displayed_period_dates.append(parsed_date)
791
848
  except ValueError:
792
849
  week_label = bucket
793
850
  else:
@@ -852,6 +909,12 @@ def _build_weekly_snapshot(
852
909
  _lib_share.Totalled(label="Avg %/wk", value=f"{avg_pct:.1f}%"),
853
910
  _lib_share.Totalled(label="Peak %", value=f"{peak_pct:.1f}%"),
854
911
  )
912
+ period_start = _share_resolve_period_start(
913
+ period_start,
914
+ since_explicit=since_explicit,
915
+ displayed_dates=displayed_period_dates,
916
+ display_tz=display_tz,
917
+ )
855
918
  title = (
856
919
  f"Weekly usage — last {len(rows)} weeks"
857
920
  if rows
@@ -1438,6 +1501,7 @@ def _build_session_snapshot(
1438
1501
  version: str,
1439
1502
  top_n: int | None,
1440
1503
  tz: "ZoneInfo | None",
1504
+ since_explicit: bool,
1441
1505
  ) -> "ShareSnapshot":
1442
1506
  """Build a ShareSnapshot for `cctally session`.
1443
1507
 
@@ -1528,6 +1592,7 @@ def _build_session_snapshot(
1528
1592
  augmented = _session_disambiguate_labels(sorted_sessions)
1529
1593
  snap_rows: list = []
1530
1594
  chart_pts: list = []
1595
+ displayed_period_dates: list[dt.date] = []
1531
1596
  for idx, s in enumerate(sorted_sessions):
1532
1597
  bare_label = (
1533
1598
  os.path.basename(s.project_path or "")
@@ -1545,6 +1610,9 @@ def _build_session_snapshot(
1545
1610
  last_str = format_display_dt(
1546
1611
  s.last_activity, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
1547
1612
  )
1613
+ displayed_period_dates.append(
1614
+ s.last_activity.astimezone(_share_display_zone(tz)).date()
1615
+ )
1548
1616
  models_text = ", ".join(s.models) if s.models else "—"
1549
1617
  snap_rows.append(_lib_share.Row(cells={
1550
1618
  "session": _lib_share.TextCell(sid_short),
@@ -1576,6 +1644,12 @@ def _build_session_snapshot(
1576
1644
  _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
1577
1645
  _lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
1578
1646
  )
1647
+ period_start = _share_resolve_period_start(
1648
+ period_start,
1649
+ since_explicit=since_explicit,
1650
+ displayed_dates=displayed_period_dates,
1651
+ display_tz=display_tz,
1652
+ )
1579
1653
  if sorted_sessions:
1580
1654
  if truncated:
1581
1655
  title = f"Top {len(snap_rows)} sessions"
@@ -748,7 +748,7 @@ def stats_open_time_guard(
748
748
  except (BlockingIOError, OSError):
749
749
  if time.monotonic() >= deadline:
750
750
  raise _cctally_db.StatsDbMaintenanceError(
751
- _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
751
+ _stats_open_maintenance_timeout_message()
752
752
  )
753
753
  time.sleep(0.02)
754
754
  _cctally_core.note_stats_maintenance_acquired()
@@ -829,6 +829,18 @@ _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG = (
829
829
  )
830
830
 
831
831
 
832
+ def _stats_open_maintenance_timeout_message() -> str:
833
+ """Name a confirmed detached rebuild when it owns the maintenance wait."""
834
+ request = _read_stats_heal_request()
835
+ if (
836
+ request
837
+ and request.get("forensicsDisposition") == "confirmed"
838
+ and _stats_heal_worker_active()
839
+ ):
840
+ return str(_cctally_db.StatsHealDeferred("pending"))
841
+ return _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
842
+
843
+
832
844
  def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
833
845
  """Poll for ``operation`` on ``lock_fh`` until ``timeout_s`` expires.
834
846
 
@@ -953,10 +965,10 @@ def _pending_stats_publication_never_replaced(db_path) -> bool:
953
965
  reads that as "replaced". The proxy is used only across processes, where
954
966
  that cleanup cannot reach, so the weaker property is the one it needs.
955
967
 
956
- A marker carrying no `scratchPath` cannot prove it published, so it is
957
- treated as never-replaced. No released binary has ever written one — the
958
- marker and this field ship together — so the branch exists only to keep an
959
- unreadable marker from wedging every open.
968
+ A marker carrying no `scratchPath` proves neither outcome and therefore
969
+ still owes a verdict. No released binary has ever written one — the marker
970
+ and this field ship together — but failing closed keeps that malformed
971
+ state from silently accepting unvalidated live bytes.
960
972
 
961
973
  **In-place publication** answers with the publication's own stamp, because
962
974
  it attaches the scratch read-only and the scratch survives commit and
@@ -973,16 +985,9 @@ def _pending_stats_publication_never_replaced(db_path) -> bool:
973
985
  return True
974
986
  if str(state.get("status") or "") != "pending":
975
987
  return False
976
- if str(state.get("mechanism") or "replace") == "in_place":
977
- import _cctally_journal
988
+ import _cctally_journal
978
989
 
979
- return _cctally_journal.in_place_publication_proven_predecessor(
980
- db_path, state
981
- )
982
- scratch = state.get("scratchPath")
983
- if not isinstance(scratch, str) or not scratch:
984
- return True
985
- return pathlib.Path(scratch).exists()
990
+ return _cctally_journal._pending_publication_owes_nothing(db_path, state)
986
991
 
987
992
 
988
993
  def _stats_publication_failed_error(
@@ -1411,7 +1416,7 @@ def stats_open_guarded(
1411
1416
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1412
1417
  ):
1413
1418
  raise _cctally_db.StatsDbMaintenanceError(
1414
- _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
1419
+ _stats_open_maintenance_timeout_message()
1415
1420
  )
1416
1421
  if marker.exists():
1417
1422
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
@@ -1425,7 +1430,7 @@ def stats_open_guarded(
1425
1430
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1426
1431
  ):
1427
1432
  raise _cctally_db.StatsDbMaintenanceError(
1428
- _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
1433
+ _stats_open_maintenance_timeout_message()
1429
1434
  )
1430
1435
  try:
1431
1436
  if marker.exists():
@@ -1450,7 +1455,7 @@ def stats_open_guarded(
1450
1455
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1451
1456
  ):
1452
1457
  raise _cctally_db.StatsDbMaintenanceError(
1453
- _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
1458
+ _stats_open_maintenance_timeout_message()
1454
1459
  )
1455
1460
  # Recovery calls `rebuild_stats_index`, whose in-place publisher
1456
1461
  # reopens the live destination through `stats_open_guarded`.
@@ -1496,7 +1501,7 @@ def stats_open_guarded(
1496
1501
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1497
1502
  ):
1498
1503
  raise _cctally_db.StatsDbMaintenanceError(
1499
- _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
1504
+ _stats_open_maintenance_timeout_message()
1500
1505
  )
1501
1506
  if marker.exists() or pending.exists():
1502
1507
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
@@ -1516,7 +1521,7 @@ def stats_open_guarded(
1516
1521
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1517
1522
  ):
1518
1523
  raise _cctally_db.StatsDbMaintenanceError(
1519
- _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
1524
+ _stats_open_maintenance_timeout_message()
1520
1525
  )
1521
1526
  try:
1522
1527
  _resolve_stats_publication_marker(db_path)
@@ -1526,7 +1531,7 @@ def stats_open_guarded(
1526
1531
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1527
1532
  ):
1528
1533
  raise _cctally_db.StatsDbMaintenanceError(
1529
- _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
1534
+ _stats_open_maintenance_timeout_message()
1530
1535
  )
1531
1536
  if marker.exists() or pending.exists():
1532
1537
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
@@ -2538,7 +2543,7 @@ def cmd_stats_corruption_heal_internal(args) -> int:
2538
2543
  heal_id = str(request.get("healId") or "")
2539
2544
  try:
2540
2545
  outcome = _run_stats_corruption_heal(request)
2541
- except Exception as exc:
2546
+ except (_cctally_db.StatsRebuildDeferred, Exception) as exc:
2542
2547
  # Retryable: the marker stays so a later detection is admitted
2543
2548
  # once its retry window expires.
2544
2549
  _record_stats_heal_outcome(heal_id, "failed", error=exc)
@@ -4304,10 +4304,18 @@ def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
4304
4304
  )
4305
4305
 
4306
4306
 
4307
+ def _stats_open_failure_is_corruption(exc: BaseException) -> bool:
4308
+ """Keep epoch deferral distinct from heal deferral and read failures."""
4309
+ c = _cctally()
4310
+ if isinstance(exc, c.StatsRebuildDeferred):
4311
+ return isinstance(exc, c.StatsHealDeferred)
4312
+ return True
4313
+
4314
+
4307
4315
  def _tui_stats_retry_degraded_snapshot(
4308
4316
  *,
4309
4317
  now_utc: dt.datetime,
4310
- exc: Exception,
4318
+ exc: BaseException,
4311
4319
  precompute_envelope: bool,
4312
4320
  runtime_bind: "str | None",
4313
4321
  ) -> DataSnapshot:
@@ -4333,7 +4341,7 @@ def _tui_stats_retry_degraded_snapshot(
4333
4341
  SyncFailureAttribution(
4334
4342
  leg="stats-open",
4335
4343
  database="stats",
4336
- corruption=True,
4344
+ corruption=_stats_open_failure_is_corruption(exc),
4337
4345
  ),
4338
4346
  ),
4339
4347
  doctor_payload=doctor_payload,
@@ -6724,9 +6732,7 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6724
6732
  SyncFailureAttribution(
6725
6733
  leg="stats-open",
6726
6734
  database="stats",
6727
- corruption=isinstance(
6728
- exc, _cctally().StatsHealDeferred
6729
- ),
6735
+ corruption=_stats_open_failure_is_corruption(exc),
6730
6736
  ),
6731
6737
  ),
6732
6738
  generated_at=dt.datetime.now(dt.timezone.utc),
@@ -0,0 +1,86 @@
1
+ """Which settings leaves ``POST /api/settings`` may write.
2
+
3
+ Single source of truth for the dashboard settings contract (#513 S1). Kept
4
+ pure and dependency-free -- it imports nothing -- so the documentation test
5
+ can read the contract without loading the dashboard, and so there is no cycle
6
+ with ``_cctally_config``.
7
+
8
+ Three states, not two. A leaf the endpoint persists is ``WRITABLE``. A leaf
9
+ it deliberately accepts and does not persist is ``KNOWN_IGNORED``: there are
10
+ exactly four, pinned by #134 (the Codex partial-merge contract, where amounts
11
+ stay CLI-only) and #143 (``budget.period`` drives the forward-only reconcile
12
+ without being stored here). Anything absent from the map is unknown to this
13
+ endpoint and is rejected with the offending dotted path as ``field``.
14
+
15
+ The third state matters because two states would force ``budget.period`` to
16
+ be described as either writable or rejected, and it is neither.
17
+ """
18
+
19
+ WRITABLE = "writable"
20
+ KNOWN_IGNORED = "known_ignored"
21
+
22
+ #: Fully-qualified dotted path -> disposition. Order is the endpoint's block
23
+ #: order and is not significant; membership is.
24
+ SETTINGS_LEAF_DISPOSITIONS = {
25
+ "display.tz": WRITABLE,
26
+ "alerts.enabled": WRITABLE,
27
+ "alerts.projected_enabled": WRITABLE,
28
+ "alerts.notifier": WRITABLE,
29
+ "dashboard.cache_failure_markers": WRITABLE,
30
+ "dashboard.live_tail": WRITABLE,
31
+ "dashboard.lan_auth": WRITABLE,
32
+ "update.check.enabled": WRITABLE,
33
+ "update.check.ttl_hours": WRITABLE,
34
+ "update.channel": WRITABLE,
35
+ "cache_report.anomaly_threshold_pp": WRITABLE,
36
+ "budget.weekly_usd": WRITABLE,
37
+ "budget.alerts_enabled": WRITABLE,
38
+ "budget.alert_thresholds": WRITABLE,
39
+ "budget.projected_enabled": WRITABLE,
40
+ "budget.project_alerts_enabled": WRITABLE,
41
+ "budget.codex.alerts_enabled": WRITABLE,
42
+ "budget.codex.projected_enabled": WRITABLE,
43
+ # #143: answered 200 and drives the forward-only reconcile, never stored.
44
+ "budget.period": KNOWN_IGNORED,
45
+ # #134: CLI-only amounts, preserved from the persisted block on a merge.
46
+ "budget.codex.amount_usd": KNOWN_IGNORED,
47
+ "budget.codex.period": KNOWN_IGNORED,
48
+ "budget.codex.alert_thresholds": KNOWN_IGNORED,
49
+ }
50
+
51
+
52
+ def _ancestors(path):
53
+ """Yield every proper dotted prefix of ``path``, outermost first."""
54
+ parts = path.split(".")
55
+ for i in range(1, len(parts)):
56
+ yield ".".join(parts[:i])
57
+
58
+
59
+ #: Every interior path a request may descend through, derived from the leaf
60
+ #: paths so a new leaf can never forget to register its parent.
61
+ SETTINGS_OBJECT_PATHS = frozenset(
62
+ parent
63
+ for path in SETTINGS_LEAF_DISPOSITIONS
64
+ for parent in _ancestors(path)
65
+ )
66
+
67
+ #: The six blocks the endpoint accepts at the top level.
68
+ SETTINGS_TOP_LEVEL_BLOCKS = frozenset(
69
+ path.split(".")[0] for path in SETTINGS_LEAF_DISPOSITIONS
70
+ )
71
+
72
+ #: Blocks that are NOT a valid no-op when sent empty. A named block carrying
73
+ #: no leaves is an ordinary partial-PUT no-op everywhere else -- notably
74
+ #: ``{"cache_report": {}}``, which is what a combined save sends when the user
75
+ #: never opened that tab, and which must keep answering 200.
76
+ SETTINGS_REQUIRED_LEAVES = {"display": frozenset({"display.tz"})}
77
+
78
+
79
+ def disposition_for(path):
80
+ """Return ``WRITABLE``, ``KNOWN_IGNORED``, or ``None`` for an unknown path."""
81
+ return SETTINGS_LEAF_DISPOSITIONS.get(path)
82
+
83
+
84
+ def is_object_path(path):
85
+ """True when ``path`` is an interior node a request may descend through."""
86
+ return path in SETTINGS_OBJECT_PATHS
@@ -259,8 +259,10 @@ def reusable_bootstrap_name(candidate_digest, candidate_size, existing):
259
259
 
260
260
  Reusing an older match instead would stamp the cursor behind a bootstrap the
261
261
  cursor does not cover, and the next ingest would fold that stale bootstrap's
262
- records into stats.db. Writing a fresh segment is the pre-reuse behaviour and
263
- restores the pre-reuse invariant, because a minted name always sorts last.
262
+ records into stats.db. Writing a fresh segment invokes the caller's
263
+ monotonic name selector, which compares wall time with the published
264
+ bootstrap set and mints after the canonically newest segment when the clock
265
+ moved backwards (#509).
264
266
 
265
267
  Returns None when the newest bootstrap does not match, which covers the
266
268
  ordinary first-cutover path, the genuinely-differing-export path, and the
@@ -28,6 +28,8 @@ Sibling dependencies (loaded at module-load time via ``_load_lib``):
28
28
  used across every breakdown-aware table).
29
29
  * ``_lib_display_tz`` — ``_resolve_tz`` (IANA tz resolution for the
30
30
  Codex session-table date columns).
31
+ * ``_lib_share`` — ``disambiguate_basenames`` (the single project-label
32
+ collision algorithm shared with artifact preparation).
31
33
 
32
34
  ``bin/cctally`` back-references via module-level callable shims
33
35
  (spec §5.5; same precedent as ``bin/_cctally_record.py``'s 34 shims):
@@ -98,6 +100,7 @@ _lib_pricing = _load_lib("_lib_pricing")
98
100
  _short_model_name = _lib_pricing._short_model_name
99
101
 
100
102
  _lib_display_tz = _load_lib("_lib_display_tz")
103
+ _lib_share = _load_lib("_lib_share")
101
104
  _resolve_tz = _lib_display_tz._resolve_tz
102
105
 
103
106
  # JSON wire-format kernel — the additive camelCase schemaVersion stamp
@@ -2722,11 +2725,11 @@ def _project_disambiguate_labels(rows: list[dict]) -> dict[int, str]:
2722
2725
  """Return ``{row_index: disambiguated_label}`` for project rows whose
2723
2726
  bare ``display_key`` collides with another row's basename.
2724
2727
 
2725
- When two projects share a basename (e.g., two ``app`` directories under
2726
- different parents), suffix the colliding rows with the parent-directory
2727
- segment ("(work)" / "(personal)") so they remain visually and
2728
- semantically distinct. Prefer ``key.git_root`` as the disambiguation
2729
- source when present; fall back to ``key.bucket_path`` for no-git rows.
2728
+ ``_lib_share.disambiguate_basenames`` owns the collision algorithm. This
2729
+ adapter identifies the colliding project identities in the row-shaped
2730
+ renderer input, deduplicates repeated rows for one canonical bucket, and
2731
+ fans the kernel labels back out. Prefer ``key.git_root`` as the kernel
2732
+ path when present; fall back to ``key.bucket_path`` for no-git rows.
2730
2733
 
2731
2734
  Used by:
2732
2735
  - ``_render_project_table`` (terminal table render).
@@ -2737,20 +2740,45 @@ def _project_disambiguate_labels(rows: list[dict]) -> dict[int, str]:
2737
2740
  Rows that do not collide are absent from the returned dict; callers
2738
2741
  fall back to ``key.display_key`` for those.
2739
2742
  """
2740
- display_counts: dict[str, int] = {}
2741
- for r in rows:
2742
- dk = r["key"].display_key
2743
- display_counts[dk] = display_counts.get(dk, 0) + 1
2744
- augmented: dict[int, str] = {}
2745
- for idx, r in enumerate(rows):
2746
- if display_counts[r["key"].display_key] > 1:
2747
- source_path = r["key"].git_root or r["key"].bucket_path
2748
- if source_path:
2749
- parent = (
2750
- os.path.basename(os.path.dirname(source_path)) or "/"
2751
- )
2752
- augmented[idx] = f"{r['key'].display_key} ({parent})"
2753
- return augmented
2743
+ identity_to_unique: dict[str, int] = {}
2744
+ unique_projects: list[tuple[str, str]] = []
2745
+ row_unique_indices: list[int] = []
2746
+ display_groups: dict[str, list[int]] = {}
2747
+
2748
+ for row in rows:
2749
+ key = row["key"]
2750
+ identity = key.bucket_path
2751
+ unique_idx = identity_to_unique.get(identity)
2752
+ if unique_idx is None:
2753
+ unique_idx = len(unique_projects)
2754
+ identity_to_unique[identity] = unique_idx
2755
+ source_path = key.git_root or key.bucket_path
2756
+ unique_projects.append((key.display_key, source_path))
2757
+ display_groups.setdefault(key.display_key, []).append(unique_idx)
2758
+ row_unique_indices.append(unique_idx)
2759
+
2760
+ colliding_unique = {
2761
+ unique_idx
2762
+ for unique_indices in display_groups.values()
2763
+ if len(unique_indices) > 1
2764
+ for unique_idx in unique_indices
2765
+ }
2766
+ if not colliding_unique:
2767
+ return {}
2768
+
2769
+ ordered_unique = sorted(colliding_unique)
2770
+ kernel_labels = _lib_share.disambiguate_basenames(
2771
+ [unique_projects[unique_idx][1] for unique_idx in ordered_unique]
2772
+ )
2773
+ labels_by_unique = {
2774
+ unique_idx: kernel_labels[kernel_idx]
2775
+ for kernel_idx, unique_idx in enumerate(ordered_unique)
2776
+ }
2777
+ return {
2778
+ row_idx: labels_by_unique[unique_idx]
2779
+ for row_idx, unique_idx in enumerate(row_unique_indices)
2780
+ if unique_idx in labels_by_unique
2781
+ }
2754
2782
 
2755
2783
 
2756
2784
  def _render_project_table(
@@ -3214,4 +3242,3 @@ def _render_five_hour_blocks_table(
3214
3242
  print(_boxed_table(headers, rows, aligns, compact=args.compact))
3215
3243
  glyph = " · ⚡ = block crossed weekly reset" if has_crossed else ""
3216
3244
  print(f"\n{len(block_dicts)} blocks · cost: ${total_cost:.2f}{glyph}")
3217
-