cctally 1.118.1 → 1.118.2

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.
@@ -4774,6 +4774,10 @@ def _tui_build_source_bundle(
4774
4774
  codex_version,
4775
4775
  prior_codex.combined_accounting if prior_codex is not None else None,
4776
4776
  )
4777
+ # #1059 ack (b): set only where this build constructs the Claude
4778
+ # state or reuses it at its own version; a degraded, retained or
4779
+ # unavailable state never qualifies.
4780
+ claude_published_current = False
4777
4781
  if claude_ingest_failed:
4778
4782
  warning = SourceDashboardWarning(
4779
4783
  "source_ingest_failed", "Source ingest failed.", "ingest",
@@ -4796,6 +4800,9 @@ def _tui_build_source_bundle(
4796
4800
  claude = reuse_coherent_source_state(
4797
4801
  prior_claude, data_version=claude_reuse_version,
4798
4802
  )
4803
+ # #1059 ack (b): an exact-version reuse at this build's own
4804
+ # version is current; a retained refused partial is not.
4805
+ claude_published_current = claude is not None
4799
4806
  # #834 S2 (#830): exact-version reuse now refuses every `partial`
4800
4807
  # prior. The kernel decides whether a REPEATED retainable cause
4801
4808
  # may nonetheless be retained for this tick, so refusing reuse
@@ -4808,6 +4815,7 @@ def _tui_build_source_bundle(
4808
4815
  data_version=claude_reuse_version,
4809
4816
  ):
4810
4817
  claude = prior_claude
4818
+ claude_published_current = False
4811
4819
  # #556 S2 §3.6, gate 2 of 2. The version fragment above already
4812
4820
  # rejects a failed generation, but this gate is stated explicitly
4813
4821
  # rather than left implicit in string arithmetic: exact-version
@@ -5001,6 +5009,7 @@ def _tui_build_source_bundle(
5001
5009
  cache_conn.rollback()
5002
5010
  cache_read_tx = False
5003
5011
  if claude is None:
5012
+ claude_published_current = True
5004
5013
  claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
5005
5014
  # #341 Task 4 (Ruling C): the conditional per-account Claude wire,
5006
5015
  # symmetric with Codex. Built ONLY when the Claude provider has >1
@@ -5331,6 +5340,10 @@ def _tui_build_source_bundle(
5331
5340
  return _tui_withdraw_codex_quota_dependency(prior_bundle)
5332
5341
  return prior_bundle
5333
5342
  raise RuntimeError("source read generation moved during build")
5343
+ if attempt is not None:
5344
+ attempt["claude_published_current"] = bool(
5345
+ claude_published_current
5346
+ and not aggregate_scope_failed(claude))
5334
5347
  return bundle
5335
5348
  finally:
5336
5349
  _record_cache_pin()
@@ -5642,6 +5655,134 @@ def _tui_build_snapshot_impl(
5642
5655
  )
5643
5656
 
5644
5657
 
5658
+ def _tui_project_identity_now() -> float:
5659
+ """The monotonic clock of the project-identity sweep (a test seam)."""
5660
+ return time.monotonic()
5661
+
5662
+
5663
+ def _tui_codex_regroup_selector(buckets: "frozenset"):
5664
+ """Select retained Codex entries whose opaque key names a bucket in ``B``.
5665
+
5666
+ #1059 §6.2 step 4. The opaque key is per root, so the stale keys are
5667
+ computed once per root an entry carries. An entry whose key came from
5668
+ ``git_json`` never matches, because it was not built from a bucket path.
5669
+ Over-selection costs a reload, never correctness.
5670
+ """
5671
+ opaque_project_key = _cctally()._load_sibling(
5672
+ "_lib_source_analytics").opaque_project_key
5673
+ stale_by_root: "dict[str, frozenset]" = {}
5674
+
5675
+ def select(entry) -> bool:
5676
+ if not buckets:
5677
+ return False
5678
+ root = str(entry.source_root_key)
5679
+ stale = stale_by_root.get(root)
5680
+ if stale is None:
5681
+ stale = frozenset(
5682
+ opaque_project_key("codex", root, bucket) for bucket in buckets)
5683
+ stale_by_root[root] = stale
5684
+ return entry.project_key in stale
5685
+
5686
+ return select
5687
+
5688
+
5689
+ def _tui_revalidate_project_identities(
5690
+ *, now_monotonic: "float | None" = None,
5691
+ ) -> int:
5692
+ """Run the #1059 project-identity sweep when due; apply its flips.
5693
+
5694
+ Called once per snapshot build, after the single top-of-build ingest and
5695
+ before the dispatch read, for every build (the dashboard's periodic,
5696
+ ``/api/sync`` and settings builds, and the TUI's). On a successful sweep
5697
+ with flips, with ``B`` its stale buckets, in this order: evict every cached
5698
+ closed week holding a bucket in ``B`` (totals included); reset the
5699
+ current-week slot iff it holds one; reset the whole-envelope memo; force
5700
+ the retained Codex entries keyed to a bucket in ``B`` to be re-qualified;
5701
+ bump the generation exactly once and record it as the pending regroup
5702
+ generation ``G``. The bump leaves the idle path in the same build and
5703
+ mismatches the range memo's ``base``, which refolds it cold. Nothing else
5704
+ is cleared.
5705
+
5706
+ Cadence retry: a successful due sweep without flips bumps once (and
5707
+ records the new ``G``) while a regroup is still pending or Codex
5708
+ re-qualification is outstanding, so a regroup whose rebuild failed is
5709
+ retried at most once per interval. A failed sweep never bumps; its
5710
+ deadline stays due and it retries itself.
5711
+
5712
+ Returns the build generation, read after these steps; the acknowledgement
5713
+ at the end of the build compares it with ``G``.
5714
+ """
5715
+ c = _cctally()
5716
+ sc = c._load_sibling("_lib_snapshot_cache")
5717
+ now = _tui_project_identity_now() if now_monotonic is None else now_monotonic
5718
+ sweep = sc.revalidate_project_identities(
5719
+ resolve=c._resolve_project_key, now_monotonic=now)
5720
+ if sweep is not None:
5721
+ if sweep.flips:
5722
+ buckets = sweep.old_buckets
5723
+ try:
5724
+ sc.evict_projects_env_weeks_for_buckets(buckets)
5725
+ sc.reset_projects_env_current_if_holds(buckets)
5726
+ c._projects_reset_memo()
5727
+ sc.force_codex_accounting_paths(
5728
+ _tui_codex_regroup_selector(buckets))
5729
+ sc.bump_generation()
5730
+ sc.mark_project_regroup_pending(sc.current_generation())
5731
+ except BaseException:
5732
+ # The sweep already committed `{fresh}`; without the stale
5733
+ # identities back, no later sweep would see this flip and
5734
+ # the stale grouping would last until restart. The deadline
5735
+ # stays, so the next due sweep retries the whole flip.
5736
+ sc.restore_project_identity_flips(sweep)
5737
+ raise
5738
+ elif (
5739
+ sc.project_regroup_pending() is not None
5740
+ or sc.codex_accounting_work_outstanding()
5741
+ ):
5742
+ sc.bump_generation()
5743
+ sc.note_project_identity_retry_bump()
5744
+ sc.mark_project_regroup_pending(sc.current_generation())
5745
+ return sc.current_generation()
5746
+
5747
+
5748
+ def _tui_acknowledge_project_regroup(
5749
+ *,
5750
+ build_generation: int,
5751
+ envelope_built: bool,
5752
+ published_claude=None,
5753
+ claude_published_current: bool = True,
5754
+ dashboard: bool,
5755
+ ) -> None:
5756
+ """Clear the pending regroup generation ``G`` once this build published it.
5757
+
5758
+ (a) Every path: this build constructed the projects envelope without an
5759
+ exception, and its build generation (read right after the sweep) is at
5760
+ least ``G``. (b) The dashboard path also requires that the published
5761
+ Claude source is the one this build constructed or reused at its own
5762
+ input version (``claude_published_current``), that its aggregate scope
5763
+ did not fail, and that the range memo committed under a generation at
5764
+ least ``G`` (its ``base[3]``), which needs both of its folds to succeed.
5765
+ A build that fails any condition leaves ``G`` set for the cadence retry.
5766
+ """
5767
+ sc = _cctally()._load_sibling("_lib_snapshot_cache")
5768
+ pending = sc.project_regroup_pending()
5769
+ if pending is None:
5770
+ return
5771
+ if not envelope_built or build_generation < pending:
5772
+ return
5773
+ if dashboard:
5774
+ if not claude_published_current or published_claude is None:
5775
+ return
5776
+ if aggregate_scope_failed(published_claude):
5777
+ return
5778
+ state = sys.modules["_cctally_dashboard"]._CLAUDE_RANGE_AGGREGATE_MEMO.get(
5779
+ "state")
5780
+ base = state.get("base") if isinstance(state, dict) else None
5781
+ if not isinstance(base, tuple) or len(base) < 4 or int(base[3]) < pending:
5782
+ return
5783
+ sc.acknowledge_project_regroup()
5784
+
5785
+
5645
5786
  def _tui_build_snapshot_once(
5646
5787
  *,
5647
5788
  now_utc: dt.datetime | None = None,
@@ -5834,6 +5975,29 @@ def _tui_build_snapshot_once(
5834
5975
  # Force pure reads for every view builder below, independent of the
5835
5976
  # caller's flag: the single ingest above is the only glob per tick.
5836
5977
  skip_sync = True
5978
+ # ── #1059: project-identity sweep (cadence-gated, every build) ──
5979
+ # After the ingest and BEFORE the dispatch read, so a filesystem-only
5980
+ # flip bumps the generation this build's dispatch key reads and the
5981
+ # regroup is published by this same build. Outside the
5982
+ # `precompute_envelope` branch and not gated on the caller's
5983
+ # `skip_sync`: an `/api/sync`, settings or TUI build sweeps when due.
5984
+ # `regroup_build_generation` is read once here, before any envelope
5985
+ # construction, and is what the acknowledgement compares with `G`.
5986
+ # Its own phase: a due sweep re-resolves every registered spelling
5987
+ # through the git-root resolver, which is filesystem work that grows
5988
+ # with the project corpus and would otherwise land in the root's
5989
+ # unattributed remainder.
5990
+ with _perf.phase("project_identity"):
5991
+ try:
5992
+ regroup_build_generation = _tui_revalidate_project_identities()
5993
+ except Exception as exc:
5994
+ capture_failure("project-identity", "other", exc)
5995
+ regroup_build_generation = _cctally()._load_sibling(
5996
+ "_lib_snapshot_cache").current_generation()
5997
+ projects_envelope_built = False
5998
+ # The out-parameter of `_tui_build_source_bundle`: whether the Claude
5999
+ # source it published is current for this build (#1059 ack (b)).
6000
+ source_attempt: dict[str, bool] = {}
5837
6001
  # ── #269 M3.1: shared per-weekref immutable-cost cache (spec §4/§6) ──
5838
6002
  # Gated OFF by default; flipped ON only on the dashboard sync-thread
5839
6003
  # NON-IDLE path below, AFTER the once-per-rebuild `reconcile_weekref_cache`
@@ -6459,7 +6623,12 @@ def _tui_build_snapshot_once(
6459
6623
  content_revision_leg=(
6460
6624
  claude_input_identity[0].claude_content_revision
6461
6625
  if claude_input_identity is not None else None),
6626
+ # #1059: this owner build retains the result through the
6627
+ # envelope memo, so its resolutions feed the identity sweep.
6628
+ project_resolver_cache=c._load_sibling(
6629
+ "_lib_snapshot_cache").observing_project_memo(),
6462
6630
  )
6631
+ projects_envelope_built = True
6463
6632
  except Exception as exc:
6464
6633
  capture_failure("projects-envelope", "stats_or_cache", exc)
6465
6634
  finally:
@@ -6504,7 +6673,10 @@ def _tui_build_snapshot_once(
6504
6673
  if bp and k and bp not in key_by_bucket_path:
6505
6674
  key_by_bucket_path[bp] = k
6506
6675
  _resolve = _cctally()._resolve_project_key
6507
- resolver_cache: dict = {}
6676
+ # #1059: an observing memo, so the identity sweep covers the
6677
+ # spellings this binding resolves on the owner.
6678
+ resolver_cache: dict = _cctally()._load_sibling(
6679
+ "_lib_snapshot_cache").observing_project_memo()
6508
6680
  annotated: list[TuiSessionRow] = []
6509
6681
  for srow in sessions:
6510
6682
  pkey = None
@@ -6702,6 +6874,7 @@ def _tui_build_snapshot_once(
6702
6874
  evidence_eligible_at=evidence_eligible_at,
6703
6875
  claude_input_identity=claude_input_identity,
6704
6876
  codex_speed=codex_speed,
6877
+ attempt=source_attempt,
6705
6878
  )
6706
6879
  if source_bundle is None:
6707
6880
  raise RuntimeError("source bundle builder returned no bundle")
@@ -6741,6 +6914,21 @@ def _tui_build_snapshot_once(
6741
6914
  _cctally()._load_sibling("_lib_snapshot_cache").store_dispatch_state(
6742
6915
  dispatch_key, snap,
6743
6916
  )
6917
+ # #1059: acknowledge a pending regroup only once this build published
6918
+ # it — on the dashboard after the snapshot is stored above, and only
6919
+ # when a dispatch key let it be stored at all.
6920
+ if not precompute_envelope or dispatch_key is not None:
6921
+ published_bundle = snap.source_bundle
6922
+ _tui_acknowledge_project_regroup(
6923
+ build_generation=regroup_build_generation,
6924
+ envelope_built=projects_envelope_built,
6925
+ published_claude=(
6926
+ published_bundle.sources.get("claude")
6927
+ if published_bundle is not None else None),
6928
+ claude_published_current=bool(
6929
+ source_attempt.get("claude_published_current")),
6930
+ dashboard=precompute_envelope,
6931
+ )
6744
6932
  # #276 perf: close the "snapshot" root and freeze the completed tree
6745
6933
  # into the process-global slot for the loopback /api/debug/backend
6746
6934
  # endpoint. Whole-dict atomic assignment; never mutated after. No-op
package/bin/_lib_quota.py CHANGED
@@ -252,6 +252,37 @@ class ResetAnchorComponents:
252
252
  for raw in raws:
253
253
  self.add(raw)
254
254
 
255
+ @classmethod
256
+ def from_evidence(
257
+ cls, entries: "Iterable[tuple[dt.datetime, tuple[str, int, int], int]]",
258
+ *, tolerance_seconds: int = CODEX_RESET_ANCHOR_TOLERANCE_SECONDS,
259
+ ) -> "ResetAnchorComponents":
260
+ """Rebuild a group's components from its per-raw evidence (#1033).
261
+
262
+ Each entry is ``(instant, order_key, row_count)``: one raw reset
263
+ spelling's parsed instant, the exact minimum order key of its rows and
264
+ how many rows carry it. Adding the entries in ascending ``order_key``
265
+ order reproduces the one-``add``-per-row seed exactly: a member enters
266
+ when its minimum key is reached, so members, member orders, anchors and
267
+ iteration order match, and a later row of an existing instant never
268
+ changes anything the minimum did not already set. ``_next_order``
269
+ becomes the total row count, the value the per-row seed leaves, so an
270
+ order-key-less ``add`` afterwards draws the same order.
271
+ """
272
+ materialized = list(entries)
273
+ total = 0
274
+ for _instant, _order_key, row_count in materialized:
275
+ if (not isinstance(row_count, int) or isinstance(row_count, bool)
276
+ or row_count < 1):
277
+ raise ValueError("row_count must be a positive int")
278
+ total += row_count
279
+ components = cls(tolerance_seconds=tolerance_seconds)
280
+ for instant, order_key, _row_count in sorted(
281
+ materialized, key=lambda entry: entry[1]):
282
+ components.add(instant, order_key=order_key)
283
+ components._next_order = total
284
+ return components
285
+
255
286
  @property
256
287
  def tolerance_seconds(self) -> int:
257
288
  return self._tolerance