cctally 1.116.1 → 1.117.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +263 -17
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +44 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +446 -22
  15. package/bin/_cctally_forecast.py +302 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_milestone_history.py +379 -91
  18. package/bin/_cctally_oauth_samples.py +185 -7
  19. package/bin/_cctally_parser.py +38 -0
  20. package/bin/_cctally_project.py +34 -8
  21. package/bin/_cctally_quarantine.py +821 -343
  22. package/bin/_cctally_quota.py +69 -4
  23. package/bin/_cctally_quota_calibration.py +677 -3
  24. package/bin/_cctally_quota_model.py +927 -127
  25. package/bin/_cctally_record.py +922 -445
  26. package/bin/_cctally_rederive.py +119 -15
  27. package/bin/_cctally_statusline.py +1048 -176
  28. package/bin/_cctally_store.py +544 -110
  29. package/bin/_cctally_tui.py +455 -42
  30. package/bin/_cctally_update.py +77 -14
  31. package/bin/_lib_account_protocol.py +227 -19
  32. package/bin/_lib_claude_attribution.py +421 -0
  33. package/bin/_lib_clone_copy.py +30 -0
  34. package/bin/_lib_codex_conversation_query.py +45 -9
  35. package/bin/_lib_codex_conversation_watch.py +72 -13
  36. package/bin/_lib_codex_rollout_container.py +234 -19
  37. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  38. package/bin/_lib_credit.py +22 -13
  39. package/bin/_lib_dashboard_sources.py +257 -18
  40. package/bin/_lib_detector_access_registry.py +1335 -166
  41. package/bin/_lib_doctor.py +276 -92
  42. package/bin/_lib_ingest_frontier.py +269 -36
  43. package/bin/_lib_journal.py +273 -22
  44. package/bin/_lib_jsonl.py +12 -0
  45. package/bin/_lib_meter_rate_change.py +72 -0
  46. package/bin/_lib_outcome_quarantine.py +917 -554
  47. package/bin/_lib_provider_activity.py +24 -6
  48. package/bin/_lib_quota_calibration.py +57 -3
  49. package/bin/_lib_quota_copy.py +7 -2
  50. package/bin/_lib_quota_lineage.py +613 -0
  51. package/bin/_lib_quota_model.py +464 -33
  52. package/bin/_lib_rate_change_delivery.py +16 -0
  53. package/bin/_lib_reclaim_planner.py +6 -4
  54. package/bin/_lib_rederive.py +2046 -21
  55. package/bin/_lib_selector_state.py +131 -3
  56. package/bin/_lib_snapshot_cache.py +8 -0
  57. package/bin/_lib_stale_proof.py +113 -4
  58. package/bin/_lib_statusline.py +72 -3
  59. package/bin/_lib_statusline_candidates.py +246 -42
  60. package/bin/_lib_tick_stats.py +10 -5
  61. package/bin/_lib_write_io.py +419 -6
  62. package/bin/cctally +28 -11
  63. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
  64. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
  65. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
  66. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
  67. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
  68. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
  69. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
  70. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  71. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
  72. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  73. package/dashboard/static/dashboard.html +3 -3
  74. package/package.json +5 -1
  75. package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
  76. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -435,6 +435,8 @@ class ObservedFile(NamedTuple):
435
435
  container_known: bool = False
436
436
  kind: str = "plain"
437
437
  mtime_ns: "int | None" = None
438
+ #: #970: ``"dated"`` or ``"archive"`` — where the selected container is.
439
+ location: str = _rc.DATED
438
440
 
439
441
 
440
442
  class ContainerEvidence(NamedTuple):
@@ -448,6 +450,30 @@ class ContainerEvidence(NamedTuple):
448
450
  inode: "int | None"
449
451
  state: str
450
452
  pending_fingerprint: "str | None"
453
+ #: #970 spec 5.5: the stored ``container_path`` — None means the accepted
454
+ #: container is the logical path; otherwise the container's path.
455
+ location: "str | None" = None
456
+
457
+ def accepted_location(self, logical, archive_dir) -> str:
458
+ """``"archive"`` when the accepted container sits in ``archive_dir``.
459
+
460
+ The stored path's DIRECTORY is compared, canonically when the
461
+ spellings differ, so a symlinked file in the archive is still an
462
+ archive container — exactly as selection names its location. No
463
+ archive directory, or an uncontained stored path, is ``"dated"``.
464
+ """
465
+ if self.location is None or archive_dir is None:
466
+ return _rc.DATED
467
+ parent = pathlib.Path(self.location).parent
468
+ if parent == pathlib.Path(logical).parent:
469
+ return _rc.DATED
470
+ if parent == pathlib.Path(archive_dir):
471
+ return _rc.ARCHIVE
472
+ try:
473
+ same = os.path.realpath(parent) == os.path.realpath(archive_dir)
474
+ except (OSError, ValueError):
475
+ return _rc.DATED
476
+ return _rc.ARCHIVE if same else _rc.DATED
451
477
 
