cctally 1.115.0 → 1.116.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 (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/_cctally_cache.py +85 -4
  3. package/bin/_cctally_completion.py +80 -12
  4. package/bin/_cctally_core.py +106 -9
  5. package/bin/_cctally_dashboard.py +6 -9
  6. package/bin/_cctally_dashboard_envelope.py +26 -32
  7. package/bin/_cctally_db.py +85 -4
  8. package/bin/_cctally_doctor.py +38 -5
  9. package/bin/_cctally_five_hour.py +83 -1017
  10. package/bin/_cctally_journal.py +1307 -180
  11. package/bin/_cctally_journal_repair.py +9 -7
  12. package/bin/_cctally_oauth_samples.py +280 -0
  13. package/bin/_cctally_parser.py +5 -0
  14. package/bin/_cctally_quarantine.py +14 -4
  15. package/bin/_cctally_quota.py +70 -8
  16. package/bin/_cctally_quota_model.py +35 -4
  17. package/bin/_cctally_record.py +414 -123
  18. package/bin/_cctally_rederive.py +632 -235
  19. package/bin/_cctally_refresh.py +29 -0
  20. package/bin/_cctally_statusline.py +984 -47
  21. package/bin/_cctally_store.py +972 -58
  22. package/bin/_cctally_tui.py +62 -7
  23. package/bin/_cctally_update.py +25 -0
  24. package/bin/_lib_account_protocol.py +201 -18
  25. package/bin/_lib_clone_copy.py +46 -11
  26. package/bin/_lib_dashboard_sources.py +57 -15
  27. package/bin/_lib_detector_access_registry.py +273 -52
  28. package/bin/_lib_doctor.py +302 -1
  29. package/bin/_lib_journal.py +145 -25
  30. package/bin/_lib_journal_router.py +166 -48
  31. package/bin/_lib_oauth_samples.py +238 -0
  32. package/bin/_lib_pricing_check.py +0 -12
  33. package/bin/_lib_provider_activity.py +770 -0
  34. package/bin/_lib_quarantine.py +3 -2
  35. package/bin/_lib_quota_model.py +96 -6
  36. package/bin/_lib_rebuild_progress.py +146 -0
  37. package/bin/_lib_record.py +22 -0
  38. package/bin/_lib_rederive.py +299 -3
  39. package/bin/_lib_snapshot_cache.py +43 -1
  40. package/bin/_lib_stale_proof.py +708 -0
  41. package/bin/_lib_statusline_candidates.py +65 -5
  42. package/bin/_lib_worker_launches.py +315 -0
  43. package/bin/cctally +6 -11
  44. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  45. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  46. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  47. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  48. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  49. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  50. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  51. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  52. package/dashboard/static/dashboard.html +1 -1
  53. package/package.json +7 -2
  54. package/bin/_lib_merge_gate.py +0 -452
  55. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.116.0] - 2026-10-28
9
+
10
+ ### Added
11
+ - `cctally doctor`, `cctally db status` and the dashboard's Doctor chip now say when a stats.db upgrade rebuild or auto-heal is running, has stalled, failed or died, or used more memory than its bound.
12
+
13
+ ### Changed
14
+ - The stats.db upgrade rebuild that runs after an update, `cctally db rebuild` and `cctally db rederive` use far less memory and disk, and the upgrade rebuild finishes faster.
15
+
16
+ ### Fixed
17
+ - After a usage credit, `cctally statusline` refuses an idle session's cached usage once it is proven stale, and five-hour views stop hiding genuine weekly values. `cctally db rederive` adds `retract`; stats.db rebuilds once after upgrading.
18
+ - Under Python 3.11, `cctally quota --json` no longer reports a mix-support radius that differs in its last digits from one run to the next.
19
+ - `cctally quota --json` reports the same `eligibilityFence` on Linux as on macOS; on Linux it could differ in its last digits.
20
+ - A sync with nothing new to read no longer rewrites the compressed Codex rollout status in `cache.db` and `conversations.db`, so an idle `cctally dashboard` writes less to disk.
21
+ - `cctally cache-sync --rebuild` rebuilds transcripts again on a filesystem without copy-on-write clones, such as ext4 on Linux, instead of failing every time.
22
+ - On Python 3.11, the upgrade that moves transcripts out of `cache.db` now shrinks the file as intended. If you already upgraded on Python 3.11, run `cctally db vacuum --db cache` to reclaim the space.
23
+
8
24
  ## [1.115.0] - 2026-10-21
9
25
 
10
26
  ### Added
@@ -102,6 +102,7 @@ import bisect
102
102
  import collections
103
103
  import contextlib
104
104
  import datetime as dt
105
+ import errno
105
106
  import fcntl
