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.
- package/CHANGELOG.md +12 -0
- package/README.md +45 -74
- package/bin/_cctally_cache.py +51 -0
- package/bin/_cctally_dashboard_envelope.py +70 -0
- package/bin/_cctally_dashboard_share.py +10 -1
- package/bin/_cctally_dashboard_sources.py +209 -85
- package/bin/_cctally_tui.py +71 -10
- package/bin/_lib_conversation_query.py +45 -0
- package/bin/_lib_dashboard_sources.py +51 -1
- package/bin/_lib_readme_refresh.py +401 -0
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-BKM43pxK.js} +52 -52
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
210
|
-
else ("stale" if
|
|
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
|
|
1302
|
-
#
|
|
1303
|
-
#
|
|
1304
|
-
#
|
|
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
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
1561
|
-
return
|
|
1562
|
-
if
|
|
1563
|
-
return
|
|
1564
|
-
return
|
|
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
|
-
|
|
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"] =
|
|
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
|
|
1764
|
+
if row_freshness == "future":
|
|
1667
1765
|
forecast["status"] = "future"
|
|
1668
|
-
elif
|
|
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":
|
|
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
|
-
|
|
1731
|
-
|
|
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={
|
|
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
|
)
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -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
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
-
|
|
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
|