452
478
  def _same_container(self, kind: str, st) -> bool:
453
479
  if kind != self.kind or source_identity_replaced(
@@ -479,7 +505,8 @@ class ContainerEvidence(NamedTuple):
479
505
 
480
506
  _CONTAINER_EVIDENCE_COLUMNS = (
481
507
  "path, container_kind, container_size_bytes, decoded_size_bytes, "
482
- "mtime_ns, device_id, inode, container_state, pending_container_fingerprint")
508
+ "mtime_ns, device_id, inode, container_state, pending_container_fingerprint, "
509
+ "container_path")
483
510
 
484
511
 
485
512
  def _container_evidence_for(conn, table: str, paths) -> "dict[str, ContainerEvidence]":
@@ -502,43 +529,61 @@ def _container_evidence_for(conn, table: str, paths) -> "dict[str, ContainerEvid
502
529
  f"WHERE path IN ({placeholders})", chunk):
503
530
  out[str(row[0])] = ContainerEvidence(
504
531
  row[1] or _rc.PLAIN, row[2], row[3], row[4], row[5],
505
- row[6], row[7] or "accepted", row[8])
532
+ row[6], row[7] or "accepted", row[8], row[9])
506
533
  except sqlite3.OperationalError:
507
534
  return {}
508
535
  return out
509
536
 
510
537
 
511
- def _observe_logical(path: str, evidence: "ContainerEvidence | None"):
538
+ def _observe_logical(path: str, evidence: "ContainerEvidence | None", *,
539
+ archive_dir=None):
512
540
  """Restat one LOGICAL rollout through its selected container (5.2, 5.9).
513
541
 
514
542
  Plain wins and keeps today's single ``pathlib`` stat, so a plain estate
515
543
  is observed exactly as before; the ``.zst`` twin is statted only when the
516
- plain file is absent.
544
+ plain file is absent, and — #970 — the archive candidates only when neither dated
545
+ container exists and the caller names the archive directory, in the
546
+ leaf's selection order.
547
+
548
+ #970 A8: location is observed separately from content. A container at a
549
+ different location than the cursor accepted is pending location
550
+ adoption — never a cursor gap or a vanished file. When the stat evidence
551
+ otherwise accepts it, the walk adopts the location on its next pass, so
552
+ the hold is ``container_known`` (the move itself changes certified
553
+ directory evidence, which is what schedules that pass); any other
554
+ relocation is a new container transition and is requested once.
517
555
  """
518
- kind = _rc.PLAIN
519
- try:
520
- st = pathlib.Path(path).stat()
521
- except OSError:
556
+ st = None
557
+ for kind, location, candidate in _rc.container_candidates(
558
+ path, archive_dir=archive_dir):
522
559
  try:
523
- st = pathlib.Path(_rc.zstd_twin(path)).stat()
560
+ st = candidate.stat()
524
561
  except OSError:
525
- return None
526
- kind = _rc.ZSTD
562
+ continue
563
+ break
564
+ if st is None:
565
+ return None
566
+ if (evidence is not None and evidence.state == "accepted"
567
+ and evidence.accepted_location(path, archive_dir) != location):
568
+ return ObservedFile(
569
+ 0, st.st_dev, st.st_ino, container_pending=True,
570
+ container_known=evidence.accepts(kind, st),
571
+ kind=kind, mtime_ns=st.st_mtime_ns, location=location)
527
572
  if evidence is not None and evidence.accepts(kind, st):
528
573
  size = (st.st_size if kind == _rc.PLAIN
529
574
  else int(evidence.decoded_size))
530
575
  return ObservedFile(size, st.st_dev, st.st_ino, kind=kind,
531
- mtime_ns=st.st_mtime_ns)
576
+ mtime_ns=st.st_mtime_ns, location=location)
532
577
  if kind == _rc.PLAIN and (evidence is None or (
533
578
  evidence.state == "accepted" and evidence.kind == _rc.PLAIN)):