106
107
  import json
107
108
  import os
@@ -9268,6 +9269,18 @@ def _record_codex_container_status(conn, stats, *, census_complete: bool,
9268
9269
  if cursor_table == "codex_session_files"
9269
9270
  else prior.get("barrier", {})),
9270
9271
  }
9272
+ # #901 W8: a pass that observes the status already recorded writes
9273
+ # nothing. Every caller runs this at finalization, the idle tick and the
9274
+ # idle conversation pass included, and stamping `observedAt` alone wrote a
9275
+ # WAL frame in each store on every pass more than a second after the last.
9276
+ # `observedAt` therefore keeps the time this status was first recorded;
9277
+ # the other timestamps already change only with the state they date.
9278
+ def _content(status):
9279
+ return json.dumps({k: v for k, v in status.items() if k != "observedAt"},
9280
+ sort_keys=True)
9281
+
9282
+ if prior and "observedAt" in prior and _content(prior) == _content(record):
9283
+ return
9271
9284
  _set_cache_meta(conn, CODEX_CONTAINER_STATUS_KEY,
9272
9285
  json.dumps(record, sort_keys=True))
9273
9286
 
@@ -15496,6 +15509,54 @@ def _conversation_probe_snapshot(path: pathlib.Path):
15496
15509
  yield snapshot
15497
15510
 
15498
15511
 
