cctally 1.82.0 → 1.82.1

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.
@@ -43,9 +43,11 @@ from _lib_quota import (
43
43
  build_blocks,
44
44
  build_history,
45
45
  forecast_quota,
46
+ latest_physical_observation,
46
47
  percent_milestones,
47
48
  quota_freshness,
48
49
  select_baseline,
50
+ stale_after_seconds,
49
51
  )
50
52
  from _lib_jsonl import CodexEntry, codex_model_scoped_quota_pool
51
53
  from _lib_fmt import stable_sum
@@ -140,6 +142,10 @@ class CodexCycleBoundary:
140
142
  # Exact server-side quota identity selected for the hero. It is never
141
143
  # serialized; milestone-history keys hash it opaquely.
142
144
  quota_identity: QuotaWindowIdentity | None = None
145
+ # #350: whether this boundary won the §3.2 ranking on STALE evidence (no
146
+ # fresh boundary existed for the account). Backward-looking actuals stay
147
+ # bounded, but the hero discloses it through ``hero.cycle_freshness``.
148
+ evidence_stale: bool = False
143
149
 
144
150
 
145
151
  @dataclass(frozen=True)
@@ -180,10 +186,29 @@ def _resolve_codex_weekly_cycle(
180
186
  Raises ``CodexCycleUnavailable`` only when NO account yields a live cycle. A
181
187
  single-account install returns a 1-element list = today's boundary
182
188
  byte-for-byte (the hero path is unchanged, spec R8).
189
+
190
+ #350 — FRESH-FIRST ranking (spec §3.2). Codex has no background quota poll,
191
+ so ``stale_after_seconds(10_080) == 3600`` makes an idle weekly observation
192
+ stale after exactly one hour. Discarding a stale-but-FUTURE boundary blanked
193
+ the hero's backward-looking actuals even though the spend was never lost, so
194
+ each account's future weekly boundaries are now collected into a fresh set
195
+ and a stale set and ranked:
196
+
197
+ 1. exactly one FRESH boundary -> valid, cycle fresh;
198
+ 2. else, no fresh boundaries and exactly one STALE boundary -> valid, cycle
199
+ stale (``CodexCycleBoundary.evidence_stale``, surfaced as the additive
200
+ ``hero.cycle_freshness``);
201
+ 3. else -> invalid, with today's ``conflicting``/``stale``/``missing`` reason.
202
+
203
+ Fresh-first ordering is load-bearing: a flat count over the union would
204
+ regress one-fresh-plus-one-stale, which resolves valid today. Only the EXACT
205
+ ``"stale"`` freshness state is eligible — ``"future"`` (a capture ahead of
206
+ ``now``) and ``"unavailable"`` stay invalid and keep today's reason.
183
207
  """
184
- per_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
208
+ fresh_by_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
209
+ stale_by_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
185
210
  accounts_seen: set[str] = set()
186
- stale_by_account: dict[str, bool] = {}
211
+ ineligible_by_account: dict[str, bool] = {}
187
212
  for history in build_history(tuple(observations)):
188
213
  if history.identity.window_minutes != 10_080:
189
214
  continue
@@ -194,20 +219,33 @@ def _resolve_codex_weekly_cycle(
194
219
  baseline = select_baseline(history.observations, now_utc)
195
220
  if baseline is None or baseline.resets_at <= now_utc:
196
221
  continue
197
- if quota_freshness(history.physical_observations, now_utc).state != "fresh":
198
- stale_by_account[account] = True
199
- continue
222
+ state = quota_freshness(history.physical_observations, now_utc).state
200
223
  boundary = (history.identity.window_minutes, baseline.resets_at)
201
- per_account.setdefault(account, {}).setdefault(boundary, []).append((history, baseline))
224
+ if state == "fresh":
225
+ bucket = fresh_by_account
226
+ elif state == "stale":
227
+ bucket = stale_by_account
228
+ else:
229
+ # "future"/"unavailable" evidence is never a stale fallback; it keeps
230
+ # today's non-fresh reason so the envelope degrades exactly as before.
231
+ ineligible_by_account[account] = True
232
+ continue
233
+ bucket.setdefault(account, {}).setdefault(boundary, []).append((history, baseline))
202
234
  cycles: list[CodexCycleBoundary] = []
203
235
  reasons: list[str] = []
204
- for account in sorted(accounts_seen | set(per_account)):
205
- boundaries = per_account.get(account, {})
206
- if len(boundaries) != 1:
207
- # Within one account: 0 boundaries -> stale/missing; >=2 -> conflicting.
236
+ for account in sorted(accounts_seen | set(fresh_by_account) | set(stale_by_account)):
237
+ fresh_boundaries = fresh_by_account.get(account, {})
238
+ stale_boundaries = stale_by_account.get(account, {})
239
+ if len(fresh_boundaries) == 1:
240
+ boundaries, evidence_stale = fresh_boundaries, False
241
+ elif not fresh_boundaries and len(stale_boundaries) == 1:
242
+ boundaries, evidence_stale = stale_boundaries, True
243
+ else:
244
+ # Within one account: >=2 fresh, or no fresh and >=2 stale ->
245
+ # conflicting; nothing eligible -> today's stale/missing reason.
208
246
  reasons.append(
209
- "conflicting" if boundaries
210
- else ("stale" if stale_by_account.get(account) else "missing")
247
+ "conflicting" if (fresh_boundaries or stale_boundaries)
248
+ else ("stale" if ineligible_by_account.get(account) else "missing")
211
249
  )
212
250
  continue
213
251
  (window_minutes, resets_at), candidates = next(iter(boundaries.items()))
@@ -232,6 +270,7 @@ def _resolve_codex_weekly_cycle(
232
270
  source_root_keys=(selected_identity.source_root_key,),
233
271
  used_percent=float(baseline.used_percent),
234
272
  quota_identity=selected_identity,
273
+ evidence_stale=evidence_stale,
235
274
  ))
236
275
  if not cycles:
237
276
  # Aggregate reason: for a single account this is exactly the old reason
@@ -244,6 +283,97 @@ def _resolve_codex_weekly_cycle(
244
283
  return cycles
245
284
 
246
285
 
286
+ def _codex_next_decision_at(
287
+ observations: Iterable[object],
288
+ cycles: Iterable[CodexCycleBoundary],
289
+ now_utc: dt.datetime,
290
+ ) -> dt.datetime | None:
291
+ """The earliest future instant at which weekly-cycle resolution can change.
292
+
293
+ #350 spec §3.3. Cycle validity is time-dependent even on FROZEN evidence:
294
+ ``_resolve_codex_weekly_cycle`` passes ``now_utc`` to both ``select_baseline``
295
+ (a future-dated capture becomes baseline-eligible purely because time passed,
296
+ which can switch the selected reset) and ``quota_freshness`` (fresh flips to
297
+ stale as age crosses ``stale_after_seconds``). One fresh plus one stale
298
+ boundary resolves FRESH today and ``conflicting`` an hour later on the very
299
+ same rows. The idle clock cannot re-resolve that itself — the public
300
+ histories it sees are capped at ``SOURCE_HISTORY_LIMIT`` and omit
301
+ ``logical_limit_key`` (§2.3) — so build time instead records WHEN the clock
302
+ must stop trusting its verdict, and the tick rebuilds authoritatively at the
303
+ crossing. That is one rebuild per deadline (a handful per weekly cycle), not
304
+ one per tick.
305
+
306
+ The deadline is the ``min`` of three candidate kinds, dropping any candidate
307
+ at or before ``now_utc``:
308
+
309
+ 1. every selected cycle's ``resets_at`` (expiry — including the
310
+ #341 multi-account case where account A expires while B stays live);
311
+ 2. ``latest_physical_capture + stale_after_seconds(window)`` for every weekly
312
+ history with a live baseline (fresh -> stale). This is a deliberate
313
+ SUPERSET of the §3.2 ranking participants — it also covers histories whose
314
+ freshness is ``"future"``/``"unavailable"`` and so never enter the ranking
315
+ — because an extra candidate can only pull the deadline EARLIER, and an
316
+ earlier deadline is always the conservative direction;
317
+ 3. the ``captured_at`` of any future-dated weekly observation
318
+ (future -> fresh / baseline eligibility).
319
+
320
+ Returns ``None`` when nothing can flip. Server-only: it rides ``clock_data``
321
+ and never reaches the public source envelope.
322
+ """
323
+ candidates: list[dt.datetime] = []
324
+ for cycle in cycles:
325
+ candidates.append(cycle.resets_at.astimezone(UTC))
326
+ for history in build_history(tuple(observations)):
327
+ if history.identity.window_minutes != 10_080:
328
+ continue
329
+ if _is_model_scoped_codex_quota(history.identity.logical_limit_key):
330
+ continue
331
+ baseline = select_baseline(history.observations, now_utc)
332
+ if baseline is not None and baseline.resets_at > now_utc:
333
+ latest = latest_physical_observation(history.physical_observations)
334
+ if latest is not None:
335
+ candidates.append(
336
+ latest.captured_at.astimezone(UTC)
337
+ + dt.timedelta(
338
+ seconds=stale_after_seconds(history.identity.window_minutes),
339
+ )
340
+ )
341
+ # A capture ahead of ``now`` is not baseline-eligible yet, so this runs
342
+ # even for a history with no live baseline at all.
343
+ for observation in (
344
+ *history.observations, *history.physical_observations,
345
+ ):
346
+ captured_at = observation.captured_at.astimezone(UTC)
347
+ if captured_at > now_utc:
348
+ candidates.append(captured_at)
349
+ live = [candidate for candidate in candidates if candidate > now_utc]
350
+ return min(live) if live else None
351
+
352
+
353
+ def codex_decision_deadline_passed(
354
+ state: object,
355
+ now_utc: dt.datetime,
356
+ ) -> bool:
357
+ """Whether a published Codex state's cycle decision deadline has elapsed.
358
+
359
+ #350 spec §3.3. When this holds the tick MUST rebuild Codex authoritatively
360
+ via ``build_codex_source_state`` — bypassing both the idle clock and
361
+ ``reuse_coherent_source_state`` — because the frozen evidence would now
362
+ resolve to a different cycle (or to none). A state with no recorded deadline
363
+ (``None``, or an older generation that predates the field) never forces a
364
+ rebuild; the clock's expiry guard remains its safety net.
365
+ """
366
+ clock_data = getattr(state, "clock_data", None)
367
+ if not isinstance(clock_data, Mapping):
368
+ return False
369
+ deadline = clock_data.get("codex_next_decision_at")
370
+ if not isinstance(deadline, dt.datetime):
371
+ return False
372
+ if deadline.tzinfo is None or deadline.utcoffset() is None:
373
+ return False
374
+ return now_utc.astimezone(UTC) >= deadline.astimezone(UTC)
375
+
376
+
247
377
  def _codex_weekly_periods(
248
378
  stats_conn: sqlite3.Connection,
249
379
  *,
@@ -1298,10 +1428,14 @@ def _quota_read_model(
1298
1428
  milestone_rows: list[dict[str, object]] = []
1299
1429
  active_rows: list[dict[str, object]] = []
1300
1430
  # R8 (#341 Task 4): the per-account `account_key` is serialized onto each
1301
- # history row ONLY when the Codex provider has >1 REAL account, so the idle
1302
- # clock (`_clock_cycle_validity`) can scope weekly-cycle validity per account
1303
- # instead of degrading a genuine multi-account state to `conflicting`. A
1304
- # <=1-real-account install (all fixtures) stays byte-identical (no key added).
1431
+ # history row ONLY when the Codex provider has >1 REAL account, so the
1432
+ # dashboard client can scope per-account quota rows instead of merging them.
1433
+ # A <=1-real-account install (all fixtures) stays byte-identical (no key
1434
+ # added). #350 removed the original consumer, `_clock_cycle_validity`: the
1435
+ # idle clock no longer re-derives weekly-cycle validity at all, because this
1436
+ # public history view is LOSSY — capped at `SOURCE_HISTORY_LIMIT` and without
1437
+ # `logical_limit_key` — so it cannot resolve the cycle authoritatively. Build
1438
+ # time owns resolution; a `clock_data` decision deadline forces the rebuild.
1305
1439
  _codex_decorated = False
1306
1440
  try:
1307
1441
  import _cctally_account
@@ -1501,67 +1635,25 @@ def _clock_freshness(
1501
1635
  return "stale" if age_seconds > stale_after else "fresh"
1502
1636
 
1503
1637
 
1504
- def _clock_cycle_validity(
1505
- histories: Iterable[object],
1506
- now_utc: dt.datetime,
1507
- ) -> tuple[bool, str]:
1508
- """Re-evaluate frozen weekly evidence without touching cache or rollouts.
1509
-
1510
- Per-account (#341 Task 4): boundaries are grouped by the history row's
1511
- ``account_key`` (serialized only when the Codex provider is DECORATED, i.e.
1512
- >1 real account — R8). The idle clock mirrors the build-time
1513
- ``_resolve_codex_weekly_cycle`` resolution: an account yields a live cycle iff
1514
- it has EXACTLY ONE fresh future boundary, and the hero is valid iff AT LEAST
1515
- ONE account does (raising the aggregate ``conflicting > stale > missing``
1516
- reason only when NO account yields one). When ``account_key`` is absent
1517
- (<=1-real-account install — no decoration) every row falls into one global
1518
- bucket, so this reduces EXACTLY to the prior single-boundary logic
1519
- (byte-stable). This removes the documented placeholder degrade where two real
1520
- accounts with distinct weekly cycles collapsed the whole hero to
1521
- ``conflicting`` on the idle clock.
1638
+ def _clock_cycle_expired(cycle: object, now_utc: dt.datetime) -> bool:
1639
+ """Whether a retained hero cycle has already reset (#350 spec §3.3).
1640
+
1641
+ The one invariant the idle clock still enforces on frozen evidence: a cycle
1642
+ whose ``resets_at`` is at or before ``now_utc`` cannot bound current
1643
+ accounting. Fails CLOSED on an unparseable or absent boundary, matching the
1644
+ prior behavior where malformed evidence yielded no valid boundary.
1522
1645
  """
1523
- per_account: dict[str, set[dt.datetime]] = {}
1524
- stale_by_account: dict[str, bool] = {}
1525
- for raw_history in histories:
1526
- if not isinstance(raw_history, Mapping):
1527
- continue
1528
- if raw_history.get("window_minutes") != 10_080:
1529
- continue
1530
- current = raw_history.get("current_percent")
1531
- forecast = raw_history.get("forecast")
1532
- if current is None or not isinstance(forecast, Mapping):
1533
- continue
1534
- try:
1535
- resets_at = dt.datetime.fromisoformat(
1536
- str(forecast.get("resets_at")).replace("Z", "+00:00")
1537
- ).astimezone(UTC)
1538
- except (TypeError, ValueError):
1539
- continue
1540
- if resets_at <= now_utc:
1541
- continue
1542
- # None account_key (undecorated / <=1 real account) -> one global bucket
1543
- # == today's behavior. A real key buckets per account.
1544
- acct = raw_history.get("account_key")
1545
- bucket = acct if acct is not None else "__all__"
1546
- if raw_history.get("freshness") != "fresh":
1547
- stale_by_account[bucket] = True
1548
- continue
1549
- per_account.setdefault(bucket, set()).add(resets_at)
1550
- accounts = set(per_account) | set(stale_by_account)
1551
- reasons: list[str] = []
1552
- for acct in accounts:
1553
- boundaries = per_account.get(acct, set())
1554
- if len(boundaries) == 1:
1555
- return True, "ok" # at least one account yields a live cycle
1556
- reasons.append(
1557
- "conflicting" if boundaries
1558
- else ("stale" if stale_by_account.get(acct) else "missing")
1646
+ if not isinstance(cycle, Mapping):
1647
+ return True
1648
+ try:
1649
+ resets_at = dt.datetime.fromisoformat(
1650
+ str(cycle.get("resets_at")).replace("Z", "+00:00")
1559
1651
  )
1560
- if "conflicting" in reasons:
1561
- return False, "conflicting"
1562
- if "stale" in reasons:
1563
- return False, "stale"
1564
- return False, "missing"
1652
+ except (TypeError, ValueError):
1653
+ return True
1654
+ if resets_at.tzinfo is None or resets_at.utcoffset() is None:
1655
+ return True
1656
+ return resets_at.astimezone(UTC) <= now_utc
1565
1657
 
1566
1658
 
1567
1659
  def _refresh_budget_status_clock(
@@ -1646,10 +1738,16 @@ def refresh_codex_source_clock(
1646
1738
  if not isinstance(raw_history, Mapping):
1647
1739
  continue
1648
1740
  history = dict(raw_history)
1649
- freshness = _clock_freshness(
1741
+ # #350 spec §3.9: this is a PER-ROW value and must never shadow the
1742
+ # envelope-level `freshness`. It used to, so after the loop the
1743
+ # envelope held the LAST retained history row's freshness — often an
1744
+ # inactive row, and with a single weekly history the active weekly
1745
+ # one, which silently marked the whole provider stale on an idle
1746
+ # stale crossing and tripped idle eligibility on its own.
1747
+ row_freshness = _clock_freshness(
1650
1748
  history.get("captured_at"), history.get("stale_after_seconds"), now_utc,
1651
1749
  )
1652
- history["freshness"] = freshness
1750
+ history["freshness"] = row_freshness
1653
1751
  forecast = history.get("forecast")
1654
1752
  if isinstance(forecast, Mapping):
1655
1753
  forecast = dict(forecast)
@@ -1663,9 +1761,9 @@ def refresh_codex_source_clock(
1663
1761
  remaining = max(0, int((reset - now_utc).total_seconds())) if reset else None
1664
1762
  forecast["remaining_seconds"] = remaining
1665
1763
  sample_count = int(forecast.get("sample_count") or 0)
1666
- if freshness == "future":
1764
+ if row_freshness == "future":
1667
1765
  forecast["status"] = "future"
1668
- elif freshness == "stale":
1766
+ elif row_freshness == "stale":
1669
1767
  forecast["status"] = "stale"
1670
1768
  elif sample_count == 0:
1671
1769
  forecast["status"] = "insufficient-history"
@@ -1688,7 +1786,7 @@ def refresh_codex_source_clock(
1688
1786
  "current_percent": current,
1689
1787
  "captured_at": history.get("captured_at"),
1690
1788
  "resets_at": resets_at,
1691
- "freshness": freshness,
1789
+ "freshness": row_freshness,
1692
1790
  "stale_after_seconds": history.get("stale_after_seconds"),
1693
1791
  })
1694
1792
  refreshed_histories.append(history)
@@ -1727,8 +1825,18 @@ def refresh_codex_source_clock(
1727
1825
  and hero_capability is not None
1728
1826
  and hero_capability.status == "supported"
1729
1827
  ):
1730
- cycle_valid, cycle_reason = _clock_cycle_validity(refreshed_histories, now_utc)
1731
- if not cycle_valid:
1828
+ # #350 spec §3.3: the clock no longer RE-DERIVES cycle validity.
1829
+ # Its public-history view is lossy (capped, no `logical_limit_key`,
1830
+ # no `quota_identity`), so it cannot resolve the cycle correctly —
1831
+ # and per §2.2 it cannot simply trust the old verdict forever either,
1832
+ # because resolution is time-dependent on frozen evidence. Build time
1833
+ # owns resolution and records a decision deadline in `clock_data`; the
1834
+ # tick rebuilds authoritatively at the crossing. All the clock keeps
1835
+ # is this cheap invariant guard: a cycle that has already RESET cannot
1836
+ # bound current accounting, so it degrades exactly as before.
1837
+ # Expiry is also deadline candidate #1, so the two paths are disjoint
1838
+ # belt-and-suspenders rather than a single mechanism.
1839
+ if _clock_cycle_expired(hero.get("cycle"), now_utc):
1732
1840
  hero = dict(hero)
1733
1841
  for field in (
1734
1842
  "cost_usd", "input_tokens", "cached_input_tokens", "output_tokens",
@@ -1750,8 +1858,6 @@ def refresh_codex_source_clock(
1750
1858
  "hero",
1751
1859
  ),)
1752
1860
  availability = "partial"
1753
- if cycle_reason == "stale":
1754
- freshness = "stale"
1755
1861
  cycle_changed = True
1756
1862
  budget_domain = data.get("budget")
1757
1863
  budget_changed = False
@@ -2609,6 +2715,16 @@ def build_codex_source_state(
2609
2715
  }
2610
2716
  if cycle is not None and not hero_failure else None
2611
2717
  ),
2718
+ # #350 (spec §3.4): additive, hero-local staleness disclosure.
2719
+ # OMITTED when the cycle is fresh — never emitted as "fresh" —
2720
+ # which is what keeps every existing envelope golden byte-
2721
+ # identical. availability/freshness/warnings/capabilities stay
2722
+ # untouched because five separate gates read them as one meaning.
2723
+ **(
2724
+ {"cycle_freshness": "stale"}
2725
+ if cycle is not None and not hero_failure and cycle.evidence_stale
2726
+ else {}
2727
+ ),
2612
2728
  "quota": quota["summary"],
2613
2729
  "budget": configured_budget,
2614
2730
  "alerts": {"count": len(alerts)},
@@ -2635,5 +2751,13 @@ def build_codex_source_state(
2635
2751
  },
2636
2752
  "cache_report": cache_report,
2637
2753
  },
2638
- clock_data={"codex_budget_cost_events": budget_cost_events},
2754
+ clock_data={
2755
+ "codex_budget_cost_events": budget_cost_events,
2756
+ # #350 spec §3.3: when the tick passes this instant it must rebuild
2757
+ # Codex authoritatively instead of idle-clocking or reusing, because
2758
+ # weekly-cycle resolution can change on identical frozen evidence.
2759
+ "codex_next_decision_at": _codex_next_decision_at(
2760
+ quota_observations, cycles_all, context.now_utc,
2761
+ ),
2762
+ },
2639
2763
  )
@@ -266,6 +266,7 @@ from _cctally_dashboard_sources import (
266
266
  _claude_accounts_wire,
267
267
  accounts_identity_digest,
268
268
  build_codex_source_state,
269
+ codex_decision_deadline_passed,
269
270
  refresh_codex_source_clock,
270
271
  resolve_dashboard_source_semantics,
271
272
  )
@@ -1724,6 +1725,7 @@ def _tui_build_sessions(
1724
1725
  limit: int = 100,
1725
1726
  skip_sync: bool = False,
1726
1727
  use_session_cache: bool = False,
1728
+ with_titles: bool = False,
1727
1729
  ) -> list[TuiSessionRow]:
1728
1730
  """Load the last `limit` Claude sessions (merged across resumes).
1729
1731
 
@@ -1753,6 +1755,13 @@ def _tui_build_sessions(
1753
1755
  ``False`` → the from-scratch 365-day fetch, so a non-sync-thread caller
1754
1756
  with a shifted ``now`` can NEVER pollute the shared cache (the Bundle 2
1755
1757
  Group A lesson). The visible rows are byte-identical either way.
1758
+
1759
+ ``with_titles``: attach each row's transcript-derived title from the
1760
+ independent conversation store. DASHBOARD-only — ``TuiSessionRow.title`` is
1761
+ read by the dashboard envelope alone (the terminal TUI never renders it), so
1762
+ the default keeps the core/TUI build free of any transcript-store access
1763
+ (#320). The read itself is bounded and fail-soft; see
1764
+ ``read_session_titles_bounded``.
1756
1765
  """
1757
1766
  # Bounded scan window — the sessions pane promises "last `limit`". A
1758
1767
  # 365-day scan covers virtually all users (even one-session-every-few-days
@@ -1781,12 +1790,33 @@ def _tui_build_sessions(
1781
1790
  (), now_utc=now_utc, limit=limit, display_tz=None,
1782
1791
  aggregated_override=aggregated_override,
1783
1792
  )
1784
- # #320: transcript-derived titles are optional decoration. The core
1785
- # dashboard/TUI snapshot must never open conversations.db, because even a
1786
- # fail-soft read pays SQLite's lock timeout before it can fail. Conversation
1787
- # routes retain title derivation; the accounting Sessions panel renders its
1788
- # existing em-dash fallback when the independent store is unavailable.
1789
- return list(view.rows)
1793
+ rows = list(view.rows)
1794
+ if not with_titles:
1795
+ # #320: transcript-derived titles are optional decoration, and the TUI
1796
+ # has no consumer for them (the field is dashboard-only), so the core
1797
+ # build never touches the independent transcript store at all.
1798
+ return rows
1799
+ # Dashboard build (see ``with_titles`` in the docstring): re-attach the
1800
+ # Session-column titles the #320 store split dropped. ``read_session_titles_bounded``
1801
+ # never uses ``open_conversations_db`` and never waits out a lock — a store
1802
+ # that is missing, locked, or mid-rebuild yields no titles and the panel
1803
+ # renders its em-dash fallback, which self-heals on a later tick. Titles are
1804
+ # stashed unconditionally on this server-internal row; the privacy gate is
1805
+ # applied later, at envelope serialization
1806
+ # (``snapshot_to_envelope(transcripts_visible=...)``).
1807
+ session_ids = [r.session_id for r in rows if r.session_id]
1808
+ if not session_ids:
1809
+ return rows
1810
+ titles = c._load_sibling("_cctally_cache").read_session_titles_bounded(
1811
+ session_ids,
1812
+ )
1813
+ if not titles:
1814
+ return rows
1815
+ return [
1816
+ dataclasses.replace(r, title=titles[r.session_id])
1817
+ if r.session_id in titles else r
1818
+ for r in rows
1819
+ ]
1790
1820
 
1791
1821
 
1792
1822
  def _tui_sessions_cached(
@@ -2542,10 +2572,20 @@ def _tui_build_source_bundle(
2542
2572
  # incoherence generation is intentionally not retained through
2543
2573
  # that repair opportunity; all other fresh partial states remain
2544
2574
  # eligible for exact reuse.
2575
+ # #350 spec §3.3: a passed cycle decision deadline forces an
2576
+ # AUTHORITATIVE rebuild. Weekly-cycle resolution is time-dependent
2577
+ # even on frozen evidence, and the reuse path would otherwise hand
2578
+ # back the exact prior object with no re-check at all — so an idle
2579
+ # dashboard would diverge from a freshly rebuilt one. Leaving
2580
+ # ``codex = None`` routes to the existing build below. Bounded by
2581
+ # construction: one rebuild per crossing, not one per tick.
2545
2582
  codex = (
2546
- None if prior_codex is not None and any(
2547
- warning.code == "codex_projection_incoherent"
2548
- for warning in prior_codex.warnings
2583
+ None if prior_codex is not None and (
2584
+ any(
2585
+ warning.code == "codex_projection_incoherent"
2586
+ for warning in prior_codex.warnings
2587
+ )
2588
+ or codex_decision_deadline_passed(prior_codex, now_utc)
2549
2589
  ) else reuse_coherent_source_state(
2550
2590
  prior_codex, data_version=codex_version,
2551
2591
  )
@@ -2582,6 +2622,14 @@ def _tui_build_source_bundle(
2582
2622
  if prior_codex is not None
2583
2623
  else unavailable_source_state("codex", warning)
2584
2624
  )
2625
+ # #350 spec §3.3: clock Codex UNCONDITIONALLY — after every build /
2626
+ # reuse / degrade branch and before composition — so the retained
2627
+ # cycle's expiry invariant holds on EVERY path, including the reuse
2628
+ # path that returns the exact prior object (§2.5). Same-instant identity
2629
+ # is preserved by ``refresh_codex_source_clock``'s own data-equality
2630
+ # guard, so a freshly built state is handed back unchanged. Claude is
2631
+ # deliberately untouched.
2632
+ codex = refresh_codex_source_clock(codex, now_utc=now_utc)
2585
2633
  combined = compose_all_state(claude, codex)
2586
2634
  bundle = SourceDashboardBundle(
2587
2635
  source_schema_version=1,
@@ -3108,6 +3156,12 @@ def _tui_build_snapshot(
3108
3156
  # both avoid ingest latency/lock contention.
3109
3157
  sessions = _tui_build_sessions(
3110
3158
  now_utc, skip_sync=skip_sync, use_session_cache=True,
3159
+ # ``precompute_envelope`` is this build's documented
3160
+ # DASHBOARD marker (set by the sync-thread rebuild + the
3161
+ # initial snapshot, never by the terminal TUI), and the
3162
+ # Session-column title is a dashboard-only field — so it
3163
+ # also gates the bounded transcript-store title read.
3164
+ with_titles=precompute_envelope,
3111
3165
  )
3112
3166
  except Exception as exc:
3113
3167
  errors.append(f"sessions: {exc}")
@@ -3773,7 +3827,14 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
3773
3827
  try:
3774
3828
  prior_claude = source_bundle.sources["claude"]
3775
3829
  prior_codex = source_bundle.sources["codex"]
3776
- if _tui_source_bundle_can_idle(source_bundle):
3830
+ # #350 spec §3.3: once the Codex cycle decision deadline has passed
3831
+ # the idle clock is no longer entitled to speak for the cycle — its
3832
+ # public-history view cannot re-resolve it — so fall through to the
3833
+ # bounded source-adapter path, which rebuilds Codex authoritatively.
3834
+ if (
3835
+ _tui_source_bundle_can_idle(source_bundle)
3836
+ and not codex_decision_deadline_passed(prior_codex, now_utc)
3837
+ ):
3777
3838
  codex = refresh_codex_source_clock(prior_codex, now_utc=now_utc)
3778
3839
  if codex is not prior_codex:
3779
3840
  source_bundle = SourceDashboardBundle(
@@ -325,6 +325,51 @@ def _session_first_prompt_titles_map(conn, session_ids):
325
325
  return titles
326
326
 
327
327
 
328
+ def _session_rollup_titles_map(conn, session_ids):
329
+ """{sid: title} from the STORED ``conversation_sessions.title`` rollup — the
330
+ first-prompt title #302 materializes so the hot read paths never re-run
331
+ ``_session_first_prompt_titles_map``'s windowed ``conversation_messages``
332
+ scan. One PK lookup per id.
333
+
334
+ Falsey titles are DROPPED (a rollup row exists for every indexed session,
335
+ titled or not), so an untitled session is simply absent. Tolerates the table
336
+ being absent (pre-migration / rebuilding store) by returning {}."""
337
+ if not session_ids:
338
+ return {}
339
+ out = {}
340
+ try:
341
+ ph = ",".join("?" for _ in session_ids)
342
+ for sid, title in conn.execute(
343
+ f"SELECT session_id, title FROM conversation_sessions "
344
+ f"WHERE session_id IN ({ph})", tuple(session_ids)
345
+ ).fetchall():
346
+ if title:
347
+ out[sid] = title
348
+ except sqlite3.OperationalError:
349
+ pass # table absent -> {} (caller degrades to no title)
350
+ return out
351
+
352
+
353
+ def session_titles_indexed_map(conn, session_ids):
354
+ """{sid: title} composed from INDEXED reads only — the truthy AI title wins,
355
+ else the stored rollup title. Same precedence as ``_session_titles_map``,
356
+ and identical values whenever the rollup is current (#302: the stored title
357
+ IS the first-prompt title), but it NEVER falls back to the windowed
358
+ ``conversation_messages`` scan.
359
+
360
+ That bound is the contract: this is what a latency-sensitive, fail-soft
361
+ caller (the dashboard Sessions panel, which reads the transcript store over
362
+ a bounded read-only connection) may run per tick. A session missing from
363
+ both indexes is simply absent — the caller renders its em-dash fallback and
364
+ the next rollup recompute fills it in."""
365
+ if not session_ids:
366
+ return {}
367
+ titles = dict(_session_ai_titles_map(conn, session_ids))
368
+ for sid, t in _session_rollup_titles_map(conn, session_ids).items():
369
+ titles.setdefault(sid, t) # AI title (truthy) wins; else the rollup
370
+ return titles
371
+
372
+
328
373
  def _session_titles_map(conn, session_ids):
329
374
  """{sid: title} — the TRUTHY AI title wins, else the first-prompt title.
330
375
  Contract UNCHANGED (the live/degraded rail branch still calls this as-is); now