534
579
  # Today's plain evidence: append, shrink and replacement are decided
535
580
  # by the existing classifier from size and identity.
536
581
  return ObservedFile(st.st_size, st.st_dev, st.st_ino, kind=kind,
537
- mtime_ns=st.st_mtime_ns)
582
+ mtime_ns=st.st_mtime_ns, location=location)
538
583
  return ObservedFile(
539
584
  0, st.st_dev, st.st_ino, container_pending=True,
540
585
  container_known=evidence is not None and evidence.holds(kind, st),
541
- kind=kind, mtime_ns=st.st_mtime_ns)
586
+ kind=kind, mtime_ns=st.st_mtime_ns, location=location)
542
587
 
543
588
 
544
589
  def source_identity_replaced(
@@ -781,9 +826,10 @@ def _bump_counter(name: str, amount: int = 1) -> None:
781
826
 
782
827
 
783
828
  def _observe_file(path: str, table: str,
784
- evidence: "ContainerEvidence | None" = None) -> "ObservedFile | None":
829
+ evidence: "ContainerEvidence | None" = None, *,
830
+ archive_dir=None) -> "ObservedFile | None":
785
831
  _bump_counter(f"retained_file_visits.{table}")
786
- return _observe_logical(path, evidence)
832
+ return _observe_logical(path, evidence, archive_dir=archive_dir)
787
833
 
788
834
 
789
835
  #: The two Codex cursor tables the restat may read, and whether each carries
@@ -820,7 +866,7 @@ def _ingest_source_table(provider: str) -> str:
820
866
 
821
867
 
822
868
  def plan_recent_active_restat(
823
- conn, *, table: str = "codex_session_files", extra_paths=(),
869
+ conn, *, table: str = "codex_session_files", extra_paths=(), roots=(),
824
870
  ):
825
871
  """Restat the bounded recent set and return ``(targets, escalation)``.
826
872
 
@@ -829,7 +875,9 @@ def plan_recent_active_restat(
829
875
  targeted work at all, in which case the caller must walk exhaustively.
830
876
 
831
877
  An empty recent set performs ZERO stats, which is the whole shape of an
832
- idle caught-up tick on the production store.
878
+ idle caught-up tick on the production store. ``roots`` are the plan's
879
+ frontier roots; their archive directories are the candidates an archived
880
+ rollout is observed in (#970).
833
881
  """
834
882
  try:
