cctally 1.111.0 → 1.112.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 (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -6
  3. package/bin/_cctally_cache.py +14 -7
  4. package/bin/_cctally_core.py +9 -1
  5. package/bin/_cctally_dashboard_envelope.py +7 -6
  6. package/bin/_cctally_dashboard_sources.py +90 -55
  7. package/bin/_cctally_doctor.py +73 -27
  8. package/bin/_cctally_forecast.py +2 -1
  9. package/bin/_cctally_journal.py +147 -6
  10. package/bin/_cctally_milestones.py +29 -3
  11. package/bin/_cctally_quota.py +10 -1
  12. package/bin/_cctally_record.py +136 -4
  13. package/bin/_cctally_rederive.py +311 -8
  14. package/bin/_cctally_source_analytics.py +20 -8
  15. package/bin/_cctally_tui.py +546 -45
  16. package/bin/_lib_codex_conversation_query.py +40 -4
  17. package/bin/_lib_codex_metadata.py +57 -11
  18. package/bin/_lib_cost_provenance.py +303 -0
  19. package/bin/_lib_dashboard_sources.py +263 -28
  20. package/bin/_lib_doctor.py +2 -2
  21. package/bin/_lib_ingest_frontier.py +137 -38
  22. package/bin/_lib_pricing.py +13 -1
  23. package/bin/_lib_rederive.py +704 -36
  24. package/bin/_lib_snapshot_cache.py +332 -9
  25. package/dashboard/static/assets/{ConversationsView-BB9vUiL6.js → ConversationsView-CEZ-BUyi.js} +1 -1
  26. package/dashboard/static/assets/{DoctorModal-DFY2iPDf.js → DoctorModal-CE1oH_Zy.js} +1 -1
  27. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +1 -0
  28. package/dashboard/static/assets/{ProjectsDrillPanel-DAv39uSo.js → ProjectsDrillPanel-B3DZoCeD.js} +1 -1
  29. package/dashboard/static/assets/{SourceDetailModal-DSAWo2Fp.js → SourceDetailModal-CHp9EgLP.js} +1 -1
  30. package/dashboard/static/assets/{UpdateModal-R_9dOVnh.js → UpdateModal-5iKST6jo.js} +1 -1
  31. package/dashboard/static/assets/{index-DdU62sov.js → index-Cm0VTxJC.js} +2 -2
  32. package/dashboard/static/assets/index-Dt4tqf43.css +1 -0
  33. package/dashboard/static/assets/{outlineNavigation-BOCLcRa6.js → outlineNavigation-DkXGL8J6.js} +1 -1
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +2 -1
  36. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +0 -1
  37. package/dashboard/static/assets/index-COhxe3vU.css +0 -1
@@ -302,6 +302,7 @@ from _lib_dashboard_sources import (
302
302
  dashboard_resource_key,
303
303
  degrade_source_state,
304
304
  reuse_coherent_source_state,
305
+ settled_codex_cycle_unavailable,
305
306
  unavailable_source_state,
306
307
  )
307
308
  from _lib_source_retry import (
@@ -3595,6 +3596,223 @@ def _tui_retain_refused_partial(
3595
3596
  return decision.action == "retain"
3596
3597
 
3597
3598
 
3599
+ #: The server-only ``clock_data`` key carrying the Codex quota-dependency
3600
+ #: identity a Codex generation was BUILT from (#857). Stamped only on a fresh
3601
+ #: build, from the same pinned cache read as that build's capture; never
3602
+ #: re-stamped on a reuse, degrade or retained-bundle path, because those publish
3603
+ #: a generation an earlier tick read. The three handlers that retain the prior
3604
+ #: bundle after an attempted rebuild and return normally withdraw it (``None``,
3605
+ #: `_tui_withdraw_codex_quota_dependency`). Those withdrawals are per-site and
3606
+ #: do NOT cover every way an attempt can end unpublished — a failure escalated
3607
+ #: as stats corruption leaves before any handler runs, and a tick with no
3608
+ #: dispatch key memoizes nothing to withdraw on. What closes that class
3609
+ #: generically is the SECOND stamp, the accounting provenance token
3610
+ #: (`_lib_snapshot_cache.CODEX_ACCOUNTING_PROVENANCE_KEY`,
3611
+ #: `_tui_codex_accounting_provenance_current`): any capture that consumed
3612
+ #: accounting moves the process's consumed token past every generation it did
3613
+ #: not publish.
3614
+ _CODEX_QUOTA_DEPENDENCY_KEY = "codex_quota_dependency"
3615
+
3616
+
3617
+ def _tui_cache_file_before_open() -> "tuple[int, int] | None":
3618
+ """The cache file a tick is about to open, observed BEFORE it opens (#857).
3619
+
3620
+ ``(st_dev, st_ino)`` of the configured cache path, or ``None`` when it
3621
+ cannot be stat'ed — an absent file included. Taken immediately before the
3622
+ tick's cache handle is opened; it is the ``opened_file`` that
3623
+ `_tui_read_codex_quota_dependency` binds the identity read on that handle
3624
+ to, and ``None`` binds nothing.
3625
+ """
3626
+ try:
3627
+ return _cctally()._load_sibling(
3628
+ "_lib_snapshot_cache"
3629
+ ).codex_cache_file_identity(_cctally_core.CACHE_DB_PATH)
3630
+ except Exception: # noqa: BLE001 — an unobserved file binds nothing
3631
+ return None
3632
+
3633
+
3634
+ def _tui_read_codex_quota_dependency(conn, *, opened_file) -> "tuple | None":
3635
+ """Read the #857 identity on ``conn``; any failure is "no identity".
3636
+
3637
+ ``opened_file`` is `_tui_cache_file_before_open`'s observation, taken
3638
+ before ``conn`` was opened, and the identity is bound to it STRICTLY
3639
+ (`codex_quota_dependency_identity`): it exists only when the file ``conn``
3640
+ reports after the identity's last read is the file observed before the
3641
+ open. A failed or missing pre-open observation, a replacement inside that
3642
+ bracket, and a handle opened on any other file all read as ``None`` —
3643
+ "rebuild" — never as a late bracket that begins after the open.
3644
+ """
3645
+ try:
3646
+ return _cctally()._load_sibling(
3647
+ "_lib_snapshot_cache"
3648
+ ).codex_quota_dependency_identity(conn, opened_file=opened_file)
3649
+ except Exception: # noqa: BLE001 — no identity means rebuild, never a crash
3650
+ return None
3651
+
3652
+
3653
+ def _tui_withdraw_codex_quota_dependency(bundle):
3654
+ """``bundle`` with its Codex generation's identity stamp withdrawn (#857).
3655
+
3656
+ For a PRIOR bundle a tick republishes after its own rebuild attempt was
3657
+ abandoned. Three sites withdraw: the full path's source build raised
3658
+ (`_tui_retain_failed_source_bundle`, always); a build completed but lost
3659
+ the stats generation race and returned the prior bundle instead of its own;
3660
+ or the idle path's bounded source adapter raised and
3661
+ `_tui_build_idle_snapshot` kept ``prior.source_bundle``. The last two
3662
+ withdraw only when that build SCHEDULED the Codex pass — a superset of
3663
+ running it, since a pass that fails before consuming anything counts too,
3664
+ which over-withdraws safely. In every case the retained generation was not
3665
+ re-validated against what this tick read, and the attempt may already have
3666
+ consumed evidence the NEXT tick needs to see as moved: the Codex pass
3667
+ publishes the process accounting cache before the post-build
3668
+ reconciliation, which can raise as well as lose the race, so accounting
3669
+ that moved in place (window-spend adoption stamps ``account_key`` without
3670
+ moving ``MAX(id)``, the physical sequence or the quota identity) no longer
3671
+ reads as pending, and the Codex version cannot see it. The idle adapter is
3672
+ no exception for running under an unchanged key: it reads the cache on its
3673
+ OWN snapshot, opened after the dispatch read, so accounting that commits
3674
+ between the two is pending to the adapter and absent from the key. The
3675
+ stamp is what admits a Codex generation to the idle gate — ordinary and
3676
+ settled alike — and to exact-version reuse
3677
+ (`_tui_codex_dependency_current`), so leaving it would let the next tick
3678
+ republish the old generation for as long as nothing else moves. Withdrawn,
3679
+ the next tick rebuilds Codex once, whichever path it takes. Nothing
3680
+ published changes; ``clock_data`` never reaches the wire.
3681
+
3682
+ These withdrawals are belt-and-braces now, not the closure. They run only
3683
+ in a handler that returns normally, so they cannot reach a failure that
3684
+ escalates as stats corruption before the handler's withdrawal, or a tick
3685
+ with no dispatch key, whose retained bundle nothing memoizes. The
3686
+ accounting provenance token covers every such site at once
3687
+ (`_tui_codex_accounting_provenance_current`): the capture that consumed
3688
+ moved the consumed token past the retained generation's stamp.
3689
+
3690
+ A raced build or failed idle adapter that reused or degraded Codex
3691
+ scheduled no pass, consumed nothing, and keeps the stamp. That is safe:
3692
+ accounting that moved is still pending, and an accounting commit moves the
3693
+ dispatch key, so the next tick takes the full path and rebuilds Codex. A
3694
+ race moves the next key in any case, because every stats leg its check
3695
+ compares is also a dispatch signature leg; after a failed idle adapter with
3696
+ nothing committed, the retained generation is exactly as current as it was.
3697
+ """
3698
+ if not isinstance(bundle, SourceDashboardBundle):
3699
+ return bundle
3700
+ codex = bundle.sources.get("codex")
3701
+ if (
3702
+ not isinstance(codex, SourceDashboardState)
3703
+ or not isinstance(codex.clock_data, Mapping)
3704
+ or codex.clock_data.get(_CODEX_QUOTA_DEPENDENCY_KEY) is None
3705
+ ):
3706
+ return bundle
3707
+ withdrawn = dataclasses.replace(codex, clock_data={
3708
+ **codex.clock_data, _CODEX_QUOTA_DEPENDENCY_KEY: None,
3709
+ })
3710
+ return dataclasses.replace(
3711
+ bundle, sources={**bundle.sources, "codex": withdrawn},
3712
+ )
3713
+
3714
+
3715
+ def _tui_retain_failed_source_bundle(prior_bundle, *, dispatch_key):
3716
+ """The bundle a full tick publishes when its source build FAILED (#857).
3717
+
3718
+ The prior bundle, retained whole as before. When this tick also memoizes
3719
+ the snapshot under its new ``dispatch_key``, the retained Codex generation
3720
+ loses its identity stamp (`_tui_withdraw_codex_quota_dependency`): dispatch
3721
+ movement guaranteed this tick an ATTEMPTED rebuild, not a successful one,
3722
+ and the next tick must not read an unchanged key as proof that the retained
3723
+ generation is current. With no key, nothing is memoized — the dispatch memo
3724
+ keeps the snapshot the prior bundle was validated under — so the retained
3725
+ object is returned untouched; if the failed build consumed accounting, that
3726
+ memoized generation is refused anyway, by its accounting provenance token
3727
+ (`_tui_codex_accounting_provenance_current`).
3728
+ """
3729
+ if dispatch_key is None:
3730
+ return prior_bundle
3731
+ return _tui_withdraw_codex_quota_dependency(prior_bundle)
3732
+
3733
+
3734
+ def _tui_codex_accounting_provenance_current(state) -> bool:
3735
+ """Whether ``state`` names the accounting population last consumed (#857).
3736
+
3737
+ ``state``'s stamped accounting provenance token — the one its build's
3738
+ capture returned — must equal the token of the population the process
3739
+ accounting cache most recently consumed
3740
+ (`_lib_snapshot_cache.codex_accounting_consumed_provenance`). That closes
3741
+ unpublished consumption as a CLASS: a capture that consumed pending
3742
+ accounting (a ledger advance, a revealed row, a reconstruction) mints a new
3743
+ token, so a build that then failed to publish what it built — a raise, a
3744
+ lost race, a corruption escalation and its heal retry, a tick with no
3745
+ dispatch key — leaves every retained generation behind the consumed token,
3746
+ whatever the failure site. The consumed token lives outside the evictable
3747
+ accounting state, so snapshot memory enforcement alone never refuses a
3748
+ published generation. A missing stamp (a bypassing build, a hand-built
3749
+ prior) and a process that has consumed nothing yet are not current. The
3750
+ comparison is an in-memory read, O(1) per tick.
3751
+ """
3752
+ if not isinstance(state, SourceDashboardState):
3753
+ return False
3754
+ # A `sys.modules` hit in every load context: `_cctally_dashboard_sources`,
3755
+ # imported at this module's top, imports it at ITS top, and its captures
3756
+ # are what write the token. Deliberately not `_cctally()._load_sibling`,
3757
+ # so the idle gate needs no `cctally` module to answer.
3758
+ import _lib_snapshot_cache as sc
3759
+ consumed = sc.codex_accounting_consumed_provenance()
3760
+ if consumed is None:
3761
+ return False
3762
+ clock_data = state.clock_data
3763
+ return isinstance(clock_data, Mapping) and clock_data.get(
3764
+ sc.CODEX_ACCOUNTING_PROVENANCE_KEY) == consumed
3765
+
3766
+
3767
+ def _tui_codex_dependency_current(state, *, codex_dependency) -> bool:
3768
+ """Whether ``state`` was built from exactly the inputs current this tick.
3769
+
3770
+ #857. The one comparison behind every Codex admission that skips a
3771
+ rebuild: the idle gate, for EVERY availability (the settled exception and
3772
+ an ordinary ``ok``/``empty`` generation alike), the settled active-path
3773
+ exception and ordinary exact-version reuse. Two provenance legs, both
3774
+ required: the quota-dependency identity read this tick, and the accounting
3775
+ provenance token of the population the process last consumed
3776
+ (`_tui_codex_accounting_provenance_current`). An unavailable identity on
3777
+ either side — none read now, none stamped, or a stamp withdrawn after an
3778
+ abandoned rebuild — or a token behind the consumed one is not current, so
3779
+ a generation carrying one is rebuilt once and, once its fresh stamps
3780
+ match, idles again.
3781
+ """
3782
+ if codex_dependency is None or not isinstance(state, SourceDashboardState):
3783
+ return False
3784
+ clock_data = state.clock_data
3785
+ return (
3786
+ isinstance(clock_data, Mapping)
3787
+ and clock_data.get(_CODEX_QUOTA_DEPENDENCY_KEY) == codex_dependency
3788
+ and _tui_codex_accounting_provenance_current(state)
3789
+ )
3790
+
3791
+
3792
+ def _tui_codex_settled_cycle_holds(
3793
+ state, *, codex_dependency, now_utc: dt.datetime,
3794
+ ) -> bool:
3795
+ """Whether a settled cycle-unavailable Codex generation is still current.
3796
+
3797
+ #857. ``settled_codex_cycle_unavailable`` states the shape; this adds the
3798
+ facts only the tick holds. Its provenance must be current
3799
+ (`_tui_codex_dependency_current`): the identity the generation was built
3800
+ from equals the one read now — an unavailable identity on either side
3801
+ fails — and its accounting provenance token is the one the process last
3802
+ consumed; and its decision deadline must not have passed, since cycle
3803
+ resolution is time-dependent on frozen evidence and the deadline is the
3804
+ first instant it could resolve differently. Each caller adds its own path's
3805
+ conditions.
3806
+ """
3807
+ if not settled_codex_cycle_unavailable(state):
3808
+ return False
3809
+ if not _tui_codex_dependency_current(
3810
+ state, codex_dependency=codex_dependency,
3811
+ ):
3812
+ return False
3813
+ return not codex_decision_deadline_passed(state, now_utc)
3814
+
3815
+
3598
3816
  def _tui_note_codex_regime(value: str) -> None:
3599
3817
  """Stamp the REALISED Codex source-leg decision on the open tick (§1.5).
3600
3818
 
@@ -3633,14 +3851,25 @@ def _tui_build_source_bundle(
3633
3851
  projects_envelope: dict | None = None,
3634
3852
  prior_bundle: SourceDashboardBundle | None = None,
3635
3853
  raw_config: dict[str, object] | None = None,
3854
+ attempt: dict[str, bool] | None = None,
3636
3855
  ) -> SourceDashboardBundle:
3637
3856
  """Build one frozen source bundle after the dashboard's coordinated ingest.
3638
3857
 
3639
3858
  This helper is reachable only from ``precompute_envelope=True``. It opens
3640
3859
  a fresh cache handle after the ingest handle has closed, then performs
3641
3860
  read-only provider adaptation with no implicit sync or rollout fallback.
3861
+
3862
+ ``attempt`` (#857), when given, gets ``attempt["codex_pass"] = True`` as
3863
+ soon as this build schedules the Codex pass, so a caller that retains the
3864
+ prior bundle after this build RAISED knows whether the attempt may have
3865
+ consumed pending accounting (`_tui_withdraw_codex_quota_dependency`).
3642
3866
  """
3867
+ global _PARTIAL_RETRY_STATE
3643
3868
  c = _cctally()
3869
+ # #857: observed BEFORE the handle opens, so the Codex quota-dependency
3870
+ # identity below is bound to the file this handle actually reads. Strict:
3871
+ # an unobserved file stamps no identity (the next tick rebuilds Codex once).
3872
+ cache_file_before_open = _tui_cache_file_before_open()
3644
3873
  with _perf.phase("source.store_open"):
3645
3874
  cache_conn = c.open_cache_db()
3646
3875
  cache_read_tx = False
@@ -3772,6 +4001,18 @@ def _tui_build_source_bundle(
3772
4001
  accounts_digest=accounts_digest,
3773
4002
  claude_stats_digest=claude_digest,
3774
4003
  )
4004
+ # #857: the Codex quota-dependency identity, read on the SAME pinned
4005
+ # cache snapshot the Codex capture below reads, so a generation built
4006
+ # this tick is stamped with exactly the inputs it saw — never with a
4007
+ # later identity it did not read. Bound STRICTLY to the file observed
4008
+ # before the handle opened: an unobserved file, or a replacement since
4009
+ # the open, is `None`, never the new inode beside the old rows. The
4010
+ # capture needs no second check — it reads through the descriptor this
4011
+ # bracket bound, and a replacement after it is a later file the next
4012
+ # dispatch read names.
4013
+ codex_dependency = _tui_read_codex_quota_dependency(
4014
+ cache_conn, opened_file=cache_file_before_open,
4015
+ )
3775
4016
  _acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
3776
4017
  # public #5: the hook's budgeted ingest can change what the Codex
3777
4018
  # envelope owes without moving `codex_physical_mutation_seq` — a tick
@@ -3909,9 +4150,25 @@ def _tui_build_source_bundle(
3909
4150
  # list of warning strings was a second and incomplete definition
3910
4151
  # of non-reusability, and `reuse_coherent_source_state` now
3911
4152
  # refuses every `partial` prior unconditionally.
4153
+ # #857: the quota-dependency identity is the third forcing leg.
4154
+ # The version string carries none of its inputs — the quota change
4155
+ # ledger, the attribution revision, the projection certificate,
4156
+ # the cache file — so exact-version reuse cannot see them move:
4157
+ # a certificate lost behind an `ok` generation would be reused
4158
+ # here, and every later idle tick would retain the obsolete
4159
+ # verdict. A moved, unreadable or withdrawn identity (a bundle
4160
+ # retained across a failed rebuild) refuses every reuse and
4161
+ # retention path below, exactly like pending accounting. The same
4162
+ # comparison requires the generation's accounting provenance token
4163
+ # to be the process's consumed one: pending accounting that an
4164
+ # earlier, unpublished attempt already consumed no longer reads as
4165
+ # pending here, and only the token still sees it.
3912
4166
  _codex_forced_rebuild = prior_codex is not None and (
3913
4167
  _codex_accounting_pending
3914
4168
  or codex_decision_deadline_passed(prior_codex, now_utc)
4169
+ or not _tui_codex_dependency_current(
4170
+ prior_codex, codex_dependency=codex_dependency,
4171
+ )
3915
4172
  )
3916
4173
  codex = (
3917
4174
  None if _codex_forced_rebuild
@@ -3919,7 +4176,33 @@ def _tui_build_source_bundle(
3919
4176
  prior_codex, data_version=codex_reuse_version,
3920
4177
  )
3921
4178
  )
3922
- if codex is None and not _codex_forced_rebuild and (
4179
+ if (
4180
+ codex is None
4181
+ and not _codex_forced_rebuild
4182
+ and prior_codex is not None
4183
+ and prior_codex.data_version == codex_reuse_version
4184
+ and _tui_codex_settled_cycle_holds(
4185
+ prior_codex,
4186
+ codex_dependency=codex_dependency,
4187
+ now_utc=now_utc,
4188
+ )
4189
+ ):
4190
+ # #857: the settled cycle-unavailable generation over UNCHANGED
4191
+ # Codex inputs — exact version, exact quota-dependency identity
4192
+ # and accounting provenance, no pending accounting and no
4193
+ # elapsed decision deadline (all but the version are
4194
+ # `_codex_forced_rebuild`). Rebuilding it re-derives
4195
+ # the same partial generation, so a tick that another source
4196
+ # forced onto this path must not pay a Codex build for it. Its
4197
+ # own exception bypasses `_tui_retain_refused_partial`, so it
4198
+ # clears the Codex retry key itself: a metadata key armed before
4199
+ # this generation is obsolete, and left armed it would retain a
4200
+ # later metadata recurrence instead of rebuilding it.
4201
+ codex = prior_codex
4202
+ _PARTIAL_RETRY_STATE = clear_partial_retry(
4203
+ _PARTIAL_RETRY_STATE, provider="codex",
4204
+ )
4205
+ elif codex is None and not _codex_forced_rebuild and (
3923
4206
  _tui_retain_refused_partial(
3924
4207
  prior_codex,
3925
4208
  provider="codex",
@@ -3964,6 +4247,17 @@ def _tui_build_source_bundle(
3964
4247
  codex_capture = None
3965
4248
  codex_capture_failed = False
3966
4249
  codex_context = None
4250
+ # #857: whether this build schedules the Codex pass — the one step
4251
+ # that can consume pending accounting (it publishes the process
4252
+ # accounting cache). Only such a build withdraws the prior stamp if it
4253
+ # loses the generation race below, or (through `attempt`) if it raises
4254
+ # under the idle path's adapter; a reused or degraded Codex consumed
4255
+ # nothing. Scheduled, not completed: a pass that fails before it
4256
+ # consumes anything (a `DashboardReadContext` failure, say) still
4257
+ # counts, which over-withdraws safely at the cost of one Codex rebuild.
4258
+ codex_pass_attempted = codex is None
4259
+ if attempt is not None and codex_pass_attempted:
4260
+ attempt["codex_pass"] = True
3967
4261
  codex_split_seams_unpatched = (
3968
4262
  build_codex_source_state
3969
4263
  is sys.modules["_cctally_dashboard_sources"].build_codex_source_state
@@ -4181,9 +4475,15 @@ def _tui_build_source_bundle(
4181
4475
  # Attached ONLY on a fresh build, never on the reuse or degrade
4182
4476
  # paths: those carry rows this tick did not produce, and their
4183
4477
  # own carrier already describes the range that bounds them.
4478
+ # #857: the quota-dependency identity follows the same rule, for
4479
+ # the same reason — it names the inputs THIS build read.
4184
4480
  codex = dataclasses.replace(
4185
4481
  codex,
4186
4482
  aggregate_scope=build_aggregate_scope(published_range),
4483
+ clock_data={
4484
+ **(codex.clock_data or {}),
4485
+ _CODEX_QUOTA_DEPENDENCY_KEY: codex_dependency,
4486
+ },
4187
4487
  )
4188
4488
  codex_built_this_tick = True
4189
4489
  except Exception:
@@ -4199,6 +4499,29 @@ def _tui_build_source_bundle(
4199
4499
  if prior_codex is not None
4200
4500
  else unavailable_source_state("codex", warning)
4201
4501
  )
4502
+ codex_health = (
4503
+ codex.metadata_health
4504
+ if isinstance(codex.metadata_health, Mapping)
4505
+ else {}
4506
+ )
4507
+ codex_retry_entry = _PARTIAL_RETRY_STATE.get("codex")
4508
+ if codex_built_this_tick and (
4509
+ codex.availability != "partial"
4510
+ or bool(codex_health.get("retryable"))
4511
+ or (
4512
+ codex_health.get("state") == "healthy"
4513
+ and getattr(codex_retry_entry, "cause", None)
4514
+ == "metadata_incomplete"
4515
+ )
4516
+ ):
4517
+ # A forced rebuild bypasses `_tui_retain_refused_partial`. Once it
4518
+ # publishes healthy metadata, the prior metadata retry is obsolete
4519
+ # even when an independent projection condition keeps the overall
4520
+ # provider partial. Never clear another partial cause on that
4521
+ # metadata fact alone.
4522
+ _PARTIAL_RETRY_STATE = clear_partial_retry(
4523
+ _PARTIAL_RETRY_STATE, provider="codex",
4524
+ )
4202
4525
  # #350 spec §3.3: clock Codex UNCONDITIONALLY — after every build /
4203
4526
  # reuse / degrade branch and before composition — so the retained
4204
4527
  # cycle's expiry invariant holds on EVERY path, including the reuse
@@ -4244,6 +4567,14 @@ def _tui_build_source_bundle(
4244
4567
  # The cache pin ended after carrier capture. These cheap post-build
4245
4568
  # signatures intentionally reject only stats-side movement; a cache
4246
4569
  # commit during the pure folds belongs to the next generation.
4570
+ #
4571
+ # The two digests below may be served by the warm-digest memo, and that
4572
+ # is safe only because its key is the stats file's own write identity:
4573
+ # any commit that lands mid-build moves it, whether or not it moved the
4574
+ # journal cursor, because an entry is stored only for a settled file
4575
+ # and the settle window guarantees the next commit a newer timestamp
4576
+ # even on a coarse-timestamp filesystem. So these reads rescan and see
4577
+ # the new generation (#857; `_stats_relations_digest_signal`).
4247
4578
  with _perf.phase("source.reconciliation"):
4248
4579
  post_stats_digest = codex_stats_digest(stats_conn)
4249
4580
  post_accounts_digest = accounts_identity_digest(stats_conn)
@@ -4266,6 +4597,16 @@ def _tui_build_source_bundle(
4266
4597
  )
4267
4598
  if stats_generation_moved:
4268
4599
  if prior_bundle is not None:
4600
+ # #857: the prior bundle, never re-validated by this build.
4601
+ # When this build scheduled the Codex pass, that pass may have
4602
+ # consumed the pending accounting — so the retained Codex
4603
+ # generation loses its provenance and the next tick rebuilds it
4604
+ # once, whichever path it takes
4605
+ # (`_tui_withdraw_codex_quota_dependency`). A build that reused
4606
+ # or degraded Codex consumed nothing, and the retained stamp is
4607
+ # exactly as current as before.
4608
+ if codex_pass_attempted:
4609
+ return _tui_withdraw_codex_quota_dependency(prior_bundle)
4269
4610
  return prior_bundle
4270
4611
  raise RuntimeError("source read generation moved during build")
4271
4612
  return bundle
@@ -4317,7 +4658,13 @@ def _tui_hydrating_source_bundle() -> SourceDashboardBundle:
4317
4658
  )
4318
4659
 
4319
4660
 
4320
- def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
4661
+ def _tui_source_bundle_can_idle(
4662
+ bundle: SourceDashboardBundle | None,
4663
+ *,
4664
+ codex_dependency: "tuple | None",
4665
+ codex_ingest_degraded: bool = False,
4666
+ now_utc: dt.datetime | None = None,
4667
+ ) -> bool:
4321
4668
  """Return whether both physical provider generations are safe to retain.
4322
4669
 
4323
4670
  A stable dispatch key alone cannot prove that a previously unavailable or
@@ -4325,13 +4672,74 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
4325
4672
  repaired without changing accounting facts. Such a source must take the
4326
4673
  full source-bundle path again; that path still independently reuses the
4327
4674
  healthy provider by its own data version.
4675
+
4676
+ #857: a Codex generation of ANY availability idles only on current
4677
+ provenance (`_tui_codex_dependency_current`): the identity it was built
4678
+ from equals ``codex_dependency``, the identity read this tick, and its
4679
+ accounting provenance token equals the token of the population the process
4680
+ last consumed. The key being unchanged establishes neither. A generation
4681
+ retained across an abandoned rebuild may be memoized under a key naming
4682
+ inputs it never read (its stamp is then WITHDRAWN), or may have had its
4683
+ accounting consumed by the attempt (its token is then behind the consumed
4684
+ one); one built while the identity was unreadable carries no stamp. Any of
4685
+ them would otherwise be retained for as long as the key stays unchanged.
4686
+ Refused, it takes the bounded source adapter, which rebuilds Codex once and
4687
+ stamps the result, after which it idles again. ``codex_dependency`` is
4688
+ REQUIRED, with no default: ``None`` idles no Codex generation, so a caller
4689
+ that could omit it would silently disable Codex idling.
4690
+
4691
+ ONE degraded Codex shape is exempt from the availability refusal — the
4692
+ settled cycle-unavailable generation (``settled_codex_cycle_unavailable``),
4693
+ whose rebuild re-derives the same partial state on every tick. It also
4694
+ needs ``now_utc`` with its decision deadline not passed, and no Codex
4695
+ ingest failed or contended this tick. Entering the exception clears
4696
+ Codex's retry key, never Claude's. The retryable-carrier scan still runs
4697
+ first, for both providers.
4328
4698
  """
4699
+ global _PARTIAL_RETRY_STATE
4329
4700
  if not isinstance(bundle, SourceDashboardBundle):
4330
4701
  return False
4702
+ physical_states: dict[str, SourceDashboardState] = {}
4331
4703
  for source in ("claude", "codex"):
4332
4704
  state = bundle.sources.get(source)
4333
4705
  if not isinstance(state, SourceDashboardState):
4334
4706
  return False
4707
+ physical_states[source] = state
4708
+ retryable_carrier = False
4709
+ for source, state in physical_states.items():
4710
+ # #846 §4.6: the retryable carrier is authoritative and must be read
4711
+ # for BOTH providers before any broader availability refusal. A real
4712
+ # transient metadata generation is partial, so checking availability
4713
+ # source-by-source could return on Claude without clearing Codex's
4714
+ # armed key. Refuse AND clear each retryable provider at the idle
4715
+ # boundary; provider-keyed clearing leaves unrelated deadlines intact.
4716
+ health = state.metadata_health
4717
+ if health is not None and bool(health.get("retryable")):
4718
+ _PARTIAL_RETRY_STATE = clear_partial_retry(
4719
+ _PARTIAL_RETRY_STATE, provider=source,
4720
+ )
4721
+ retryable_carrier = True
4722
+ if retryable_carrier:
4723
+ return False
4724
+ settled_codex = False
4725
+ for source, state in physical_states.items():
4726
+ if source == "codex" and not _tui_codex_dependency_current(
4727
+ state, codex_dependency=codex_dependency,
4728
+ ):
4729
+ # #857: no current provenance, whatever the availability.
4730
+ return False
4731
+ if (
4732
+ source == "codex"
4733
+ and now_utc is not None
4734
+ and not codex_ingest_degraded
4735
+ and _tui_codex_settled_cycle_holds(
4736
+ state, codex_dependency=codex_dependency, now_utc=now_utc,
4737
+ )
4738
+ ):
4739
+ # #857: the settled cycle-unavailable generation over unchanged
4740
+ # inputs. Its predicate already excludes a failed aggregate fold.
4741
+ settled_codex = True
4742
+ continue
4335
4743
  # Idle eligibility is provider-generation coherence, deliberately not a
4336
4744
  # hero/quota/sessions age aggregate.
4337
4745
  if (state.availability not in ("ok", "empty")
@@ -4346,31 +4754,13 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
4346
4754
  # rebuild per tick, so it creates no retry loop.
4347
4755
  if aggregate_scope_failed(state):
4348
4756
  return False
4349
- # #834 S2 (#829, #830), gate 2 of 2, and the same shape as gate 1. A
4350
- # caught detail-probe read failure ALSO leaves an otherwise `ok` and
4351
- # `fresh` provider, because `availability` turns `partial` only on
4352
- # incomplete accounting rows, a failed hero projection or an unreadable
4353
- # account registry -- never on the probe. Without this leg the bundle
4354
- # qualifies for idle reuse and the transient state is republished for
4355
- # the life of the process, which is what the browser measured: 4 minutes
4356
- # 47 seconds across roughly twenty generations with no rebuild.
4357
- #
4358
- # `_lib_dashboard_sources._reusable_provider` refuses the same
4359
- # generation, and refusing it there is NOT sufficient: this branch
4360
- # republishes through the clock refreshes without ever calling
4361
- # `reuse_coherent_source_state`, so the predicate is bypassed before it
4362
- # is consulted. Both legs are required, and the browser gate found this
4363
- # one by reading the path the first fix did not reach.
4364
- health = state.metadata_health
4365
- if health is not None and bool(health.get("retryable")):
4366
- # #846 §4.6: refuse AND clear any armed key, so the tick after the
4367
- # fault clears rebuilds rather than finding a key still inside its
4368
- # deadline.
4369
- global _PARTIAL_RETRY_STATE
4370
- _PARTIAL_RETRY_STATE = clear_partial_retry(
4371
- _PARTIAL_RETRY_STATE, provider=source,
4372
- )
4373
- return False
4757
+ if settled_codex:
4758
+ # #857: the exception bypasses `_tui_retain_refused_partial`, so it
4759
+ # retires an obsolete Codex key itself (see the active-path twin in
4760
+ # `_tui_build_source_bundle`).
4761
+ _PARTIAL_RETRY_STATE = clear_partial_retry(
4762
+ _PARTIAL_RETRY_STATE, provider="codex",
4763
+ )
4374
4764
  return True
4375
4765
 
4376
4766
 
@@ -4786,9 +5176,16 @@ def _tui_build_snapshot_once(
4786
5176
  except Exception as exc:
4787
5177
  capture_failure("prior-source-bundle", "other", exc)
4788
5178
  dispatch_sig = None
5179
+ # #857: the Codex quota-dependency identity, read on the
5180
+ # signature's own cache handle. An unreadable identity is `None`.
5181
+ codex_dependency = None
4789
5182
  with _perf.phase("signature"):
4790
5183
  try:
4791
- dispatch_sig = _tui_compute_dispatch_signature(conn)
5184
+ dispatch_sig, codex_dependency = (
5185
+ _tui_compute_dispatch_signature(
5186
+ conn, with_codex_dependency=True,
5187
+ )
5188
+ )
4792
5189
  except QuotaProjectionIncomplete as exc:
4793
5190
  # #496 S5b §4.7: ahead of the generic handler on purpose.
4794
5191
  # Below it the refusal is sanitized into a generic
@@ -4796,9 +5193,11 @@ def _tui_build_snapshot_once(
4796
5193
  # remedy; the message this leg records carries both.
4797
5194
  capture_failure("quota-projection", "other", exc)
4798
5195
  dispatch_sig = None
5196
+ codex_dependency = None
4799
5197
  except Exception as exc:
4800
5198
  capture_failure("dispatch-signature", "stats_or_cache", exc)
4801
5199
  dispatch_sig = None
5200
+ codex_dependency = None
4802
5201
  if dispatch_sig is not None:
4803
5202
  # The idle decision keys on the DB signature AND a render key
4804
5203
  # that captures the config-derived inputs the composite
@@ -4814,7 +5213,15 @@ def _tui_build_snapshot_once(
4814
5213
  display_tz_pref_override,
4815
5214
  json.dumps(raw_config, sort_keys=True, default=str),
4816
5215
  )
4817
- dispatch_key = (dispatch_sig, render_key)
5216
+ # #857: the Codex quota-dependency identity — the dedicated
5217
+ # quota change ledger, the attribution revision, the projection
5218
+ # certificate and the cache file's identity, none of which the
5219
+ # signature above carries. It rides the dispatch KEY rather
5220
+ # than `SnapshotSignature`, so the published `data_version`
5221
+ # bytes and the signature's own contract are unchanged. `None`
5222
+ # (an unreadable identity) is a value like any other here; the
5223
+ # idle gate is what refuses to idle any Codex generation on it.
5224
+ dispatch_key = (dispatch_sig, render_key, codex_dependency)
4818
5225
  # #300: carry the change signal onto the non-idle snapshot built
4819
5226
  # below. (The idle path returns `idle_snap`, which inherits the
4820
5227
  # prior — equal, since idle ⇒ signature unchanged — value.)
@@ -4848,6 +5255,7 @@ def _tui_build_snapshot_once(
4848
5255
  claude_ingest_contended=claude_ingest_contended,
4849
5256
  claude_ingest_failed=claude_ingest_failed,
4850
5257
  errors=errors,
5258
+ codex_dependency=codex_dependency,
4851
5259
  )
4852
5260
  assert _sc is not None
4853
5261
  _sc.store_dispatch_state(dispatch_key, idle_snap)
@@ -5465,12 +5873,20 @@ def _tui_build_snapshot_once(
5465
5873
  # source panel with no cause and no remedy stated. The message
5466
5874
  # this leg records carries both.
5467
5875
  capture_failure("quota-projection", "other", exc)
5468
- source_bundle = prior_source_bundle
5876
+ source_bundle = _tui_retain_failed_source_bundle(
5877
+ prior_source_bundle, dispatch_key=dispatch_key,
5878
+ )
5469
5879
  except Exception as exc:
5470
5880
  # Public source warnings are stable/sanitized; the detailed
5471
5881
  # exception remains only on the internal rebuild-error string.
5882
+ # #857: `capture_failure` can raise `_StatsSnapshotCorruption`
5883
+ # before the retention below; the prior generation's accounting
5884
+ # provenance token refuses it on the heal's retry if this
5885
+ # build consumed accounting.
5472
5886
  capture_failure("source-bundle", "stats_or_cache", exc)
5473
- source_bundle = prior_source_bundle
5887
+ source_bundle = _tui_retain_failed_source_bundle(
5888
+ prior_source_bundle, dispatch_key=dispatch_key,
5889
+ )
5474
5890
  snap = dataclasses.replace(
5475
5891
  snap,
5476
5892
  last_sync_error=("; ".join(errors) if errors else None),
@@ -5587,7 +6003,35 @@ def _tui_precompute_envelope_config(raw_config: dict) -> dict:
5587
6003
  }
5588
6004
 
5589
6005
 
5590
- def _tui_compute_dispatch_signature(stats_conn):
6006
+ def _tui_open_observed_cache(c):
6007
+ """The dispatch read's cache handle, opened after observing its file (#857).
6008
+
6009
+ ``(opened_file, conn)``: `_tui_cache_file_before_open`'s observation and
6010
+ the handle opened immediately after it, so the Codex quota-dependency
6011
+ identity read on ``conn`` is bound to ``opened_file`` strictly.
6012
+
6013
+ When that observation fails, the handle has opened anyway, and the usual
6014
+ cause is the absence this very open repaired: a fresh install's first
6015
+ dispatch read is what creates cache.db. Nothing binds an identity to that
6016
+ handle — a stat taken after an open cannot show which file the open got —
6017
+ so it is closed, the file observed again, and a second handle opened,
6018
+ which is bound exactly like any other. Without that, a fresh install's
6019
+ first dispatch key would carry no identity and its second would carry one,
6020
+ and the second tick would pay a full rebuild for nothing. A second failed
6021
+ observation is final: ``opened_file`` stays ``None`` and so does the
6022
+ identity (rebuild). The extra open happens only on a tick whose file could
6023
+ not be observed, never on an ordinary tick.
6024
+ """
6025
+ opened_file = _tui_cache_file_before_open()
6026
+ conn = c.open_cache_db()
6027
+ if opened_file is not None:
6028
+ return opened_file, conn
6029
+ conn.close()
6030
+ opened_file = _tui_cache_file_before_open()
6031
+ return opened_file, c.open_cache_db()
6032
+
6033
+
6034
+ def _tui_compute_dispatch_signature(stats_conn, *, with_codex_dependency=False):
5591
6035
  """Composite data-version signature for the three-path dispatch (#268 M5.1).
5592
6036
 
5593
6037
  Cheap ``MAX(id)`` b-tree descents over cache.db + stats.db, the reset-event
@@ -5597,6 +6041,15 @@ def _tui_compute_dispatch_signature(stats_conn):
5597
6041
  ``compute_signature`` never raises (each leg degrades to 0 on a missing
5598
6042
  table); a cache-open failure propagates so the caller skips the idle path
5599
6043
  and does a full rebuild.
6044
+
6045
+ ``with_codex_dependency`` (#857) returns ``(signature, codex_dependency)``
6046
+ instead: the Codex quota-dependency identity read on the SAME throwaway
6047
+ handle (`_tui_open_observed_cache`), strictly bound to the file observed
6048
+ before that handle opened, and read in the SAME read transaction as the
6049
+ signature's cache legs — so a commit landing between the two cannot pair
6050
+ an older signature with a newer identity in one dispatch key. The tick
6051
+ pays no second connection for it. ``None`` when it cannot be established,
6052
+ which the idle gate treats as "rebuild".
5600
6053
  """
5601
6054
  c = _cctally()
5602
6055
  sc = c._load_sibling("_lib_snapshot_cache")
@@ -5605,16 +6058,29 @@ def _tui_compute_dispatch_signature(stats_conn):
5605
6058
  # projection tables from a pure kernel (#496 S5b section 4.7).
5606
6059
  from _cctally_quota import assert_projection_readable
5607
6060
  assert_projection_readable(stats_conn)
5608
- cache_conn = c.open_cache_db()
6061
+ if with_codex_dependency:
6062
+ opened_file, cache_conn = _tui_open_observed_cache(c)
6063
+ else:
6064
+ opened_file, cache_conn = None, c.open_cache_db()
5609
6065
  try:
5610
- return sc.compute_signature(
5611
- cache_conn,
5612
- stats_conn,
5613
- generation=sc.current_generation(),
5614
- codex_stats_digest=codex_stats_digest(stats_conn),
5615
- accounts_digest=accounts_identity_digest(stats_conn),
5616
- claude_stats_digest=claude_stats_digest(stats_conn),
5617
- )
6066
+ # One deferred read transaction on the cache handle: it pins nothing
6067
+ # until `compute_signature`'s first cache read, and every cache read
6068
+ # after that — the signature's legs and the identity's — shares that
6069
+ # snapshot. The stats legs stay statement-scoped, as before.
6070
+ with sc.one_read_snapshot(cache_conn):
6071
+ signature = sc.compute_signature(
6072
+ cache_conn,
6073
+ stats_conn,
6074
+ generation=sc.current_generation(),
6075
+ codex_stats_digest=codex_stats_digest(stats_conn),
6076
+ accounts_digest=accounts_identity_digest(stats_conn),
6077
+ claude_stats_digest=claude_stats_digest(stats_conn),
6078
+ )
6079
+ if not with_codex_dependency:
6080
+ return signature
6081
+ return signature, _tui_read_codex_quota_dependency(
6082
+ cache_conn, opened_file=opened_file,
6083
+ )
5618
6084
  finally:
5619
6085
  cache_conn.close()
5620
6086
 
@@ -5681,7 +6147,8 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
5681
6147
  claude_ingest_contended=False,
5682
6148
  claude_ingest_failed=False,
5683
6149
  failures=None,
5684
- stats_heal_attempted=False):
6150
+ stats_heal_attempted=False,
6151
+ codex_dependency):
5685
6152
  """Fresh snapshot reusing ``prior``'s heavy rows, re-patching only the
5686
6153
  time-derived fields + the doctor payload / envelope precompute on each idle
5687
6154
  tick (spec §3 idle path).
@@ -5708,6 +6175,10 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
5708
6175
  the idle path (the short-circuit only runs when the render key — which bundles
5709
6176
  the full raw config — is unchanged), so recomputing is cheap small-JSON I/O,
5710
6177
  not CPU, and byte-matches a full rebuild's envelope for the same state.
6178
+
6179
+ ``codex_dependency`` (#857) is this tick's Codex quota-dependency identity,
6180
+ REQUIRED with no default for the reason `_tui_source_bundle_can_idle`
6181
+ states: ``None`` idles no Codex generation.
5711
6182
  """
5712
6183
  import time
5713
6184
  doctor_payload = prior.doctor_payload
@@ -5723,6 +6194,8 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
5723
6194
  errors.append(f"envelope-precompute: {exc}")
5724
6195
  idle_failures = failures if failures is not None else []
5725
6196
  source_bundle = prior.source_bundle
6197
+ # #857: whether the bounded source adapter below scheduled the Codex pass.
6198
+ source_attempt: dict[str, bool] = {}
5726
6199
  if source_bundle is not None:
5727
6200
  try:
5728
6201
  prior_claude = source_bundle.sources["claude"]
@@ -5731,8 +6204,21 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
5731
6204
  # the idle clock is no longer entitled to speak for the cycle — its
5732
6205
  # public-history view cannot re-resolve it — so fall through to the
5733
6206
  # bounded source-adapter path, which rebuilds Codex authoritatively.
6207
+ #
6208
+ # #857: `codex_dependency` is this tick's Codex quota-dependency
6209
+ # identity; a Codex generation of any availability idles only
6210
+ # while it equals the identity that generation was built from, and
6211
+ # the settled cycle-unavailable one never across a failed or
6212
+ # contended Codex ingest.
5734
6213
  if (
5735
- _tui_source_bundle_can_idle(source_bundle)
6214
+ _tui_source_bundle_can_idle(
6215
+ source_bundle,
6216
+ codex_dependency=codex_dependency,
6217
+ codex_ingest_degraded=(
6218
+ codex_ingest_failed or codex_ingest_contended
6219
+ ),
6220
+ now_utc=now_utc,
6221
+ )
5736
6222
  and not codex_decision_deadline_passed(prior_codex, now_utc)
5737
6223
  ):
5738
6224
  claude = _refresh_claude_source_clock(
@@ -5794,6 +6280,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
5794
6280
  projects_envelope=prior.projects_envelope,
5795
6281
  prior_bundle=source_bundle,
5796
6282
  raw_config=raw_config,
6283
+ attempt=source_attempt,
5797
6284
  )
5798
6285
  except _StatsSnapshotCorruption:
5799
6286
  raise
@@ -5806,7 +6293,13 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
5806
6293
  idle_failures.append(SyncFailureAttribution(
5807
6294
  leg="quota-projection", database="other", corruption=False,
5808
6295
  ))
5809
- source_bundle = prior.source_bundle
6296
+ # #857: a failed adapter that scheduled the Codex pass may have
6297
+ # consumed accounting that committed after the dispatch read
6298
+ # (`_tui_withdraw_codex_quota_dependency`).
6299
+ source_bundle = (
6300
+ _tui_withdraw_codex_quota_dependency(prior.source_bundle)
6301
+ if source_attempt.get("codex_pass") else prior.source_bundle
6302
+ )
5810
6303
  except Exception as exc: # noqa: BLE001 — retain prior complete bundle
5811
6304
  # #496 S3 §8 (F16). This branch read stats through
5812
6305
  # `source_stats_conn` and swallowed the failure into a plain
@@ -5828,7 +6321,15 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
5828
6321
  )
5829
6322
  else:
5830
6323
  errors.append(f"source-clock-refresh: {exc}")
5831
- source_bundle = prior.source_bundle
6324
+ # #857: as in the handler above. A failure escalated as stats
6325
+ # corruption raised out of `_tui_capture_sync_failure` before this
6326
+ # line and never reaches it; the retained generation's accounting
6327
+ # provenance token refuses it on the heal's retry instead
6328
+ # (`_tui_codex_accounting_provenance_current`).
6329
+ source_bundle = (
6330
+ _tui_withdraw_codex_quota_dependency(prior.source_bundle)
6331
+ if source_attempt.get("codex_pass") else prior.source_bundle
6332
+ )
5832
6333
  return dataclasses.replace(
5833
6334
  prior,
5834
6335
  generated_at=now_utc,