15512
+ class ConversationProbeCloneUnavailable(OSError):
15513
+ """The filesystem or platform cannot clone the transcript family.
15514
+
15515
+ The clone helper raises it when `cp` is missing, the platform has no
15516
+ clone command, or `cp` reports that the filesystem refused the clone (see
15517
+ :func:`_cp_refused_clone`). The pre-rebuild quick-check catches it, skips
15518
+ and lets the rebuild continue; recovery still declines without a snapshot.
15519
+ Every other clone failure stays a plain OSError and fails the rebuild.
15520
+ """
15521
+
15522
+
15523
+ # errno values that mean "this filesystem cannot clone these files", as
15524
+ # opposed to a failure while cloning them. Linux ioctl_ficlone(2) documents
15525
+ # EOPNOTSUPP (the filesystem cannot reflink, e.g. ext4), EXDEV (the paths are
15526
+ # on different mounts, e.g. a bind mount) and EINVAL (the filesystem cannot
15527
+ # reflink these files); ioctl(2) returns ENOTTY where the kernel or filesystem
15528
+ # has no FICLONE at all. macOS clonefile(2) documents ENOTSUP and EXDEV. Each
15529
+ # is a permanent property of where the store lives. Anything else (ENOSPC,
15530
+ # EDQUOT, EACCES, EPERM, ENOENT, EIO, or a failure to open, stat or create
15531
+ # before the clone) is a real failure and must fail the rebuild: an I/O error
15532
+ # reading a damaged store is the very signal the probe exists to catch.
15533
+ _CLONE_REFUSAL_ERRNOS = frozenset(
15534
+ getattr(errno, name)
15535
+ for name in ("EOPNOTSUPP", "ENOTSUP", "EXDEV", "EINVAL", "ENOTTY")
15536
+ if hasattr(errno, name)
15537
+ )
15538
+
15539
+
15540
+ def _cp_refused_clone(stderr: str) -> bool:
15541
+ """Whether `cp` reported a clone refused for one of those errno values.
15542
+
15543
+ `cp` runs under ``LC_ALL=C``, so its strerror text is the C locale's, the
15544
+ same text :func:`os.strerror` returns. GNU `cp --reflink=always` reports a
15545
+ failed FICLONE as ``failed to clone DST from SRC: <strerror>``. macOS
15546
+ `cp -c` falls back to copyfile(2), a full copy, when the target cannot
15547
+ clone, so it rarely reports a refusal at all; when clonefile(2) does fail
15548
+ it warns ``DST: clonefile failed: <strerror>``.
15549
+ """
15550
+ reasons = tuple(f": {os.strerror(code)}" for code in _CLONE_REFUSAL_ERRNOS)
15551
+ for line in stderr.splitlines():
15552
+ line = line.strip()
15553
+ if "failed to clone " not in line and ": clonefile failed: " not in line:
15554
+ continue
15555
+ if line.endswith(reasons):
15556
+ return True
15557
+ return False
15558
+
15559
+
15499
15560
  def _clone_conversation_probe_member(
15500
15561
  source: pathlib.Path,
15501
15562
  destination: pathlib.Path,
@@ -15513,7 +15574,7 @@ def _clone_conversation_probe_member(
15513
15574
  return
15514
15575
  cp = shutil.which("cp")
15515
15576
  if cp is None:
15516
- raise OSError(
15577
+ raise ConversationProbeCloneUnavailable(
15517
15578
  "copy-on-write transcript probe unavailable: `cp` was not found"
15518
15579
  )
15519
15580
  if sys.platform == "darwin":
@@ -15523,7 +15584,7 @@ def _clone_conversation_probe_member(
15523
15584
  cp, "--reflink=always", "--", str(source), str(destination),
15524
15585
  ]
15525
15586
  else:
15526
- raise OSError(
15587
+ raise ConversationProbeCloneUnavailable(
15527
15588
  "copy-on-write transcript probe is unsupported on "
15528
15589
  f"{sys.platform}"
15529
15590
  )
@@ -15535,6 +15596,7 @@ def _clone_conversation_probe_member(
15535
15596
  text=True,
15536
15597
  timeout=_CONVERSATION_PROBE_CLONE_TIMEOUT_SECONDS,
15537
15598
  check=False,
15599
+ env={**os.environ, "LC_ALL": "C"},
15538
15600
  )
15539
15601
  except (OSError, subprocess.TimeoutExpired) as exc:
15540
15602
  raise OSError(
@@ -15544,9 +15606,14 @@ def _clone_conversation_probe_member(
15544
15606
  reason = (result.stderr or "").strip() or (
15545
15607
  f"cp exited {result.returncode}"
15546
15608
  )
15609
+ if _cp_refused_clone(result.stderr or ""):
15610
+ raise ConversationProbeCloneUnavailable(
15611
+ "copy-on-write transcript probe unavailable for "
15612
+ f"{source.name}: {reason}"
15613
+ )
15547
15614
  raise OSError(
15548
- "copy-on-write transcript probe unavailable for "
15549
- f"{source.name}: {reason}"
15615
+ f"copy-on-write transcript probe failed for {source.name}: "
15616
+ f"{reason}"
15550
15617
  )
15551
15618
 
15552
15619
 
@@ -15622,6 +15689,20 @@ def _probe_conversation_rebuild(
15622
15689
  probe.close()
15623
15690
  except sqlite3.DatabaseError as exc:
15624
15691
  return exc
15692
+ except ConversationProbeCloneUnavailable as exc:
15693
+ # The clone helper raised this because the filesystem cannot
15694
+ # clone, which never changes: failing here failed every explicit
15695
+ # rebuild over an existing store on such a filesystem. Skip the
15696
+ # check (never a byte copy) and rebuild. Corruption quick_check
15697
+ # would have found may then go unnoticed, or surface later in a
15698
+ # rebuild worker; recovery declines without a snapshot either
15699
+ # way, so nothing is quarantined.
15700
+ eprint(
15701
+ "[cache-sync] conversations.db integrity check skipped "
15702
+ f"before the transcript rebuild ({exc}); continuing without "
15703
+ "automatic corruption recovery"
15704
+ )
15705
+ return None
15625
15706
  finally:
15626
15707
  keeper_yield.close()
15627
15708
  if provider_locks is not None:
@@ -114,6 +114,7 @@ import _lib_account_protocol
114
114
  import _lib_accounts
115
115
  import _lib_journal
116
116
  import _lib_journal_router
117
+ import _lib_rebuild_progress
117
118
  import _lib_rederive
118
119
 
119
120
  #: The correction-batch family this step (and the live collision check)
@@ -152,7 +153,7 @@ def is_deriving_input(record) -> bool:
152
153
  return False
153
154
  if record.get("t") == "obs":
154
155
  import _cctally_rederive
155
- return bool(_cctally_rederive._rederivable_raw_records([record]))
156
+ return _cctally_rederive._is_rederivable_raw(record)
156
157
  if record.get("t") == "op":
157
158
  return (record.get("payload") or {}).get("kind") in _DERIVING_OP_KINDS
158
159
  return False
@@ -800,6 +801,9 @@ def _complete(context, trusted, target, boundary):
800
801
  with tempfile.TemporaryDirectory(
801
802
  prefix="cctally-completion-", dir=str(_cctally_core.APP_DIR)) as tmp:
802
803
  base = pathlib.Path(tmp) / "predecessor.db"
804
+ # #997 D9: progress for a detached worker's state file (a no-op
805
+ # unless one installed a sink).
806
+ _lib_rebuild_progress.phase("completion:predecessor")
803
807
  _journal.rebuild_stats_index(
804
808
  context=_journal.RebuildContext(trigger=context.trigger),
805
809
  target_path=str(base), high_water=start,
@@ -847,6 +851,7 @@ def _apply_owed_outputs(base, start, output_end, boundary) -> frozenset:
847
851
  revision through that output end, in the rebuild's fold order (structural
848
852
  folds, the open 5h block projection, then milestones). Returns the ids
849
853
  applied, which the predecessor then holds."""
854
+ _lib_rebuild_progress.phase("completion:owed-outputs")
850
855
  prefix = _read_prefix(start, output_end, boundary)
851
856
  if prefix.cutover_claude is _journal._CUTOVER_UNSEEN:
852
857
  prefix.cutover_claude = _journal._resolve_cutover_for_rebuild(
@@ -873,6 +878,9 @@ def _apply_owed_outputs(base, start, output_end, boundary) -> frozenset:
873
878
  _journal._fold_order(item[1]), item[0]))
874
879
  conn = _cctally_core.open_db(_target_path=str(base))
875
880
  try:
881
+ # #997 (spec D1): the predecessor is a disposable scratch too.
882
+ import _cctally_store
883
+ _cctally_store.apply_disposable_scratch_policy(conn)
876
884
  for phase in ("structural", "tail"):
877
885
  if phase == "tail":
878
886
  import sys
@@ -917,6 +925,10 @@ class _Prefix:
917
925
  self.excluded_revisions: dict = {}
918
926
  self.inert_revisions: dict = {}
919
927
  self.begin_at: dict = {}
928
+ #: #797/#823/#840 spec 4.6 B1: the begin markers per batch id, so a
929
+ #: retry of a batch an earlier writer began without the revision-2
930
+ #: stamp is authored under a derived id instead of restamped.
931
+ self.begins: dict = {}
920
932
  self.batch_family: dict = {}
921
933
  self.cutover_claude = _journal._CUTOVER_UNSEEN
922
934
  # Admitted decision ids before the window (the predecessor holds
@@ -933,25 +945,57 @@ def _bump(table: dict, record) -> None:
933
945
  table[event_id] = max(rev, table.get(event_id, rev))
934
946
 
935
947
 
948
+ def _window_omits(record) -> bool:
949
+ """#997 spec D3: whether an ADMITTED record is left out of the window —
950
+ an observation the replay provably ignores: not a rederivable raw input
951
+ and not a Claude reading, so it yields no candidate and the quarantine
952
+ fold returns before touching state (`reading_from_record` requires
953
+ `is_claude_reading`). Codex quota observations, 85 % of a production
954
+ journal, are the population. Its line still feeds the prefix hasher and
955
+ its `None` slot, and `window_has_derivable_inputs` still scans it; an
956
+ EXCLUDED observation keeps its placeholder, which the fold's exclusion
957
+ trigger needs. Every window consumer reads the same compacted window
958
+ within one `_complete_window` call, so indexing stays consistent."""
959
+ if record.get("t") != "obs":
960
+ return False
961
+ import _cctally_quarantine
962
+ import _cctally_rederive
963
+ return (not _cctally_rederive._is_rederivable_raw(record)
964
+ and not _cctally_quarantine.is_claude_reading(record))
965
+
966
+
936
967
  def _read_prefix(start, target, boundary) -> _Prefix:
937
968
  out = _Prefix()
938
969
  segments = _journal.list_segments()
939
- hasher = _lib_journal_router.PrefixHashAccumulator()
970
+ hasher = _journal._new_prefix_hasher()
940
971
  prior = {"hw": None}
941
972
  placeholder = _lib_account_protocol.excluded_placeholder()
942
973
  start_key = _order(start)
974
+ lines = 0
943
975
  for segment, offset, raw in _journal._iter_range_with_segments(
944
976
  None, target, segments,
945
- on_segment=lambda name: hasher.begin_segment(name, prior["hw"]),
977
+ on_segment=lambda name, size: hasher.begin_segment(
978
+ name, prior["hw"], size=size),
946
979
  on_bytes=hasher.extend,
947
980
  ):
981
+ lines += 1
982
+ if not lines & 4095:
983
+ _lib_rebuild_progress.tick(lines=lines)
948
984
  end = (segment, offset + len(raw) + 1)
949
985
  record = _lib_journal.decode_line(raw)
950
986
  if record is None:
951
987
  prior["hw"] = end
952
988
  continue
953
989
  in_window = _order((segment, offset)) >= start_key
954
- if not _journal._is_admitted(record, segment, offset, boundary):
990
+ admitted = _journal._is_admitted(record, segment, offset, boundary)
991
+ t = record.get("t")
992
+ omitted = admitted and in_window and _window_omits(record)
993
+ if (in_window and not omitted) or (
994
+ admitted and t in _lib_journal_router.RETAINED_RECORD_TYPES):
995
+ # #997 D15: a record the window or the selector slots keep is
996
+ # compacted before anything holds a reference to it.
997
+ record = _lib_journal.compact_retained(record)
998
+ if not admitted:
955
999
  out.slots.append(None)
956
1000
  if record.get("t") in ("evt", "correction"):
957
1001
  _bump(out.excluded_revisions, record)
@@ -961,7 +1005,6 @@ def _read_prefix(start, target, boundary) -> _Prefix:
961
1005
  out.coordinates.append((segment, offset))
962
1006
  prior["hw"] = end
963
1007
  continue
964
- t = record.get("t")
965
1008
  if t == "op":
966
1009
  _journal._capture_protocol_prefix_evidence(
967
1010
  record, prior["hw"], out.evidence, hasher=hasher)
@@ -976,10 +1019,11 @@ def _read_prefix(start, target, boundary) -> _Prefix:
976
1019
  out.legacy_origin_ids.add(record["id"])
977
1020
  if t == "correction_batch" and record.get("phase") == "begin":
978
1021
  out.begin_at.setdefault(record.get("id"), record.get("at"))
1022
+ out.begins.setdefault(record.get("id"), []).append(record)
979
1023
  out.batch_family.setdefault(record.get("id"), record.get("family"))
980
1024
  out.slots.append(
981
1025
  record if t in _lib_journal_router.RETAINED_RECORD_TYPES else None)
982
- if in_window:
1026
+ if in_window and not omitted:
983
1027
  out.window.append(record)
984
1028
  out.coordinates.append((segment, offset))
985
1029
  prior["hw"] = end
@@ -1055,6 +1099,17 @@ def _no_cache_read_adapters():
1055
1099
  setattr(cctally, name, value)
1056
1100
 
1057
1101
 
1102
+ def _normalized_copy(record, cutover_claude):
1103
+ """One window record as a replay sees it: a private deep copy, its legacy
1104
+ account stamp normalized under ``cutover_claude`` (an excluded placeholder
1105
+ is only copied). Reads only this record and the journal-wide cutover
1106
+ value (#997 spec D5)."""
1107
+ out = copy.deepcopy(record)
1108
+ if out.get("t") != _lib_account_protocol.EXCLUDED_PLACEHOLDER_TYPE:
1109
+ _journal._normalize_legacy_account_stamp(out, cutover_claude)
1110
+ return out
1111
+
1112
+
1058
1113
  def _replay(base, tmp, name, prefix, held_ids, cache_conn, *, retention,
1059
1114
  skip=frozenset()):
1060
1115
  """One scratch replay of the window from the predecessor state, the
@@ -1065,17 +1120,19 @@ def _replay(base, tmp, name, prefix, held_ids, cache_conn, *, retention,
1065
1120
  import _cctally_rederive
1066
1121
  copy_path = tmp / name
1067
1122
  shutil.copyfile(base, copy_path)
1068
- records = copy.deepcopy(prefix.window)
1069
- for record in records:
1070
- if record.get("t") != _lib_account_protocol.EXCLUDED_PLACEHOLDER_TYPE:
1071
- _journal._normalize_legacy_account_stamp(
1072
- record, prefix.cutover_claude)
1123
+ cutover_claude = prefix.cutover_claude
1073
1124
  by_input: dict = {}
1074
1125
  conn = _cctally_core.open_db(_target_path=str(copy_path))
1075
1126
  try:
1127
+ # #997 (spec D1): a disposable replay copy, never published.
1128
+ import _cctally_store
1129
+ _cctally_store.apply_disposable_scratch_policy(conn)
1076
1130
  fold = _cctally_quarantine.QuarantineFold.load(conn, held_ids=held_ids)
1131
+ # #997 spec D5: each record is copied and normalized as it is
1132
+ # offered, never the whole window up front.
1077
1133
  events = _cctally_rederive.replay_scratch_records(
1078
- conn, records, cache_conn, fold=fold, held_ids=held_ids,
1134
+ conn, prefix.window, cache_conn, fold=fold, held_ids=held_ids,
1135
+ prepare=lambda record: _normalized_copy(record, cutover_claude),
1079
1136
  coordinates=prefix.coordinates, skip_indexes=skip,
1080
1137
  on_input=lambda index, derived: by_input.__setitem__(
1081
1138
  index, list(derived)),
@@ -1177,6 +1234,7 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
1177
1234
  holds the fresh candidate only where it differs from the reference, so a
1178
1235
  gap's effect reaches its descendants while every unaffected decision
1179
1236
  stays the retained one. Their difference is the causal suffix."""
1237
+ _lib_rebuild_progress.phase("completion:read", round=round_index)
1180
1238
  prefix = _read_prefix(start, target, boundary)
1181
1239
  if prefix.cutover_claude is _journal._CUTOVER_UNSEEN:
1182
1240
  prefix.cutover_claude = _journal._resolve_cutover_for_rebuild(
@@ -1203,6 +1261,7 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
1203
1261
  legacy = _legacy_decided_inputs(prefix)
1204
1262
 
1205
1263
  with _cache_read_view() as cache_conn:
1264
+ _lib_rebuild_progress.phase("completion:replay-all", round=round_index)
1206
1265
  _events, by_input = _replay(
1207
1266
  base, tmp, f"all-{round_index}.db", prefix, held_ids, cache_conn,
1208
1267
  retention=_retention(effective, prefix, owed, legacy=legacy))
@@ -1210,6 +1269,8 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
1210
1269
  gap = _gap_inputs(by_input, selection, legacy)
1211
1270
  if not gap:
1212
1271
  return False
1272
+ _lib_rebuild_progress.phase(
1273
+ "completion:replay-without", round=round_index)
1213
1274
  events_a, _ = _replay(
1214
1275
  base, tmp, f"without-{round_index}.db", prefix, held_ids,
1215
1276
  cache_conn, skip=gap,
@@ -1218,6 +1279,8 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
1218
1279
  for event in events_a:
1219
1280
  reference.setdefault(
1220
1281
  event["id"], _lib_journal.event_content_hash(event))
1282
+ _lib_rebuild_progress.phase(
1283
+ "completion:replay-with", round=round_index)
1221
1284
  events_b, _ = _replay(
1222
1285
  base, tmp, f"with-{round_index}.db", prefix, held_ids, cache_conn,
1223
1286
  retention=_retention(effective, prefix, owed, legacy=legacy,
@@ -1271,6 +1334,9 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
1271
1334
  actions, sort_keys=True, separators=(",", ":"),
1272
1335
  ensure_ascii=False).encode("utf-8")).hexdigest()
1273
1336
  batch_id = f"{FAMILY}:{digest}"
1337
+ if batch_id not in selection.completed_batches:
1338
+ batch_id = _lib_journal.revision_batch_id(
1339
+ batch_id, prefix.begins.get(batch_id, ()))
1274
1340
  if batch_id not in selection.completed_batches:
1275
1341
  records.extend(_lib_journal.make_correction_batch(
1276
1342
  batch_id=batch_id, family=FAMILY,
@@ -1283,5 +1349,7 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
1283
1349
  records.extend(plain)
1284
1350
  if not records:
1285
1351
  return False
1352
+ _lib_rebuild_progress.phase(
1353
+ "completion:append", round=round_index, records=len(records))
1286
1354
  _journal.append_records(records)
1287
1355
  return True
@@ -78,6 +78,7 @@ def _init_paths_from_env() -> None:
78
78
  global STATUSLINE_AUTHORITATIVE_7D_PATH, STATUSLINE_AUTHORITATIVE_5H_PATH
79
79
  global STATUSLINE_ACCOUNT_BOUND_DIR, LEGACY_SPOOL_BASELINE_PATH
80
80
  global ACCOUNT_BOUND_CUTOVER_CACHE_PATH, LEGACY_WRITER_EVIDENCE_PATH
81
+ global STATUSLINE_ACCOUNT_BOUND_V1_DIR, ACCOUNT_BOUND_REVISION_CACHE_PATH
81
82
  global OAUTH_BACKOFF_MARKER_PATH, OAUTH_BACKOFF_COUNT_PATH
82
83
  global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
83
84
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
@@ -192,11 +193,25 @@ def _init_paths_from_env() -> None:
192
193
  # the journal-anchored legacy spool baseline. The coordinate cache only
193
194
  # accelerates locating the cutover op and never confers authority; the
194
195
  # legacy-writer evidence sidecar is diagnostic only (spec section 8).
195
- STATUSLINE_ACCOUNT_BOUND_DIR = APP_DIR / "statusline-account-bound-v1"
196
+ #
197
+ # #797/#823/#840 (spec 4.6 B4): revision 2 of the fence moves every
198
+ # current coordination file — candidates, intake, evaluations, control,
199
+ # pending and retry state, acknowledgements, tombstones, frozen plans,
200
+ # locks, transition and recovery metadata, and the protocol files below
201
+ # — to the v2 root. No v1 state is imported. The v1 root is retired: its
202
+ # `accounts/` tree and `transport.last` join the legacy spool inventory
203
+ # (`_cctally_journal._legacy_spool_paths`), and this binary never writes,
204
+ # prunes or deletes anything under it.
205
+ STATUSLINE_ACCOUNT_BOUND_V1_DIR = APP_DIR / "statusline-account-bound-v1"
206
+ STATUSLINE_ACCOUNT_BOUND_DIR = APP_DIR / "statusline-account-bound-v2"
196
207
  LEGACY_SPOOL_BASELINE_PATH = (
197
208
  STATUSLINE_ACCOUNT_BOUND_DIR / "legacy-spool-baseline.json")
198
209
  ACCOUNT_BOUND_CUTOVER_CACHE_PATH = (
199
210
  STATUSLINE_ACCOUNT_BOUND_DIR / "cutover-coordinate.json")
211
+ #: The revision boundary's coordinate cache; like the cutover's, it only
212
+ #: accelerates the lookup and never confers authority.
213
+ ACCOUNT_BOUND_REVISION_CACHE_PATH = (
214
+ STATUSLINE_ACCOUNT_BOUND_DIR / "revision-coordinate.json")
200
215
  LEGACY_WRITER_EVIDENCE_PATH = (
201
216
  STATUSLINE_ACCOUNT_BOUND_DIR / "legacy-writer-evidence.json")
202
217
  OAUTH_BACKOFF_MARKER_PATH = APP_DIR / "oauth-backoff.until"
@@ -523,7 +538,23 @@ _init_paths_from_env()
523
538
  # `_cctally_store.validated_read_snapshot` checks inside each decision
524
539
  # scope's own snapshot. An epoch transition, not a numbered migration: the
525
540
  # registry stays frozen at 13.
526
- STATS_INDEX_EPOCH = 1018
541
+ # 1018 -> 1019 (#797/#823/#840 spec 4.6 B6): revision 2 of the fence changes
542
+ # the authoritative interpretation of retained journal records again — after
543
+ # the `account_bound_revision` boundary a Claude-scoped record without
544
+ # `wpr == 2` is excluded — so an index, a derivation-completeness proof or a
545
+ # selector continuation produced under revision 1 is not current. The #825
546
+ # section 6 precedent; still no numbered migration and no accounting column.
547
+ # The same epoch carries the provider-sample index state of stage 1 (#797
548
+ # Task 2, spec 4.3 D8 and D11), so 1019 is introduced once: the
549
+ # `claude_oauth_samples` projection of raw api observations carrying
550
+ # `oauth_request_started_at`, the `claude_weekly_observation_decisions`
551
+ # projection of reviewed weekly decision ops that resolves a sample's weekly
552
+ # eligibility at read time, and four NULLABLE identity columns on
553
+ # `five_hour_credit_confirmation_state` (`baseline_observation_id`,
554
+ # `baseline_generation`, `pending_generation`, `pending_position`) that the
555
+ # five-hour credit generation (Task 5) writes. Both projections are rebuilt
556
+ # from the journal; the confirmation state stays disposable.
557
+ STATS_INDEX_EPOCH = 1019
527
558
  LEGACY_STATS_HEAD = 13
528
559
 
529
560
 
@@ -2691,20 +2722,86 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2691
2722
  # above: a rebuild legitimately loses it, and the cold-start
2692
2723
  # consequence is stated at `_read_five_hour_source_state` in
2693
2724
  # `bin/_cctally_record.py`.
2725
+ #
2726
+ # #797/#823/#840 epoch 1019 (spec 4.9 D11): the four identity columns
2727
+ # the five-hour credit generation writes, all NULL until it does.
2728
+ # `baseline_observation_id` names the observation that set the
2729
+ # baseline; `baseline_generation` / `pending_generation` are the
2730
+ # effective five-hour credit generation the baseline and the armed
2731
+ # descent were observed under, encoded `"<event_id>@<revision>"` with
2732
+ # the no-credit sentinel `"none"`; `pending_position` is the arming
2733
+ # observation's logical position. Columns of the CREATE, not an
2734
+ # `add_column_if_missing`: an epoch-current open returns before any
2735
+ # schema work, so only the epoch's fresh CREATE can carry them.
2694
2736
  conn.execute(
2695
2737
  """
2696
2738
  CREATE TABLE IF NOT EXISTS five_hour_credit_confirmation_state (
2697
- account_key TEXT NOT NULL,
2698
- five_hour_window_key INTEGER NOT NULL,
2699
- source TEXT NOT NULL,
2700
- baseline_pct REAL NOT NULL,
2701
- pending_low_pct REAL,
2702
- pending_at_utc TEXT,
2703
- pending_observation_id TEXT,
2739
+ account_key TEXT NOT NULL,
2740
+ five_hour_window_key INTEGER NOT NULL,
2741
+ source TEXT NOT NULL,
2742
+ baseline_pct REAL NOT NULL,
2743
+ pending_low_pct REAL,
2744
+ pending_at_utc TEXT,
2745
+ pending_observation_id TEXT,
2746
+ baseline_observation_id TEXT,
2747
+ baseline_generation TEXT,
2748
+ pending_generation TEXT,
2749
+ pending_position TEXT,
2704
2750
  PRIMARY KEY (account_key, five_hour_window_key, source)
2705
2751
  )
2706
2752
  """
2707
2753
  )
2754
+ # #797/#823/#840 epoch 1019 (spec 4.3 D8, D9): provider samples. One
2755
+ # row per ADMITTED Claude api observation carrying a valid
2756
+ # `oauth_request_started_at` (`requested_at_utc`, canonical
2757
+ # full-precision UTC, so text order is time order), projected from
2758
+ # the RAW record by `_cctally_oauth_samples.project_sample` on every
2759
+ # derivation path, independently of the snapshot fold, the five-hour
2760
+ # adjustment and credit suppression or retirement. Values are the raw
2761
+ # readings, never the MAX-clamped stored ones; `weekly_resets_at_utc`
2762
+ # is nearest-hour normalized and `five_hour_window_key` is the
2763
+ # authoritative canonical key. Journal truth (rebuilt, never pruned),
2764
+ # indexed by account, axis window and `q` for the exact-window read.
2765
+ conn.execute(
2766
+ """
2767
+ CREATE TABLE IF NOT EXISTS claude_oauth_samples (
2768
+ account_key TEXT NOT NULL,
2769
+ observation_id TEXT NOT NULL,
2770
+ requested_at_utc TEXT NOT NULL,
2771
+ captured_at_utc TEXT NOT NULL,
2772
+ weekly_percent REAL,
2773
+ weekly_resets_at_utc TEXT,
2774
+ five_hour_percent REAL,
2775
+ five_hour_resets_at_utc TEXT,
2776
+ five_hour_window_key INTEGER,
2777
+ PRIMARY KEY (account_key, observation_id)
2778
+ )
2779
+ """
2780
+ )
2781
+ conn.execute(
2782
+ "CREATE INDEX IF NOT EXISTS idx_claude_oauth_samples_weekly "
2783
+ "ON claude_oauth_samples "
2784
+ "(account_key, weekly_resets_at_utc, requested_at_utc)"
2785
+ )
2786
+ conn.execute(
2787
+ "CREATE INDEX IF NOT EXISTS idx_claude_oauth_samples_five_hour "
2788
+ "ON claude_oauth_samples "
2789
+ "(account_key, five_hour_window_key, requested_at_utc)"
2790
+ )
2791
+ # The effective reviewed weekly decision per observation (latest
2792
+ # `claude_weekly_observation_decision[_v2]` op wins, journal order),
2793
+ # projected by that op's fold applier so a sample's weekly
2794
+ # eligibility is resolved at read time: `hold` excludes the weekly
2795
+ # sample only, a later `accept` restores it.
2796
+ conn.execute(
2797
+ """
2798
+ CREATE TABLE IF NOT EXISTS claude_weekly_observation_decisions (
2799
+ observation_id TEXT PRIMARY KEY,
2800
+ disposition TEXT NOT NULL,
2801
+ decision_op_id TEXT NOT NULL
2802
+ )
2803
+ """
2804
+ )
2708
2805
  _backfill_week_reset_events(conn)
2709
2806
 
2710
2807
  # ── five_hour_reset_events (Anthropic-issued in-place 5h credits) ──
@@ -11999,15 +11999,12 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
11999
11999
  # worker owns maintenance-exclusive, doing that before HTTP bind reintroduces
12000
12000
  # the exact multi-second startup stall this path exists to remove. Supply a
12001
12001
  # schema-valid, privacy-safe warning block; the first successful background
12002
- # snapshot replaces it with a real doctor payload.
12003
- doctor_payload = {
12004
- "severity": "warn",
12005
- "counts": {"ok": 0, "warn": 1, "fail": 0},
12006
- "generated_at": now_utc.astimezone(dt.timezone.utc).strftime(
12007
- "%Y-%m-%dT%H:%M:%SZ"
12008
- ),
12009
- "fingerprint": "sha1:" + ("0" * 40),
12010
- }
12002
+ # snapshot replaces it with a real doctor payload. #997 D10: the block still
12003
+ # carries the rebuild-worker aggregate, from the worker snapshots alone
12004
+ # (lock-free, never opens stats), so the chip names the running or dead
12005
+ # rebuild from the first frame.
12006
+ doctor_payload = tui._deferred_doctor_placeholder(now_utc)
12007
+ doctor_payload["rebuild_worker"] = tui._tui_rebuild_worker_value(now_utc)
12011
12008
  replacements = {
12012
12009
  # #583 S2 §6.1: this frame is degraded by construction — it carries a
12013
12010
  # `stats-open` error and no successful build ran, so it must not stamp