835
883
  candidates = select_recently_active_codex_paths(
@@ -847,7 +895,8 @@ def plan_recent_active_restat(
847
895
  for path, (size, offset, complete, device, inode) in sorted(
848
896
  candidates.items()
849
897
  ):
850
- observed = _observe_file(path, table, evidence.get(path))
898
+ observed = _observe_file(path, table, evidence.get(path),
899
+ archive_dir=_archive_dir_for(path, roots))
851
900
  verdict = classify_recent_active_path(
852
901
  committed_size=size,
853
902
  committed_offset=offset,
@@ -968,12 +1017,27 @@ def _configuration_generation_reason(
968
1017
  return "configuration_generation_changed"
969
1018
 
970
1019
 
1020
+ def _codex_ticket_logical(path, roots) -> str:
1021
+ """One Codex ticket's logical target (#968 5.9, #970 5.8).
1022
+
1023
+ Either container spelling names one logical rollout; an archive spelling
1024
+ of a native home names its dated logical path. Anything else keeps its
1025
+ own logical spelling, which containment then judges.
1026
+ """
1027
+ dated = _rc.dated_logical_for_archive(path, _codex_locations_of(roots))
1028
+ if dated is not None:
1029
+ return os.fspath(dated)
1030
+ return os.fspath(_rc.logical_path(path))
1031
+
1032
+
971
1033
  def _ticket_target_paths(provider: str, records, roots):
972
1034
  """The ticketed paths this plan may target, or a refusal reason.
973
1035
 
974
1036
  #968 spec 5.9: a Codex ticket may name either spelling; it is
975
1037
  normalized to ONE logical target, and containment is enforced on the
976
- selected physical container.
1038
+ selected physical container. #970 spec 5.8: an archive spelling is
1039
+ normalized to its dated logical path BEFORE de-duplication, so the
1040
+ archive pathname never becomes a second target.
977
1041
  """
978
1042
  provider_paths = tuple(
979
1043
  path for kind, path, _e, _s, _c in records if kind == provider)
@@ -981,7 +1045,7 @@ def _ticket_target_paths(provider: str, records, roots):
981
1045
  return None, "ambiguous_activity"
982
1046
  if provider == "codex":
983
1047
  provider_paths = tuple(
984
- os.fspath(_rc.logical_path(path)) for path in provider_paths)
1048
+ _codex_ticket_logical(path, roots) for path in provider_paths)
985
1049
  paths = frozenset(provider_paths)
986
1050
  if any(not _target_in_roots(path, roots,
987
1051
  allow_compressed=provider == "codex")
@@ -992,7 +1056,7 @@ def _ticket_target_paths(provider: str, records, roots):
992
1056
 
993
1057
  def _restat_and_return(
994
1058
  provider: str, conn, *, cursor_table: str, paths, marker_end: int,
995
- ledger_epoch, ledger_sequence: int,
1059
+ ledger_epoch, ledger_sequence: int, roots=(),
996
1060
  ):
997
1061
  """#724's bounded restat, then the caught-up or targeted plan it implies.
998
1062
 
@@ -1012,7 +1076,7 @@ def _restat_and_return(
1012
1076
  restat_paths = frozenset()
1013
1077
  if provider == "codex":
1014
1078
  restat_paths, escalation = plan_recent_active_restat(
1015
- conn, table=cursor_table, extra_paths=paths)
1079
+ conn, table=cursor_table, extra_paths=paths, roots=roots)
1016
1080
  if escalation is not None:
1017
1081
  return FrontierPlan(provider, "full", reason=escalation)
1018
1082
  combined = paths | restat_paths
@@ -1687,6 +1751,70 @@ def _write_generation_stamp(conn, provider: str, generation: str) -> None:
1687
1751
  pass
1688
1752
 
1689
1753
 
1754
+ def codex_frontier_roots(configured_homes) -> "tuple[_rc.CodexLocations, ...]":
1755
+ """The Codex frontier roots for the configured homes (#970 spec 5.1, 5.8).
1756
+
1757
+ One ``CodexLocations`` per configured entry, by the leaf's native-home
1758
+ recognition: a home with ``sessions/`` or ``archived_sessions/`` walks
1759
+ ``<home>/sessions`` and owns ``<home>/archived_sessions``; any other
1760
+ existing directory is walked directly with no archive; anything else is
1761
+ not a root. De-duplicated by walk root in configured order, exactly as the
1762
+ walk's own roots are. Both the dashboard and the TUI build their frontier
1763
+ roots here, so the two planners and the walk agree on the scope.
1764
+ """
1765
+ out: "list[_rc.CodexLocations]" = []
1766
+ seen: set = set()
1767
+ for configured in configured_homes:
1768
+ locations = _rc.home_locations(configured)
1769
+ if locations is None or locations.walk_root in seen:
1770
+ continue
1771
+ seen.add(locations.walk_root)
1772
+ out.append(locations)
1773
+ # #970 N4: an archive another configured root walks belongs to that root,
1774
+ # exactly as in the walk's own roots.
1775
+ return tuple(_rc.release_walked_archives(out))
1776
+
1777
+
1778
+ def _codex_locations_of(roots) -> "tuple[_rc.CodexLocations, ...]":
1779
+ """The ``CodexLocations`` among frontier roots (a bare path has none)."""
1780
+ return tuple(root for root in roots if isinstance(root, _rc.CodexLocations))
1781
+
1782
+
1783
+ def _flatten_frontier_roots(roots) -> tuple:
1784
+ """The directory roots the certificate and containment are stated over.
1785
+
1786
+ A ``CodexLocations`` contributes its walk root and, when it exists, its
1787
+ archive directory (#970 spec 5.8: an archive directory is certified
1788
+ directory evidence and a permitted container location). A bare path — the
1789
+ Claude roots, and a caller that predates #970 — contributes itself. A home
1790
+ without an archive therefore flattens to exactly the pre-#970 root, so its
1791
+ certificate identity is unchanged; an archive appearing later changes the
1792
+ root set, which is itself a reason to walk.
1793
+ """
1794
+ flat: list = []
1795
+ for root in roots:
1796
+ if isinstance(root, _rc.CodexLocations):
1797
+ flat.append(root.walk_root)
1798
+ archive = root.archive_root
1799
+ if archive is not None:
1800
+ try:
1801
+ if pathlib.Path(archive).is_dir():
1802
+ flat.append(archive)
1803
+ except OSError:
1804
+ pass
1805
+ else:
1806
+ flat.append(root)
1807
+ return tuple(flat)
1808
+
1809
+
1810
+ def _archive_dir_for(path, roots):
1811
+ """The archive directory holding ``path``'s archive candidates, or None."""
1812
+ locations = _codex_locations_of(roots)
1813
+ if not locations:
1814
+ return None
1815
+ return _rc.archive_dir_for(path, locations)
1816
+
1817
+
1690
1818
  def normalized_root_membership(roots) -> tuple[str, ...]:
1691
1819
  """The configured root SET, as certificate identity.
1692
1820
 
@@ -1694,10 +1822,12 @@ def normalized_root_membership(roots) -> tuple[str, ...]:
1694
1822
  while adding or removing an entry is. Without this a configured root could
1695
1823
  be added or removed with no filesystem change anywhere and an old
1696
1824
  certificate would still look caught up, because plan-time roots were used
1697
- only to validate ticket targets.
1825
+ only to validate ticket targets. Codex roots are flattened first, so an
1826
+ existing archive directory is a member (#970).
1698
1827
  """
1699
1828
  return tuple(sorted({
1700
- str(pathlib.Path(root).absolute()) for root in roots
1829
+ str(pathlib.Path(root).absolute())
1830
+ for root in _flatten_frontier_roots(roots)
1701
1831
  }))
1702
1832
 
1703
1833
 
@@ -1831,7 +1961,8 @@ def _source_directory_closure(read_sources, roots) -> tuple[pathlib.Path, ...]:
1831
1961
  the seed path treats as a non-certifiable walk rather than behind a store
1832
1962
  error.
1833
1963
  """
1834
- normalized_roots = tuple(pathlib.Path(root).absolute() for root in roots)
1964
+ normalized_roots = tuple(
1965
+ pathlib.Path(root).absolute() for root in _flatten_frontier_roots(roots))
1835
1966
  root_strings = tuple(str(root) for root in normalized_roots)
1836
1967
  # A canonical parent strictly below root `r` starts with `r + "/"`; the
1837
1968
  # filesystem root is already its own prefix. A root anchored at `//` can
@@ -1984,7 +2115,8 @@ def _validate_marker_prefix(marker: pathlib.Path, cutoff: "MarkerCutoff") -> Non
1984
2115
  _decode_marker_records(raw)
1985
2116
 
1986
2117
 
1987
- def _target_has_cursor_gap(conn, provider: str, source_path: str) -> bool:
2118
+ def _target_has_cursor_gap(conn, provider: str, source_path: str, *,
2119
+ roots=()) -> bool:
1988
2120
  table = "session_files" if provider == "claude" else "codex_session_files"
1989
2121
  row = conn.execute(
1990
2122
  f"SELECT size_bytes, last_byte_offset FROM {table} WHERE path=?",
@@ -1996,7 +2128,8 @@ def _target_has_cursor_gap(conn, provider: str, source_path: str) -> bool:
1996
2128
  if provider == "codex":
1997
2129
  observed = _observe_logical(
1998
2130
  source_path,
1999
- _container_evidence_for(conn, table, [source_path]).get(source_path))
2131
+ _container_evidence_for(conn, table, [source_path]).get(source_path),
2132
+ archive_dir=_archive_dir_for(source_path, roots))
2000
2133
  if observed is None:
2001
2134
  return True
2002
2135
  if observed.container_pending:
@@ -2011,13 +2144,26 @@ def _target_has_cursor_gap(conn, provider: str, source_path: str) -> bool:
2011
2144
 
2012
2145
  def _target_in_roots(source_path: str, roots, *,
2013
2146
  allow_compressed: bool = False) -> bool:
2147
+ """Whether a ticketed path's selected container lies inside the roots.
2148
+
2149
+ #970 spec 5.8 (A7): a Codex logical path is selected through its archive
2150
+ candidates too, and the container is bounded by the union of the walk
2151
+ roots and the existing archive directories. A logical path spelled
2152
+ INSIDE an archive directory has no dated identity (an unmappable or
2153
+ nested archive name) and is refused, because discovery never ingests it.
2154
+ """
2014
2155
  path = pathlib.Path(source_path)
2015
2156
  if not path.is_absolute():
2016
2157
  return False
2017
2158
  if allow_compressed:
2018
2159
  if not _rc.is_container_name(path.name):
2019
2160
  return False
2020
- selected = _rc.select_container(path)
2161
+ for location in _codex_locations_of(roots):
2162
+ archive = location.archive_root
2163
+ if archive is not None and path.is_relative_to(archive):
2164
+ return False
2165
+ selected = _rc.select_container(
2166
+ path, archive_dir=_archive_dir_for(path, roots))
2021
2167
  if selected is None:
2022
2168
  return False
2023
2169
  physical = selected.path
@@ -2031,7 +2177,7 @@ def _target_in_roots(source_path: str, roots, *,
2031
2177
  return False
2032
2178
  if not resolved.is_file():
2033
2179
  return False
2034
- for root in roots:
2180
+ for root in _flatten_frontier_roots(roots):
2035
2181
  try:
2036
2182
  resolved.relative_to(pathlib.Path(root).resolve(strict=True))
2037
2183
  except (OSError, ValueError):
@@ -2086,6 +2232,57 @@ def provider_sync_certifiable(mode: str, stats: object) -> bool:
2086
2232
  return False
2087
2233
 
2088
2234
 
2235
+
2236
+ #: #939 D5: what an accounting tick could not finish, per provider. A refusal
2237
+ #: or a per-file leftover keeps ``drained`` from releasing writeIo's admission
2238
+ #: hold, and a change in it between two counted conversation passes keeps the
2239
+ #: second from settling.
2240
+ ACCOUNTING_RESIDUAL_FIELDS = (
2241
+ "deferred_reason", "rebuild_refused", "prune_refused",
2242
+ "finalization_deferred", "maintenance_failed", "files_failed",
2243
+ "files_deferred_torn", "container_pending",
2244
+ )
2245
+
2246
+
2247
+ def accounting_census_complete(
2248
+ mode: str, stats: object, *, expected_paths: int = 0,
2249
+ ) -> bool:
2250
+ """Whether one accounting result visited its whole census (#939 D5).
2251
+
2252
+ A frontier-certified ``caught_up`` plan covers its census; a missing result
2253
+ in any other mode does not. Otherwise only what cut the walk short fails
2254
+ it (lock contention, an exhausted budget, a drain yield, Codex backlog
2255
+ files), and a targeted census must be exactly its plan. It deliberately
2256
+ reads neither ``full_walk_complete`` nor ``targeted_clean``: one
2257
+ persistently unreadable file clears both, and a refusal or a leftover is
2258
+ the tick's residual (``accounting_residual``), not an unvisited census.
2259
+ """
2260
+ if mode == "caught_up":
2261
+ return True
2262
+ if stats is None:
2263
+ return False
2264
+ if any((
2265
+ getattr(stats, "lock_contended", False),
2266
+ getattr(stats, "budget_exhausted", False),
2267
+ getattr(stats, "drain_yielded", False),
2268
+ getattr(stats, "backlog_files", 0),
2269
+ )):
2270
+ return False
2271
+ if mode == "targeted":
2272
+ return int(getattr(stats, "files_total", 0) or 0) == expected_paths
2273
+ return mode == "full"
2274
+
2275
+
2276
+ def accounting_residual(stats: object) -> tuple:
2277
+ """The ``(name, value)`` pairs of what one accounting result left
2278
+ unfinished (#939 D5); ``()`` when nothing."""
2279
+ if stats is None:
2280
+ return ()
2281
+ return tuple(
2282
+ (name, value) for name in ACCOUNTING_RESIDUAL_FIELDS
2283
+ if (value := getattr(stats, name, None)))
2284
+
2285
+
2089
2286
  class DashboardIngestFrontier:
2090
2287
  """One process-local certificate per provider, rebuilt after full sync."""
2091
2288
 
@@ -2295,12 +2492,13 @@ class DashboardIngestFrontier:
2295
2492
  if reason is not None:
2296
2493
  return FrontierPlan(
2297
2494
  provider, "full", marker_end=marker_end, reason=reason)
2298
- if any(_target_has_cursor_gap(conn, provider, path) for path in paths):
2495
+ if any(_target_has_cursor_gap(conn, provider, path, roots=roots)
2496
+ for path in paths):
2299
2497
  return FrontierPlan(provider, "full", reason="cursor_gap")
2300
2498
  return _restat_and_return(
2301
2499
  provider, conn, cursor_table=_ingest_source_table(provider),
2302
2500
  paths=paths, marker_end=marker_end, ledger_epoch=ledger_epoch,
2303
- ledger_sequence=ledger_sequence,
2501
+ ledger_sequence=ledger_sequence, roots=roots,
2304
2502
  )
2305
2503
 
2306
2504
  def commit_provider(
@@ -2392,7 +2590,8 @@ def _conversation_directory_identity(conn, provider: str, roots):
2392
2590
  }
2393
2591
 
2394
2592
 
2395
- def _conversation_target_risk(conn, provider: str, source_path: str) -> str | None:
2593
+ def _conversation_target_risk(conn, provider: str, source_path: str, *,
2594
+ roots=()) -> str | None:
2396
2595
  """Classify a ticketed path that cannot use ordinary append ingest."""
2397
2596
  table = _conversation_source_table(provider)
2398
2597
  row = conn.execute(
@@ -2408,7 +2607,8 @@ def _conversation_target_risk(conn, provider: str, source_path: str) -> str | No
2408
2607
  # planner, so the two planners converge.
2409
2608
  observed = _observe_logical(
2410
2609
  source_path,
2411
- _container_evidence_for(conn, table, [source_path]).get(source_path))
2610
+ _container_evidence_for(conn, table, [source_path]).get(source_path),
2611
+ archive_dir=_archive_dir_for(source_path, roots))
2412
2612
  if observed is None:
2413
2613
  return "cursor_gap"
2414
2614
  if observed.container_pending:
@@ -2470,6 +2670,39 @@ def conversation_sync_certifiable(
2470
2670
  return False
2471
2671
 
2472
2672
 
2673
+
2674
+ def conversation_census_complete(
2675
+ mode: str, stats: object, *, expected_paths: int = 0,
2676
+ ) -> bool:
2677
+ """Whether one transcript pass visited every file of its census (#939 D5).
2678
+
2679
+ `conversation_sync_certifiable` without the predicates and the
2680
+ completed-count equality that describe per-file leftovers (failed, torn,
2681
+ pending, held and deferred files, refused prunes and prune debt,
2682
+ ``barrier_retry``): those are the pass's residual, which writeIo's
2683
+ admission hold compares between passes. Only what cut the walk short and
2684
+ finishes on its own fails it (lock contention, an exhausted budget), and a
2685
+ targeted census must be exactly its plan. A deferred reason (a refused
2686
+ pricing write, a pending rebuild, an unavailable barrier, too little free
2687
+ space) and a maintenance failure can repeat for the dashboard's whole
2688
+ life, so they join the residual instead
2689
+ (`_lib_write_io.conversation_residual`): a changed one restarts the
2690
+ settled chain and an identical one settles.
2691
+ """
2692
+ if mode == "caught_up":
2693
+ return True
2694
+ if stats is None:
2695
+ return False
2696
+ if any((
2697
+ getattr(stats, "lock_contended", False),
2698
+ getattr(stats, "budget_exhausted", False),
2699
+ )):
2700
+ return False
2701
+ if mode == "targeted":
2702
+ return int(getattr(stats, "files_total", 0) or 0) == expected_paths
2703
+ return mode == "full"
2704
+
2705
+
2473
2706
  def _codex_publication_backlog(conn) -> "tuple[bool, frozenset[str]]":
2474
2707
  """Certificates newer than their publication (#968 spec 5.11).
2475
2708
 
@@ -2692,7 +2925,7 @@ class ConversationSyncFrontier:
2692
2925
  return FrontierPlan(
2693
2926
  provider, "full", marker_end=marker_end, reason=reason)
2694
2927
  target_risks = {
2695
- _conversation_target_risk(conn, provider, path)
2928
+ _conversation_target_risk(conn, provider, path, roots=roots)
2696
2929
  for path in paths
2697
2930
  }
2698
2931
  if "source_replaced" in target_risks:
@@ -2702,7 +2935,7 @@ class ConversationSyncFrontier:
2702
2935
  plan = _restat_and_return(
2703
2936
  provider, conn, cursor_table=_conversation_source_table(provider),
2704
2937
  paths=paths, marker_end=marker_end, ledger_epoch=ledger_epoch,
2705
- ledger_sequence=ledger_sequence,
2938
+ ledger_sequence=ledger_sequence, roots=roots,
2706
2939
  )
2707
2940
  if provider == "codex" and plan.mode in ("caught_up", "targeted"):
2708
2941
  due, backlog = _codex_publication_backlog(conn)